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.
- vgi/__init__.py +152 -0
- vgi/_duckdb.py +62 -0
- vgi/_storage_profile.py +132 -0
- vgi/_test_fixtures/__init__.py +20 -0
- vgi/_test_fixtures/accumulate/__init__.py +19 -0
- vgi/_test_fixtures/accumulate/worker.py +762 -0
- vgi/_test_fixtures/aggregate/__init__.py +62 -0
- vgi/_test_fixtures/aggregate/_common.py +21 -0
- vgi/_test_fixtures/aggregate/basic.py +232 -0
- vgi/_test_fixtures/aggregate/dynamic.py +409 -0
- vgi/_test_fixtures/aggregate/generic.py +86 -0
- vgi/_test_fixtures/aggregate/listagg.py +71 -0
- vgi/_test_fixtures/aggregate/percentile.py +107 -0
- vgi/_test_fixtures/aggregate/streaming.py +192 -0
- vgi/_test_fixtures/aggregate/varargs.py +75 -0
- vgi/_test_fixtures/aggregate/window.py +380 -0
- vgi/_test_fixtures/attach_options.py +308 -0
- vgi/_test_fixtures/bad_protocol.py +62 -0
- vgi/_test_fixtures/cancellable.py +336 -0
- vgi/_test_fixtures/catalog.py +813 -0
- vgi/_test_fixtures/http_server.py +394 -0
- vgi/_test_fixtures/nest_tensor.py +614 -0
- vgi/_test_fixtures/orchard_catalog.py +47 -0
- vgi/_test_fixtures/projection_repro/__init__.py +6 -0
- vgi/_test_fixtures/projection_repro/worker.py +454 -0
- vgi/_test_fixtures/scalar/__init__.py +116 -0
- vgi/_test_fixtures/scalar/_common.py +69 -0
- vgi/_test_fixtures/scalar/arithmetic.py +321 -0
- vgi/_test_fixtures/scalar/binary.py +120 -0
- vgi/_test_fixtures/scalar/formatting.py +176 -0
- vgi/_test_fixtures/scalar/geo.py +300 -0
- vgi/_test_fixtures/scalar/null_handling.py +107 -0
- vgi/_test_fixtures/scalar/random_demo.py +171 -0
- vgi/_test_fixtures/scalar/settings_secrets.py +102 -0
- vgi/_test_fixtures/scalar/type_info.py +219 -0
- vgi/_test_fixtures/schema_reconcile/__init__.py +29 -0
- vgi/_test_fixtures/schema_reconcile/worker.py +653 -0
- vgi/_test_fixtures/simple_writable.py +793 -0
- vgi/_test_fixtures/table/__init__.py +221 -0
- vgi/_test_fixtures/table/_common.py +162 -0
- vgi/_test_fixtures/table/batch_index.py +283 -0
- vgi/_test_fixtures/table/batch_index_broken.py +200 -0
- vgi/_test_fixtures/table/catalog_scans.py +162 -0
- vgi/_test_fixtures/table/filters.py +1005 -0
- vgi/_test_fixtures/table/late_materialization.py +249 -0
- vgi/_test_fixtures/table/make_series.py +273 -0
- vgi/_test_fixtures/table/misc.py +499 -0
- vgi/_test_fixtures/table/order_modes.py +164 -0
- vgi/_test_fixtures/table/pairs.py +437 -0
- vgi/_test_fixtures/table/partition_columns.py +472 -0
- vgi/_test_fixtures/table/partition_columns_broken.py +304 -0
- vgi/_test_fixtures/table/profiling_example.py +195 -0
- vgi/_test_fixtures/table/required_filters.py +234 -0
- vgi/_test_fixtures/table/sequence.py +710 -0
- vgi/_test_fixtures/table/settings.py +426 -0
- vgi/_test_fixtures/table/transaction_storage.py +162 -0
- vgi/_test_fixtures/table/tt_pushdown.py +191 -0
- vgi/_test_fixtures/table/versioned.py +230 -0
- vgi/_test_fixtures/table_in_out.py +1392 -0
- vgi/_test_fixtures/versioned.py +155 -0
- vgi/_test_fixtures/versioned_tables.py +595 -0
- vgi/_test_fixtures/worker.py +1631 -0
- vgi/_test_fixtures/writable/__init__.py +8 -0
- vgi/_test_fixtures/writable/generic.py +236 -0
- vgi/_test_fixtures/writable/table.py +149 -0
- vgi/_test_fixtures/writable/worker.py +1148 -0
- vgi/aggregate_function.py +607 -0
- vgi/argument_spec.py +472 -0
- vgi/arguments.py +1747 -0
- vgi/auth.py +55 -0
- vgi/catalog/__init__.py +88 -0
- vgi/catalog/attach_option.py +206 -0
- vgi/catalog/catalog_interface.py +2767 -0
- vgi/catalog/descriptors.py +870 -0
- vgi/catalog/duckdb_statistics.py +377 -0
- vgi/catalog/secret_type.py +96 -0
- vgi/catalog/setting.py +253 -0
- vgi/catalog/storage.py +372 -0
- vgi/client/__init__.py +67 -0
- vgi/client/catalog_mixin.py +1251 -0
- vgi/client/cli.py +582 -0
- vgi/client/cli_catalog.py +182 -0
- vgi/client/cli_schema.py +270 -0
- vgi/client/cli_table.py +907 -0
- vgi/client/cli_transaction.py +97 -0
- vgi/client/cli_utils.py +441 -0
- vgi/client/cli_view.py +303 -0
- vgi/client/client.py +2183 -0
- vgi/exceptions.py +205 -0
- vgi/function.py +245 -0
- vgi/function_storage.py +1636 -0
- vgi/function_storage_azure_sql.py +922 -0
- vgi/function_storage_cf_do.py +740 -0
- vgi/http/__init__.py +25 -0
- vgi/http/demo_storage.py +212 -0
- vgi/http/worker_page.py +1252 -0
- vgi/invocation.py +154 -0
- vgi/logging_config.py +93 -0
- vgi/meta_worker.py +661 -0
- vgi/metadata.py +1403 -0
- vgi/otel.py +406 -0
- vgi/protocol.py +2418 -0
- vgi/protocol_version.txt +1 -0
- vgi/py.typed +0 -0
- vgi/scalar_function.py +1211 -0
- vgi/schema_utils.py +234 -0
- vgi/secret_protocol.py +124 -0
- vgi/secret_service.py +238 -0
- vgi/serve.py +769 -0
- vgi/table_buffering_function.py +443 -0
- vgi/table_filter_pushdown.py +1528 -0
- vgi/table_function.py +1130 -0
- vgi/table_in_out_function.py +383 -0
- vgi/transactor/__init__.py +24 -0
- vgi/transactor/_duckdb_compat.py +27 -0
- vgi/transactor/client.py +137 -0
- vgi/transactor/protocol.py +149 -0
- vgi/transactor/server.py +740 -0
- vgi/worker.py +4761 -0
- vgi_python-0.8.0.dist-info/METADATA +735 -0
- vgi_python-0.8.0.dist-info/RECORD +124 -0
- vgi_python-0.8.0.dist-info/WHEEL +4 -0
- vgi_python-0.8.0.dist-info/entry_points.txt +5 -0
- 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
|
+
)
|