Skip to content

Commit 4a51ea4

Browse files
committed
fix: Drop state changes of ended syncs instead of rejecting them
1 parent 3f3a22b commit 4a51ea4

3 files changed

Lines changed: 97 additions & 31 deletions

File tree

‎cloudquery/sdk/internal/checkpoint.py‎

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,13 @@ def current_scope(self) -> Optional[Scope]:
112112
"""Returns the scope of the table resolver running in this context, or None outside table resolvers."""
113113
return self._scope.get()
114114

115-
def check_scope(self, scope: Optional[Scope]) -> None:
116-
"""
117-
Raises if scope belongs to a sync that ended. The state client calls it before it records
118-
a change, so that a resolver still running after its sync cannot change the state of the
119-
next one: neither the values the next sync reads nor the keys it flushes.
120-
121-
Raises:
122-
RuntimeError: if scope belongs to a sync that ended.
123-
"""
124-
if scope is not None and scope.sync is not self._sync:
125-
raise RuntimeError(
126-
"the state of a table resolver can only be changed during its own sync"
127-
)
115+
def superseded(self, scope: Optional[Scope]) -> bool:
116+
"""Returns True if scope belongs to a sync that ended while another sync runs now."""
117+
return (
118+
scope is not None
119+
and self._sync is not None
120+
and scope.sync is not self._sync
121+
)
128122

129123
def resolver_finished(self, scope: Scope) -> None:
130124
"""
@@ -166,7 +160,7 @@ def schedule(
166160
"e.g. from a table resolver or the post-sync hook"
167161
)
168162
for scopes, _, _ in checkpoints:
169-
if any(s is not None and s.sync is not self._sync for s in scopes):
163+
if any(self.superseded(s) for s in scopes):
170164
raise RuntimeError(
171165
"state checkpoints of a table resolver can only be flushed during its own sync"
172166
)

‎cloudquery/sdk/stateclient/stateclient.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,17 @@ def __init__(
7979

8080
self.migrate_state_table()
8181
self.read_all_state()
82+
self._flushed = dict(self.mem)
8283

8384
def get_key(self, key: str) -> Optional[str]:
8485
with self._lock:
86+
self._forget_changes_of_ended_syncs(key)
8587
return self.mem.get(key)
8688

8789
def set_key(self, key: str, value: str) -> None:
88-
scope = None
89-
if self.checkpoints:
90-
scope = self.checkpoints.current_scope()
91-
self.checkpoints.check_scope(scope)
90+
scope = self.checkpoints.current_scope() if self.checkpoints else None
91+
if self.checkpoints and self.checkpoints.superseded(scope):
92+
return
9293
with self._lock:
9394
self.mem[key] = value
9495
self.changes.setdefault(key, set()).add(scope)
@@ -98,9 +99,13 @@ def flush(self):
9899
Persists the keys changed since the last flush: writes them to the backend, or, with
99100
checkpoints, sends them on the sync stream after the resources they cover.
100101
If that fails, the keys stay changed, so a later flush retries them.
102+
Changes made by table resolvers of an ended sync are dropped once another sync runs,
103+
as the resources they cover may never have been committed.
101104
"""
102105
with self._flush_lock:
103106
with self._lock:
107+
for key in list(self.changes):
108+
self._forget_changes_of_ended_syncs(key)
104109
if not self.changes:
105110
return
106111
changes, self.changes = self.changes, {}
@@ -115,6 +120,21 @@ def flush(self):
115120
for key, scopes in changes.items():
116121
self.changes.setdefault(key, set()).update(scopes)
117122
raise
123+
with self._lock:
124+
self._flushed.update(values)
125+
126+
def _forget_changes_of_ended_syncs(self, key: str) -> None:
127+
if self.checkpoints is None or key not in self.changes:
128+
return
129+
scopes = {s for s in self.changes[key] if not self.checkpoints.superseded(s)}
130+
if scopes:
131+
self.changes[key] = scopes
132+
return
133+
del self.changes[key]
134+
if key in self._flushed:
135+
self.mem[key] = self._flushed[key]
136+
else:
137+
self.mem.pop(key, None)
118138

