Skip to content

Commit 0dc34a5

Browse files
committed
feat: Send state as checkpoints after the resources it covers
When the CLI sets Sync.Request.supports_checkpoints, a state client built with the scheduler's checkpoints sends flushed state as Sync.MessageCheckpoint instead of writing it to the backend. The CLI then persists it only after every destination commits the records synced before it, so a failed sync keeps the progress it made without moving a cursor past records that were never written. Each checkpoint goes on the sync stream after the resources the table resolvers that changed its keys produced before the flush, including the relations spawned from them, and after earlier unsent checkpoints of the same keys. Also makes the state client thread-safe, clears changed keys after a successful flush and keeps them after a failed one.
1 parent 489c847 commit 0dc34a5

10 files changed

Lines changed: 711 additions & 49 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 queue
17+
import threading
18+
from contextlib import contextmanager
19+
from typing import Dict, Iterable, Iterator, List, Optional, Set
20+
21+
from cloudquery.sdk.message import SyncCheckpointMessage
22+
23+
24+
class Scope:
25+
"""
26+
A table resolver call and the relations its resources spawn.
27+
28+
A scope is finished once its resolver returned and every child scope it
29+
spawned is finished.
30+
"""
31+
32+
def __init__(self, parent: Optional[Scope]):
33+
self.parent = parent
34+
# child scopes that are not finished yet
35+
self.open_children: Set[Scope] = set()
36+
self.resolving = True
37+
# checkpoints waiting for this scope to finish
38+
self.waiters: List[_Pending] = []
39+
40+
def finished(self) -> bool:
41+
return not self.resolving and not self.open_children
42+
43+
44+
class _Pending:
45+
"""A checkpoint waiting for scopes to finish and for earlier checkpoints of its keys to be sent."""
46+
47+
def __init__(self, msg: SyncCheckpointMessage, keys: List[str]):
48+
self.msg = msg
49+
self.keys = keys
50+
self.blockers = 0
51+
self.dependents: List[_Pending] = []
52+
self.sent = False
53+
54+
55+
class Checkpoints:
56+
"""
57+
Sends state checkpoints on the scheduler's queue after the resources they cover.
58+
59+
The scheduler owns an instance and passes it to the state client through
60+
:meth:`cloudquery.sdk.stateclient.StateClientBuilder.build`.
61+
"""
62+
63+
def __init__(self):
64+
self._lock = threading.Lock()
65+
self._local = threading.local()
66+
self._res: Optional[queue.Queue] = None
67+
# newest unsent checkpoint of each key
68+
self._unsent: Dict[str, _Pending] = {}
69+
70+
def start(self, res: queue.Queue) -> None:
71+
"""Sends later checkpoints on res. Called by the scheduler when a sync starts."""
72+
with self._lock:
73+
self._res = res
74+
self._unsent = {}
75+
76+
def stop(self) -> None:
77+
"""Drops unsent checkpoints and rejects later ones. Called by the scheduler when a sync ends."""
78+
with self._lock:
79+
self._res = None
80+
self._unsent = {}
81+
82+
def new_scope(self, parent: Optional[Scope]) -> Scope:
83+
"""Returns a scope for a table resolver call, before the call is submitted."""
84+
scope = Scope(parent)
85+
if parent is not None:
86+
with self._lock:
87+
parent.open_children.add(scope)
88+
return scope
89+
90+
@contextmanager
91+
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
95+
try:
96+
yield
97+
finally:
98+
self._local.scope = previous
99+
100+
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)
103+
104+
def resolver_finished(self, scope: Scope) -> None:
105+
"""
106+
Marks the resolver of scope as returned, once every resource it produced is on the queue.
107+
"""
108+
with self._lock:
109+
scope.resolving = False
110+
ready: List[_Pending] = []
111+
while scope is not None and scope.finished():
112+
for pending in scope.waiters:
113+
pending.blockers -= 1
114+
if pending.blockers == 0:
115+
ready.append(pending)
116+
scope.waiters = []
117+
if scope.parent is not None:
118+
scope.parent.open_children.discard(scope)
119+
scope = scope.parent
120+
self._send(ready)
121+
122+
def schedule(
123+
self,
124+
scopes: Iterable[Optional[Scope]],
125+
keys: List[str],
126+
msg: SyncCheckpointMessage,
127+
) -> None:
128+
"""
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.
132+
133+
Raises:
134+
RuntimeError: if no sync is running, as the checkpoint could not be sent.
135+
"""
136+
with self._lock:
137+
if self._res is None:
138+
raise RuntimeError(
139+
"state checkpoints can only be flushed while the scheduler syncs, "
140+
"e.g. from a table resolver or the post-sync hook"
141+
)
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])
159+
160+
def _send(self, ready: List[_Pending]) -> None:
161+
"""Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
162+
while ready:
163+
pending = ready.pop()
164+
pending.sent = True
165+
if self._res is not None:
166+
self._res.put(pending.msg)
167+
for key in pending.keys:
168+
if self._unsent.get(key) is pending:
169+
del self._unsent[key]
170+
for dependent in pending.dependents:
171+
dependent.blockers -= 1
172+
if dependent.blockers == 0:
173+
ready.append(dependent)
174+
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)