Skip to content

Commit b5d79c8

Browse files
authored
feat: Send state as checkpoints after the resources it covers (#415)
#### Summary Brings the source side of state checkpoints (cloudquery/plugin-pb#42, cloudquery/plugin-sdk#2632, cloudquery/cloudquery#23546) to the Python SDK. When the CLI sets `Sync.Request.supports_checkpoints`, flushed state goes on the sync stream as `Sync.MessageCheckpoint` instead of being written straight to the backend. The CLI then persists it only after every destination commits the records synced before it. Why we want this: some of our Python syncs run for many hours, often limited by provider rate limits. A transient failure late in the sync currently throws away all the progress, because state is only safe to write once the sync ends. With checkpoints, a failed sync keeps the cursors whose records were committed, and the next run resumes from there. Opt-in, and unchanged unless both sides opt in: ```python self._scheduler = Scheduler(concurrency=...) self._state_client = StateClientBuilder.build( backend_options=options.backend_options, checkpoints=self._scheduler.checkpoints, # new, optional ) self._scheduler.set_post_sync_hook(self._state_client.flush) # in a table resolver, with one cursor per form key = state_key("responses", client_id=client.id(), form_id=form["id"]) for item in api.list_responses(form["id"], since=state_client.get_key(key)): yield item state_client.set_key(key, item["updated_at"]) state_client.flush() # a checkpoint, sent once this item and its relations are on the stream ``` Without `checkpoints`, or when the CLI doesn't support them, `flush()` writes to the backend as it does today. #### Ordering This follows the Go SDK's semantics: - A state change made inside a table resolver is tied to that resolver call's scope. The scheduler records the scope in a thread-local while the resolver runs. - On `flush()`, each group of keys becomes a checkpoint. It goes on the scheduler's queue once every resource those scopes produced before the flush is on it, including the relations those resources spawned. - Child resolvers run asynchronously on the depth pools, so the checkpoint waits for every child scope that was still open at flush time. - Unlike Go, the Python scheduler has no batcher: resources reach the stream in queue order, so nothing needs flushing first. - A checkpoint also waits for earlier unsent checkpoints of the same keys. The state table has no `version` column, so the CLI relies on stream order. - Changes made outside table resolvers wait for nothing. Flush them after the resources they summarize, for example in the post-sync hook. Checkpoints flushed by the post-sync hook are now sent before `Scheduler.sync` returns. - Flushing in checkpoint mode while no sync is running raises an error instead of dropping the state. #### Granularity Two things set how much a checkpointing sync writes, and today plugin authors and users control only one of them: - **How many keys change between commits.** That's the plugin's choice: one cursor per table, or one per parent, account or other partition. This PR adds `state_key(table, *, client_id=None, **partition)`, so plugins choose the granularity without each inventing a key format. Keys are deterministic, partition fields are sorted, and separators are escaped, so tables, clients and partitions never share a key. Each key is one state row, and a partition's checkpoint waits only for the resolver that set it. Finer keys mean a resumed sync re-fetches less, at the cost of more rows per commit. - **How often the CLI commits.** The CLI merges checkpoints and commits at most every 30 seconds (`checkpointInterval` in `cli/cmd/sync_v3_checkpoints.go`). Each commit flushes every destination, then writes every key changed since the last commit to the state backend. The interval is hard-coded. We'd like the commit interval to be configurable per sync, for example in the source spec next to `backend_options`, including an option to commit only at the end. The right value depends on the destination and the sync, not on the plugin: - **Destination batches:** each flush ends the destination's batch, so a 30-second interval caps batch size by time. For S3 it also writes a new object per table every 30 seconds. - **State backend load:** a sync with per-partition keys writes up to one row per partition per commit to a relational state backend. - **Sync length:** for a sync that runs many hours against rate-limited APIs, committing every few minutes loses little on failure and writes far less. A short sync gains nothing from mid-sync commits. Keeping the plugin-side flush cheap matters too, and it is: `flush()` in checkpoint mode only puts a message on the sync stream, and nothing touches a database until the CLI commits. #### Other changes - **State client:** now thread-safe. It clears changed keys after a successful flush (previously every flush rewrote every key changed since the start), and it keeps them after a failed one so a later flush retries them. - **Backend options:** `BackendOptions` gains `supports_checkpoints`. The server now builds `BackendOptions` from the request instead of passing the protobuf message through. - **Server guard:** the server refuses to send a checkpoint when the CLI didn't ask for them. - **Key helper:** `state_key` is exported from `cloudquery.sdk.stateclient`. #### Depends on The `plugin-pb-python` bindings with the new messages (cloudquery/plugin-pb-python#250), which aren't released yet (release PR cloudquery/plugin-pb-python#225). `pyproject.toml` requires `>=0.0.55`, but `uv.lock` still needs updating after that release, so CI will fail until then. That's why this is a draft. #### Open questions 1. **Failed relations.** Like Go, a parent's checkpoint still goes out when one of its child resolvers fails. For a parent cursor such as `updated_since`, that can skip the failed parent's children for good. Would you take an opt-in to hold back a scope's checkpoints when anything in its subtree errored? 2. **`version` column.** Should the Python state table get the `version` column, to match the Go client? 3. **Python destinations.** Python destination plugins don't implement `Write.MessageFlush` yet. I left that for a separate PR, since the CLI only enables checkpoints when every destination supports flushing. 4. **Full syncs.** Checkpoints only help tables that keep cursors. Resuming long full (non-incremental) syncs, and the interaction with `delete-stale`, is a bigger question. I'd like to raise it in a separate issue. #### Testing - New tests in `tests/scheduler/checkpoints.py`, `tests/serve/checkpoints.py` and `tests/stateclient/keys.py`. They cover: - parent checkpoints following all child resources of the parents they cover, with randomized child latency - child checkpoints - per-key order under concurrent flushes - post-sync hook checkpoints - backend writes without checkpoints - failed-flush retry - the builder and server gating - `state_key` ordering, escaping and uniqueness - Removing either the wait on child scopes or the per-key ordering makes the parent-checkpoint test fail. - The full suite passes locally against `plugin-pb-python` main, except `test_plugin_package`, which builds a Docker image and fails the same way locally on `main`.
1 parent 489c847 commit b5d79c8

15 files changed

Lines changed: 931 additions & 61 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""
2+
Orders state checkpoints after the resources they summarize.
3+
4+
When the CLI supports checkpoints, it persists the state rows a source sends as
5+
``Sync.MessageCheckpoint`` once every destination has committed the messages sent
6+
before them. A checkpoint must therefore go on the sync stream only after every
7+
resource its state covers: the resources produced, before the flush, by the table
8+
resolvers that changed its keys, and the relations those resources spawned.
9+
10+
The scheduler sends every message through a single queue, so a checkpoint put on
11+
the queue once those resources are on it is ordered after them on the stream.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import contextvars
17+
import queue
18+
import threading
19+
from contextlib import contextmanager
20+
from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
21+
22+
from cloudquery.sdk.message import SyncCheckpointMessage
23+
24+
25+
class Scope:
26+
"""
27+
A table resolver call and the relations its resources spawn.
28+
29+
A scope is finished once its resolver returned and every child scope it
30+
spawned is finished.
31+
"""
32+
33+
def __init__(self, parent: Optional[Scope], sync: Optional[object]):
34+
self.parent = parent
35+
# the sync the scope belongs to, so that resolvers still running after it ended cannot checkpoint into the next one
36+
self.sync = sync
37+
# child scopes that are not finished yet
38+
self.open_children: Set[Scope] = set()
39+
self.resolving = True
40+
# checkpoints waiting for this scope to finish
41+
self.waiters: List[_Pending] = []
42+
43+
def finished(self) -> bool:
44+
return not self.resolving and not self.open_children
45+
46+
47+
class _Pending:
48+
"""A checkpoint waiting for scopes to finish and for earlier checkpoints of its keys to be sent."""
49+
50+
def __init__(self, msg: SyncCheckpointMessage, keys: List[str], sync: object):
51+
self.msg = msg
52+
self.keys = keys
53+
self.sync = sync
54+
self.blockers = 0
55+
self.dependents: List[_Pending] = []
56+
57+
58+
class Checkpoints:
59+
"""
60+
Sends state checkpoints on the scheduler's queue after the resources they cover.
61+
62+
The scheduler owns an instance and passes it to the state client through
63+
:meth:`cloudquery.sdk.stateclient.StateClientBuilder.build`.
64+
"""
65+
66+
def __init__(self):
67+
self._lock = threading.Lock()
68+
self._scope: contextvars.ContextVar[Optional[Scope]] = contextvars.ContextVar(
69+
"cloudquery_checkpoint_scope", default=None
70+
)
71+
self._res: Optional[queue.Queue] = None
72+
# newest unsent checkpoint of each key
73+
self._unsent: Dict[str, _Pending] = {}
74+
self._sync: Optional[object] = None
75+
76+
def start(self, res: queue.Queue) -> None:
77+
"""Sends later checkpoints on res. Called by the scheduler when a sync starts."""
78+
with self._lock:
79+
self._res = res
80+
self._sync = object()
81+
self._unsent = {}
82+
83+
def stop(self) -> None:
84+
"""Drops unsent checkpoints and rejects later ones. Called by the scheduler when a sync ends."""
85+
with self._lock:
86+
self._res = None
87+
self._sync = None
88+
self._unsent = {}
89+
90+
def new_scope(self, parent: Optional[Scope]) -> Scope:
91+
"""Returns a scope for a table resolver call, before the call is submitted."""
92+
scope = Scope(parent, parent.sync if parent is not None else self._sync)
93+
if parent is not None:
94+
with self._lock:
95+
parent.open_children.add(scope)
96+
return scope
97+
98+
@contextmanager
99+
def entered(self, scope: Scope) -> Iterator[None]:
100+
"""
101+
Makes scope the current one in this context, for the state changes its resolver makes.
102+
To change state from other threads, run them in a copy of the context, e.g. with
103+
``contextvars.copy_context().run``; asyncio tasks copy it already.
104+
"""
105+
token = self._scope.set(scope)
106+
try:
107+
yield
108+
finally:
109+
self._scope.reset(token)
110+
111+
def current_scope(self) -> Optional[Scope]:
112+
"""Returns the scope of the table resolver running in this context, or None outside table resolvers."""
113+
return self._scope.get()
114+
115+
def resolver_finished(self, scope: Scope) -> None:
116+
"""
117+
Marks the resolver of scope as returned, once every resource it produced is on the queue.
118+
"""
119+
with self._lock:
120+
scope.resolving = False
121+
ready: List[_Pending] = []
122+
while scope is not None and scope.finished():
123+
for pending in scope.waiters:
124+
pending.blockers -= 1
125+
if pending.blockers == 0:
126+
ready.append(pending)
127+
scope.waiters = []
128+
if scope.parent is not None:
129+
scope.parent.open_children.discard(scope)
130+
scope = scope.parent
131+
self._send(ready)
132+
133+
def schedule(
134+
self,
135+
checkpoints: List[
136+
Tuple[Iterable[Optional[Scope]], List[str], SyncCheckpointMessage]
137+
],
138+
) -> None:
139+
"""
140+
Sends each (scopes, keys, msg) once every resource the scopes produced so far is on the
141+
queue, including the relations spawned from them, and after every earlier unsent
142+
checkpoint of keys. A None scope stands for changes made outside table resolvers, which
143+
wait for nothing. Schedules all of them or, if it raises, none of them.
144+
145+
Raises:
146+
RuntimeError: if no sync is running, as the checkpoints could not be sent.
147+
"""
148+
with self._lock:
149+
if self._res is None:
150+
raise RuntimeError(
151+
"state checkpoints can only be flushed while the scheduler syncs, "
152+
"e.g. from a table resolver or the post-sync hook"
153+
)
154+
for scopes, _, _ in checkpoints:
155+
if any(s is not None and s.sync is not self._sync for s in scopes):
156+
raise RuntimeError(
157+
"state checkpoints of a table resolver can only be flushed during its own sync"
158+
)
159+
for scopes, keys, msg in checkpoints:
160+
self._schedule(scopes, keys, msg)
161+
162+
def _schedule(
163+
self,
164+
scopes: Iterable[Optional[Scope]],
165+
keys: List[str],
166+
msg: SyncCheckpointMessage,
167+
) -> None:
168+
pending = _Pending(msg, keys, self._sync)
169+
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)
174+
pending.blockers += 1
175+
earlier = set()
176+
for key in keys:
177+
previous = self._unsent.get(key)
178+
if previous is not None and previous not in earlier:
179+
earlier.add(previous)
180+
previous.dependents.append(pending)
181+
pending.blockers += 1
182+
self._unsent[key] = pending
183+
if pending.blockers == 0:
184+
self._send([pending])
185+
186+
def _send(self, ready: List[_Pending]) -> None:
187+
"""Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
188+
while ready:
189+
pending = ready.pop()
190+
if pending.sync is self._sync:
191+
self._res.put(pending.msg)
192+
for key in pending.keys:
193+
if self._unsent.get(key) is pending:
194+
del self._unsent[key]
195+
for dependent in pending.dependents:
196+
dependent.blockers -= 1
197+
if dependent.blockers == 0:
198+
ready.append(dependent)
199+
pending.dependents = []

‎cloudquery/sdk/internal/servers/plugin_v3/plugin.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
SyncInsertMessage,
99
SyncMigrateTableMessage,
1010
SyncErrorMessage,
11+
SyncCheckpointMessage,
1112
WriteInsertMessage,
1213
WriteMigrateTableMessage,
1314
WriteMessage,
1415
WriteDeleteStale,
1516
)
16-
from cloudquery.sdk.plugin.plugin import Plugin, SyncOptions, TableOptions
17+
from cloudquery.sdk.plugin.plugin import (
18+
BackendOptions,
19+
Plugin,
20+
SyncOptions,
21+
TableOptions,
22+
)
1723
from cloudquery.sdk.schema import tables_to_arrow_schemas, Table
1824
from cloudquery.sdk.schema.table import flatten_tables
1925

@@ -64,7 +70,11 @@ def Sync(self, request, context):
6470
skip_dependent_tables=request.skip_dependent_tables,
6571
skip_tables=request.skip_tables,
6672
tables=request.tables,
67-
backend_options=request.backend,
73+
backend_options=BackendOptions(
74+
connection=request.backend.connection,
75+
table_name=request.backend.table_name,
76+
supports_checkpoints=request.supports_checkpoints,
77+
),
6878
)
6979

7080
for msg in self._plugin.sync(options):
@@ -78,6 +88,16 @@ def Sync(self, request, context):
7888
yield plugin_pb2.Sync.Response(
7989
migrate_table=plugin_pb2.Sync.MessageMigrateTable(table=buf)
8090
)
91+
elif isinstance(msg, SyncCheckpointMessage):
92+
if not request.supports_checkpoints:
93+
# the state would be lost, as the CLI does not persist checkpoints
94+
raise RuntimeError(
95+
"plugin sent a state checkpoint, but the CLI does not support checkpoints"
96+
)
97+
buf = arrow.record_to_bytes(msg.record)
98+
yield plugin_pb2.Sync.Response(
99+
checkpoint=plugin_pb2.Sync.MessageCheckpoint(record=buf)
100+
)
81101
elif isinstance(msg, SyncErrorMessage) and request.withErrorMessages:
82102
yield plugin_pb2.Sync.Response(
83103
error=plugin_pb2.Sync.MessageError(

‎cloudquery/sdk/message/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
SyncInsertMessage,
44
SyncMigrateTableMessage,
55
SyncErrorMessage,
6+
SyncCheckpointMessage,
67
)
78
from .write import (
89
WriteMessage,

‎cloudquery/sdk/message/sync.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ class SyncErrorMessage(SyncMessage):
1919
def __init__(self, table_name: str, error: str):
2020
self.table_name = table_name
2121
self.error = error
22+
23+
24+
class SyncCheckpointMessage(SyncMessage):
25+
"""
26+
State backend rows for the CLI to persist once every message sent before this one
27+
is committed to all destinations. Only sent when the CLI supports checkpoints.
28+
"""
29+
30+
def __init__(self, record: pa.RecordBatch):
31+
self.record = record

‎cloudquery/sdk/plugin/plugin.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class TableOptions:
1818
class BackendOptions:
1919
connection: str = None
2020
table_name: str = None
21+
# set when the CLI persists state sent as checkpoints on the sync stream
22+
supports_checkpoints: bool = False
2123

2224

2325
@dataclass

0 commit comments

Comments
 (0)