Skip to content

Commit 1b4fde9

Browse files
committed
fix: Address checkpoint review findings
- Wait for _sync to finish submitting resolvers before ending the sync - Yield every message left after the post-sync hook - Track the resolver scope in a ContextVar so copied contexts keep it - Schedule a flush's checkpoints all or none - Accept non-string client ids in state_key - Drop unused _Pending.sent and the redundant root scope in _sync
1 parent 9eecf8f commit 1b4fde9

6 files changed

Lines changed: 144 additions & 72 deletions

File tree

‎cloudquery/sdk/internal/checkpoint.py‎

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313

1414
from __future__ import annotations
1515

16+
import contextvars
1617
import queue
1718
import threading
1819
from contextlib import contextmanager
19-
from typing import Dict, Iterable, Iterator, List, Optional, Set
20+
from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
2021

2122
from cloudquery.sdk.message import SyncCheckpointMessage
2223

@@ -49,7 +50,6 @@ def __init__(self, msg: SyncCheckpointMessage, keys: List[str]):
4950
self.keys = keys
5051
self.blockers = 0
5152
self.dependents: List[_Pending] = []
52-
self.sent = False
5353

5454

5555
class Checkpoints:
@@ -62,7 +62,9 @@ class Checkpoints:
6262

6363
def __init__(self):
6464
self._lock = threading.Lock()
65-
self._local = threading.local()
65+
self._scope: contextvars.ContextVar[Optional[Scope]] = contextvars.ContextVar(
66+
"cloudquery_checkpoint_scope", default=None
67+
)
6668
self._res: Optional[queue.Queue] = None
6769
# newest unsent checkpoint of each key
6870
self._unsent: Dict[str, _Pending] = {}
@@ -89,17 +91,20 @@ def new_scope(self, parent: Optional[Scope]) -> Scope:
8991

9092
@contextmanager
9193
def entered(self, scope: Scope) -> Iterator[None]:
92-
"""Makes scope the current one on this thread, for the state changes its resolver makes."""
93-
previous = getattr(self._local, "scope", None)
94-
self._local.scope = scope
94+
"""
95+
Makes scope the current one in this context, for the state changes its resolver makes.
96+
To change state from other threads, run them in a copy of the context, e.g. with
97+
``contextvars.copy_context().run``; asyncio tasks copy it already.
98+
"""
99+
token = self._scope.set(scope)
95100
try:
96101
yield
97102
finally:
98-
self._local.scope = previous
103+
self._scope.reset(token)
99104

100105
def current_scope(self) -> Optional[Scope]:
101-
"""Returns the scope of the table resolver running on this thread, or None outside table resolvers."""
102-
return getattr(self._local, "scope", None)
106+
"""Returns the scope of the table resolver running in this context, or None outside table resolvers."""
107+
return self._scope.get()
103108

104109
def resolver_finished(self, scope: Scope) -> None:
105110
"""
@@ -121,47 +126,56 @@ def resolver_finished(self, scope: Scope) -> None:
121126

122127
def schedule(
123128
self,
124-
scopes: Iterable[Optional[Scope]],
125-
keys: List[str],
126-
msg: SyncCheckpointMessage,
129+
checkpoints: List[
130+
Tuple[Iterable[Optional[Scope]], List[str], SyncCheckpointMessage]
131+
],
127132
) -> None:
128133
"""
129-
Sends msg once every resource the scopes produced so far is on the queue, including
130-
the relations spawned from them, and after every earlier unsent checkpoint of keys.
131-
A None scope stands for changes made outside table resolvers, which wait for nothing.
134+
Sends each (scopes, keys, msg) once every resource the scopes produced so far is on the
135+
queue, including the relations spawned from them, and after every earlier unsent
136+
checkpoint of keys. A None scope stands for changes made outside table resolvers, which
137+
wait for nothing. Schedules all of them or, if it raises, none of them.
132138
133139
Raises:
134-
RuntimeError: if no sync is running, as the checkpoint could not be sent.
140+
RuntimeError: if no sync is running, as the checkpoints could not be sent.
135141
"""
136142
with self._lock:
137143
if self._res is None:
138144
raise RuntimeError(
139145
"state checkpoints can only be flushed while the scheduler syncs, "
140146
"e.g. from a table resolver or the post-sync hook"
141147
)
142-
pending = _Pending(msg, keys)
143-
for scope in {s for s in scopes if s is not None}:
144-
# the scope's own resources are on the queue already: resolvers put each
145-
# resource on it before they resume, so only its relations can be in flight
146-
for child in scope.open_children:
147-
child.waiters.append(pending)
148-
pending.blockers += 1
149-
earlier = set()
150-
for key in keys:
151-
previous = self._unsent.get(key)
152-
if previous is not None and previous not in earlier:
153-
earlier.add(previous)
154-
previous.dependents.append(pending)
155-
pending.blockers += 1
156-
self._unsent[key] = pending
157-
if pending.blockers == 0:
158-
self._send([pending])
148+
for scopes, keys, msg in checkpoints:
149+
self._schedule(scopes, keys, msg)
150+
151+
def _schedule(
152+
self,
153+
scopes: Iterable[Optional[Scope]],
154+
keys: List[str],
155+
msg: SyncCheckpointMessage,
156+
) -> None:
157+
pending = _Pending(msg, keys)
158+
for scope in {s for s in scopes if s is not None}:
159+
# the scope's own resources are on the queue already: resolvers put each
160+
# resource on it before they resume, so only its relations can be in flight
161+
for child in scope.open_children:
162+
child.waiters.append(pending)
163+
pending.blockers += 1
164+
earlier = set()
165+
for key in keys:
166+
previous = self._unsent.get(key)
167+
if previous is not None and previous not in earlier:
168+
earlier.add(previous)
169+
previous.dependents.append(pending)
170+
pending.blockers += 1
171+
self._unsent[key] = pending
172+
if pending.blockers == 0:
173+
self._send([pending])
159174

160175
def _send(self, ready: List[_Pending]) -> None:
161176
"""Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
162177
while ready:
163178
pending = ready.pop()
164-
pending.sent = True
165179
if self._res is not None:
166180
self._res.put(pending.msg)
167181
for key in pending.keys:

