Skip to content

Commit fa9a120

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/scheduler-child-queue-size
2 parents 93c94a1 + 39aaf90 commit fa9a120

17 files changed

Lines changed: 961 additions & 63 deletions

File tree

‎.release-please-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.1.54"
2+
".": "0.1.55"
33
}

‎CHANGELOG.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Changelog
22

3+
## [0.1.55](https://github.com/cloudquery/plugin-sdk-python/compare/v0.1.54...v0.1.55) (2026-10-20)
4+
5+
6+
### Features
7+
8+
* Send state as checkpoints after the resources it covers ([#415](https://github.com/cloudquery/plugin-sdk-python/issues/415)) ([b5d79c8](https://github.com/cloudquery/plugin-sdk-python/commit/b5d79c8da1f1f913eb5f30225c4125cbaaca0b62))
9+
10+
11+
### Bug Fixes
12+
13+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#385](https://github.com/cloudquery/plugin-sdk-python/issues/385)) ([faea392](https://github.com/cloudquery/plugin-sdk-python/commit/faea392b91bd4902359faef0f141e0b1b74527b7))
14+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#387](https://github.com/cloudquery/plugin-sdk-python/issues/387)) ([e7814d1](https://github.com/cloudquery/plugin-sdk-python/commit/e7814d1a4c93fcc279c06d958b44760a07948041))
15+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#388](https://github.com/cloudquery/plugin-sdk-python/issues/388)) ([13046ac](https://github.com/cloudquery/plugin-sdk-python/commit/13046ac85f9440d136988b23f393e59f0de74627))
16+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#389](https://github.com/cloudquery/plugin-sdk-python/issues/389)) ([447bf67](https://github.com/cloudquery/plugin-sdk-python/commit/447bf67e17883e33940dcb7dd7727251f667c3d6))
17+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#390](https://github.com/cloudquery/plugin-sdk-python/issues/390)) ([10e2c31](https://github.com/cloudquery/plugin-sdk-python/commit/10e2c31bd598e18a627aa4f4e619577ba2278ef3))
18+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#391](https://github.com/cloudquery/plugin-sdk-python/issues/391)) ([85a3cdc](https://github.com/cloudquery/plugin-sdk-python/commit/85a3cdc725debbfec91ae4a98fbd8b80f7519e69))
19+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#392](https://github.com/cloudquery/plugin-sdk-python/issues/392)) ([45ec16f](https://github.com/cloudquery/plugin-sdk-python/commit/45ec16f64db0c34315671220c26dda8da6d977ad))
20+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#394](https://github.com/cloudquery/plugin-sdk-python/issues/394)) ([f935ee4](https://github.com/cloudquery/plugin-sdk-python/commit/f935ee474dad12340578809645ef862c9e323a6e))
21+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#395](https://github.com/cloudquery/plugin-sdk-python/issues/395)) ([793e068](https://github.com/cloudquery/plugin-sdk-python/commit/793e0684bf521b76497ec471096fd4d3cb0792e7))
22+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#396](https://github.com/cloudquery/plugin-sdk-python/issues/396)) ([1e2bd60](https://github.com/cloudquery/plugin-sdk-python/commit/1e2bd604be8881ed047f5d949d00d72bb5e66976))
23+
* **deps:** Update dependency pytest to v9.0.3 [SECURITY] ([#397](https://github.com/cloudquery/plugin-sdk-python/issues/397)) ([41dd0e6](https://github.com/cloudquery/plugin-sdk-python/commit/41dd0e6da58001923fdae130c3836c66ac33603f))
24+
* **deps:** Update ghcr.io/astral-sh/uv Docker tag to v0.11.17 ([#402](https://github.com/cloudquery/plugin-sdk-python/issues/402)) ([cdc4dc8](https://github.com/cloudquery/plugin-sdk-python/commit/cdc4dc8996224667532b222ab5eddf9e7c7c6b16))
25+
* **deps:** Update ghcr.io/astral-sh/uv Docker tag to v0.11.26 ([#404](https://github.com/cloudquery/plugin-sdk-python/issues/404)) ([68422bc](https://github.com/cloudquery/plugin-sdk-python/commit/68422bc3607d502c77b695e298e130006cd2027f))
26+
* **deps:** Update ghcr.io/astral-sh/uv Docker tag to v0.11.8 ([#399](https://github.com/cloudquery/plugin-sdk-python/issues/399)) ([f080e0c](https://github.com/cloudquery/plugin-sdk-python/commit/f080e0ce12b7bfe4813bcb9ffc345dab1d39694c))
27+
* **deps:** Update ghcr.io/astral-sh/uv Docker tag to v0.12.1 ([#407](https://github.com/cloudquery/plugin-sdk-python/issues/407)) ([4a5f440](https://github.com/cloudquery/plugin-sdk-python/commit/4a5f440ace8d47ea553b8fcda6f0f46cf007243f))
28+
* **deps:** Update ghcr.io/astral-sh/uv Docker tag to v0.12.21 ([#413](https://github.com/cloudquery/plugin-sdk-python/issues/413)) ([489c847](https://github.com/cloudquery/plugin-sdk-python/commit/489c847e12c3856bbdb878e899718494682e87a7))
29+
* **deps:** Update ghcr.io/astral-sh/uv Docker tag to v0.12.8 ([#410](https://github.com/cloudquery/plugin-sdk-python/issues/410)) ([7b13f96](https://github.com/cloudquery/plugin-sdk-python/commit/7b13f96ec01852a3555ab37125d03ee7b2762865))
30+
331
## [0.1.54](https://github.com/cloudquery/plugin-sdk-python/compare/v0.1.53...v0.1.54) (2026-03-13)
432

533

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)