Skip to content

Commit 3f3a22b

Browse files
committed
fix: Reject state changes from resolvers of an ended sync
A resolver still running after its sync ended could change the state client of the next sync, e.g. one a plugin keeps on an attribute. Its key then failed every flush of that sync, as a failed flush keeps its changes, and the next sync read its value. set_key now raises before it records the change.
1 parent 39aaf90 commit 3f3a22b

3 files changed

Lines changed: 66 additions & 1 deletion

File tree

‎cloudquery/sdk/internal/checkpoint.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ 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+
)
128+
115129
def resolver_finished(self, scope: Scope) -> None:
116130
"""
117131
Marks the resolver of scope as returned, once every resource it produced is on the queue.

‎cloudquery/sdk/stateclient/stateclient.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ def get_key(self, key: str) -> Optional[str]:
8585
return self.mem.get(key)
8686

8787
def set_key(self, key: str, value: str) -> None:
88-
scope = self.checkpoints.current_scope() if self.checkpoints else None
88+
scope = None
89+
if self.checkpoints:
90+
scope = self.checkpoints.current_scope()
91+
self.checkpoints.check_scope(scope)
8992
with self._lock:
9093
self.mem[key] = value
9194
self.changes.setdefault(key, set()).add(scope)

‎tests/scheduler/checkpoints.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,51 @@ def test_resolvers_of_an_ended_sync_do_not_checkpoint_into_the_next_one():
395395
]
396396
assert cursors
397397
assert all(cursor.startswith("next-") for cursor in cursors)
398+
399+
400+
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] = {}
403+
next_started, leftover_done = threading.Event(), threading.Event()
404+
leftover_errors: List[Exception] = []
405+
406+
class LeftoverResolver(TableResolver):
407+
def __init__(self):
408+
super().__init__(Table("leftover", [Column("id", pa.int64())]))
409+
410+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
411+
yield {"id": 1}
412+
next_started.wait(5)
413+
try:
414+
shared["state"].set_key("leftover_cursor", "ended")
415+
except RuntimeError as e:
416+
leftover_errors.append(e)
417+
finally:
418+
leftover_done.set()
419+
420+
class NextResolver(CursorResolver):
421+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
422+
next_started.set()
423+
leftover_done.wait(5)
424+
yield from super().resolve(client, parent_resource)
425+
426+
scheduler = Scheduler(4)
427+
try:
428+
ended = scheduler.sync(TestClient(), [LeftoverResolver()])
429+
while not isinstance(next(ended), SyncInsertMessage):
430+
pass
431+
ended.close()
432+
433+
shared["state"] = MemoryStateClient(checkpoints=scheduler.checkpoints)
434+
scheduler.set_post_sync_hook(shared["state"].flush)
435+
messages = list(
436+
scheduler.sync(TestClient(), [NextResolver(shared["state"], "next")])
437+
)
438+
finally:
439+
scheduler.shutdown()
440+
assert len(leftover_errors) == 1
441+
assert "leftover_cursor" not in shared["state"].mem
442+
checkpoints = [
443+
checkpoint_rows(m) for m in messages if isinstance(m, SyncCheckpointMessage)
444+
]
445+
assert [c["cursor"] for c in checkpoints] == [f"next-{i}" for i in range(1, 30)]

0 commit comments

Comments
 (0)