‎cloudquery/sdk/scheduler/scheduler.py‎

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
SyncInsertMessage,
1010
SyncMigrateTableMessage,
1111
SyncErrorMessage,
12-
SyncCheckpointMessage,
1312
)
1413
from cloudquery.sdk.internal.checkpoint import Checkpoints, Scope
1514
from cloudquery.sdk.schema import Resource
@@ -35,6 +34,11 @@ def __init__(self) -> None:
3534
pass
3635

3736

37+
class TableResolversSubmitted:
38+
def __init__(self) -> None:
39+
pass
40+
41+
3842
class Scheduler:
3943
def __init__(
4044
self, concurrency: int, queue_size: int = 0, max_depth: int = 3, logger=None
@@ -95,8 +99,7 @@ def resolve_table(
9599
res: queue.Queue,
96100
scope: Optional[Scope] = None,
97101
):
98-
if scope is None:
99-
scope = self.checkpoints.new_scope(None)
102+
scope = scope or self.checkpoints.new_scope(None)
100103
try:
101104
with self.checkpoints.entered(scope):
102105
self._resolve_table(resolver, depth, client, parent_item, res, scope)
@@ -197,19 +200,16 @@ def _sync(
197200
res: queue.Queue,
198201
deterministic_cq_id=False,
199202
):
200-
for resolver in resolvers:
201-
clients = resolver.multiplex(client)
202-
for client in clients:
203-
res.put(TableResolverStarted())
204-
self._pools[0].submit(
205-
self.resolve_table,
206-
resolver,
207-
0,
208-
client,
209-
None,
210-
res,
211-
self.checkpoints.new_scope(None),
212-
)
203+
try:
204+
for resolver in resolvers:
205+
clients = resolver.multiplex(client)
206+
for client in clients:
207+
res.put(TableResolverStarted())
208+
self._pools[0].submit(
209+
self.resolve_table, resolver, 0, client, None, res
210+
)
211+
finally:
212+
res.put(TableResolversSubmitted())
213213

214214
def sync(
215215
self, client, resolvers: List[TableResolver], deterministic_cq_id=False
@@ -221,31 +221,24 @@ def sync(
221221
try:
222222
thread = futures.ThreadPoolExecutor()
223223
thread.submit(self._sync, client, resolvers, res, deterministic_cq_id)
224+
submitted = False
224225
total_table_resolvers = 0
225226
finished_table_resolvers = 0
226-
while True:
227+
while not (submitted and total_table_resolvers == finished_table_resolvers):
227228
message = res.get()
228-
if isinstance(message, TableResolverStarted):
229+
if isinstance(message, TableResolversSubmitted):
230+
submitted = True
231+
elif isinstance(message, TableResolverStarted):
229232
total_table_resolvers += 1
230-
if total_table_resolvers == finished_table_resolvers:
231-
break
232-
continue
233-
if isinstance(message, TableResolverFinished):
233+
elif isinstance(message, TableResolverFinished):
234234
finished_table_resolvers += 1
235-
if total_table_resolvers == finished_table_resolvers:
236-
break
237-
continue
238-
yield message
235+
else:
236+
yield message
239237

240238
self._post_sync_hook()
241239
# checkpoints the post-sync hook flushed, after every resource
242-
while True:
243-
try:
244-
message = res.get_nowait()
245-
except queue.Empty:
246-
break
247-
if isinstance(message, SyncCheckpointMessage):
248-
yield message
240+
while not res.empty():
241+
yield res.get_nowait()
249242
thread.shutdown(wait=True)
250243
finally:
251244
self.checkpoints.stop()

‎cloudquery/sdk/stateclient/keys.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def _escape(part: str) -> str:
1818
def state_key(
1919
table: str,
2020
*,
21-
client_id: Optional[str] = None,
21+
client_id: Optional[Union[str, int]] = None,
2222
**partition: Union[str, int],
2323
) -> str:
2424
"""
@@ -38,7 +38,7 @@ def state_key(
3838
"""
3939
parts = [_escape(table)]
4040
if client_id is not None:
41-
parts.append(f"client_id{_ASSIGN}{_escape(client_id)}")
41+
parts.append(f"client_id{_ASSIGN}{_escape(str(client_id))}")
4242
for name in sorted(partition):
4343
parts.append(f"{_escape(name)}{_ASSIGN}{_escape(str(partition[name]))}")
4444
return _SEPARATOR.join(parts)

‎cloudquery/sdk/stateclient/stateclient.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,18 @@ def _send_checkpoints(
120120
groups: Dict[FrozenSet[Optional[Scope]], List[str]] = {}
121121
for key, scopes in changes.items():
122122
groups.setdefault(frozenset(scopes), []).append(key)
123-
for scopes, keys in groups.items():
124-
record = self.table.record([(key, values[key]) for key in keys])
125-
self.checkpoints.schedule(scopes, keys, SyncCheckpointMessage(record))
123+
self.checkpoints.schedule(
124+
[
125+
(
126+
scopes,
127+
keys,
128+
SyncCheckpointMessage(
129+
self.table.record([(key, values[key]) for key in keys])
130+
),
131+
)
132+
for scopes, keys in groups.items()
133+
]
134+
)
126135

127136
@abstractmethod
128137
def migrate_state_table(self):

‎tests/scheduler/checkpoints.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import contextvars
12
import random
23
import threading
34
import time
5+
from concurrent import futures
46
from typing import Any, Dict, Generator, List, Optional, Tuple
57

68
import pyarrow as pa
@@ -300,3 +302,53 @@ def test_builder_uses_checkpoints_only_when_the_cli_supports_them(monkeypatch):
300302
assert supported.checkpoints is scheduler.checkpoints
301303
assert unsupported.checkpoints is None
302304
assert not_requested.checkpoints is None
305+
306+
307+
class SingleItemResolver(TableResolver):
308+
def __init__(self, name: str, multiplex_delay: float = 0):
309+
super().__init__(Table(name, [Column("id", pa.int64())]))
310+
self._multiplex_delay = multiplex_delay
311+
312+
def multiplex(self, client):
313+
time.sleep(self._multiplex_delay)
314+
return [client]
315+
316+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
317+
yield {"id": 1}
318+
319+
320+
def test_sync_waits_for_resolvers_submitted_after_earlier_ones_finished():
321+
scheduler = Scheduler(1)
322+
resolvers = [
323+
SingleItemResolver("fast"),
324+
SingleItemResolver("slow_to_multiplex", multiplex_delay=0.2),
325+
]
326+
try:
327+
messages = list(scheduler.sync(TestClient(), resolvers))
328+
finally:
329+
scheduler.shutdown()
330+
inserts = [m for m in messages if isinstance(m, SyncInsertMessage)]
331+
assert len(inserts) == len(resolvers)
332+
333+
334+
def test_scope_follows_copied_context_into_other_threads():
335+
scheduler = Scheduler(1)
336+
scopes = []
337+
338+
class Resolver(TableResolver):
339+
def __init__(self):
340+
super().__init__(Table("items", [Column("id", pa.int64())]))
341+
342+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
343+
with futures.ThreadPoolExecutor(1) as pool:
344+
ctx = contextvars.copy_context()
345+
scopes.append(
346+
pool.submit(ctx.run, scheduler.checkpoints.current_scope).result()
347+
)
348+
yield {"id": 1}
349+
350+
try:
351+
list(scheduler.sync(TestClient(), [Resolver()]))
352+
finally:
353+
scheduler.shutdown()
354+
assert scopes[0] is not None

‎tests/stateclient/keys.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ def test_tables_clients_and_partitions_never_share_a_key():
3131
state_key("t", a=1, b=2),
3232
]
3333
assert len(set(keys)) == len(keys)
34+
35+
36+
def test_client_id_that_is_not_a_string():
37+
assert state_key("t", client_id=123) == "t|client_id=123"

0 commit comments

Comments
 (0)