cloudquery-plugin-sdk 0.1.53__tar.gz → 0.1.55__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. cloudquery_plugin_sdk-0.1.55/PKG-INFO +50 -0
  2. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/README.md +1 -1
  3. cloudquery_plugin_sdk-0.1.55/cloudquery/sdk/internal/checkpoint.py +199 -0
  4. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/servers/plugin_v3/plugin.py +22 -2
  5. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/message/__init__.py +1 -0
  6. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/message/sync.py +10 -0
  7. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/plugin/plugin.py +2 -0
  8. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scheduler/scheduler.py +64 -30
  9. cloudquery_plugin_sdk-0.1.55/cloudquery/sdk/stateclient/__init__.py +1 -0
  10. cloudquery_plugin_sdk-0.1.55/cloudquery/sdk/stateclient/keys.py +44 -0
  11. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/stateclient/stateclient.py +94 -23
  12. cloudquery_plugin_sdk-0.1.55/cloudquery_plugin_sdk.egg-info/PKG-INFO +50 -0
  13. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery_plugin_sdk.egg-info/SOURCES.txt +3 -4
  14. cloudquery_plugin_sdk-0.1.55/cloudquery_plugin_sdk.egg-info/requires.txt +16 -0
  15. cloudquery_plugin_sdk-0.1.55/pyproject.toml +64 -0
  16. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/setup.cfg +0 -3
  17. cloudquery_plugin_sdk-0.1.53/PKG-INFO +0 -64
  18. cloudquery_plugin_sdk-0.1.53/cloudquery/sdk/stateclient/__init__.py +0 -0
  19. cloudquery_plugin_sdk-0.1.53/cloudquery_plugin_sdk.egg-info/PKG-INFO +0 -64
  20. cloudquery_plugin_sdk-0.1.53/cloudquery_plugin_sdk.egg-info/namespace_packages.txt +0 -1
  21. cloudquery_plugin_sdk-0.1.53/cloudquery_plugin_sdk.egg-info/not-zip-safe +0 -1
  22. cloudquery_plugin_sdk-0.1.53/cloudquery_plugin_sdk.egg-info/requires.txt +0 -22
  23. cloudquery_plugin_sdk-0.1.53/setup.py +0 -84
  24. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/LICENSE +0 -0
  25. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/__init__.py +0 -0
  26. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/__init__.py +0 -0
  27. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/memdb/__init__.py +0 -0
  28. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/memdb/memdb.py +0 -0
  29. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/servers/__init__.py +0 -0
  30. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/servers/discovery_v1/__init__.py +0 -0
  31. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/servers/discovery_v1/discovery.py +0 -0
  32. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/internal/servers/plugin_v3/__init__.py +0 -0
  33. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/message/read.py +0 -0
  34. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/message/write.py +0 -0
  35. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/plugin/__init__.py +0 -0
  36. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/py.typed +0 -0
  37. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/__init__.py +0 -0
  38. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/binary.py +0 -0
  39. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/bool.py +0 -0
  40. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/date32.py +0 -0
  41. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/date64.py +0 -0
  42. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/float.py +0 -0
  43. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/int.py +0 -0
  44. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/json.py +0 -0
  45. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/list.py +0 -0
  46. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/scalar.py +0 -0
  47. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/scalar_factory.py +0 -0
  48. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/string.py +0 -0
  49. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/timestamp.py +0 -0
  50. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/uint.py +0 -0
  51. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/uuid.py +0 -0
  52. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scalar/vector.py +0 -0
  53. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scheduler/__init__.py +0 -0
  54. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/scheduler/table_resolver.py +0 -0
  55. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/schema/__init__.py +0 -0
  56. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/schema/arrow.py +0 -0
  57. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/schema/column.py +0 -0
  58. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/schema/resource.py +0 -0
  59. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/schema/table.py +0 -0
  60. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/serve/__init__.py +0 -0
  61. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/serve/plugin.py +0 -0
  62. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/transformers/__init__.py +0 -0
  63. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/transformers/openapi.py +0 -0
  64. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/transformers/transformers.py +0 -0
  65. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/types/__init__.py +0 -0
  66. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/types/json.py +0 -0
  67. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery/sdk/types/uuid.py +0 -0
  68. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery_plugin_sdk.egg-info/dependency_links.txt +0 -0
  69. {cloudquery_plugin_sdk-0.1.53 → cloudquery_plugin_sdk-0.1.55}/cloudquery_plugin_sdk.egg-info/top_level.txt +0 -0
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudquery-plugin-sdk
3
+ Version: 0.1.55
4
+ Summary: CloudQuery Plugin SDK for Python
5
+ Author-email: CloudQuery LTD <pypi-packages@cloudquery.io>
6
+ License-Expression: MPL-2.0
7
+ Project-URL: Homepage, https://github.com/cloudquery/plugin-sdk-python
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Internet
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: cloudquery-plugin-pb>=0.0.55
20
+ Requires-Dist: grpcio>=1.78.0
21
+ Requires-Dist: grpcio-tools>=1.78.0
22
+ Requires-Dist: Jinja2>=3.1.6
23
+ Requires-Dist: MarkupSafe>=3.0.3
24
+ Requires-Dist: numpy>=2.4.2
25
+ Requires-Dist: packaging>=26.0
26
+ Requires-Dist: pandas>=3.0.0
27
+ Requires-Dist: protobuf>=6.31.1
28
+ Requires-Dist: pyarrow>=23.0.0
29
+ Requires-Dist: python-dateutil>=2.8.1
30
+ Requires-Dist: pytz>=2025.2
31
+ Requires-Dist: six>=1.17.0
32
+ Requires-Dist: structlog>=25.5.0
33
+ Requires-Dist: tomli>=2.4.0
34
+ Requires-Dist: tzdata>=2025.3
35
+ Dynamic: license-file
36
+
37
+ # CloudQuery Plugin SDK for Python
38
+
39
+ This is the high-level package to use for developing CloudQuery plugins in Python.
40
+
41
+ ## Installation
42
+
43
+ ```commandline
44
+ uv add cloudquery-plugin-sdk
45
+ ```
46
+
47
+ ## Links
48
+
49
+ - [Developer Guide](https://www.cloudquery.io/docs/developers/creating-new-plugin/python-source)
50
+ - [PyPI](https://pypi.org/project/cloudquery-plugin-sdk/)
@@ -5,7 +5,7 @@ This is the high-level package to use for developing CloudQuery plugins in Pytho
5
5
  ## Installation
6
6
 
7
7
  ```commandline
8
- pip install cloudquery-plugin-sdk
8
+ uv add cloudquery-plugin-sdk
9
9
  ```
10
10
 
11
11
  ## Links
@@ -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 = []
@@ -8,12 +8,18 @@ from cloudquery.sdk.message import (
8
8
  SyncInsertMessage,
9
9
  SyncMigrateTableMessage,
10
10
  SyncErrorMessage,
11
+ SyncCheckpointMessage,
11
12
  WriteInsertMessage,
12
13
  WriteMigrateTableMessage,
13
14
  WriteMessage,
14
15
  WriteDeleteStale,
15
16
  )
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
+ )
17
23
  from cloudquery.sdk.schema import tables_to_arrow_schemas, Table
18
24
  from cloudquery.sdk.schema.table import flatten_tables
19
25
 
@@ -64,7 +70,11 @@ class PluginServicer(plugin_pb2_grpc.PluginServicer):
64
70
  skip_dependent_tables=request.skip_dependent_tables,
65
71
  skip_tables=request.skip_tables,
66
72
  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
+ ),
68
78
  )
