Skip to content

Commit fe8cc8f

Browse files
authored
fix: Send checkpoints from resource hooks after their resources (#419)
Checkpoints flushed while a resource is being resolved, for example from `pre_resource_resolve` or `post_resource_resolve`, now wait for that resource and its relations. Before, the scheduler ran a resource's hooks before putting the resource on the queue, so the CLI could save a cursor past rows it never received. Also: - Flushing checkpoints outside their sync now raises `SyncNotRunningError`, a `RuntimeError` exported from `cloudquery.sdk.stateclient`. - Without checkpoints, setting a key to its current value no longer writes it to the backend again. With checkpoints it still sends a checkpoint, because later values of the key must follow the caller's resources. - New tests check that checkpoints follow grandchild relations. - The scheduler's thread pools keep their own `max_workers` instead of reading `ThreadPoolExecutor`'s private copy, and a comment explains why the queue limit is a semaphore taken before `submit()`.
1 parent 0a4b408 commit fe8cc8f

5 files changed

Lines changed: 318 additions & 42 deletions

File tree

‎cloudquery/sdk/internal/checkpoint.py‎

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222
from cloudquery.sdk.message import SyncCheckpointMessage
2323

2424

25+
class SyncNotRunningError(RuntimeError):
26+
"""
27+
Raised when state checkpoints are flushed while their sync is not running, so they cannot be
28+
sent: before or after Scheduler.sync, or from a resolver of an ended sync. Flush state that
29+
summarizes the whole sync in the scheduler's post-sync hook instead.
30+
"""
31+
32+
2533
class Scope:
2634
"""
2735
A table resolver call and the relations its resources spawn.
@@ -39,6 +47,10 @@ def __init__(self, parent: Optional[Scope], sync: Optional[object]):
3947
self.resolving = True
4048
# checkpoints waiting for this scope to finish
4149
self.waiters: List[_Pending] = []
50+
# set while a resource is resolved, until it is on the queue and its relations are submitted
51+
self.resource_in_flight = False
52+
# checkpoints waiting for that resource and its relations
53+
self.after_resource: List[_Pending] = []
4254

4355
def finished(self) -> bool:
4456
return not self.resolving and not self.open_children
@@ -112,6 +124,29 @@ def current_scope(self) -> Optional[Scope]:
112124
"""Returns the scope of the table resolver running in this context, or None outside table resolvers."""
113125
return self._scope.get()
114126

127+
@contextmanager
128+
def resource(self, scope: Scope) -> Iterator[None]:
129+
"""
130+
Marks a resource of scope as in flight until it is on the queue and its relations are
131+
submitted, so that checkpoints flushed meanwhile, e.g. from its resource hooks, follow
132+
the resource and its relations.
133+
"""
134+
with self._lock:
135+
scope.resource_in_flight = True
136+
try:
137+
yield
138+
finally:
139+
with self._lock:
140+
scope.resource_in_flight = False
141+
ready: List[_Pending] = []
142+
for pending in scope.after_resource:
143+
pending.blockers -= 1
144+
self._wait_for_children(pending, scope)
145+
if pending.blockers == 0:
146+
ready.append(pending)
147+
scope.after_resource = []
148+
self._send(ready)
149+
115150
def superseded(self, scope: Optional[Scope]) -> bool:
116151
"""Returns True if scope belongs to a sync that ended while another sync runs now."""
117152
return (
@@ -151,17 +186,17 @@ def schedule(
151186
wait for nothing. Schedules all of them or, if it raises, none of them.
152187
153188
Raises:
154-
RuntimeError: if no sync is running, as the checkpoints could not be sent.
189+
SyncNotRunningError: if the sync of the checkpoints is not running.
155190
"""
156191
with self._lock:
157192
if self._res is None:
158-
raise RuntimeError(
193+
raise SyncNotRunningError(
159194
"state checkpoints can only be flushed while the scheduler syncs, "
160195
"e.g. from a table resolver or the post-sync hook"
161196
)
162197
for scopes, _, _ in checkpoints:
163198
if any(self.superseded(s) for s in scopes):
164-
raise RuntimeError(
199+
raise SyncNotRunningError(
165200
"state checkpoints of a table resolver can only be flushed during its own sync"
166201
)
167202
for scopes, keys, msg in checkpoints:
@@ -175,11 +210,11 @@ def _schedule(
175210
) -> None:
176211
pending = _Pending(msg, keys, self._sync)
177212
for scope in {s for s in scopes if s is not None}:
178-
# the scope's own resources are on the queue already: resolvers put each
179-
# resource on it before they resume, so only its relations can be in flight
180-
for child in scope.open_children:
181-
child.waiters.append(pending)
213+
if scope.resource_in_flight:
214+
scope.after_resource.append(pending)
182215
pending.blockers += 1
216+
else:
217+
self._wait_for_children(pending, scope)
183218
earlier = set()
184219
for key in keys:
185220
previous = self._unsent.get(key)
@@ -191,6 +226,16 @@ def _schedule(
191226
if pending.blockers == 0:
192227
self._send([pending])
193228

229+
@staticmethod
230+
def _wait_for_children(pending: _Pending, scope: Scope) -> None:
231+
"""
232+
Makes pending wait for the relations of scope's resources. Its resources are on the queue
233+
already, as none is in flight. Called with the lock held.
234+
"""
235+
for child in scope.open_children:
236+
child.waiters.append(pending)
237+
pending.blockers += 1
238+
194239
def _send(self, ready: List[_Pending]) -> None:
195240
"""Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
196241
while ready:

‎cloudquery/sdk/scheduler/scheduler.py‎

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020

2121

2222
class ThreadPoolExecutorWithQueueSizeLimit(futures.ThreadPoolExecutor):
23-
def __init__(self, maxsize, *args, **kwargs):
24-
super().__init__(*args, **kwargs)
23+
def __init__(self, maxsize: int, max_workers: int, *args, **kwargs):
24+
super().__init__(max_workers, *args, **kwargs)
2525
self.maxsize = maxsize
26-
self._slots = threading.BoundedSemaphore(maxsize + self._max_workers)
26+
# submit() puts on the work queue while holding a lock that every pool shares, so a
27+
# full bounded queue would block the other pools' workers too: limit it before submit
28+
self._slots = threading.BoundedSemaphore(maxsize + max_workers)
2729

2830
def submit(self, fn, /, *args, **kwargs):
2931
self._slots.acquire()
@@ -146,37 +148,38 @@ def _resolve_table(
146148
depth=depth,
147149
)
148150
for item in resolver.resolve(client, parent_item):
149-
try:
150-
resource = self.resolve_resource(
151-
resolver, client, parent_item, item
152-
)
153-
except Exception as e:
154-
self._logger.error(
155-
"failed to resolve resource",
156-
client_id=client.id(),
157-
table=resolver.table.name,
158-
depth=depth,
159-
exc_info=e,
160-
)
161-
self._logger.debug(
162-
"details about resource that failed to resolve",
163-
client_id=client.id(),
164-
table=resolver.table.name,
165-
resource=item,
166-
)
167-
continue
168-
res.put(SyncInsertMessage(resource.to_arrow_record()))
169-
for child_resolvers in resolver.child_resolvers:
170-
res.put(TableResolverStarted())
171-
self._pools[depth + 1].submit(
172-
self.resolve_table,
173-
child_resolvers,
174-
depth + 1,
175-
client,
176-
resource,
177-
res,
178-
self.checkpoints.new_scope(scope),
179-
)
151+
with self.checkpoints.resource(scope):
152+
try:
153+
resource = self.resolve_resource(
154+
resolver, client, parent_item, item
155+
)
156+
except Exception as e:
157+
self._logger.error(
158+
"failed to resolve resource",
159+
client_id=client.id(),
160+
table=resolver.table.name,
161+
depth=depth,
162+
exc_info=e,
163+
)
164+
self._logger.debug(
165+
"details about resource that failed to resolve",
166+
client_id=client.id(),
167+
table=resolver.table.name,
168+
resource=item,
169+
)
170+
continue
171+
res.put(SyncInsertMessage(resource.to_arrow_record()))
172+
for child_resolvers in resolver.child_resolvers:
173+
res.put(TableResolverStarted())
174+
self._pools[depth + 1].submit(
175+
self.resolve_table,
176+
child_resolvers,
177+
depth + 1,
178+
client,
179+
resource,
180+
res,
181+
self.checkpoints.new_scope(scope),
182+
)
180183
total_resources += 1
181184
if depth == 0:
182185
self._logger.info(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
from cloudquery.sdk.internal.checkpoint import SyncNotRunningError
12
from .keys import state_key

‎cloudquery/sdk/stateclient/stateclient.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ def set_key(self, key: str, value: str) -> None:
9191
if self.checkpoints and self.checkpoints.superseded(scope):
9292
return
9393
with self._lock:
94+
# with checkpoints, the value also covers the caller's resources, so later checkpoints
95+
# of the key must still follow them even if it is unchanged
96+
if self.checkpoints is None and self.mem.get(key) == value:
97+
return
9498
self.mem[key] = value
9599
self.changes.setdefault(key, set()).add(scope)
96100

@@ -101,6 +105,10 @@ def flush(self):
101105
If that fails, the keys stay changed, so a later flush retries them.
102106
Changes made by table resolvers of an ended sync are dropped once another sync runs,
103107
as the resources they cover may never have been committed.
108+
109+
Raises:
110+
SyncNotRunningError: with checkpoints, if the sync is not running, e.g. once
111+
Scheduler.sync returned. Flush in the scheduler's post-sync hook instead.
104112
"""
105113
with self._flush_lock:
106114
with self._lock:

0 commit comments

Comments
 (0)