Skip to content

Commit 221a722

Browse files
committed
fix: Send checkpoints flushed while a resource is resolved after it and its relations
1 parent ecaaaf3 commit 221a722

3 files changed

Lines changed: 234 additions & 35 deletions

File tree

‎cloudquery/sdk/internal/checkpoint.py‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def __init__(self, parent: Optional[Scope], sync: Optional[object]):
3939
self.resolving = True
4040
# checkpoints waiting for this scope to finish
4141
self.waiters: List[_Pending] = []
42+
# set while a resource is resolved, until it is on the queue and its relations are submitted
43+
self.resource_in_flight = False
44+
# checkpoints waiting for that resource and its relations
45+
self.after_resource: List[_Pending] = []
4246

4347
def finished(self) -> bool:
4448
return not self.resolving and not self.open_children
@@ -112,6 +116,29 @@ def current_scope(self) -> Optional[Scope]:
112116
"""Returns the scope of the table resolver running in this context, or None outside table resolvers."""
113117
return self._scope.get()
114118

119+
@contextmanager
120+
def resource(self, scope: Scope) -> Iterator[None]:
121+
"""
122+
Marks a resource of scope as in flight until it is on the queue and its relations are
123+
submitted, so that checkpoints flushed meanwhile, e.g. from its resource hooks, follow
124+
the resource and its relations.
125+
"""
126+
with self._lock:
127+
scope.resource_in_flight = True
128+
try:
129+
yield
130+
finally:
131+
with self._lock:
132+
scope.resource_in_flight = False
133+
ready: List[_Pending] = []
134+
for pending in scope.after_resource:
135+
pending.blockers -= 1
136+
self._wait_for_children(pending, scope)
137+
if pending.blockers == 0:
138+
ready.append(pending)
139+
scope.after_resource = []
140+
self._send(ready)
141+
115142
def resolver_finished(self, scope: Scope) -> None:
116143
"""
117144
Marks the resolver of scope as returned, once every resource it produced is on the queue.
@@ -167,11 +194,11 @@ def _schedule(
167194
) -> None:
168195
pending = _Pending(msg, keys, self._sync)
169196
for scope in {s for s in scopes if s is not None}:
170-
# the scope's own resources are on the queue already: resolvers put each
171-
# resource on it before they resume, so only its relations can be in flight
172-
for child in scope.open_children:
173-
child.waiters.append(pending)
197+
if scope.resource_in_flight:
198+
scope.after_resource.append(pending)
174199
pending.blockers += 1
200+
else:
201+
self._wait_for_children(pending, scope)
175202
earlier = set()
176203
for key in keys:
177204
previous = self._unsent.get(key)
@@ -183,6 +210,16 @@ def _schedule(
183210
if pending.blockers == 0:
184211
self._send([pending])
185212

213+
@staticmethod
214+
def _wait_for_children(pending: _Pending, scope: Scope) -> None:
215+
"""
216+
Makes pending wait for the relations of scope's resources. Its resources are on the queue
217+
already, as none is in flight. Called with the lock held.
218+
"""
219+
for child in scope.open_children:
220+
child.waiters.append(pending)
221+
pending.blockers += 1
222+
186223
def _send(self, ready: List[_Pending]) -> None:
187224
"""Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
188225
while ready:

‎cloudquery/sdk/scheduler/scheduler.py‎

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -134,37 +134,38 @@ def _resolve_table(
134134
depth=depth,
135135
)
136136
for item in resolver.resolve(client, parent_item):
137-
try:
138-
resource = self.resolve_resource(
139-
resolver, client, parent_item, item
140-
)
141-
except Exception as e:
142-
self._logger.error(
143-
"failed to resolve resource",
144-
client_id=client.id(),
145-
table=resolver.table.name,
146-
depth=depth,
147-
exc_info=e,
148-
)
149-
self._logger.debug(
150-
"details about resource that failed to resolve",
151-
client_id=client.id(),
152-
table=resolver.table.name,
153-
resource=item,
154-
)
155-
continue
156-
res.put(SyncInsertMessage(resource.to_arrow_record()))
157-
for child_resolvers in resolver.child_resolvers:
158-
res.put(TableResolverStarted())
159-
self._pools[depth + 1].submit(
160-
self.resolve_table,
161-
child_resolvers,
162-
depth + 1,
163-
client,
164-
resource,
165-
res,
166-
self.checkpoints.new_scope(scope),
167-
)
137+
with self.checkpoints.resource(scope):
138+
try:
139+
resource = self.resolve_resource(
140+
resolver, client, parent_item, item
141+
)
142+
except Exception as e:
143+
self._logger.error(
144+
"failed to resolve resource",
145+
client_id=client.id(),
146+
table=resolver.table.name,
147+
depth=depth,
148+
exc_info=e,
149+
)
150+
self._logger.debug(
151+
"details about resource that failed to resolve",
152+
client_id=client.id(),
153+
table=resolver.table.name,
154+
resource=item,
155+
)
156+
continue
157+
res.put(SyncInsertMessage(resource.to_arrow_record()))
158+
for child_resolvers in resolver.child_resolvers:
159+
res.put(TableResolverStarted())
160+
self._pools[depth + 1].submit(
161+
self.resolve_table,
162+
child_resolvers,
163+
depth + 1,
164+
client,
165+
resource,
166+
res,
167+
self.checkpoints.new_scope(scope),
168+
)
168169
total_resources += 1
169170
if depth == 0:
170171
self._logger.info(

‎tests/scheduler/checkpoints.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,164 @@ 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+
class HookCursorResolver(TableResolver):
401+
"""Checkpoints its cursor from a resource hook, before the resource is on the queue."""
402+
403+
def __init__(self, state: StateClient, hook: str, parents: int):
404+
super().__init__(
405+
Table("parent", [Column("id", pa.int64())]),
406+
child_resolvers=[ChildResolver(2)],
407+
)
408+
self._state = state
409+
self._hook = hook
410+
self._parents = parents
411+
412+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
413+
for parent_id in range(self._parents):
414+
yield {"id": parent_id}
415+
416+
def pre_resource_resolve(self, client, resource):
417+
if self._hook == "pre":
418+
self._checkpoint(resource)
419+
420+
def post_resource_resolve(self, client, resource):
421+
if self._hook == "post":
422+
self._checkpoint(resource)
423+
424+
def _checkpoint(self, resource):
425+
self._state.set_key("hook-cursor", str(resource.item["id"]))
426+
self._state.flush()
427+
428+
429+
@pytest.mark.parametrize("hook", ["pre", "post"])
430+
def test_checkpoints_flushed_by_resource_hooks_follow_the_resource_and_its_relations(
431+
hook,
432+
):
433+
parents = 10
434+
scheduler = Scheduler(4)
435+
state = MemoryStateClient(checkpoints=scheduler.checkpoints)
436+
try:
437+
messages = list(
438+
scheduler.sync(TestClient(), [HookCursorResolver(state, hook, parents)])
439+
)
440+
finally:
441+
scheduler.shutdown()
442+
443+
seen = set()
444+
cursors = []
445+
for msg in messages:
446+
if isinstance(msg, SyncInsertMessage):
447+
table = msg.record.schema.metadata[METADATA_TABLE_NAME]
448+
row = msg.record.to_pylist()[0]
449+
if table == b"parent":
450+
seen.add((row["id"],))
451+
else:
452+
seen.add((row["parent_id"], row["child_id"]))
453+
elif isinstance(msg, SyncCheckpointMessage):
454+
cursor = int(checkpoint_rows(msg)["hook-cursor"])
455+
cursors.append(cursor)
456+
for parent_id in range(cursor + 1):
457+
assert (parent_id,) in seen, f"cursor {cursor} sent before its parent"
458+
for child_id in range(2):
459+
assert (parent_id, child_id) in seen, (
460+
f"cursor {cursor} sent before child {child_id} of parent "
461+
f"{parent_id}"
462+
)
463+
assert cursors and cursors[-1] == parents - 1
464+
465+
466+
class GrandchildResolver(TableResolver):
467+
"""Yields slowly, so it still runs after the child resolver that spawned it returned."""
468+
469+
def __init__(self):
470+
super().__init__(
471+
Table(
472+
"grandchild",
473+
[
474+
Column("parent_id", pa.int64()),
475+
Column("child_id", pa.int64()),
476+
Column("grandchild_id", pa.int64()),
477+
],
478+
)
479+
)
480+
481+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
482+
for grandchild_id in range(3):
483+
time.sleep(random.uniform(0.005, 0.02))
484+
yield {**parent_resource.item, "grandchild_id": grandchild_id}
485+
486+
487+
class FastChildResolver(TableResolver):
488+
def __init__(self):
489+
super().__init__(
490+
Table(
491+
"fast_child",
492+
[Column("parent_id", pa.int64()), Column("child_id", pa.int64())],
493+
),
494+
child_resolvers=[GrandchildResolver()],
495+
)
496+
497+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
498+
for child_id in range(2):
499+
yield {"parent_id": parent_resource.item["id"], "child_id": child_id}
500+
501+
502+
class GrandparentResolver(TableResolver):
503+
def __init__(self, state: StateClient, parents: int, flush_each: bool):
504+
super().__init__(
505+
Table("grandparent", [Column("id", pa.int64())]),
506+
child_resolvers=[FastChildResolver()],
507+
)
508+
self._state = state
509+
self._parents = parents
510+
self._flush_each = flush_each
511+
512+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
513+
for parent_id in range(self._parents):
514+
yield {"id": parent_id}
515+
if self._flush_each:
516+
self._state.set_key("grandparent-cursor", str(parent_id))
517+
self._state.flush()
518+
if not self._flush_each:
519+
# the child resolvers return meanwhile, while their grandchildren still run
520+
time.sleep(0.01)
521+
self._state.set_key("grandparent-cursor", str(self._parents - 1))
522+
self._state.flush()
523+
524+
525+
@pytest.mark.parametrize("flush_each", [True, False])
526+
def test_parent_checkpoints_follow_the_relations_of_their_relations(flush_each):
527+
parents = 10
528+
scheduler = Scheduler(8)
529+
state = MemoryStateClient(checkpoints=scheduler.checkpoints)
530+
try:
531+
messages = list(
532+
scheduler.sync(
533+
TestClient(), [GrandparentResolver(state, parents, flush_each)]
534+
)
535+
)
536+
finally:
537+
scheduler.shutdown()
538+
539+
seen = set()
540+
cursors = []
541+
for msg in messages:
542+
if (
543+
isinstance(msg, SyncInsertMessage)
544+
and msg.record.schema.metadata[METADATA_TABLE_NAME] == b"grandchild"
545+
):
546+
row = msg.record.to_pylist()[0]
547+
seen.add((row["parent_id"], row["child_id"], row["grandchild_id"]))
548+
elif isinstance(msg, SyncCheckpointMessage):
549+
cursor = int(checkpoint_rows(msg)["grandparent-cursor"])
550+
cursors.append(cursor)
551+
for parent_id in range(cursor + 1):
552+
for child_id in range(2):
553+
for grandchild_id in range(3):
554+
assert (parent_id, child_id, grandchild_id) in seen, (
555+
f"cursor {cursor} sent before grandchild "
556+
f"{(parent_id, child_id, grandchild_id)}"
557+
)
558+
assert cursors and cursors[-1] == parents - 1

0 commit comments

Comments
 (0)