vgi-python 0.8.0__py3-none-any.whl

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 (124) hide show
  1. vgi/__init__.py +152 -0
  2. vgi/_duckdb.py +62 -0
  3. vgi/_storage_profile.py +132 -0
  4. vgi/_test_fixtures/__init__.py +20 -0
  5. vgi/_test_fixtures/accumulate/__init__.py +19 -0
  6. vgi/_test_fixtures/accumulate/worker.py +762 -0
  7. vgi/_test_fixtures/aggregate/__init__.py +62 -0
  8. vgi/_test_fixtures/aggregate/_common.py +21 -0
  9. vgi/_test_fixtures/aggregate/basic.py +232 -0
  10. vgi/_test_fixtures/aggregate/dynamic.py +409 -0
  11. vgi/_test_fixtures/aggregate/generic.py +86 -0
  12. vgi/_test_fixtures/aggregate/listagg.py +71 -0
  13. vgi/_test_fixtures/aggregate/percentile.py +107 -0
  14. vgi/_test_fixtures/aggregate/streaming.py +192 -0
  15. vgi/_test_fixtures/aggregate/varargs.py +75 -0
  16. vgi/_test_fixtures/aggregate/window.py +380 -0
  17. vgi/_test_fixtures/attach_options.py +308 -0
  18. vgi/_test_fixtures/bad_protocol.py +62 -0
  19. vgi/_test_fixtures/cancellable.py +336 -0
  20. vgi/_test_fixtures/catalog.py +813 -0
  21. vgi/_test_fixtures/http_server.py +394 -0
  22. vgi/_test_fixtures/nest_tensor.py +614 -0
  23. vgi/_test_fixtures/orchard_catalog.py +47 -0
  24. vgi/_test_fixtures/projection_repro/__init__.py +6 -0
  25. vgi/_test_fixtures/projection_repro/worker.py +454 -0
  26. vgi/_test_fixtures/scalar/__init__.py +116 -0
  27. vgi/_test_fixtures/scalar/_common.py +69 -0
  28. vgi/_test_fixtures/scalar/arithmetic.py +321 -0
  29. vgi/_test_fixtures/scalar/binary.py +120 -0
  30. vgi/_test_fixtures/scalar/formatting.py +176 -0
  31. vgi/_test_fixtures/scalar/geo.py +300 -0
  32. vgi/_test_fixtures/scalar/null_handling.py +107 -0
  33. vgi/_test_fixtures/scalar/random_demo.py +171 -0
  34. vgi/_test_fixtures/scalar/settings_secrets.py +102 -0
  35. vgi/_test_fixtures/scalar/type_info.py +219 -0
  36. vgi/_test_fixtures/schema_reconcile/__init__.py +29 -0
  37. vgi/_test_fixtures/schema_reconcile/worker.py +653 -0
  38. vgi/_test_fixtures/simple_writable.py +793 -0
  39. vgi/_test_fixtures/table/__init__.py +221 -0
  40. vgi/_test_fixtures/table/_common.py +162 -0
  41. vgi/_test_fixtures/table/batch_index.py +283 -0
  42. vgi/_test_fixtures/table/batch_index_broken.py +200 -0
  43. vgi/_test_fixtures/table/catalog_scans.py +162 -0
  44. vgi/_test_fixtures/table/filters.py +1005 -0
  45. vgi/_test_fixtures/table/late_materialization.py +249 -0
  46. vgi/_test_fixtures/table/make_series.py +273 -0
  47. vgi/_test_fixtures/table/misc.py +499 -0
  48. vgi/_test_fixtures/table/order_modes.py +164 -0
  49. vgi/_test_fixtures/table/pairs.py +437 -0
  50. vgi/_test_fixtures/table/partition_columns.py +472 -0
  51. vgi/_test_fixtures/table/partition_columns_broken.py +304 -0
  52. vgi/_test_fixtures/table/profiling_example.py +195 -0
  53. vgi/_test_fixtures/table/required_filters.py +234 -0
  54. vgi/_test_fixtures/table/sequence.py +710 -0
  55. vgi/_test_fixtures/table/settings.py +426 -0
  56. vgi/_test_fixtures/table/transaction_storage.py +162 -0
  57. vgi/_test_fixtures/table/tt_pushdown.py +191 -0
  58. vgi/_test_fixtures/table/versioned.py +230 -0
  59. vgi/_test_fixtures/table_in_out.py +1392 -0
  60. vgi/_test_fixtures/versioned.py +155 -0
  61. vgi/_test_fixtures/versioned_tables.py +595 -0
  62. vgi/_test_fixtures/worker.py +1631 -0
  63. vgi/_test_fixtures/writable/__init__.py +8 -0
  64. vgi/_test_fixtures/writable/generic.py +236 -0
  65. vgi/_test_fixtures/writable/table.py +149 -0
  66. vgi/_test_fixtures/writable/worker.py +1148 -0
  67. vgi/aggregate_function.py +607 -0
  68. vgi/argument_spec.py +472 -0
  69. vgi/arguments.py +1747 -0
  70. vgi/auth.py +55 -0
  71. vgi/catalog/__init__.py +88 -0
  72. vgi/catalog/attach_option.py +206 -0
  73. vgi/catalog/catalog_interface.py +2767 -0
  74. vgi/catalog/descriptors.py +870 -0
  75. vgi/catalog/duckdb_statistics.py +377 -0
  76. vgi/catalog/secret_type.py +96 -0
  77. vgi/catalog/setting.py +253 -0
  78. vgi/catalog/storage.py +372 -0
  79. vgi/client/__init__.py +67 -0
  80. vgi/client/catalog_mixin.py +1251 -0
  81. vgi/client/cli.py +582 -0
  82. vgi/client/cli_catalog.py +182 -0
  83. vgi/client/cli_schema.py +270 -0
  84. vgi/client/cli_table.py +907 -0
  85. vgi/client/cli_transaction.py +97 -0
  86. vgi/client/cli_utils.py +441 -0
  87. vgi/client/cli_view.py +303 -0
  88. vgi/client/client.py +2183 -0
  89. vgi/exceptions.py +205 -0
  90. vgi/function.py +245 -0
  91. vgi/function_storage.py +1636 -0
  92. vgi/function_storage_azure_sql.py +922 -0
  93. vgi/function_storage_cf_do.py +740 -0
  94. vgi/http/__init__.py +25 -0
  95. vgi/http/demo_storage.py +212 -0
  96. vgi/http/worker_page.py +1252 -0
  97. vgi/invocation.py +154 -0
  98. vgi/logging_config.py +93 -0
  99. vgi/meta_worker.py +661 -0
  100. vgi/metadata.py +1403 -0
  101. vgi/otel.py +406 -0
  102. vgi/protocol.py +2418 -0
  103. vgi/protocol_version.txt +1 -0
  104. vgi/py.typed +0 -0
  105. vgi/scalar_function.py +1211 -0
  106. vgi/schema_utils.py +234 -0
  107. vgi/secret_protocol.py +124 -0
  108. vgi/secret_service.py +238 -0
  109. vgi/serve.py +769 -0
  110. vgi/table_buffering_function.py +443 -0
  111. vgi/table_filter_pushdown.py +1528 -0
  112. vgi/table_function.py +1130 -0
  113. vgi/table_in_out_function.py +383 -0
  114. vgi/transactor/__init__.py +24 -0
  115. vgi/transactor/_duckdb_compat.py +27 -0
  116. vgi/transactor/client.py +137 -0
  117. vgi/transactor/protocol.py +149 -0
  118. vgi/transactor/server.py +740 -0
  119. vgi/worker.py +4761 -0
  120. vgi_python-0.8.0.dist-info/METADATA +735 -0
  121. vgi_python-0.8.0.dist-info/RECORD +124 -0
  122. vgi_python-0.8.0.dist-info/WHEEL +4 -0
  123. vgi_python-0.8.0.dist-info/entry_points.txt +5 -0
  124. vgi_python-0.8.0.dist-info/licenses/LICENSE +134 -0
