smart-data-engine-sdk 0.1.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.
- sde/__init__.py +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
sde/write_fence.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Engine-enforced write generations and named barriers, entirely on the client's side.
|
|
2
|
+
|
|
3
|
+
These are primitives for a cutover executor, not permission to switch a placement map. The caller
|
|
4
|
+
must still close the source and target, compare their stable contents and durably commit its choice.
|
|
5
|
+
A generation travels with each write; checking it before an INSERT would leave a paused process
|
|
6
|
+
able to commit against a stale placement. Native CHECK constraints make that test part of INSERT.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal, Protocol
|
|
15
|
+
|
|
16
|
+
from .errors import MigrationRefused
|
|
17
|
+
from .generation import DRAIN_TABLE as DRAIN_TABLE
|
|
18
|
+
from .generation import EPOCH_COLUMN as EPOCH_COLUMN
|
|
19
|
+
from .generation import MAX_EPOCH as MAX_EPOCH
|
|
20
|
+
from .generation import check_epoch as check_epoch
|
|
21
|
+
from .placement import BACKFILL_TABLE, WATERMARK_TABLE
|
|
22
|
+
|
|
23
|
+
FENCE_PREFIX = "__sde_f_"
|
|
24
|
+
SETUP = FENCE_PREFIX + "setup"
|
|
25
|
+
ColumnState = Literal["absent", "valid", "conflict"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class FenceMetadata:
|
|
30
|
+
identity: str
|
|
31
|
+
column: ColumnState
|
|
32
|
+
constraints: Mapping[str, str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FenceBackend(Protocol):
|
|
36
|
+
"""DDL capability held by provisioning/execution roles, never required by a runtime role."""
|
|
37
|
+
|
|
38
|
+
def metadata(self, table: str) -> FenceMetadata: ...
|
|
39
|
+
def add_column(self, table: str) -> None: ...
|
|
40
|
+
def add_constraint(self, table: str, name: str, expression: str) -> None: ...
|
|
41
|
+
def drop_constraint(self, table: str, name: str) -> None: ...
|
|
42
|
+
def drain(self, table: str, *, project_id: str, hold: str) -> None: ...
|
|
43
|
+
def restore(self, table: str, *, project_id: str, hold: str) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _identity(value: str, label: str) -> None:
|
|
47
|
+
if not isinstance(value, str) or re.fullmatch("[0-9a-f]{32}", value) is None:
|
|
48
|
+
raise MigrationRefused(f"write fence {label} must be 32 lowercase hexadecimal digits")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _predicate(value: str) -> str:
|
|
52
|
+
"""Normalize only our predicate grammar, preserving quoted identifiers and their spaces."""
|
|
53
|
+
value = value.strip()
|
|
54
|
+
if value.startswith("CHECK"):
|
|
55
|
+
value = value[5:].strip()
|
|
56
|
+
value = re.sub(r"\s+NOT\s+VALID$", "", value)
|
|
57
|
+
while value.startswith("(") and value.endswith(")"):
|
|
58
|
+
value = value[1:-1].strip()
|
|
59
|
+
if value in ("true", "1"):
|
|
60
|
+
return "1"
|
|
61
|
+
if value in ("false", "0"):
|
|
62
|
+
return "0"
|
|
63
|
+
matched = re.fullmatch(
|
|
64
|
+
r'\(*\s*(?:"__sde_write_epoch"|`__sde_write_epoch`|__sde_write_epoch)'
|
|
65
|
+
r"\s*\)*\s*(>=|<=)\s*\(*\s*(?:([0-9]+)|'([0-9]+)'::bigint)\s*\)*",
|
|
66
|
+
value,
|
|
67
|
+
)
|
|
68
|
+
if matched is None:
|
|
69
|
+
return "<unrecognized>"
|
|
70
|
+
number = matched.group(2) or matched.group(3)
|
|
71
|
+
return EPOCH_COLUMN + matched.group(1) + str(int(number))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class FenceState:
|
|
76
|
+
identity: str
|
|
77
|
+
project_id: str | None
|
|
78
|
+
column: ColumnState
|
|
79
|
+
minimums: tuple[int, ...]
|
|
80
|
+
maximums: tuple[int, ...]
|
|
81
|
+
holds: tuple[str, ...]
|
|
82
|
+
retired: tuple[str, ...]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def lower_epoch(self) -> int | None:
|
|
86
|
+
return max(self.minimums) if self.minimums else None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def upper_epoch(self) -> int | None:
|
|
90
|
+
return min(self.maximums) if self.maximums else None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def complete(self) -> bool:
|
|
94
|
+
return (
|
|
95
|
+
self.project_id is not None
|
|
96
|
+
and self.column == "valid"
|
|
97
|
+
and bool(self.minimums and self.maximums)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def closed(self) -> bool:
|
|
102
|
+
low, high = self.lower_epoch, self.upper_epoch
|
|
103
|
+
return bool(self.holds) or (low is not None and high is not None and low > high)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def epoch(self) -> int | None:
|
|
107
|
+
if self.complete and self.lower_epoch == self.upper_epoch:
|
|
108
|
+
return self.lower_epoch
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def as_record(self) -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"identity": self.identity,
|
|
114
|
+
"project_id": self.project_id,
|
|
115
|
+
"column": self.column,
|
|
116
|
+
"lower_epoch": self.lower_epoch,
|
|
117
|
+
"upper_epoch": self.upper_epoch,
|
|
118
|
+
"holds": list(self.holds),
|
|
119
|
+
"retired": list(self.retired),
|
|
120
|
+
"closed": self.closed,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def fence_state(metadata: FenceMetadata) -> FenceState:
|
|
125
|
+
owners: list[str] = []
|
|
126
|
+
minima: list[int] = []
|
|
127
|
+
maxima: list[int] = []
|
|
128
|
+
holds: list[str] = []
|
|
129
|
+
retired: list[str] = []
|
|
130
|
+
for name, raw in sorted(metadata.constraints.items()):
|
|
131
|
+
if not name.startswith(FENCE_PREFIX):
|
|
132
|
+
continue
|
|
133
|
+
suffix = name[len(FENCE_PREFIX) :]
|
|
134
|
+
predicate = _predicate(raw)
|
|
135
|
+
if re.fullmatch("owner_[0-9a-f]{32}", suffix):
|
|
136
|
+
owners.append(suffix[6:])
|
|
137
|
+
expected = "1"
|
|
138
|
+
elif re.fullmatch("retired_[0-9a-f]{32}", suffix):
|
|
139
|
+
retired.append(suffix[8:])
|
|
140
|
+
expected = "1"
|
|
141
|
+
elif suffix == "setup" or re.fullmatch("hold_[0-9a-f]{32}", suffix):
|
|
142
|
+
holds.append("setup" if suffix == "setup" else suffix[5:])
|
|
143
|
+
expected = "0"
|
|
144
|
+
elif re.fullmatch("(?:min|max)_[1-9][0-9]*", suffix):
|
|
145
|
+
value = int(suffix[4:])
|
|
146
|
+
check_epoch(value)
|
|
147
|
+
(minima if suffix.startswith("min_") else maxima).append(value)
|
|
148
|
+
expected = EPOCH_COLUMN + (">=" if suffix.startswith("min_") else "<=") + str(value)
|
|
149
|
+
else:
|
|
150
|
+
raise MigrationRefused("unrecognized constraint in the reserved write-fence namespace")
|
|
151
|
+
if predicate != expected:
|
|
152
|
+
raise MigrationRefused(f"write fence constraint {name} has an unexpected predicate")
|
|
153
|
+
if len(owners) > 1:
|
|
154
|
+
raise MigrationRefused("the table carries write fences from more than one project")
|
|
155
|
+
if not owners and (minima or maxima or holds or retired):
|
|
156
|
+
raise MigrationRefused("write fence constraints have no project owner")
|
|
157
|
+
return FenceState(
|
|
158
|
+
metadata.identity,
|
|
159
|
+
owners[0] if owners else None,
|
|
160
|
+
metadata.column,
|
|
161
|
+
tuple(sorted(minima)),
|
|
162
|
+
tuple(sorted(maxima)),
|
|
163
|
+
tuple(sorted(holds)),
|
|
164
|
+
tuple(sorted(retired)),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class WriteFence:
|
|
169
|
+
"""A table's provisioning capability. Serialize executor commands per project state directory.
|
|
170
|
+
|
|
171
|
+
Writes carry a number from their own immutable placement. Replaying
|
|
172
|
+
a stale administrative command cannot lower the native minimum. A partial command can leave the
|
|
173
|
+
table closed, so an I/O error calls for inspection/resumption, not an assumption of rollback.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, backend: FenceBackend, table: str, *, project_id: str) -> None:
|
|
177
|
+
_identity(project_id, "project_id")
|
|
178
|
+
if not isinstance(table, str) or not table or "\0" in table:
|
|
179
|
+
raise MigrationRefused("write fence table must be a nonempty identifier")
|
|
180
|
+
if table in (DRAIN_TABLE, BACKFILL_TABLE, WATERMARK_TABLE):
|
|
181
|
+
raise MigrationRefused("write fences cannot take over an SDK metadata table")
|
|
182
|
+
self._backend = backend
|
|
183
|
+
self.table = table
|
|
184
|
+
self.project_id = project_id
|
|
185
|
+
|
|
186
|
+
def state(self) -> FenceState:
|
|
187
|
+
state = fence_state(self._backend.metadata(self.table))
|
|
188
|
+
if state.project_id is not None and state.project_id != self.project_id:
|
|
189
|
+
raise MigrationRefused("the write fence belongs to another project")
|
|
190
|
+
if state.column == "conflict":
|
|
191
|
+
raise MigrationRefused("the reserved write-epoch column has an incompatible definition")
|
|
192
|
+
return state
|
|
193
|
+
|
|
194
|
+
def _ready(self) -> FenceState:
|
|
195
|
+
state = self.state()
|
|
196
|
+
if not state.complete:
|
|
197
|
+
raise MigrationRefused("the write fence is not fully provisioned")
|
|
198
|
+
return state
|
|
199
|
+
|
|
200
|
+
def prepare(self, epoch: int) -> FenceState:
|
|
201
|
+
epoch = check_epoch(epoch)
|
|
202
|
+
state = self.state()
|
|
203
|
+
if state.complete and "setup" not in state.holds:
|
|
204
|
+
if state.epoch != epoch:
|
|
205
|
+
raise MigrationRefused("provisioning cannot change an existing write epoch")
|
|
206
|
+
return state
|
|
207
|
+
if state.project_id is None and state.column != "absent":
|
|
208
|
+
raise MigrationRefused("the reserved write-epoch column is not owned by this project")
|
|
209
|
+
if state.lower_epoch is not None and state.lower_epoch > epoch:
|
|
210
|
+
raise MigrationRefused("a write epoch cannot move backwards")
|
|
211
|
+
if state.project_id is None:
|
|
212
|
+
self._backend.add_constraint(self.table, FENCE_PREFIX + "owner_" + self.project_id, "1")
|
|
213
|
+
self._backend.add_constraint(self.table, SETUP, "0")
|
|
214
|
+
self._backend.add_column(self.table)
|
|
215
|
+
self._bounds(epoch)
|
|
216
|
+
self._backend.drain(self.table, project_id=self.project_id, hold="setup")
|
|
217
|
+
self._backend.drop_constraint(self.table, SETUP)
|
|
218
|
+
return self._ready()
|
|
219
|
+
|
|
220
|
+
def freeze(self, request_id: str) -> FenceState:
|
|
221
|
+
_identity(request_id, "request_id")
|
|
222
|
+
state = self._ready()
|
|
223
|
+
if request_id in state.retired:
|
|
224
|
+
raise MigrationRefused("a completed write barrier id cannot be reused")
|
|
225
|
+
self._backend.add_constraint(self.table, FENCE_PREFIX + "hold_" + request_id, "0")
|
|
226
|
+
# Always drain, even on a retry: existence of the constraint proves admission is closed,
|
|
227
|
+
# not that an INSERT which captured older metadata has finished.
|
|
228
|
+
self._backend.drain(self.table, project_id=self.project_id, hold=request_id)
|
|
229
|
+
return self._ready()
|
|
230
|
+
|
|
231
|
+
def resume(self, request_id: str) -> FenceState:
|
|
232
|
+
_identity(request_id, "request_id")
|
|
233
|
+
self._backend.restore(self.table, project_id=self.project_id, hold=request_id)
|
|
234
|
+
return self.freeze(request_id)
|
|
235
|
+
|
|
236
|
+
def resume_prepare(self, epoch: int) -> FenceState:
|
|
237
|
+
epoch = check_epoch(epoch)
|
|
238
|
+
self._backend.restore(self.table, project_id=self.project_id, hold="setup")
|
|
239
|
+
return self.prepare(epoch)
|
|
240
|
+
|
|
241
|
+
def advance(self, epoch: int) -> FenceState:
|
|
242
|
+
epoch = check_epoch(epoch)
|
|
243
|
+
state = self._ready()
|
|
244
|
+
if not state.holds:
|
|
245
|
+
raise MigrationRefused("changing a write epoch needs a named write barrier")
|
|
246
|
+
if state.lower_epoch is not None and epoch < state.lower_epoch:
|
|
247
|
+
raise MigrationRefused("a write epoch cannot move backwards")
|
|
248
|
+
self._bounds(epoch)
|
|
249
|
+
return self._ready()
|
|
250
|
+
|
|
251
|
+
def _bounds(self, epoch: int) -> None:
|
|
252
|
+
self._backend.add_constraint(
|
|
253
|
+
self.table, FENCE_PREFIX + "min_" + str(epoch), f"{EPOCH_COLUMN} >= {epoch}"
|
|
254
|
+
)
|
|
255
|
+
self._backend.add_constraint(
|
|
256
|
+
self.table, FENCE_PREFIX + "max_" + str(epoch), f"{EPOCH_COLUMN} <= {epoch}"
|
|
257
|
+
)
|
|
258
|
+
# Remove only bounds weaker/older than the one just installed, by their exact names. A
|
|
259
|
+
# stale caller must never remove a newer constraint discovered after its earlier check.
|
|
260
|
+
state = self.state()
|
|
261
|
+
if state.lower_epoch is not None and state.lower_epoch > epoch:
|
|
262
|
+
raise MigrationRefused(
|
|
263
|
+
"another executor installed a newer write epoch; table stays closed"
|
|
264
|
+
)
|
|
265
|
+
for old in state.maximums:
|
|
266
|
+
if old < epoch:
|
|
267
|
+
self._backend.drop_constraint(self.table, FENCE_PREFIX + "max_" + str(old))
|
|
268
|
+
for old in state.minimums:
|
|
269
|
+
if old < epoch:
|
|
270
|
+
self._backend.drop_constraint(self.table, FENCE_PREFIX + "min_" + str(old))
|
|
271
|
+
|
|
272
|
+
def release(self, request_id: str) -> FenceState:
|
|
273
|
+
_identity(request_id, "request_id")
|
|
274
|
+
state = self._ready()
|
|
275
|
+
if state.epoch is None:
|
|
276
|
+
raise MigrationRefused("cannot release a barrier with an incomplete epoch change")
|
|
277
|
+
if request_id not in state.holds:
|
|
278
|
+
return state
|
|
279
|
+
# Record completion before releasing admission. A delayed invocation must never recreate
|
|
280
|
+
# a completed hold, nor reuse its id for a later generation and inherit an old release.
|
|
281
|
+
self._backend.add_constraint(self.table, FENCE_PREFIX + "retired_" + request_id, "1")
|
|
282
|
+
self._backend.drop_constraint(self.table, FENCE_PREFIX + "hold_" + request_id)
|
|
283
|
+
return self._ready()
|
sde_demo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Customer-side Weather starter; data and local credentials stay in this process."""
|
sde_demo/__main__.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Run the customer-side Weather starter; engine credentials and values stay local."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import sde
|
|
14
|
+
from sde._local_state import transaction
|
|
15
|
+
|
|
16
|
+
from .model import WORKLOADS, model
|
|
17
|
+
from .project import DemoRefused, config, connections, public_keys, read, setup, write
|
|
18
|
+
from .resources import ResourceRefused
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def operator(root: Path, action: str, plan: Path | None) -> dict[str, Any]:
|
|
22
|
+
from sde.local_cutover import _local_status
|
|
23
|
+
|
|
24
|
+
from . import resources
|
|
25
|
+
|
|
26
|
+
with transaction(root):
|
|
27
|
+
settings, logical = config(root, ready=action != "status"), model()
|
|
28
|
+
keys = public_keys(settings["public_keys"])
|
|
29
|
+
if action == "status":
|
|
30
|
+
return _local_status(
|
|
31
|
+
root / "state", model=logical, project_id=settings["project_id"], public_key=keys
|
|
32
|
+
)
|
|
33
|
+
resources.verify(root)
|
|
34
|
+
with connections(root, settings) as (operators, runtime):
|
|
35
|
+
local = sde.LocalCutover(
|
|
36
|
+
root / "state",
|
|
37
|
+
model=logical,
|
|
38
|
+
project_id=settings["project_id"],
|
|
39
|
+
public_key=keys,
|
|
40
|
+
operators=operators,
|
|
41
|
+
runtime={name: [adapter] for name, adapter in runtime.items()},
|
|
42
|
+
)
|
|
43
|
+
if action == "resume":
|
|
44
|
+
return local.resume().as_record()
|
|
45
|
+
if action == "abandon":
|
|
46
|
+
return local.abandon().as_record()
|
|
47
|
+
if plan is None:
|
|
48
|
+
raise DemoRefused("Stage, index and execute require --plan from the controller.")
|
|
49
|
+
if action == "index":
|
|
50
|
+
return local.index(
|
|
51
|
+
sde.load_index_plan(
|
|
52
|
+
read(plan),
|
|
53
|
+
model=logical,
|
|
54
|
+
project_id=settings["project_id"],
|
|
55
|
+
public_key=keys,
|
|
56
|
+
)
|
|
57
|
+
).as_record()
|
|
58
|
+
if action == "stage":
|
|
59
|
+
return local.stage(
|
|
60
|
+
sde.load_staging_plan(
|
|
61
|
+
read(plan),
|
|
62
|
+
model=logical,
|
|
63
|
+
project_id=settings["project_id"],
|
|
64
|
+
public_key=keys,
|
|
65
|
+
)
|
|
66
|
+
).as_record()
|
|
67
|
+
return local.execute(
|
|
68
|
+
sde.load_cutover_plan(
|
|
69
|
+
read(plan), model=logical, project_id=settings["project_id"], public_key=keys
|
|
70
|
+
)
|
|
71
|
+
).as_record()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
75
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
76
|
+
parser.add_argument("--directory", type=Path)
|
|
77
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
78
|
+
commands.add_parser("model", help="print the neutral Weather model for the controller")
|
|
79
|
+
start = commands.add_parser("setup", help="allocate loopback-only demo resources and enroll")
|
|
80
|
+
start.add_argument("--bootstrap", type=Path, required=True)
|
|
81
|
+
reset = commands.add_parser(
|
|
82
|
+
"reset", help="remove this allocation after stopping demo processes"
|
|
83
|
+
)
|
|
84
|
+
reset.add_argument("--confirm-allocation", required=True)
|
|
85
|
+
for command in (start, reset):
|
|
86
|
+
command.add_argument("--postgres-admin-env", default="SDE_POSTGRES_DSN")
|
|
87
|
+
command.add_argument("--clickhouse-admin-env", default="SDE_CLICKHOUSE_DSN")
|
|
88
|
+
commands.add_parser("doctor", help="check the local map, connections and runtime grants")
|
|
89
|
+
workload = commands.add_parser("run", help="run a bounded logical workload with runtime rights")
|
|
90
|
+
workload.add_argument("--iterations", type=int, default=10)
|
|
91
|
+
workload.add_argument("--batch-size", type=int, default=10)
|
|
92
|
+
workload.add_argument("--interval-ms", type=int, default=100)
|
|
93
|
+
workload.add_argument("--recovery-ms", type=int, default=10000)
|
|
94
|
+
workload.add_argument("--workload", choices=WORKLOADS, default="mixed")
|
|
95
|
+
verify = commands.add_parser(
|
|
96
|
+
"verify-runs", help="verify earlier completed Python/TypeScript runs"
|
|
97
|
+
)
|
|
98
|
+
verify.add_argument("--run-id", action="append", required=True)
|
|
99
|
+
query = commands.add_parser(
|
|
100
|
+
"query-count", help="execute the current approved Weather COUNT locally"
|
|
101
|
+
)
|
|
102
|
+
query.add_argument("--record", type=Path, required=True)
|
|
103
|
+
op = commands.add_parser(
|
|
104
|
+
"operator", help="use the existing local staging/index/cutover executor"
|
|
105
|
+
)
|
|
106
|
+
op.add_argument("action", choices=("status", "stage", "index", "execute", "resume", "abandon"))
|
|
107
|
+
op.add_argument("--plan", type=Path)
|
|
108
|
+
args = parser.parse_args(argv)
|
|
109
|
+
try:
|
|
110
|
+
if args.command == "model":
|
|
111
|
+
result = sde.neutral_declaration(model())
|
|
112
|
+
else:
|
|
113
|
+
if args.directory is None:
|
|
114
|
+
raise DemoRefused("Provide --directory for this customer-side demo allocation.")
|
|
115
|
+
root = args.directory.absolute()
|
|
116
|
+
if args.command in ("setup", "reset"):
|
|
117
|
+
admin = {
|
|
118
|
+
dialect: os.environ[variable]
|
|
119
|
+
for dialect, variable in (
|
|
120
|
+
("postgres", args.postgres_admin_env),
|
|
121
|
+
("clickhouse", args.clickhouse_admin_env),
|
|
122
|
+
)
|
|
123
|
+
if os.environ.get(variable)
|
|
124
|
+
}
|
|
125
|
+
if args.command == "setup":
|
|
126
|
+
result = setup(root, read(args.bootstrap), admin)
|
|
127
|
+
else:
|
|
128
|
+
from .resources import reset as reset_resources
|
|
129
|
+
|
|
130
|
+
with transaction(root):
|
|
131
|
+
allocation = read(root / "resources.json")["allocation_id"]
|
|
132
|
+
if args.confirm_allocation != allocation:
|
|
133
|
+
raise DemoRefused(
|
|
134
|
+
"Confirm the allocation_id from local resources.json."
|
|
135
|
+
)
|
|
136
|
+
write(root / "reset-request.json", {"allocation_id": allocation}, once=True)
|
|
137
|
+
result = reset_resources(root, admin)
|
|
138
|
+
elif args.command == "doctor":
|
|
139
|
+
from .diagnostics import doctor
|
|
140
|
+
|
|
141
|
+
result = doctor(root)
|
|
142
|
+
elif args.command == "operator":
|
|
143
|
+
result = operator(root, args.action, args.plan)
|
|
144
|
+
elif args.command == "verify-runs":
|
|
145
|
+
from .verification import verify_runs
|
|
146
|
+
|
|
147
|
+
result = verify_runs(root, args.run_id)
|
|
148
|
+
elif args.command == "query-count":
|
|
149
|
+
from .query_count import run_count_query
|
|
150
|
+
|
|
151
|
+
result = run_count_query(root, read(args.record))
|
|
152
|
+
else:
|
|
153
|
+
from .runtime import run
|
|
154
|
+
|
|
155
|
+
result = run(
|
|
156
|
+
root,
|
|
157
|
+
iterations=args.iterations,
|
|
158
|
+
batch_size=args.batch_size,
|
|
159
|
+
interval_ms=args.interval_ms,
|
|
160
|
+
recovery_ms=args.recovery_ms,
|
|
161
|
+
workload=args.workload,
|
|
162
|
+
)
|
|
163
|
+
print(json.dumps(result, sort_keys=True, allow_nan=False))
|
|
164
|
+
return 0
|
|
165
|
+
except (DemoRefused, ResourceRefused) as exc:
|
|
166
|
+
print(json.dumps({"error": "demo_refused", "message": str(exc)}), file=sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
print(
|
|
170
|
+
json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"error": type(exc).__name__,
|
|
173
|
+
"message": "Operation incomplete. Inspect local state and the run report "
|
|
174
|
+
"before retrying; uncertain writes must not be replayed.",
|
|
175
|
+
}
|
|
176
|
+
),
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return 3
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
raise SystemExit(main())
|
sde_demo/diagnostics.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Local diagnostics using existing native identity and grant qualification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import sde
|
|
10
|
+
from sde._local_state import transaction
|
|
11
|
+
from sde.engines._operator import NativeOperator
|
|
12
|
+
|
|
13
|
+
from . import resources
|
|
14
|
+
from .model import model
|
|
15
|
+
from .project import config, connections, public_keys, require_drivers
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def doctor(root: Path) -> dict[str, Any]:
|
|
19
|
+
with transaction(root):
|
|
20
|
+
settings = config(root)
|
|
21
|
+
require_drivers(binding["dialect"] for binding in settings["engines"].values())
|
|
22
|
+
resources.verify(root)
|
|
23
|
+
return _diagnose(root, settings)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _diagnose(root: Path, settings: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
logical = model()
|
|
28
|
+
placement = sde.load_local_map(
|
|
29
|
+
root / "state",
|
|
30
|
+
model=logical,
|
|
31
|
+
project_id=settings["project_id"],
|
|
32
|
+
public_key=public_keys(settings["public_keys"]),
|
|
33
|
+
)
|
|
34
|
+
tables: dict[str, set[str]] = {name: {"sde_map_state"} for name in settings["engines"]}
|
|
35
|
+
for group in placement.groups.values():
|
|
36
|
+
for material in group.all():
|
|
37
|
+
tables[material.engine].update(material.layout.tables.values())
|
|
38
|
+
results = {}
|
|
39
|
+
with connections(root, settings) as (operators, runtime):
|
|
40
|
+
for name, operator in operators.items():
|
|
41
|
+
native = NativeOperator(operator, [runtime[name]])
|
|
42
|
+
native.qualify(sorted(tables[name]), sorted(tables[name]))
|
|
43
|
+
if operator.dialect == "postgres":
|
|
44
|
+
version = str(native.rows("SHOW server_version")[0][0])
|
|
45
|
+
tls = bool(
|
|
46
|
+
native.rows(
|
|
47
|
+
"SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()",
|
|
48
|
+
engine=runtime[name],
|
|
49
|
+
)[0][0]
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
version = str(native.rows("SELECT version()")[0][0])
|
|
53
|
+
tls = runtime[name]._cx.url.startswith("https://")
|
|
54
|
+
results[name] = {
|
|
55
|
+
"dialect": operator.dialect,
|
|
56
|
+
"version": version,
|
|
57
|
+
"native_identity": "matched",
|
|
58
|
+
"runtime_privileges": "qualified",
|
|
59
|
+
"connection_tls": tls,
|
|
60
|
+
}
|
|
61
|
+
with sde.Session(
|
|
62
|
+
logical, placement, runtime, project_id=settings["project_id"]
|
|
63
|
+
) as session:
|
|
64
|
+
storage = session.measure_storage()
|
|
65
|
+
# Whether each engine can give the window a group's size, by the class of the reason -
|
|
66
|
+
# never the size itself. An engine that holds no group's source has nothing to measure.
|
|
67
|
+
measured = {size.engine for size in storage.sizes}
|
|
68
|
+
for name, result in results.items():
|
|
69
|
+
sources = [
|
|
70
|
+
group for group, spot in placement.groups.items() if spot.source.engine == name
|
|
71
|
+
]
|
|
72
|
+
reasons = sorted(
|
|
73
|
+
{storage.unavailable[group] for group in sources if group in storage.unavailable}
|
|
74
|
+
)
|
|
75
|
+
result["storage_telemetry"] = (
|
|
76
|
+
"no_source" if not sources
|
|
77
|
+
else "available" if name in measured and not reasons
|
|
78
|
+
else "unavailable: " + ", ".join(reasons)
|
|
79
|
+
)
|
|
80
|
+
return {
|
|
81
|
+
"protocol": 1,
|
|
82
|
+
"status": "ready",
|
|
83
|
+
"scope": "local-weather-demo",
|
|
84
|
+
"sdk_version": sde.__version__,
|
|
85
|
+
"python_version": platform.python_version(),
|
|
86
|
+
"project_id": settings["project_id"],
|
|
87
|
+
"model_version": logical.version,
|
|
88
|
+
"map_version": placement.map_version,
|
|
89
|
+
"signature": "verified",
|
|
90
|
+
"engines": results,
|
|
91
|
+
"production_qualification": "not_performed",
|
|
92
|
+
}
|
sde_demo/model.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The fixed, portable model used by the customer Weather starter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
import sde
|
|
12
|
+
from sde.testing.loader import model_from_neutral
|
|
13
|
+
|
|
14
|
+
DECLARATION: dict[str, Any] = {
|
|
15
|
+
"entities": [
|
|
16
|
+
{
|
|
17
|
+
"name": "WeatherReading",
|
|
18
|
+
"fields": [
|
|
19
|
+
{"name": "at", "type": "timestamptz"},
|
|
20
|
+
{"name": "celsius", "type": "decimal(8,2)"},
|
|
21
|
+
{"name": "humidity", "type": "int64"},
|
|
22
|
+
{"name": "id", "type": "uuid"},
|
|
23
|
+
{"name": "station", "type": "string"},
|
|
24
|
+
],
|
|
25
|
+
"key": ["station", "at"],
|
|
26
|
+
"residency": "EU",
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"relations": [],
|
|
30
|
+
"atomic": [],
|
|
31
|
+
"cost_ceiling": {"amount": "500.00", "currency": "EUR"},
|
|
32
|
+
}
|
|
33
|
+
BASE_TIME = datetime(2026, 1, 1, tzinfo=UTC)
|
|
34
|
+
STATION_TEMPLATE = "weather-{run_id}-{worker}"
|
|
35
|
+
SEED_TEMPLATE = "sde-weather-v1:{run_id}:{worker}:{sequence}"
|
|
36
|
+
CELSIUS_BASE_CENTS, CELSIUS_MODULUS = 1525, 1000
|
|
37
|
+
HUMIDITY_BASE, HUMIDITY_MODULUS = 30, 70
|
|
38
|
+
UUID_VERSION = 4
|
|
39
|
+
WORKLOADS = ("mixed", "point", "analytics", "fleet", "alerts")
|
|
40
|
+
"""What a run can drive. The CLI offers and the runtime accepts this one tuple."""
|
|
41
|
+
GENERATOR_SPEC = {
|
|
42
|
+
"kind": "sde-weather-generator",
|
|
43
|
+
"version": 1,
|
|
44
|
+
"worker": 0,
|
|
45
|
+
"station_template": STATION_TEMPLATE,
|
|
46
|
+
"seed_template": SEED_TEMPLATE,
|
|
47
|
+
"base_time": BASE_TIME.isoformat(timespec="microseconds").replace("+00:00", "Z"),
|
|
48
|
+
"timestamp_step_microseconds": 1,
|
|
49
|
+
"celsius_base_cents": CELSIUS_BASE_CENTS,
|
|
50
|
+
"celsius_modulus": CELSIUS_MODULUS,
|
|
51
|
+
"humidity_base": HUMIDITY_BASE,
|
|
52
|
+
"humidity_modulus": HUMIDITY_MODULUS,
|
|
53
|
+
"id": "first 16 bytes of SHA256(UTF8(seed)), RFC4122 UUID version bits",
|
|
54
|
+
"uuid_version": UUID_VERSION,
|
|
55
|
+
}
|
|
56
|
+
GENERATOR_ID = "weather-v1:" + hashlib.sha256(sde.canonical_bytes(GENERATOR_SPEC)).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def model() -> sde.LogicalModel:
|
|
60
|
+
return model_from_neutral(DECLARATION)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reading(run_id: str, worker: int, sequence: int) -> dict[str, Any]:
|
|
64
|
+
"""Deterministic synthetic values; the run namespace prevents keys crossing runs."""
|
|
65
|
+
identity = hashlib.sha256(
|
|
66
|
+
SEED_TEMPLATE.format(run_id=run_id, worker=worker, sequence=sequence).encode()
|
|
67
|
+
).digest()[:16]
|
|
68
|
+
cents = CELSIUS_BASE_CENTS + sequence % CELSIUS_MODULUS
|
|
69
|
+
return {
|
|
70
|
+
"station": STATION_TEMPLATE.format(run_id=run_id, worker=worker),
|
|
71
|
+
"at": BASE_TIME + timedelta(microseconds=sequence),
|
|
72
|
+
"celsius": Decimal(f"{cents // 100}.{cents % 100:02d}"),
|
|
73
|
+
"humidity": HUMIDITY_BASE + sequence % HUMIDITY_MODULUS,
|
|
74
|
+
"id": UUID(bytes=identity, version=UUID_VERSION),
|
|
75
|
+
}
|