69
79
 
70
80
  for msg in self._plugin.sync(options):
@@ -78,6 +88,16 @@ class PluginServicer(plugin_pb2_grpc.PluginServicer):
78
88
  yield plugin_pb2.Sync.Response(
79
89
  migrate_table=plugin_pb2.Sync.MessageMigrateTable(table=buf)
80
90
  )
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
+ )
81
101
  elif isinstance(msg, SyncErrorMessage) and request.withErrorMessages:
82
102
  yield plugin_pb2.Sync.Response(
83
103
  error=plugin_pb2.Sync.MessageError(
@@ -3,6 +3,7 @@ from .sync import (
3
3
  SyncInsertMessage,
4
4
  SyncMigrateTableMessage,
5
5
  SyncErrorMessage,
6
+ SyncCheckpointMessage,
6
7
  )
7
8
  from .write import (
8
9
  WriteMessage,
@@ -19,3 +19,13 @@ class SyncErrorMessage(SyncMessage):
19
19
  def __init__(self, table_name: str, error: str):
20
20
  self.table_name = table_name
21
21
  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
@@ -18,6 +18,8 @@ class TableOptions:
18
18
  class BackendOptions:
19
19
  connection: str = None
20
20
  table_name: str = None
21
+ # set when the CLI persists state sent as checkpoints on the sync stream
22
+ supports_checkpoints: bool = False
21
23
 
22
24
 
23
25
  @dataclass
@@ -10,6 +10,7 @@ from cloudquery.sdk.message import (
10
10
  SyncMigrateTableMessage,
11
11
  SyncErrorMessage,
12
12
  )
13
+ from cloudquery.sdk.internal.checkpoint import Checkpoints, Scope
13
14
  from cloudquery.sdk.schema import Resource
14
15
  from cloudquery.sdk.stateclient.stateclient import StateClient
15
16
  from .table_resolver import TableResolver, Client
@@ -33,12 +34,19 @@ class TableResolverFinished:
33
34
  pass
34
35
 
35
36
 
37
+ class TableResolversSubmitted:
38
+ def __init__(self) -> None:
39
+ pass
40
+
41
+
36
42
  class Scheduler:
37
43
  def __init__(
38
44
  self, concurrency: int, queue_size: int = 0, max_depth: int = 3, logger=None
39
45
  ):
40
46
  self._post_sync_hook = lambda: None
41
47
  self._queue = queue.Queue()
48
+ # pass to StateClientBuilder.build so that state flushes are sent as checkpoints, when the CLI supports them
49
+ self.checkpoints = Checkpoints()
42
50
  self._max_depth = max_depth
43
51
  if logger is None:
44
52
  self._logger = structlog.get_logger()
@@ -89,7 +97,27 @@ class Scheduler:
89
97
  client: Client,
90
98
  parent_item: Optional[Resource],
91
99
  res: queue.Queue,
100
+ scope: Optional[Scope] = None,
101
+ ):
102
+ scope = scope or self.checkpoints.new_scope(None)
103
+ try:
104
+ with self.checkpoints.entered(scope):
105
+ self._resolve_table(resolver, depth, client, parent_item, res, scope)
106
+ finally:
107
+ # after every resource of the resolver is on the queue, so its checkpoints follow them
108
+ self.checkpoints.resolver_finished(scope)
109
+ res.put(TableResolverFinished())
110
+
111
+ def _resolve_table(
112
+ self,
113
+ resolver: TableResolver,
114
+ depth: int,
115
+ client: Client,
116
+ parent_item: Optional[Resource],
117
+ res: queue.Queue,
118
+ scope: Scope,
92
119
  ):
120
+ total_resources = 0
93
121
  try:
94
122
  if depth == 0:
95
123
  self._logger.info(
@@ -105,7 +133,6 @@ class Scheduler:
105
133
  table=resolver.table.name,
106
134
  depth=depth,
107
135
  )
108
- total_resources = 0
109
136
  for item in resolver.resolve(client, parent_item):
110
137
  try:
111
138
  resource = self.resolve_resource(
@@ -136,6 +163,7 @@ class Scheduler:
136
163
  client,
137
164
  resource,
138
165
  res,
166
+ self.checkpoints.new_scope(scope),
139
167
  )
140
168
  total_resources += 1
141
169
  if depth == 0:
@@ -164,8 +192,6 @@ class Scheduler:
164
192
  exc_info=e,
165
193
  )
166
194
  res.put(SyncErrorMessage(resolver.table.name, str(e)))
167
- finally:
168
- res.put(TableResolverFinished())
169
195
 
170
196
  def _sync(
171
197
  self,
@@ -174,13 +200,16 @@ class Scheduler:
174
200
  res: queue.Queue,
175
201
  deterministic_cq_id=False,
176
202
  ):
177
- for resolver in resolvers:
178
- clients = resolver.multiplex(client)
179
- for client in clients:
180
- res.put(TableResolverStarted())
181
- self._pools[0].submit(
182
- self.resolve_table, resolver, 0, client, None, res
183
- )
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())
184
213
 
