cloudquery-plugin-sdk 0.1.54__tar.gz → 0.1.56__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 (70) hide show
  1. cloudquery_plugin_sdk-0.1.56/PKG-INFO +50 -0
  2. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/README.md +1 -1
  3. cloudquery_plugin_sdk-0.1.56/cloudquery/sdk/internal/checkpoint.py +252 -0
  4. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/servers/plugin_v3/plugin.py +22 -2
  5. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/message/__init__.py +1 -0
  6. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/message/sync.py +10 -0
  7. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/plugin/plugin.py +2 -0
  8. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scheduler/scheduler.py +113 -64
  9. cloudquery_plugin_sdk-0.1.56/cloudquery/sdk/stateclient/__init__.py +2 -0
  10. cloudquery_plugin_sdk-0.1.56/cloudquery/sdk/stateclient/keys.py +44 -0
  11. cloudquery_plugin_sdk-0.1.56/cloudquery/sdk/stateclient/stateclient.py +309 -0
  12. cloudquery_plugin_sdk-0.1.56/cloudquery_plugin_sdk.egg-info/PKG-INFO +50 -0
  13. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery_plugin_sdk.egg-info/SOURCES.txt +3 -4
  14. cloudquery_plugin_sdk-0.1.56/cloudquery_plugin_sdk.egg-info/requires.txt +16 -0
  15. cloudquery_plugin_sdk-0.1.56/pyproject.toml +64 -0
  16. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/setup.cfg +0 -3
  17. cloudquery_plugin_sdk-0.1.54/PKG-INFO +0 -64
  18. cloudquery_plugin_sdk-0.1.54/cloudquery/sdk/stateclient/__init__.py +0 -0
  19. cloudquery_plugin_sdk-0.1.54/cloudquery/sdk/stateclient/stateclient.py +0 -207
  20. cloudquery_plugin_sdk-0.1.54/cloudquery_plugin_sdk.egg-info/PKG-INFO +0 -64
  21. cloudquery_plugin_sdk-0.1.54/cloudquery_plugin_sdk.egg-info/namespace_packages.txt +0 -1
  22. cloudquery_plugin_sdk-0.1.54/cloudquery_plugin_sdk.egg-info/not-zip-safe +0 -1
  23. cloudquery_plugin_sdk-0.1.54/cloudquery_plugin_sdk.egg-info/requires.txt +0 -22
  24. cloudquery_plugin_sdk-0.1.54/setup.py +0 -84
  25. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/LICENSE +0 -0
  26. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/__init__.py +0 -0
  27. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/__init__.py +0 -0
  28. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/memdb/__init__.py +0 -0
  29. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/memdb/memdb.py +0 -0
  30. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/servers/__init__.py +0 -0
  31. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/servers/discovery_v1/__init__.py +0 -0
  32. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/servers/discovery_v1/discovery.py +0 -0
  33. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/internal/servers/plugin_v3/__init__.py +0 -0
  34. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/message/read.py +0 -0
  35. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/message/write.py +0 -0
  36. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/plugin/__init__.py +0 -0
  37. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/py.typed +0 -0
  38. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/__init__.py +0 -0
  39. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/binary.py +0 -0
  40. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/bool.py +0 -0
  41. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/date32.py +0 -0
  42. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/date64.py +0 -0
  43. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/float.py +0 -0
  44. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/int.py +0 -0
  45. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/json.py +0 -0
  46. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/list.py +0 -0
  47. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/scalar.py +0 -0
  48. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/scalar_factory.py +0 -0
  49. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/string.py +0 -0
  50. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/timestamp.py +0 -0
  51. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/uint.py +0 -0
  52. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/uuid.py +0 -0
  53. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scalar/vector.py +0 -0
  54. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scheduler/__init__.py +0 -0
  55. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/scheduler/table_resolver.py +0 -0
  56. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/schema/__init__.py +0 -0
  57. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/schema/arrow.py +0 -0
  58. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/schema/column.py +0 -0
  59. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/schema/resource.py +0 -0
  60. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/schema/table.py +0 -0
  61. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/serve/__init__.py +0 -0
  62. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/serve/plugin.py +0 -0
  63. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/transformers/__init__.py +0 -0
  64. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/transformers/openapi.py +0 -0
  65. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/transformers/transformers.py +0 -0
  66. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/types/__init__.py +0 -0
  67. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/types/json.py +0 -0
  68. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery/sdk/types/uuid.py +0 -0
  69. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/cloudquery_plugin_sdk.egg-info/dependency_links.txt +0 -0
  70. {cloudquery_plugin_sdk-0.1.54 → cloudquery_plugin_sdk-0.1.56}/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.56
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,252 @@
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 SyncNotRunningError(RuntimeError):
26
+ """
27
+ Raised when state checkpoints are flushed while their sync is not running, so they cannot be
28
+ sent: before or after Scheduler.sync, or from a resolver of an ended sync. Flush state that
29
+ summarizes the whole sync in the scheduler's post-sync hook instead.
30
+ """
31
+
32
+
33
+ class Scope:
34
+ """
35
+ A table resolver call and the relations its resources spawn.
36
+
37
+ A scope is finished once its resolver returned and every child scope it
38
+ spawned is finished.
39
+ """
40
+
41
+ def __init__(self, parent: Optional[Scope], sync: Optional[object]):
42
+ self.parent = parent
43
+ # the sync the scope belongs to, so that resolvers still running after it ended cannot checkpoint into the next one
44
+ self.sync = sync
45
+ # child scopes that are not finished yet
46
+ self.open_children: Set[Scope] = set()
47
+ self.resolving = True
48
+ # checkpoints waiting for this scope to finish
49
+ self.waiters: List[_Pending] = []
50
+ # set while a resource is resolved, until it is on the queue and its relations are submitted
51
+ self.resource_in_flight = False
52
+ # checkpoints waiting for that resource and its relations
53
+ self.after_resource: List[_Pending] = []
54
+
55
+ def finished(self) -> bool:
56
+ return not self.resolving and not self.open_children
57
+
58
+
59
+ class _Pending:
60
+ """A checkpoint waiting for scopes to finish and for earlier checkpoints of its keys to be sent."""
61
+
62
+ def __init__(self, msg: SyncCheckpointMessage, keys: List[str], sync: object):
63
+ self.msg = msg
64
+ self.keys = keys
65
+ self.sync = sync
66
+ self.blockers = 0
67
+ self.dependents: List[_Pending] = []
68
+
69
+
70
+ class Checkpoints:
71
+ """
72
+ Sends state checkpoints on the scheduler's queue after the resources they cover.
73
+
74
+ The scheduler owns an instance and passes it to the state client through
75
+ :meth:`cloudquery.sdk.stateclient.StateClientBuilder.build`.
76
+ """
77
+
78
+ def __init__(self):
79
+ self._lock = threading.Lock()
80
+ self._scope: contextvars.ContextVar[Optional[Scope]] = contextvars.ContextVar(
81
+ "cloudquery_checkpoint_scope", default=None
82
+ )
83
+ self._res: Optional[queue.Queue] = None
84
+ # newest unsent checkpoint of each key
85
+ self._unsent: Dict[str, _Pending] = {}
86
+ self._sync: Optional[object] = None
87
+
88
+ def start(self, res: queue.Queue) -> None:
89
+ """Sends later checkpoints on res. Called by the scheduler when a sync starts."""
90
+ with self._lock:
91
+ self._res = res
92
+ self._sync = object()
93
+ self._unsent = {}
94
+
95
+ def stop(self) -> None:
96
+ """Drops unsent checkpoints and rejects later ones. Called by the scheduler when a sync ends."""
97
+ with self._lock:
98
+ self._res = None
99
+ self._sync = None
100
+ self._unsent = {}
101
+
102
+ def new_scope(self, parent: Optional[Scope]) -> Scope:
103
+ """Returns a scope for a table resolver call, before the call is submitted."""
104
+ scope = Scope(parent, parent.sync if parent is not None else self._sync)
105
+ if parent is not None:
106
+ with self._lock:
107
+ parent.open_children.add(scope)
108
+ return scope
109
+
110
+ @contextmanager
111
+ def entered(self, scope: Scope) -> Iterator[None]:
112
+ """
113
+ Makes scope the current one in this context, for the state changes its resolver makes.
114
+ To change state from other threads, run them in a copy of the context, e.g. with
115
+ ``contextvars.copy_context().run``; asyncio tasks copy it already.
116
+ """
117
+ token = self._scope.set(scope)
118
+ try:
119
+ yield
120
+ finally:
121
+ self._scope.reset(token)
122
+
123
+ def current_scope(self) -> Optional[Scope]:
124
+ """Returns the scope of the table resolver running in this context, or None outside table resolvers."""
125
+ return self._scope.get()
126
+
127
+ @contextmanager
128
+ def resource(self, scope: Scope) -> Iterator[None]:
129
+ """
130
+ Marks a resource of scope as in flight until it is on the queue and its relations are
131
+ submitted, so that checkpoints flushed meanwhile, e.g. from its resource hooks, follow
132
+ the resource and its relations.
133
+ """
134
+ with self._lock:
135
+ scope.resource_in_flight = True
136
+ try:
137
+ yield
138
+ finally:
139
+ with self._lock:
140
+ scope.resource_in_flight = False
141
+ ready: List[_Pending] = []
142
+ for pending in scope.after_resource:
143
+ pending.blockers -= 1
144
+ self._wait_for_children(pending, scope)
145
+ if pending.blockers == 0:
146
+ ready.append(pending)
147
+ scope.after_resource = []
148
+ self._send(ready)
149
+
150
+ def superseded(self, scope: Optional[Scope]) -> bool:
151
+ """Returns True if scope belongs to a sync that ended while another sync runs now."""
152
+ return (
153
+ scope is not None
154
+ and self._sync is not None
155
+ and scope.sync is not self._sync
156
+ )
157
+
158
+ def resolver_finished(self, scope: Scope) -> None:
159
+ """
160
+ Marks the resolver of scope as returned, once every resource it produced is on the queue.
161
+ """
162
+ with self._lock:
163
+ scope.resolving = False
164
+ ready: List[_Pending] = []
165
+ while scope is not None and scope.finished():
166
+ for pending in scope.waiters:
167
+ pending.blockers -= 1
168
+ if pending.blockers == 0:
169
+ ready.append(pending)
170
+ scope.waiters = []
171
+ if scope.parent is not None:
172
+ scope.parent.open_children.discard(scope)
173
+ scope = scope.parent
174
+ self._send(ready)
175
+
176
+ def schedule(
177
+ self,
178
+ checkpoints: List[
179
+ Tuple[Iterable[Optional[Scope]], List[str], SyncCheckpointMessage]
180
+ ],
181
+ ) -> None:
182
+ """
183
+ Sends each (scopes, keys, msg) once every resource the scopes produced so far is on the
184
+ queue, including the relations spawned from them, and after every earlier unsent
185
+ checkpoint of keys. A None scope stands for changes made outside table resolvers, which
186
+ wait for nothing. Schedules all of them or, if it raises, none of them.
187
+
188
+ Raises:
189
+ SyncNotRunningError: if the sync of the checkpoints is not running.
190
+ """
191
+ with self._lock:
192
+ if self._res is None:
193
+ raise SyncNotRunningError(
194
+ "state checkpoints can only be flushed while the scheduler syncs, "
195
+ "e.g. from a table resolver or the post-sync hook"
196
+ )
197
+ for scopes, _, _ in checkpoints:
198
+ if any(self.superseded(s) for s in scopes):
199
+ raise SyncNotRunningError(
200
+ "state checkpoints of a table resolver can only be flushed during its own sync"
201
+ )
202
+ for scopes, keys, msg in checkpoints:
203
+ self._schedule(scopes, keys, msg)
204
+
205
+ def _schedule(
206
+ self,
207
+ scopes: Iterable[Optional[Scope]],
208
+ keys: List[str],
209
+ msg: SyncCheckpointMessage,
210
+ ) -> None:
211
+ pending = _Pending(msg, keys, self._sync)
212
+ for scope in {s for s in scopes if s is not None}:
213
+ if scope.resource_in_flight:
214
+ scope.after_resource.append(pending)
215
+ pending.blockers += 1
216
+ else:
217
+ self._wait_for_children(pending, scope)
218
+ earlier = set()
219
+ for key in keys:
220
+ previous = self._unsent.get(key)
221
+ if previous is not None and previous not in earlier:
222
+ earlier.add(previous)
223
+ previous.dependents.append(pending)
224
+ pending.blockers += 1
225
+ self._unsent[key] = pending
226
+ if pending.blockers == 0:
227
+ self._send([pending])
228
+
229
+ @staticmethod
230
+ def _wait_for_children(pending: _Pending, scope: Scope) -> None:
231
+ """
232
+ Makes pending wait for the relations of scope's resources. Its resources are on the queue
233
+ already, as none is in flight. Called with the lock held.
234
+ """
235
+ for child in scope.open_children:
236
+ child.waiters.append(pending)
237
+ pending.blockers += 1
238
+
239
+ def _send(self, ready: List[_Pending]) -> None:
240
+ """Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
241
+ while ready:
242
+ pending = ready.pop()
243
+ if pending.sync is self._sync:
244
+ self._res.put(pending.msg)
245
+ for key in pending.keys:
246
+ if self._unsent.get(key) is pending:
247
+ del self._unsent[key]
248
+ for dependent in pending.dependents:
249
+ dependent.blockers -= 1
250
+ if dependent.blockers == 0:
251
+ ready.append(dependent)
252
+ 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
@@ -1,4 +1,5 @@
1
1
  import queue
2
+ import threading
2
3
  from concurrent import futures
3
4
  from typing import List, Generator, Any, Optional
4
5
 
@@ -10,6 +11,7 @@ from cloudquery.sdk.message import (
10
11
  SyncMigrateTableMessage,
11
12
  SyncErrorMessage,
12
13
  )
14
+ from cloudquery.sdk.internal.checkpoint import Checkpoints, Scope
13
15
  from cloudquery.sdk.schema import Resource
14
16
  from cloudquery.sdk.stateclient.stateclient import StateClient
15
17
  from .table_resolver import TableResolver, Client
@@ -18,9 +20,22 @@ QUEUE_PER_WORKER = 100
18
20
 
19
21
 
20
22
  class ThreadPoolExecutorWithQueueSizeLimit(futures.ThreadPoolExecutor):
21
- def __init__(self, maxsize, *args, **kwargs):
22
- super().__init__(*args, **kwargs)
23
- self._work_queue = queue.Queue(maxsize=maxsize)
23
+ def __init__(self, maxsize: int, max_workers: int, *args, **kwargs):
24
+ super().__init__(max_workers, *args, **kwargs)
25
+ self.maxsize = maxsize
26
+ # submit() puts on the work queue while holding a lock that every pool shares, so a
27
+ # full bounded queue would block the other pools' workers too: limit it before submit
28
+ self._slots = threading.BoundedSemaphore(maxsize + max_workers)
29
+
30
+ def submit(self, fn, /, *args, **kwargs):
31
+ self._slots.acquire()
32
+ try:
33
+ future = super().submit(fn, *args, **kwargs)
34
+ except BaseException:
35
+ self._slots.release()
36
+ raise
37
+ future.add_done_callback(lambda _: self._slots.release())
38
+ return future
24
39
 
25
40
 
26
41
  class TableResolverStarted:
@@ -33,12 +48,19 @@ class TableResolverFinished:
33
48
  pass
34
49
 
35
50
 
51
+ class TableResolversSubmitted:
52
+ def __init__(self) -> None:
53
+ pass
54
+
55
+
36
56
  class Scheduler:
37
57
  def __init__(
38
58
  self, concurrency: int, queue_size: int = 0, max_depth: int = 3, logger=None
39
59
  ):
40
60
  self._post_sync_hook = lambda: None
41
61
  self._queue = queue.Queue()
62
+ # pass to StateClientBuilder.build so that state flushes are sent as checkpoints, when the CLI supports them
63
+ self.checkpoints = Checkpoints()
42
64
  self._max_depth = max_depth
43
65
  if logger is None:
44
66
  self._logger = structlog.get_logger()
@@ -53,7 +75,7 @@ class Scheduler:
53
75
  )
54
76
  self._pools: List[ThreadPoolExecutorWithQueueSizeLimit] = []
55
77
  current_depth_concurrency = concurrency
56
- current_depth_queue_size = queue_size
78
+ current_depth_queue_size = self._queue_size
57
79
  for _ in range(max_depth + 1):
58
80
  self._pools.append(
59
81
  ThreadPoolExecutorWithQueueSizeLimit(
@@ -89,7 +111,27 @@ class Scheduler:
89
111
  client: Client,
90
112
  parent_item: Optional[Resource],
91
113
  res: queue.Queue,
114
+ scope: Optional[Scope] = None,
115
+ ):
116
+ scope = scope or self.checkpoints.new_scope(None)
117
+ try:
118
+ with self.checkpoints.entered(scope):
119
+ self._resolve_table(resolver, depth, client, parent_item, res, scope)
120
+ finally:
121
+ # after every resource of the resolver is on the queue, so its checkpoints follow them
122
+ self.checkpoints.resolver_finished(scope)
123
+ res.put(TableResolverFinished())
124
+
125
+ def _resolve_table(
126
+ self,
127
+ resolver: TableResolver,
128
+ depth: int,
129
+ client: Client,
130
+ parent_item: Optional[Resource],
131
+ res: queue.Queue,
132
+ scope: Scope,
92
133
  ):
134
+ total_resources = 0
93
135
  try:
94
136
  if depth == 0:
95
137
  self._logger.info(
@@ -105,38 +147,39 @@ class Scheduler:
105
147
  table=resolver.table.name,
106
148
  depth=depth,
107
149
  )
108
- total_resources = 0
109
150
  for item in resolver.resolve(client, parent_item):
110
- try:
111
- resource = self.resolve_resource(
112
- resolver, client, parent_item, item
113
- )
114
- except Exception as e:
115
- self._logger.error(
116
- "failed to resolve resource",
117
- client_id=client.id(),
118
- table=resolver.table.name,
119
- depth=depth,
120
- exc_info=e,
121
- )
122
- self._logger.debug(
123
- "details about resource that failed to resolve",
124
- client_id=client.id(),
125
- table=resolver.table.name,
126
- resource=item,
127
- )
128
- continue
129
- res.put(SyncInsertMessage(resource.to_arrow_record()))
130
- for child_resolvers in resolver.child_resolvers:
131
- res.put(TableResolverStarted())
132
- self._pools[depth + 1].submit(
133
- self.resolve_table,
134
- child_resolvers,
135
- depth + 1,
136
- client,
137
- resource,
138
- res,
139
- )
151
+ with self.checkpoints.resource(scope):
152
+ try:
153
+ resource = self.resolve_resource(
154
+ resolver, client, parent_item, item
155
+ )
156
+ except Exception as e:
157
+ self._logger.error(
158
+ "failed to resolve resource",
159
+ client_id=client.id(),
160
+ table=resolver.table.name,
161
+ depth=depth,
162
+ exc_info=e,
163
+ )
164
+ self._logger.debug(
165
+ "details about resource that failed to resolve",
166
+ client_id=client.id(),
167
+ table=resolver.table.name,
168
+ resource=item,
169
+ )
170
+ continue
171
+ res.put(SyncInsertMessage(resource.to_arrow_record()))
172
+ for child_resolvers in resolver.child_resolvers:
173
+ res.put(TableResolverStarted())
174
+ self._pools[depth + 1].submit(
175
+ self.resolve_table,
176
+ child_resolvers,
177
+ depth + 1,
178
+ client,
179
+ resource,
180
+ res,
181
+ self.checkpoints.new_scope(scope),
182
+ )
140
183
  total_resources += 1
141
184
  if depth == 0:
142
185
  self._logger.info(
@@ -164,8 +207,6 @@ class Scheduler:
164
207
  exc_info=e,
165
208
  )
166
209
  res.put(SyncErrorMessage(resolver.table.name, str(e)))
167
- finally:
168
- res.put(TableResolverFinished())
169
210
 
170
211
  def _sync(
171
212
  self,
@@ -174,13 +215,16 @@ class Scheduler:
174
215
  res: queue.Queue,
175
216
  deterministic_cq_id=False,
176
217
  ):
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
- )
218
+ try:
219
+ for resolver in resolvers:
220
+ clients = resolver.multiplex(client)
221
+ for client in clients:
222
+ res.put(TableResolverStarted())
223
+ self._pools[0].submit(
224
+ self.resolve_table, resolver, 0, client, None, res
225
+ )
226
+ finally:
227
+ res.put(TableResolversSubmitted())
184
228
 
185
229
  def sync(
186
230
  self, client, resolvers: List[TableResolver], deterministic_cq_id=False
@@ -188,26 +232,31 @@ class Scheduler:
188
232
  res = queue.Queue()
189
233
  yield from self._send_migrate_table_messages(resolvers)
190
234
 
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)
235
+ self.checkpoints.start(res)
236
+ try:
237
+ thread = futures.ThreadPoolExecutor()
238
+ thread.submit(self._sync, client, resolvers, res, deterministic_cq_id)
239
+ submitted = False
240
+ total_table_resolvers = 0
241
+ finished_table_resolvers = 0
242
+ while not (submitted and total_table_resolvers == finished_table_resolvers):
243
+ message = res.get()
244
+ if isinstance(message, TableResolversSubmitted):
245
+ submitted = True
246
+ elif isinstance(message, TableResolverStarted):
247
+ total_table_resolvers += 1
248
+ elif isinstance(message, TableResolverFinished):
249
+ finished_table_resolvers += 1
250
+ else:
251
+ yield message
252
+
253
+ self._post_sync_hook()
254
+ # checkpoints the post-sync hook flushed, after every resource
255
+ while not res.empty():
256
+ yield res.get_nowait()
257
+ thread.shutdown(wait=True)
258
+ finally:
259
+ self.checkpoints.stop()
211
260
 
212
261
  def _send_migrate_table_messages(
213
262
  self, resolvers: List[TableResolver]
@@ -0,0 +1,2 @@
1
+ from cloudquery.sdk.internal.checkpoint import SyncNotRunningError
2
+ from .keys import state_key