@@ -0,0 +1,740 @@
1
+ # Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+
3
+ """Cloudflare Durable Object storage for VGI function state.
4
+
5
+ This module provides a FunctionStorage implementation backed by a Cloudflare
6
+ Worker + Durable Object. The DO runs SQLite internally, providing the same
7
+ semantics as FunctionStorageSqlite but accessible over HTTP from any platform.
8
+
9
+ Implementation:
10
+ FunctionStorageCfDo: HTTP client for the Cloudflare DO storage backend.
11
+
12
+ Usage:
13
+ Set ``VGI_WORKER_SHARED_STORAGE=cloudflare-do`` plus ``VGI_CF_DO_URL``
14
+ to enable. ``VGI_CF_DO_TOKEN`` carries the per-worker API key minted by
15
+ the storage service's admin CLI; the multi-tenant deployment requires it.
16
+ The key resolves (server-side) to this worker's tenant, which isolates
17
+ its storage from other workers sharing the same deployment — the key is
18
+ sent as an opaque ``Authorization: Bearer`` value and is never parsed
19
+ client-side.
20
+
21
+ Workflow contract:
22
+ Every ``execution_id`` (and ``transaction_opaque_data``) has a single linear
23
+ lifecycle: create → push/put repeatedly → terminal op → DONE. The
24
+ terminal op is ``queue_clear``, ``state_drain``, or
25
+ ``execution_clear``. Ids are never reused after their terminal op.
26
+
27
+ ``_post``'s retry loop is synchronous: all retries of one logical call
28
+ (same ``attempt_id``) finish or exhaust before the caller can issue
29
+ the next call. Combined with the lifecycle above, no two different
30
+ attempts can write the same row in interleaved order — a retry of
31
+ attempt A lands before any other attempt B can be in flight against
32
+ the same id. That property is what makes the server's column-only
33
+ replay model sound. If you change this client to break lockstep
34
+ (async fire-and-forget retries, multi-coordinator writes to one
35
+ execution_id, etc.) you also need to revisit the server's replay
36
+ semantics in the ``vgi-cloudflare-durable-object-storage`` repo
37
+ (``src/index.ts``).
38
+
39
+ """
40
+
41
+ import base64
42
+ import json
43
+ import logging
44
+ import os
45
+ import time
46
+ import uuid
47
+ from collections.abc import Iterator
48
+ from typing import Any
49
+
50
+ import httpx
51
+
52
+ __all__ = [
53
+ "FunctionStorageCfDo",
54
+ ]
55
+
56
+ _logger = logging.getLogger("vgi.storage.cf_do")
57
+
58
+
59
+ # Per-shard storage round-trip profiler (opt-in via VGI_STORAGE_PROFILE=1).
60
+ #
61
+ # The shared profiler (vgi._storage_profile) normally records at the
62
+ # BoundStorage facade so any backend can be profiled locally. This backend is
63
+ # special: a single logical state_scan / state_drain fans out into many _post
64
+ # round-trips (pagination), and that per-page network cost is the whole reason
65
+ # cloudflare-do is slower than in-process sqlite. So we record at _post here for
66
+ # the true round-trip count, and FunctionStorageCfDo sets
67
+ # _profiles_at_transport=True so BoundStorage defers to us (no double-count).
68
+ from vgi._storage_profile import _PROFILE_ON, _profiler # noqa: E402
69
+
70
+ # Optional file-based debug logging
71
+ _debug_log_path = os.environ.get("VGI_CF_DO_DEBUG_LOG")
72
+ if _debug_log_path:
73
+ _fh = logging.FileHandler(_debug_log_path)
74
+ _fh.setLevel(logging.DEBUG)
75
+ _fh.setFormatter(logging.Formatter("%(asctime)s %(process)d %(message)s"))
76
+ _logger.addHandler(_fh)
77
+ _logger.setLevel(logging.DEBUG)
78
+
79
+
80
+ class FunctionStorageCfDo:
81
+ """Cloudflare Durable Object-backed storage for VGI function state.
82
+
83
+ Communicates with a Cloudflare Worker that routes requests to a single
84
+ Durable Object running SQLite. The DO is single-threaded, so all
85
+ operations are inherently atomic — no locking needed.
86
+
87
+ Uses a single ``httpx.Client`` shared across threads. ``httpx.Client`` is
88
+ thread-safe by design (its connection pool serialises access per-conn),
89
+ so callers from concurrent producer turns can hit this storage instance
90
+ without coordination.
91
+
92
+ """
93
+
94
+ # This backend self-profiles at the transport layer (``_post``), so the
95
+ # BoundStorage facade defers to avoid double-counting. See _storage_profile.
96
+ _profiles_at_transport = True
97
+
98
+ # Remote-sharding backend: every request must carry a valid shard_key, so a
99
+ # BoundStorage built without a sealed attach is a hard error rather than a
100
+ # silent collapse onto one DO. See _resolve_shard_key in function_storage.py.
101
+ requires_shard_key = True
102
+
103
+ # Connection-level retries (DNS / TCP / TLS handshake failures).
104
+ # Status- and read-level retries are layered on top in ``_post`` so
105
+ # 5xx responses and mid-response disconnects also recover.
106
+ _CONNECT_RETRIES = 2
107
+ _POST_ATTEMPTS = 3
108
+
109
+ def __init__(self, *, url: str, token: str | None = None) -> None:
110
+ """Initialize Cloudflare DO storage client.
111
+
112
+ Args:
113
+ url: Base URL of the Cloudflare Worker
114
+ (e.g., ``https://vgi-storage.myaccount.workers.dev``).
115
+ token: Optional bearer token for authentication.
116
+
117
+ """
118
+ self._url = url.rstrip("/")
119
+ self._token = token
120
+ headers: dict[str, str] = {"Content-Type": "application/json"}
121
+ if token:
122
+ headers["Authorization"] = f"Bearer {token}"
123
+ # HTTP/1.1 with keep-alive. HTTP/2 was tested (May 2026) and turned out
124
+ # to regress the cold-DO path 2.5× while only marginally helping warm
125
+ # reads (~20% on transaction_state_get/put). The bottleneck is
126
+ # geographic RTT + DO instantiation, not TLS handshake overhead, so
127
+ # HTTP/2 multiplexing brings little upside and Cloudflare's h2 frontend
128
+ # appears to add latency to cold-path calls.
129
+ self._client = httpx.Client(
130
+ base_url=self._url,
131
+ headers=headers,
132
+ timeout=httpx.Timeout(30.0),
133
+ limits=httpx.Limits(
134
+ max_keepalive_connections=20,
135
+ max_connections=100,
136
+ keepalive_expiry=30.0,
137
+ ),
138
+ transport=httpx.HTTPTransport(retries=self._CONNECT_RETRIES),
139
+ )
140
+
141
+ def close(self) -> None:
142
+ """Close the underlying HTTP client and its connection pool."""
143
+ self._client.close()
144
+
145
+ def __enter__(self) -> "FunctionStorageCfDo":
146
+ """Enter the context manager."""
147
+ return self
148
+
149
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
150
+ """Close the HTTP client on exit."""
151
+ self.close()
152
+
153
+ def _post(
154
+ self,
155
+ endpoint: str,
156
+ body: dict[str, object],
157
+ *,
158
+ attempt_id: str | None = None,
159
+ shard_key: str = "",
160
+ ) -> dict[str, Any]:
161
+ """POST JSON to the CF Worker, with retry on transient failure.
162
+
163
+ ``attempt_id`` (when provided) is spliced into the body once, before
164
+ the retry loop, so every retry carries the same id. This is what
165
+ gives the server-side idempotency check something to match against:
166
+ a retried write whose previous response was lost on the wire will
167
+ find the prior attempt's tombstone/row and replay the original
168
+ response instead of re-executing the operation.
169
+
170
+ ``shard_key`` selects the Durable Object instance via
171
+ ``idFromName(shard_key)`` on the Worker side. Empty/missing falls
172
+ back to ``"loc-anon"`` (single shared DO for anonymous, no-attach
173
+ callers) — see ``_derive_shard_key`` in function_storage.py.
174
+
175
+ Returns the parsed JSON response. Raises:
176
+ PermissionError: on 401
177
+ ValueError: on 400 (contract violation — usually a bug)
178
+ RuntimeError: on other 4xx (non-retryable) and exhausted retries
179
+ on 5xx (retryable but failed every time)
180
+ """
181
+ path = f"/{endpoint}"
182
+ last_exc: Exception | None = None
183
+
184
+ # Always send shard_key — the Worker's router rejects requests
185
+ # without one with 400. Default to "loc-anon" so direct CfDo
186
+ # callers (outside BoundStorage) still work.
187
+ body = {**body, "shard_key": shard_key or "loc-anon"}
188
+ if attempt_id is not None:
189
+ body = {**body, "attempt_id": attempt_id}
190
+
191
+ for attempt in range(self._POST_ATTEMPTS):
192
+ t0 = time.monotonic()
193
+ try:
194
+ resp = self._client.post(path, json=body)
195
+ except httpx.RequestError as exc:
196
+ # Connection error, read error, timeout, etc. Narrowed to
197
+ # ``RequestError`` (not the broader ``HTTPError``) so we
198
+ # don't accidentally swallow programmer errors like
199
+ # ``InvalidURL``. The transport layer already retried
200
+ # connect-level failures; if we're here it's something the
201
+ # higher-level retry may still help with (e.g. server
202
+ # closed an idle keep-alive between our last response and
203
+ # this request).
204
+ _logger.debug(
205
+ "post %s attempt=%d transport error: %s: %s",
206
+ endpoint,
207
+ attempt,
208
+ type(exc).__name__,
209
+ exc,
210
+ )
211
+ last_exc = exc
212
+ continue
213
+
214
+ try:
215
+ data: dict[str, Any] = resp.json()
216
+ except (json.JSONDecodeError, ValueError):
217
+ # Non-JSON response (HTML error page, empty body, etc.) —
218
+ # treat as a transient server problem rather than letting
219
+ # JSONDecodeError bubble up unhelpfully.
220
+ _logger.debug(
221
+ "post %s attempt=%d non-json status=%d body=%r",
222
+ endpoint,
223
+ attempt,
224
+ resp.status_code,
225
+ resp.content[:200],
226
+ )
227
+ last_exc = RuntimeError(
228
+ f"CF DO storage returned non-JSON response (status={resp.status_code}): {resp.content[:200]!r}"
229
+ )
230
+ continue
231
+
232
+ if resp.status_code == 401:
233
+ raise PermissionError(f"Authentication failed: {data.get('error', 'unauthorized')}")
234
+ if resp.status_code == 400:
235
+ # Client-contract violation (e.g. missing/invalid attempt_id).
236
+ # Not retryable and almost always a bug worth surfacing loudly.
237
+ raise ValueError(f"CF DO storage rejected request: {data.get('message') or data.get('error') or data}")
238
+ if 500 <= resp.status_code < 600:
239
+ # Transient server error — retry.
240
+ _logger.debug(
241
+ "post %s attempt=%d server error status=%d data=%r",
242
+ endpoint,
243
+ attempt,
244
+ resp.status_code,
245
+ data,
246
+ )
247
+ last_exc = RuntimeError(f"CF DO storage error {resp.status_code}: {data}")
248
+ continue
249
+ if resp.status_code >= 400:
250
+ # Other 4xx — don't retry, the request itself is bad.
251
+ raise RuntimeError(f"CF DO storage error {resp.status_code}: {data}")
252
+ if _PROFILE_ON:
253
+ # Largest single wire body, either direction — what provider
254
+ # request/response size caps apply to. resp.request.content is
255
+ # the actual serialized (base64-inflated) request payload.
256
+ req_len = len(resp.request.content) if resp.request is not None else 0
257
+ _profiler.record(
258
+ str(body["shard_key"]),
259
+ endpoint,
260
+ time.monotonic() - t0,
261
+ max(req_len, len(resp.content)),
262
+ )
263
+ return data
264
+
265
+ assert last_exc is not None
266
+ raise last_exc
267
+
268
+ # --- Work Queue ---
269
+
270
+ def queue_push(self, execution_id: bytes, items: list[bytes], *, shard_key: str = "") -> int:
271
+ """Add work items to the queue and register the invocation."""
272
+ t0 = time.monotonic()
273
+ data = self._post(
274
+ "queue_push",
275
+ {
276
+ "execution_id": base64.b64encode(execution_id).decode(),
277
+ "items": [base64.b64encode(item).decode() for item in items],
278
+ },
279
+ attempt_id=uuid.uuid4().hex,
280
+ shard_key=shard_key,
281
+ )
282
+ count = int(data["count"])
283
+ _logger.debug(
284
+ "queue_push eid=%s items=%d elapsed_ms=%.1f",
285
+ execution_id.hex()[:8],
286
+ count,
287
+ (time.monotonic() - t0) * 1000,
288
+ )
289
+ return count
290
+
291
+ def queue_pop(self, execution_id: bytes, *, shard_key: str = "") -> bytes | None:
292
+ """Atomically claim one work item from the queue.
293
+
294
+ Returns None when the queue is empty *or* the execution_id was
295
+ never pushed — see the base-class docstring.
296
+ """
297
+ t0 = time.monotonic()
298
+ data = self._post(
299
+ "queue_pop",
300
+ {
301
+ "execution_id": base64.b64encode(execution_id).decode(),
302
+ },
303
+ attempt_id=uuid.uuid4().hex,
304
+ shard_key=shard_key,
305
+ )
306
+ result = base64.b64decode(data["item"]) if data["item"] else None
307
+ got_item = result is not None
308
+ _logger.debug(
309
+ "queue_pop eid=%s result=%s elapsed_ms=%.1f",
310
+ execution_id.hex()[:8],
311
+ "item" if got_item else "empty",
312
+ (time.monotonic() - t0) * 1000,
313
+ )
314
+ return result
315
+
316
+ def queue_clear(self, execution_id: bytes, *, shard_key: str = "") -> int:
317
+ """Clear all remaining work items and unregister the invocation."""
318
+ t0 = time.monotonic()
319
+ data = self._post(
320
+ "queue_clear",
321
+ {
322
+ "execution_id": base64.b64encode(execution_id).decode(),
323
+ },
324
+ attempt_id=uuid.uuid4().hex,
325
+ shard_key=shard_key,
326
+ )
327
+ cleared = int(data["cleared"])
328
+ _logger.debug(
329
+ "queue_clear eid=%s cleared=%d elapsed_ms=%.1f",
330
+ execution_id.hex()[:8],
331
+ cleared,
332
+ (time.monotonic() - t0) * 1000,
333
+ )
334
+ return cleared
335
+
336
+ # ========================================================================
337
+ # Unified state_* client (composite-key K/V over (scope_id, ns, key))
338
+ # ========================================================================
339
+ #
340
+ # Mirrors the server-side handlers in
341
+ # vgi-cloudflare-durable-object-storage/src/index.ts. attempt_id is
342
+ # generated client-side and spliced into the request body before the
343
+ # retry loop in _post() so a retried HTTP call carries the same id and
344
+ # the server's replay-detection returns the prior result rather than
345
+ # re-executing. Read-only methods (state_get_many, state_scan,
346
+ # state_log_scan) don't carry attempt_id.
347
+
348
+ def state_get_many(
349
+ self,
350
+ scope_id: bytes,
351
+ ns: bytes,
352
+ keys: list[bytes],
353
+ *,
354
+ shard_key: str = "",
355
+ ) -> list[bytes | None]:
356
+ """Batched non-destructive read of values keyed by ``(scope_id, ns, key)``.
357
+
358
+ Single HTTP roundtrip regardless of key count.
359
+ """
360
+ if not keys:
361
+ return []
362
+ t0 = time.monotonic()
363
+ data = self._post(
364
+ "state_get_many",
365
+ {
366
+ "scope_id": base64.b64encode(scope_id).decode(),
367
+ "ns": base64.b64encode(ns).decode(),
368
+ "keys": [base64.b64encode(k).decode() for k in keys],
369
+ },
370
+ shard_key=shard_key,
371
+ )
372
+ # Server returns rows as a list parallel to the input keys.
373
+ rows = data["rows"]
374
+ result: list[bytes | None] = [None if r is None else base64.b64decode(r["value"]) for r in rows]
375
+ _logger.debug(
376
+ "state_get_many scope=%s ns=%s n_keys=%d hits=%d elapsed_ms=%.1f",
377
+ scope_id.hex()[:8],
378
+ ns.hex()[:8],
379
+ len(keys),
380
+ sum(1 for r in result if r is not None),
381
+ (time.monotonic() - t0) * 1000,
382
+ )
383
+ return result
384
+
385
+ def state_put_many(
386
+ self,
387
+ scope_id: bytes,
388
+ ns: bytes,
389
+ items: list[tuple[bytes, bytes]],
390
+ *,
391
+ shard_key: str = "",
392
+ ) -> None:
393
+ """Batched atomic upsert of ``(key, value)`` pairs in one namespace."""
394
+ if not items:
395
+ return
396
+ t0 = time.monotonic()
397
+ self._post(
398
+ "state_put_many",
399
+ {
400
+ "scope_id": base64.b64encode(scope_id).decode(),
401
+ "ns": base64.b64encode(ns).decode(),
402
+ "items": [
403
+ {"key": base64.b64encode(k).decode(), "value": base64.b64encode(v).decode()} for k, v in items
404
+ ],
405
+ },
406
+ attempt_id=uuid.uuid4().hex,
407
+ shard_key=shard_key,
408
+ )
409
+ _logger.debug(
410
+ "state_put_many scope=%s ns=%s n_items=%d elapsed_ms=%.1f",
411
+ scope_id.hex()[:8],
412
+ ns.hex()[:8],
413
+ len(items),
414
+ (time.monotonic() - t0) * 1000,
415
+ )
416
+
417
+ def state_scan(
418
+ self,
419
+ scope_id: bytes,
420
+ ns: bytes,
421
+ *,
422
+ start: bytes | None = None,
423
+ end: bytes | None = None,
424
+ reverse: bool = False,
425
+ limit: int | None = None,
426
+ shard_key: str = "",
427
+ ) -> Iterator[tuple[bytes, bytes]]:
428
+ """Stream (key, value) in one namespace, paging under the hood.
429
+
430
+ Ordered by key (``reverse=True`` descending), bounded to ``[start, end)``
431
+ and capped at ``limit`` rows. The server returns ordered pages bounded by
432
+ a byte budget plus a ``next_after`` continuation cursor (interpreted
433
+ server-side per ``reverse``), so an arbitrarily large range never builds
434
+ an oversized response. Yields lazily; consumers should iterate.
435
+ """
436
+ t0 = time.monotonic()
437
+ after_key: str | None = None
438
+ n = 0
439
+ remaining = limit
440
+ while True:
441
+ body: dict[str, object] = {
442
+ "scope_id": base64.b64encode(scope_id).decode(),
443
+ "ns": base64.b64encode(ns).decode(),
444
+ }
445
+ if start is not None:
446
+ body["start"] = base64.b64encode(start).decode()
447
+ if end is not None:
448
+ body["end"] = base64.b64encode(end).decode()
449
+ if reverse:
450
+ body["reverse"] = True
451
+ if remaining is not None:
452
+ body["limit"] = int(remaining)
453
+ if after_key is not None:
454
+ body["after_key"] = after_key
455
+ data = self._post("state_scan", body, shard_key=shard_key)
456
+ for r in data["rows"]:
457
+ yield (base64.b64decode(r["key"]), base64.b64decode(r["value"]))
458
+ n += 1
459
+ if remaining is not None:
460
+ remaining -= 1
461
+ after_key = data.get("next_after")
462
+ if not after_key or (remaining is not None and remaining <= 0):
463
+ break
464
+ _logger.debug(
465
+ "state_scan scope=%s ns=%s rows=%d elapsed_ms=%.1f",
466
+ scope_id.hex()[:8],
467
+ ns.hex()[:8],
468
+ n,
469
+ (time.monotonic() - t0) * 1000,
470
+ )
471
+
472
+ def state_drain(
473
+ self,
474
+ scope_id: bytes,
475
+ ns: bytes,
476
+ *,
477
+ shard_key: str = "",
478
+ ) -> Iterator[tuple[bytes, bytes]]:
479
+ """Stream-and-tombstone every (key, value) in one namespace, paged.
480
+
481
+ A single ``attempt_id`` is minted once and reused across every page so
482
+ the server's snapshot-then-page drain stays atomic and replay-safe: the
483
+ first page tombstones the whole namespace, later pages read the
484
+ tombstoned snapshot. A retried page (same attempt_id + cursor) replays
485
+ identically. Beginning to iterate commits the drain, so consume fully.
486
+ """
487
+ t0 = time.monotonic()
488
+ attempt_id = uuid.uuid4().hex
489
+ after_key: str | None = None
490
+ n = 0
491
+ while True:
492
+ body: dict[str, object] = {
493
+ "scope_id": base64.b64encode(scope_id).decode(),
494
+ "ns": base64.b64encode(ns).decode(),
495
+ }
496
+ if after_key is not None:
497
+ body["after_key"] = after_key
498
+ data = self._post("state_drain", body, attempt_id=attempt_id, shard_key=shard_key)
499
+ for r in data["rows"]:
500
+ yield (base64.b64decode(r["key"]), base64.b64decode(r["value"]))
501
+ n += 1
502
+ after_key = data.get("next_after")
503
+ if not after_key:
504
+ break
505
+ _logger.debug(
506
+ "state_drain scope=%s ns=%s rows=%d elapsed_ms=%.1f",
507
+ scope_id.hex()[:8],
508
+ ns.hex()[:8],
509
+ n,
510
+ (time.monotonic() - t0) * 1000,
511
+ )
512
+
513
+ def state_delete(
514
+ self,
515
+ scope_id: bytes,
516
+ ns: bytes,
517
+ keys: list[bytes] | None = None,
518
+ *,
519
+ start: bytes | None = None,
520
+ end: bytes | None = None,
521
+ shard_key: str = "",
522
+ ) -> int:
523
+ """Delete by key list, by ``[start, end)`` range, or wipe the namespace.
524
+
525
+ ``keys`` and the range are mutually exclusive. Naturally idempotent — an
526
+ attempt_id is sent for audit but the server doesn't gate on it
527
+ (delete-of-already-deleted is a no-op).
528
+ """
529
+ if keys is not None and (start is not None or end is not None):
530
+ raise ValueError("state_delete: keys and start/end are mutually exclusive")
531
+ t0 = time.monotonic()
532
+ body: dict[str, object] = {
533
+ "scope_id": base64.b64encode(scope_id).decode(),
534
+ "ns": base64.b64encode(ns).decode(),
535
+ }
536
+ mode = "all"
537
+ if keys is not None:
538
+ body["keys"] = [base64.b64encode(k).decode() for k in keys]
539
+ mode = f"n_keys={len(keys)}"
540
+ elif start is not None or end is not None:
541
+ if start is not None:
542
+ body["start"] = base64.b64encode(start).decode()
543
+ if end is not None:
544
+ body["end"] = base64.b64encode(end).decode()
545
+ mode = "range"
546
+ data = self._post(
547
+ "state_delete",
548
+ body,
549
+ attempt_id=uuid.uuid4().hex,
550
+ shard_key=shard_key,
551
+ )
552
+ deleted = int(data["deleted"])
553
+ _logger.debug(
554
+ "state_delete scope=%s ns=%s mode=%s deleted=%d elapsed_ms=%.1f",
555
+ scope_id.hex()[:8],
556
+ ns.hex()[:8],
557
+ mode,
558
+ deleted,
559
+ (time.monotonic() - t0) * 1000,
560
+ )
561
+ return deleted
562
+
563
+ def execution_clear(
564
+ self,
565
+ scope_id: bytes,
566
+ *,
567
+ shard_key: str = "",
568
+ ) -> int:
569
+ """Wipe ALL state and log rows for ``scope_id`` across every namespace."""
570
+ t0 = time.monotonic()
571
+ data = self._post(
572
+ "execution_clear",
573
+ {"scope_id": base64.b64encode(scope_id).decode()},
574
+ attempt_id=uuid.uuid4().hex,
575
+ shard_key=shard_key,
576
+ )
577
+ deleted = int(data["deleted"])
578
+ _logger.debug(
579
+ "execution_clear scope=%s deleted=%d elapsed_ms=%.1f",
580
+ scope_id.hex()[:8],
581
+ deleted,
582
+ (time.monotonic() - t0) * 1000,
583
+ )
584
+ return deleted
585
+
586
+ def state_append(
587
+ self,
588
+ scope_id: bytes,
589
+ ns: bytes,
590
+ key: bytes,
591
+ item: bytes,
592
+ *,
593
+ shard_key: str = "",
594
+ ) -> int:
595
+ """Append item to (scope_id, ns, key) log; return assigned ordinal.
596
+
597
+ Replay: if a prior call with the same attempt_id already inserted,
598
+ the server returns the prior ordinal so retries are idempotent.
599
+ """
600
+ t0 = time.monotonic()
601
+ data = self._post(
602
+ "state_append",
603
+ {
604
+ "scope_id": base64.b64encode(scope_id).decode(),
605
+ "ns": base64.b64encode(ns).decode(),
606
+ "key": base64.b64encode(key).decode(),
607
+ "item": base64.b64encode(item).decode(),
608
+ },
609
+ attempt_id=uuid.uuid4().hex,
610
+ shard_key=shard_key,
611
+ )
612
+ ordinal = int(data["ordinal"])
613
+ _logger.debug(
614
+ "state_append scope=%s ns=%s key=%s ordinal=%d elapsed_ms=%.1f",
615
+ scope_id.hex()[:8],
616
+ ns.hex()[:8],
617
+ key.hex()[:8],
618
+ ordinal,
619
+ (time.monotonic() - t0) * 1000,
620
+ )
621
+ return ordinal
622
+
623
+ def state_log_scan(
624
+ self,
625
+ scope_id: bytes,
626
+ ns: bytes,
627
+ key: bytes,
628
+ *,
629
+ after_id: int = -1,
630
+ limit: int | None = None,
631
+ shard_key: str = "",
632
+ ) -> list[tuple[int, bytes]]:
633
+ """Return (id, value) pairs for (scope_id, ns, key) with id > after_id."""
634
+ t0 = time.monotonic()
635
+ body: dict[str, object] = {
636
+ "scope_id": base64.b64encode(scope_id).decode(),
637
+ "ns": base64.b64encode(ns).decode(),
638
+ "key": base64.b64encode(key).decode(),
639
+ "after_id": after_id,
640
+ }
641
+ if limit is not None:
642
+ body["limit"] = int(limit)
643
+ data = self._post("state_log_scan", body, shard_key=shard_key)
644
+ rows = [(int(r["id"]), base64.b64decode(r["value"])) for r in data["rows"]]
645
+ _logger.debug(
646
+ "state_log_scan scope=%s ns=%s key=%s after_id=%d rows=%d elapsed_ms=%.1f",
647
+ scope_id.hex()[:8],
648
+ ns.hex()[:8],
649
+ key.hex()[:8],
650
+ after_id,
651
+ len(rows),
652
+ (time.monotonic() - t0) * 1000,
653
+ )
654
+ return rows
655
+
656
+ # --- Atomic int64 counters (function_counter) ---
657
+
658
+ def state_counter_get(self, scope_id: bytes, ns: bytes, key: bytes, *, shard_key: str = "") -> int:
659
+ """Read the int64 counter; 0 if absent."""
660
+ data = self._post(
661
+ "state_counter_get",
662
+ {
663
+ "scope_id": base64.b64encode(scope_id).decode(),
664
+ "ns": base64.b64encode(ns).decode(),
665
+ "key": base64.b64encode(key).decode(),
666
+ },
667
+ shard_key=shard_key,
668
+ )
669
+ return int(data["n"])
670
+
671
+ def state_counter_add(self, scope_id: bytes, ns: bytes, key: bytes, delta: int, *, shard_key: str = "") -> int:
672
+ """Atomically add ``delta`` and return the new value.
673
+
674
+ Carries an ``attempt_id`` so an HTTP retry replays the prior result on
675
+ the server instead of double-adding.
676
+ """
677
+ data = self._post(
678
+ "state_counter_add",
679
+ {
680
+ "scope_id": base64.b64encode(scope_id).decode(),
681
+ "ns": base64.b64encode(ns).decode(),
682
+ "key": base64.b64encode(key).decode(),
683
+ "delta": int(delta),
684
+ },
685
+ attempt_id=uuid.uuid4().hex,
686
+ shard_key=shard_key,
687
+ )
688
+ return int(data["n"])
689
+
690
+ def state_counter_set(self, scope_id: bytes, ns: bytes, key: bytes, value: int, *, shard_key: str = "") -> None:
691
+ """Overwrite the counter with ``value`` (idempotent)."""
692
+ self._post(
693
+ "state_counter_set",
694
+ {
695
+ "scope_id": base64.b64encode(scope_id).decode(),
696
+ "ns": base64.b64encode(ns).decode(),
697
+ "key": base64.b64encode(key).decode(),
698
+ "value": int(value),
699
+ },
700
+ attempt_id=uuid.uuid4().hex,
701
+ shard_key=shard_key,
702
+ )
703
+
704
+ def state_counter_delete(self, scope_id: bytes, ns: bytes, key: bytes, *, shard_key: str = "") -> None:
705
+ """Delete the counter (no-op if absent)."""
706
+ self._post(
707
+ "state_counter_delete",
708
+ {
709
+ "scope_id": base64.b64encode(scope_id).decode(),
710
+ "ns": base64.b64encode(ns).decode(),
711
+ "key": base64.b64encode(key).decode(),
712
+ },
713
+ attempt_id=uuid.uuid4().hex,
714
+ shard_key=shard_key,
715
+ )
716
+
717
+ # --- Factory ---
718
+
719
+ @classmethod
720
+ def from_env(cls) -> "FunctionStorageCfDo":
721
+ """Create an instance from environment variables.
722
+
723
+ Required:
724
+ VGI_CF_DO_URL: Base URL of the Cloudflare Worker.
725
+
726
+ Optional:
727
+ VGI_CF_DO_TOKEN: Per-worker API key (sent as a bearer token).
728
+ Required by the multi-tenant cloudflare-do deployment, where
729
+ it resolves server-side to this worker's tenant.
730
+
731
+ """
732
+ url = os.environ.get("VGI_CF_DO_URL")
733
+ if not url:
734
+ raise ValueError(
735
+ "VGI_CF_DO_URL environment variable is required when VGI_WORKER_SHARED_STORAGE=cloudflare-do"
736
+ )
737
+ return cls(
738
+ url=url,
739
+ token=os.environ.get("VGI_CF_DO_TOKEN") or None,
740
+ )