119139
def _send_checkpoints(
120140
self, changes: Dict[str, Set[Optional[Scope]]], values: Dict[str, str]

‎tests/scheduler/checkpoints.py‎

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
class MemoryStateClient(StateClient):
3131
"""A state client whose backend is a dict."""
3232

33-
def __init__(self, checkpoints=None, fail_writes=False):
34-
self.backend: Dict[str, str] = {}
33+
def __init__(self, checkpoints=None, fail_writes=False, backend=None):
34+
self.backend: Dict[str, str] = dict(backend or {})
3535
self.writes: List[List[Tuple[str, str]]] = []
3636
self.fail_writes = fail_writes
3737
super().__init__(
@@ -398,8 +398,7 @@ def test_resolvers_of_an_ended_sync_do_not_checkpoint_into_the_next_one():
398398

399399

400400
def test_resolvers_of_an_ended_sync_cannot_change_the_state_of_the_next_one():
401-
# the plugin keeps the current sync's state client on an attribute that resolvers read
402-
shared: Dict[str, StateClient] = {}
401+
plugin_attributes: Dict[str, StateClient] = {}
403402
next_started, leftover_done = threading.Event(), threading.Event()
404403
leftover_errors: List[Exception] = []
405404

@@ -409,18 +408,18 @@ def __init__(self):
409408

410409
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
411410
yield {"id": 1}
412-
next_started.wait(5)
413411
try:
414-
shared["state"].set_key("leftover_cursor", "ended")
415-
except RuntimeError as e:
412+
assert next_started.wait(5), "the next sync did not start"
413+
plugin_attributes["state"].set_key("leftover_cursor", "ended")
414+
except Exception as e:
416415
leftover_errors.append(e)
417416
finally:
418417
leftover_done.set()
419418

420419
class NextResolver(CursorResolver):
421420
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
422421
next_started.set()
423-
leftover_done.wait(5)
422+
assert leftover_done.wait(5), "the leftover resolver did not finish"
424423
yield from super().resolve(client, parent_resource)
425424

426425
scheduler = Scheduler(4)
@@ -430,16 +429,69 @@ def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
430429
pass
431430
ended.close()
432431

433-
shared["state"] = MemoryStateClient(checkpoints=scheduler.checkpoints)
434-
scheduler.set_post_sync_hook(shared["state"].flush)
432+
plugin_attributes["state"] = MemoryStateClient(
433+
checkpoints=scheduler.checkpoints
434+
)
435+
scheduler.set_post_sync_hook(plugin_attributes["state"].flush)
435436
messages = list(
436-
scheduler.sync(TestClient(), [NextResolver(shared["state"], "next")])
437+
scheduler.sync(
438+
TestClient(), [NextResolver(plugin_attributes["state"], "next")]
439+
)
437440
)
438441
finally:
439442
scheduler.shutdown()
440-
assert len(leftover_errors) == 1
441-
assert "leftover_cursor" not in shared["state"].mem
443+
assert leftover_errors == []
444+
assert plugin_attributes["state"].get_key("leftover_cursor") is None
442445
checkpoints = [
443446
checkpoint_rows(m) for m in messages if isinstance(m, SyncCheckpointMessage)
444447
]
445448
assert [c["cursor"] for c in checkpoints] == [f"next-{i}" for i in range(1, 30)]
449+
450+
451+
class SetKeyThenWait(TableResolver):
452+
def __init__(self, state: StateClient, release: threading.Event):
453+
super().__init__(Table("set_then_wait", [Column("id", pa.int64())]))
454+
self._state = state
455+
self._release = release
456+
457+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
458+
self._state.set_key("cursor", "ended")
459+
yield {"id": 1}
460+
self._release.wait(5)
461+
462+
463+
def test_a_state_client_reused_across_syncs_drops_unflushed_changes_of_an_ended_sync():
464+
release = threading.Event()
465+
scheduler = Scheduler(4)
466+
try:
467+
state = MemoryStateClient(
468+
checkpoints=scheduler.checkpoints, backend={"cursor": "flushed"}
469+
)
470+
ended = scheduler.sync(TestClient(), [SetKeyThenWait(state, release)])
471+
while not isinstance(next(ended), SyncInsertMessage):
472+
pass
473+
ended.close()
474+
release.set()
475+
476+
scheduler.set_post_sync_hook(state.flush)
477+
cursors_seen: List[Optional[str]] = []
478+
479+
class ReadCursor(TableResolver):
480+
def __init__(self):
481+
super().__init__(Table("read_cursor", [Column("id", pa.int64())]))
482+
483+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
484+
cursors_seen.append(state.get_key("cursor"))
485+
yield {"id": 1}
486+
state.set_key("other", "next")
487+
488+
messages = list(scheduler.sync(TestClient(), [ReadCursor()]))
489+
list(scheduler.sync(TestClient(), [ReadCursor()]))
490+
finally:
491+
release.set()
492+
scheduler.shutdown()
493+
assert cursors_seen == ["flushed", "flushed"]
494+
checkpoints = [
495+
checkpoint_rows(m) for m in messages if isinstance(m, SyncCheckpointMessage)
496+
]
497+
assert checkpoints == [{"other": "next"}]

0 commit comments

Comments
 (0)