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/__init__.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Smart Data Engine - client library.
|
|
2
|
+
|
|
3
|
+
You declare entities and relations. We decide which database engine each colocation group lives in,
|
|
4
|
+
what its physical layout is there, and when it should move - and your code never names a table or an
|
|
5
|
+
engine, which is exactly what lets us change both without touching it.
|
|
6
|
+
|
|
7
|
+
from datetime import datetime from decimal import Decimal from typing import Annotated from uuid
|
|
8
|
+
import UUID import sde
|
|
9
|
+
|
|
10
|
+
@sde.entity
|
|
11
|
+
class User:
|
|
12
|
+
id: UUID email: str
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
pii = ["email"]
|
|
16
|
+
|
|
17
|
+
@sde.entity
|
|
18
|
+
class Order:
|
|
19
|
+
id: UUID user: sde.Ref[User] total: Annotated[Decimal, sde.precision(12, 2)] created_at:
|
|
20
|
+
datetime
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
atomic_with = ["Payment"] residency = "EU"
|
|
24
|
+
|
|
25
|
+
Everything about storage is our decision. The four things you declare - atomicity, residency,
|
|
26
|
+
personal data and a cost ceiling - are the ones that cannot be read from traffic no matter how long
|
|
27
|
+
we watch it.
|
|
28
|
+
|
|
29
|
+
This library is Apache-2.0 and it works without an account: hand it a placement map you wrote
|
|
30
|
+
yourself and it will route, create schema and run, with no key and no network. That is a supported
|
|
31
|
+
mode, not a loophole.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from .bulk import MAX_BATCH_ROWS, MAX_BATCH_VALUES, BulkWritable
|
|
37
|
+
from .canonical import CanonicalError, canonical_bytes, canonical_str, digest16
|
|
38
|
+
from .capabilities import members_of, satisfies
|
|
39
|
+
from .cutover import CUTOVER_PROTOCOL, CUTOVER_RELAYOUT_PROTOCOL, CutoverPlan, load_cutover_plan
|
|
40
|
+
from .entity import Ref, clear_registry, entity, registry
|
|
41
|
+
from .errors import (
|
|
42
|
+
BulkWriteRefused,
|
|
43
|
+
DeclarationError,
|
|
44
|
+
EngineError,
|
|
45
|
+
MapError,
|
|
46
|
+
MapRolledBack,
|
|
47
|
+
MigrationRefused,
|
|
48
|
+
ModelPlanningError,
|
|
49
|
+
ResourceBusy,
|
|
50
|
+
ResourceClosed,
|
|
51
|
+
SdeError,
|
|
52
|
+
)
|
|
53
|
+
from .explain import Cost, Explains, PlanFinding, QueryPlan, QueryPlanRefused, explain
|
|
54
|
+
from .frozen_verification import FrozenVerifyReport, verify_frozen
|
|
55
|
+
from .groups import Group, colocation_groups, group_of
|
|
56
|
+
from .hashing import NameMap, hash_identifiers, load_or_create_salt
|
|
57
|
+
from .index_build import (
|
|
58
|
+
INDEX_CHANGE_PROTOCOL,
|
|
59
|
+
INDEX_PROTOCOL,
|
|
60
|
+
IndexPlan,
|
|
61
|
+
IndexReceipt,
|
|
62
|
+
index_build_name,
|
|
63
|
+
load_index_plan,
|
|
64
|
+
)
|
|
65
|
+
from .infer import InferredModel, Note, infer_model, infer_models
|
|
66
|
+
from .inspection import InspectionContext
|
|
67
|
+
from .internal import internal_failures, reset_internal_failures
|
|
68
|
+
from .layout import (
|
|
69
|
+
DIALECTS,
|
|
70
|
+
FIXED_SCHEMA,
|
|
71
|
+
ORDERBOOK_KEY,
|
|
72
|
+
ORDERBOOK_SHAPE,
|
|
73
|
+
ORDERBOOK_TABLE,
|
|
74
|
+
DerivedLayout,
|
|
75
|
+
can_store,
|
|
76
|
+
default_layout,
|
|
77
|
+
denormalized_layout,
|
|
78
|
+
fixed_schema_mismatch,
|
|
79
|
+
group_columns,
|
|
80
|
+
snake_case,
|
|
81
|
+
stored_types,
|
|
82
|
+
)
|
|
83
|
+
from .local_cutover import CutoverReceipt, CutoverRecoveryRequired, LocalCutover, load_local_map
|
|
84
|
+
from .migration import (
|
|
85
|
+
BACKFILL_TABLE,
|
|
86
|
+
CHUNK_ROWS,
|
|
87
|
+
DIALECT_PRECISION,
|
|
88
|
+
PRECISION_INDEPENDENT,
|
|
89
|
+
BackfillProgress,
|
|
90
|
+
Difference,
|
|
91
|
+
EntityProgress,
|
|
92
|
+
Migratable,
|
|
93
|
+
VerifyReport,
|
|
94
|
+
backfill,
|
|
95
|
+
precision_refusal,
|
|
96
|
+
verify,
|
|
97
|
+
)
|
|
98
|
+
from .model import CONTRACT, LogicalModel, build_model, neutral_declaration
|
|
99
|
+
from .physical import PHYSICAL_DESIGN_SINCE, PhysicalFinding
|
|
100
|
+
from .placement import (
|
|
101
|
+
ALSO_WRITE_SINCE,
|
|
102
|
+
MAP_CONTRACT,
|
|
103
|
+
MAP_CONTRACT_FLOOR,
|
|
104
|
+
RESERVED_TABLES,
|
|
105
|
+
GroupPlacement,
|
|
106
|
+
Materialization,
|
|
107
|
+
PhysicalLayout,
|
|
108
|
+
PlacementMap,
|
|
109
|
+
load_map,
|
|
110
|
+
)
|
|
111
|
+
from .provisioning import prepare_schema
|
|
112
|
+
from .query import (
|
|
113
|
+
MAX_PAGE_ROWS,
|
|
114
|
+
NumericSummary,
|
|
115
|
+
Queryable,
|
|
116
|
+
QueryRefused,
|
|
117
|
+
Range,
|
|
118
|
+
ScanPage,
|
|
119
|
+
Summarizable,
|
|
120
|
+
)
|
|
121
|
+
from .routing import Router, resolve
|
|
122
|
+
from .schema import CompatibilityViews, compatibility_views, schema_is_fixed, schema_statements
|
|
123
|
+
from .session import Engine, ManagedEngine, Session
|
|
124
|
+
from .shapes import SHAPE_KINDS, WRITE_KINDS, OperationShape, enumerate_shapes
|
|
125
|
+
from .staging import (
|
|
126
|
+
STAGING_PROTOCOL,
|
|
127
|
+
STAGING_RELAYOUT_PROTOCOL,
|
|
128
|
+
StagingPlan,
|
|
129
|
+
StagingReceipt,
|
|
130
|
+
load_staging_plan,
|
|
131
|
+
staging_table_name,
|
|
132
|
+
)
|
|
133
|
+
from .telemetry import (
|
|
134
|
+
MEASURED_FIELDS,
|
|
135
|
+
CopyFreshness,
|
|
136
|
+
FanOutStats,
|
|
137
|
+
GroupFeatures,
|
|
138
|
+
Histogram,
|
|
139
|
+
Recorder,
|
|
140
|
+
ShapeStats,
|
|
141
|
+
StorageMeasurement,
|
|
142
|
+
StorageSample,
|
|
143
|
+
StorageSize,
|
|
144
|
+
Window,
|
|
145
|
+
has_time_dimension,
|
|
146
|
+
time_fields,
|
|
147
|
+
)
|
|
148
|
+
from .types import Float32, Int32, Json, Timestamp, precision
|
|
149
|
+
from .watermark import (
|
|
150
|
+
WATERMARK_TABLE,
|
|
151
|
+
Protection,
|
|
152
|
+
WatermarkCheck,
|
|
153
|
+
WatermarkStore,
|
|
154
|
+
enforce_forward_only,
|
|
155
|
+
)
|
|
156
|
+
from .write_fence import EPOCH_COLUMN as WRITE_EPOCH_COLUMN
|
|
157
|
+
from .write_fence import FenceState, WriteFence
|
|
158
|
+
|
|
159
|
+
__version__ = "0.1.0"
|
|
160
|
+
|
|
161
|
+
__all__ = [
|
|
162
|
+
"ALSO_WRITE_SINCE",
|
|
163
|
+
"BACKFILL_TABLE",
|
|
164
|
+
"CHUNK_ROWS",
|
|
165
|
+
"CONTRACT",
|
|
166
|
+
"CUTOVER_PROTOCOL",
|
|
167
|
+
"CUTOVER_RELAYOUT_PROTOCOL",
|
|
168
|
+
"DIALECTS",
|
|
169
|
+
"DIALECT_PRECISION",
|
|
170
|
+
"FIXED_SCHEMA",
|
|
171
|
+
"INDEX_CHANGE_PROTOCOL",
|
|
172
|
+
"INDEX_PROTOCOL",
|
|
173
|
+
"MAP_CONTRACT",
|
|
174
|
+
"MAP_CONTRACT_FLOOR",
|
|
175
|
+
"MAX_BATCH_ROWS",
|
|
176
|
+
"MAX_BATCH_VALUES",
|
|
177
|
+
"MAX_PAGE_ROWS",
|
|
178
|
+
"MEASURED_FIELDS",
|
|
179
|
+
"ORDERBOOK_KEY",
|
|
180
|
+
"ORDERBOOK_SHAPE",
|
|
181
|
+
"ORDERBOOK_TABLE",
|
|
182
|
+
"PHYSICAL_DESIGN_SINCE",
|
|
183
|
+
"PRECISION_INDEPENDENT",
|
|
184
|
+
"RESERVED_TABLES",
|
|
185
|
+
"SHAPE_KINDS",
|
|
186
|
+
"STAGING_PROTOCOL",
|
|
187
|
+
"STAGING_RELAYOUT_PROTOCOL",
|
|
188
|
+
"WATERMARK_TABLE",
|
|
189
|
+
"WRITE_EPOCH_COLUMN",
|
|
190
|
+
"WRITE_KINDS",
|
|
191
|
+
"BackfillProgress",
|
|
192
|
+
"BulkWritable",
|
|
193
|
+
"BulkWriteRefused",
|
|
194
|
+
"CanonicalError",
|
|
195
|
+
"CompatibilityViews",
|
|
196
|
+
"CopyFreshness",
|
|
197
|
+
"Cost",
|
|
198
|
+
"CutoverPlan",
|
|
199
|
+
"CutoverReceipt",
|
|
200
|
+
"CutoverRecoveryRequired",
|
|
201
|
+
"DeclarationError",
|
|
202
|
+
"DerivedLayout",
|
|
203
|
+
"Difference",
|
|
204
|
+
"Engine",
|
|
205
|
+
"EngineError",
|
|
206
|
+
"EntityProgress",
|
|
207
|
+
"Explains",
|
|
208
|
+
"FanOutStats",
|
|
209
|
+
"FenceState",
|
|
210
|
+
"Float32",
|
|
211
|
+
"FrozenVerifyReport",
|
|
212
|
+
"Group",
|
|
213
|
+
"GroupFeatures",
|
|
214
|
+
"GroupPlacement",
|
|
215
|
+
"Histogram",
|
|
216
|
+
"IndexPlan",
|
|
217
|
+
"IndexReceipt",
|
|
218
|
+
"InferredModel",
|
|
219
|
+
"InspectionContext",
|
|
220
|
+
"Int32",
|
|
221
|
+
"Json",
|
|
222
|
+
"LocalCutover",
|
|
223
|
+
"LogicalModel",
|
|
224
|
+
"ManagedEngine",
|
|
225
|
+
"MapError",
|
|
226
|
+
"MapRolledBack",
|
|
227
|
+
"Materialization",
|
|
228
|
+
"Migratable",
|
|
229
|
+
"MigrationRefused",
|
|
230
|
+
"ModelPlanningError",
|
|
231
|
+
"NameMap",
|
|
232
|
+
"Note",
|
|
233
|
+
"NumericSummary",
|
|
234
|
+
"OperationShape",
|
|
235
|
+
"PhysicalFinding",
|
|
236
|
+
"PhysicalLayout",
|
|
237
|
+
"PlacementMap",
|
|
238
|
+
"PlanFinding",
|
|
239
|
+
"Protection",
|
|
240
|
+
"QueryPlan",
|
|
241
|
+
"QueryPlanRefused",
|
|
242
|
+
"QueryRefused",
|
|
243
|
+
"Queryable",
|
|
244
|
+
"Range",
|
|
245
|
+
"Recorder",
|
|
246
|
+
"Ref",
|
|
247
|
+
"ResourceBusy",
|
|
248
|
+
"ResourceClosed",
|
|
249
|
+
"Router",
|
|
250
|
+
"ScanPage",
|
|
251
|
+
"SdeError",
|
|
252
|
+
"Session",
|
|
253
|
+
"ShapeStats",
|
|
254
|
+
"StagingPlan",
|
|
255
|
+
"StagingReceipt",
|
|
256
|
+
"StorageMeasurement",
|
|
257
|
+
"StorageSample",
|
|
258
|
+
"StorageSize",
|
|
259
|
+
"Summarizable",
|
|
260
|
+
"Timestamp",
|
|
261
|
+
"VerificationRequest",
|
|
262
|
+
"VerifyReport",
|
|
263
|
+
"WatermarkCheck",
|
|
264
|
+
"WatermarkStore",
|
|
265
|
+
"Window",
|
|
266
|
+
"WriteFence",
|
|
267
|
+
"__version__",
|
|
268
|
+
"backfill",
|
|
269
|
+
"build_model",
|
|
270
|
+
"can_store",
|
|
271
|
+
"canonical_bytes",
|
|
272
|
+
"canonical_str",
|
|
273
|
+
"clear_registry",
|
|
274
|
+
"colocation_groups",
|
|
275
|
+
"compatibility_views",
|
|
276
|
+
"default_layout",
|
|
277
|
+
"denormalized_layout",
|
|
278
|
+
"digest16",
|
|
279
|
+
"enforce_forward_only",
|
|
280
|
+
"entity",
|
|
281
|
+
"enumerate_shapes",
|
|
282
|
+
"explain",
|
|
283
|
+
"fixed_schema_mismatch",
|
|
284
|
+
"group_columns",
|
|
285
|
+
"group_of",
|
|
286
|
+
"has_time_dimension",
|
|
287
|
+
"hash_identifiers",
|
|
288
|
+
"index_build_name",
|
|
289
|
+
"infer_model",
|
|
290
|
+
"infer_models",
|
|
291
|
+
"internal_failures",
|
|
292
|
+
"load_cutover_plan",
|
|
293
|
+
"load_index_plan",
|
|
294
|
+
"load_local_map",
|
|
295
|
+
"load_map",
|
|
296
|
+
"load_or_create_salt",
|
|
297
|
+
"load_staging_plan",
|
|
298
|
+
"members_of",
|
|
299
|
+
"neutral_declaration",
|
|
300
|
+
"precision",
|
|
301
|
+
"precision_refusal",
|
|
302
|
+
"prepare_schema",
|
|
303
|
+
"registry",
|
|
304
|
+
"reset_internal_failures",
|
|
305
|
+
"resolve",
|
|
306
|
+
"satisfies",
|
|
307
|
+
"schema_is_fixed",
|
|
308
|
+
"schema_statements",
|
|
309
|
+
"snake_case",
|
|
310
|
+
"staging_table_name",
|
|
311
|
+
"stored_types",
|
|
312
|
+
"time_fields",
|
|
313
|
+
"verification_request",
|
|
314
|
+
"verify",
|
|
315
|
+
"verify_frozen",
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
from .verification import VerificationRequest, verification_request
|
sde/_cutover_project.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Durable project state for a client-side cutover; no rows or credentials are stored here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import _local_state
|
|
11
|
+
from .errors import MigrationRefused
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def encode(value: Any) -> bytes:
|
|
15
|
+
# Native principal/namespace names are exact strings, not canonical map identifiers.
|
|
16
|
+
# Keep their spelling; the state checksum is over this explicitly versioned raw-JSON form.
|
|
17
|
+
return json.dumps(
|
|
18
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
19
|
+
).encode("utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _abandoned_stages(payload: Any) -> bool:
|
|
23
|
+
stages = payload.get("stages") if isinstance(payload, dict) else None
|
|
24
|
+
if not isinstance(stages, dict):
|
|
25
|
+
return False
|
|
26
|
+
return any(
|
|
27
|
+
isinstance(record, dict)
|
|
28
|
+
and isinstance(record.get("receipt"), dict)
|
|
29
|
+
and record["receipt"].get("outcome") == "abandoned"
|
|
30
|
+
for record in stages.values()
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _index_changes(payload: Any) -> bool:
|
|
35
|
+
"""Whether an in-place index record - executing or kept - also removes indexes (protocol 2)."""
|
|
36
|
+
if not isinstance(payload, dict):
|
|
37
|
+
return False
|
|
38
|
+
records: list[Any] = []
|
|
39
|
+
history = payload.get("indexes")
|
|
40
|
+
if isinstance(history, dict):
|
|
41
|
+
records.extend(history.values())
|
|
42
|
+
execution = payload.get("execution")
|
|
43
|
+
if isinstance(execution, dict) and execution.get("kind") == "index":
|
|
44
|
+
records.append(execution)
|
|
45
|
+
return any(
|
|
46
|
+
isinstance(record, dict)
|
|
47
|
+
and isinstance(record.get("plan"), dict)
|
|
48
|
+
and record["plan"].get("protocol") == 2
|
|
49
|
+
for record in records
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProjectState:
|
|
54
|
+
"""One POSIX directory and lock, shared by all local executors for this project."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, root: Path, project_id: str, model_version: str) -> None:
|
|
57
|
+
self.root = root.resolve()
|
|
58
|
+
self.project_id = project_id
|
|
59
|
+
self.model_version = model_version
|
|
60
|
+
self.path = self.root / "project.json"
|
|
61
|
+
self.map_path = self.root / "active-map.json"
|
|
62
|
+
|
|
63
|
+
def lock(self) -> Any:
|
|
64
|
+
return _local_state.transaction(self.root)
|
|
65
|
+
|
|
66
|
+
def read(self) -> dict[str, Any]:
|
|
67
|
+
try:
|
|
68
|
+
envelope = json.loads(self.path.read_bytes())
|
|
69
|
+
if not isinstance(envelope, dict) or set(envelope) != {
|
|
70
|
+
"storage_contract",
|
|
71
|
+
"payload",
|
|
72
|
+
"sha256",
|
|
73
|
+
}:
|
|
74
|
+
raise ValueError("invalid state envelope")
|
|
75
|
+
if type(envelope["storage_contract"]) is not int or envelope[
|
|
76
|
+
"storage_contract"
|
|
77
|
+
] not in (1, 2, 3, 4, 5):
|
|
78
|
+
raise ValueError("unsupported state storage contract")
|
|
79
|
+
payload = envelope["payload"]
|
|
80
|
+
if (
|
|
81
|
+
not isinstance(payload, dict)
|
|
82
|
+
or hashlib.sha256(encode(payload)).hexdigest() != envelope["sha256"]
|
|
83
|
+
):
|
|
84
|
+
raise ValueError("state checksum mismatch")
|
|
85
|
+
if (
|
|
86
|
+
payload.get("project_id") != self.project_id
|
|
87
|
+
or payload.get("model_version") != self.model_version
|
|
88
|
+
):
|
|
89
|
+
raise ValueError("state belongs to another local project/model")
|
|
90
|
+
fields = {
|
|
91
|
+
"project_id",
|
|
92
|
+
"model_version",
|
|
93
|
+
"active_map",
|
|
94
|
+
"execution",
|
|
95
|
+
"completed",
|
|
96
|
+
"retired_names",
|
|
97
|
+
}
|
|
98
|
+
if envelope["storage_contract"] >= 2:
|
|
99
|
+
fields.add("stages")
|
|
100
|
+
if not isinstance(payload.get("stages"), dict):
|
|
101
|
+
raise ValueError("staging history must be an object")
|
|
102
|
+
if envelope["storage_contract"] >= 3:
|
|
103
|
+
# In-place index builds: a reader of contracts 1 and 2 refuses this state rather
|
|
104
|
+
# than ignoring a history it would not know to keep.
|
|
105
|
+
fields.add("indexes")
|
|
106
|
+
if not isinstance(payload.get("indexes"), dict):
|
|
107
|
+
raise ValueError("index build history must be an object")
|
|
108
|
+
if envelope["storage_contract"] < 4 and _abandoned_stages(payload):
|
|
109
|
+
# Contract 4 is what makes an older operator refuse an abandoned staging's record
|
|
110
|
+
# instead of reading a receipt whose tables may name no identity.
|
|
111
|
+
raise ValueError("an abandoned staging needs state storage contract 4")
|
|
112
|
+
if envelope["storage_contract"] < 5 and _index_changes(payload):
|
|
113
|
+
# Contract 5 is what makes an older operator refuse a build that also removes
|
|
114
|
+
# indexes, instead of resuming it with no idea that removals follow the decision.
|
|
115
|
+
raise ValueError("an index change needs state storage contract 5")
|
|
116
|
+
if set(payload) != fields:
|
|
117
|
+
raise ValueError("unknown or missing project state fields")
|
|
118
|
+
return payload
|
|
119
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
120
|
+
raise MigrationRefused(
|
|
121
|
+
"local cutover state is missing or corrupt; "
|
|
122
|
+
"restore verified state before proceeding"
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
def confirm(self) -> None:
|
|
126
|
+
# Call under the project lock. Neither reading matching bytes nor an in-memory receipt
|
|
127
|
+
# confirms the directory fsync that may have failed after an earlier publication.
|
|
128
|
+
_local_state.confirm_file(self.map_path)
|
|
129
|
+
_local_state.confirm_file(self.path)
|
|
130
|
+
|
|
131
|
+
def write(self, payload: dict[str, Any]) -> None:
|
|
132
|
+
body = encode(payload)
|
|
133
|
+
envelope = {
|
|
134
|
+
"storage_contract": 5
|
|
135
|
+
if _index_changes(payload)
|
|
136
|
+
else 4
|
|
137
|
+
if _abandoned_stages(payload)
|
|
138
|
+
else 3
|
|
139
|
+
if "indexes" in payload
|
|
140
|
+
else 2
|
|
141
|
+
if "stages" in payload
|
|
142
|
+
else 1,
|
|
143
|
+
"payload": payload,
|
|
144
|
+
"sha256": hashlib.sha256(body).hexdigest(),
|
|
145
|
+
}
|
|
146
|
+
_local_state.write_bytes(self.path, encode(envelope))
|
|
147
|
+
|
|
148
|
+
def enroll(self, document: dict[str, Any], map_payload: bytes) -> None:
|
|
149
|
+
with self.lock():
|
|
150
|
+
if self.map_path.exists() and self.map_path.read_bytes() != map_payload:
|
|
151
|
+
raise MigrationRefused("an existing active map differs from the enrolled state")
|
|
152
|
+
if self.path.exists():
|
|
153
|
+
state = self.read()
|
|
154
|
+
if (
|
|
155
|
+
state["active_map"] != document
|
|
156
|
+
or state["execution"] is not None
|
|
157
|
+
or state["completed"]
|
|
158
|
+
):
|
|
159
|
+
raise MigrationRefused(
|
|
160
|
+
"local project is already enrolled; use its current state"
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
state = {
|
|
164
|
+
"project_id": self.project_id,
|
|
165
|
+
"model_version": self.model_version,
|
|
166
|
+
"active_map": document,
|
|
167
|
+
"execution": None,
|
|
168
|
+
"completed": {},
|
|
169
|
+
"retired_names": [],
|
|
170
|
+
}
|
|
171
|
+
self.write(state)
|
|
172
|
+
if not self.map_path.exists():
|
|
173
|
+
_local_state.write_bytes(self.map_path, map_payload, replace=False)
|
|
174
|
+
# Matching visible files may come from a publication whose directory fsync failed.
|
|
175
|
+
# An identical retry must confirm their durability before enrollment succeeds.
|
|
176
|
+
self.confirm()
|
|
177
|
+
|
|
178
|
+
def publish(self, payload: bytes) -> None:
|
|
179
|
+
_local_state.write_bytes(self.map_path, payload)
|
sde/_local_state.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Private implementation of durable local executor files on a POSIX filesystem.
|
|
2
|
+
|
|
3
|
+
A local executor project has one writer at a time. The lock covers the read/decide/write
|
|
4
|
+
operation, not just the final syscall; a per-process reentrant mutex and flock cover threads
|
|
5
|
+
and independent processes.
|
|
6
|
+
All state files are published only after their complete contents have been fsynced. Immutable
|
|
7
|
+
files use link, so even a writer outside the lock cannot overwrite an existing artifact.
|
|
8
|
+
|
|
9
|
+
Readers opening a file see an old or new complete inode. Multi-file readers must hold transaction(),
|
|
10
|
+
as the local executor does for the entire operation. This is not a cross-host/NFS locking protocol.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import errno
|
|
16
|
+
import fcntl
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
from collections.abc import Callable, Iterator
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from functools import wraps
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from threading import Lock, RLock
|
|
24
|
+
from typing import Concatenate, ParamSpec, Protocol, TypeVar
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DurabilityUncertain(OSError):
|
|
28
|
+
"""Publication happened, but its durability could not be confirmed. Inspect before retrying."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _LockState:
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.mutex = RLock()
|
|
34
|
+
self.fd: int | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_locks: dict[Path, _LockState] = {}
|
|
38
|
+
_guard = Lock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _after_fork() -> None:
|
|
42
|
+
# Close inherited descriptions, without LOCK_UN (which would unlock the parent's flock).
|
|
43
|
+
# A child must neither inherit a reentrancy decision nor keep a dead parent's lock alive.
|
|
44
|
+
global _guard
|
|
45
|
+
for _, state in sorted(_locks.items()):
|
|
46
|
+
if state.fd is not None:
|
|
47
|
+
os.close(state.fd)
|
|
48
|
+
_locks.clear()
|
|
49
|
+
_guard = Lock()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _before_fork() -> None:
|
|
53
|
+
# Fork must not fall between opening/closing a descriptor and updating its registration.
|
|
54
|
+
_guard.acquire()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _after_parent_fork() -> None:
|
|
58
|
+
_guard.release()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
os.register_at_fork(
|
|
62
|
+
before=_before_fork, after_in_parent=_after_parent_fork, after_in_child=_after_fork
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sync_directory(path: Path) -> None:
|
|
67
|
+
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
|
68
|
+
try:
|
|
69
|
+
os.fsync(fd)
|
|
70
|
+
finally:
|
|
71
|
+
os.close(fd)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def confirm_file(path: Path) -> None:
|
|
75
|
+
"""Confirm an already visible result before treating an uncertain publication as complete."""
|
|
76
|
+
fd = os.open(path, os.O_RDONLY)
|
|
77
|
+
try:
|
|
78
|
+
os.fsync(fd)
|
|
79
|
+
finally:
|
|
80
|
+
os.close(fd)
|
|
81
|
+
sync_directory(path.parent)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_directory(path: Path) -> None:
|
|
85
|
+
"""Create parents and persist the directory entries, not only the files placed in them."""
|
|
86
|
+
if path.is_dir():
|
|
87
|
+
return
|
|
88
|
+
ensure_directory(path.parent)
|
|
89
|
+
try:
|
|
90
|
+
path.mkdir(mode=0o700)
|
|
91
|
+
except FileExistsError:
|
|
92
|
+
if not path.is_dir():
|
|
93
|
+
raise
|
|
94
|
+
sync_directory(path.parent)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@contextmanager
|
|
98
|
+
def transaction(directory: Path) -> Iterator[None]:
|
|
99
|
+
root = directory.resolve()
|
|
100
|
+
ensure_directory(root)
|
|
101
|
+
with _guard:
|
|
102
|
+
state = _locks.setdefault(root, _LockState())
|
|
103
|
+
with state.mutex:
|
|
104
|
+
if state.fd is not None:
|
|
105
|
+
yield
|
|
106
|
+
return
|
|
107
|
+
with _guard:
|
|
108
|
+
fd = os.open(root / ".state.lock", os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
|
|
109
|
+
state.fd = fd
|
|
110
|
+
owner_pid = os.getpid()
|
|
111
|
+
try:
|
|
112
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
113
|
+
yield
|
|
114
|
+
finally:
|
|
115
|
+
# Closing releases flock even after an exception. The lock file is never unlinked:
|
|
116
|
+
# replacing it could give a new process a different inode from an existing waiter.
|
|
117
|
+
if os.getpid() == owner_pid:
|
|
118
|
+
with _guard:
|
|
119
|
+
state.fd = None
|
|
120
|
+
os.close(fd)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _Store(Protocol):
|
|
124
|
+
@property
|
|
125
|
+
def root(self) -> Path: ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
S = TypeVar("S", bound=_Store)
|
|
129
|
+
P = ParamSpec("P")
|
|
130
|
+
R = TypeVar("R")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def serialized(method: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
|
|
134
|
+
@wraps(method)
|
|
135
|
+
def wrapped(self: S, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
136
|
+
with transaction(self.root):
|
|
137
|
+
return method(self, *args, **kwargs)
|
|
138
|
+
|
|
139
|
+
return wrapped
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_all(fd: int, payload: bytes) -> None:
|
|
143
|
+
remaining = memoryview(payload)
|
|
144
|
+
while remaining:
|
|
145
|
+
written = os.write(fd, remaining)
|
|
146
|
+
if written == 0:
|
|
147
|
+
raise OSError(errno.EIO, "state write made no progress")
|
|
148
|
+
remaining = remaining[written:]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def write_bytes(path: Path, payload: bytes, *, replace: bool = True) -> None:
|
|
152
|
+
"""Publish a complete, durable file, with mode 0600 before any bytes are written.
|
|
153
|
+
|
|
154
|
+
Before publication, failure leaves the prior file untouched (or the name absent). After
|
|
155
|
+
publication an fsync failure is an explicitly uncertain result, never a claimed rollback.
|
|
156
|
+
"""
|
|
157
|
+
with transaction(path.parent):
|
|
158
|
+
ensure_directory(path.parent)
|
|
159
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
160
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
161
|
+
)
|
|
162
|
+
temporary = Path(temporary_name)
|
|
163
|
+
published = False
|
|
164
|
+
try:
|
|
165
|
+
try:
|
|
166
|
+
_write_all(fd, payload)
|
|
167
|
+
os.fsync(fd)
|
|
168
|
+
finally:
|
|
169
|
+
os.close(fd)
|
|
170
|
+
if replace:
|
|
171
|
+
os.replace(temporary, path)
|
|
172
|
+
else:
|
|
173
|
+
os.link(temporary, path)
|
|
174
|
+
published = True
|
|
175
|
+
sync_directory(path.parent)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
if published:
|
|
178
|
+
raise DurabilityUncertain(
|
|
179
|
+
f"{path.name} was published, but directory durability could not be confirmed. "
|
|
180
|
+
"Inspect the stored state before retrying; the previous value was not restored."
|
|
181
|
+
) from exc
|
|
182
|
+
raise
|
|
183
|
+
finally:
|
|
184
|
+
temporary.unlink(missing_ok=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_text(path: Path, text: str, *, replace: bool = True) -> None:
|
|
188
|
+
write_bytes(path, text.encode("utf-8"), replace=replace)
|