185
214
  def sync(
186
215
  self, client, resolvers: List[TableResolver], deterministic_cq_id=False
@@ -188,26 +217,31 @@ class Scheduler:
188
217
  res = queue.Queue()
189
218
  yield from self._send_migrate_table_messages(resolvers)
190
219
 
191
- thread = futures.ThreadPoolExecutor()
192
- thread.submit(self._sync, client, resolvers, res, deterministic_cq_id)
193
- total_table_resolvers = 0
194
- finished_table_resolvers = 0
195
- while True:
196
- message = res.get()
197
- if isinstance(message, TableResolverStarted):
198
- total_table_resolvers += 1
199
- if total_table_resolvers == finished_table_resolvers:
200
- break
201
- continue
202
- if isinstance(message, TableResolverFinished):
203
- finished_table_resolvers += 1
204
- if total_table_resolvers == finished_table_resolvers:
205
- break
206
- continue
207
- yield message
208
-
209
- self._post_sync_hook()
210
- thread.shutdown(wait=True)
220
+ self.checkpoints.start(res)
221
+ try:
222
+ thread = futures.ThreadPoolExecutor()
223
+ thread.submit(self._sync, client, resolvers, res, deterministic_cq_id)
224
+ submitted = False
225
+ total_table_resolvers = 0
226
+ finished_table_resolvers = 0
227
+ while not (submitted and total_table_resolvers == finished_table_resolvers):
228
+ message = res.get()
229
+ if isinstance(message, TableResolversSubmitted):
230
+ submitted = True
231
+ elif isinstance(message, TableResolverStarted):
232
+ total_table_resolvers += 1
233
+ elif isinstance(message, TableResolverFinished):
234
+ finished_table_resolvers += 1
235
+ else:
236
+ yield message
237
+
238
+ self._post_sync_hook()
239
+ # checkpoints the post-sync hook flushed, after every resource
240
+ while not res.empty():
241
+ yield res.get_nowait()
242
+ thread.shutdown(wait=True)
243
+ finally:
244
+ self.checkpoints.stop()
211
245
 
212
246
  def _send_migrate_table_messages(
213
247
  self, resolvers: List[TableResolver]
@@ -0,0 +1 @@
1
+ from .keys import state_key
@@ -0,0 +1,44 @@
1
+ """Builds state keys, so that plugins choose how finely they track progress without inventing key formats."""
2
+
3
+ from typing import Optional, Union
4
+
5
+ _SEPARATOR = "|"
6
+ _ASSIGN = "="
7
+ _ESCAPE = "\\"
8
+
9
+
10
+ def _escape(part: str) -> str:
11
+ return (
12
+ part.replace(_ESCAPE, _ESCAPE * 2)
13
+ .replace(_SEPARATOR, _ESCAPE + _SEPARATOR)
14
+ .replace(_ASSIGN, _ESCAPE + _ASSIGN)
15
+ )
16
+
17
+
18
+ def state_key(
19
+ table: str,
20
+ *,
21
+ client_id: Optional[Union[str, int]] = None,
22
+ **partition: Union[str, int],
23
+ ) -> str:
24
+ """
25
+ Returns the state key of a table's progress, optionally for one client and one partition of the table.
26
+
27
+ A table with one cursor uses ``state_key("items")``. A table that tracks progress per parent, account or
28
+ other partition passes the fields that identify it, e.g. ``state_key("responses", form_id=form["id"])``.
29
+ Each such key is its own state row: with checkpoints, a partition's state waits only for the resources of
30
+ the table resolver that set it, and the CLI persists the keys changed since its last commit together.
31
+
32
+ Keys are readable and deterministic: partition fields are sorted, and separators in names and values are
33
+ escaped, so different tables, clients or partitions never share a key.
34
+
35
+ Example:
36
+ >>> state_key("responses", client_id="acct-1", form_id=123)
37
+ 'responses|client_id=acct-1|form_id=123'
38
+ """
39
+ parts = [_escape(table)]
40
+ if client_id is not None:
41
+ parts.append(f"client_id{_ASSIGN}{_escape(str(client_id))}")
42
+ for name in sorted(partition):
43
+ parts.append(f"{_escape(name)}{_ASSIGN}{_escape(str(partition[name]))}")
44
+ return _SEPARATOR.join(parts)