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.
Files changed (73) hide show
  1. sde/__init__.py +318 -0
  2. sde/_cutover_project.py +179 -0
  3. sde/_local_state.py +188 -0
  4. sde/_operator_deadline.py +50 -0
  5. sde/_usage.py +314 -0
  6. sde/bulk.py +79 -0
  7. sde/canonical.py +141 -0
  8. sde/capabilities.py +62 -0
  9. sde/cutover.py +286 -0
  10. sde/engines/__init__.py +0 -0
  11. sde/engines/_clickhouse_connection.py +224 -0
  12. sde/engines/_index_build.py +294 -0
  13. sde/engines/_operator.py +394 -0
  14. sde/engines/_staging.py +222 -0
  15. sde/engines/_storage.py +22 -0
  16. sde/engines/_write_fences.py +271 -0
  17. sde/engines/clickhouse.py +1115 -0
  18. sde/engines/orderbook.py +457 -0
  19. sde/engines/postgres.py +967 -0
  20. sde/entity.py +170 -0
  21. sde/errors.py +103 -0
  22. sde/explain.py +300 -0
  23. sde/frozen_verification.py +152 -0
  24. sde/generation.py +131 -0
  25. sde/groups.py +97 -0
  26. sde/hashing.py +242 -0
  27. sde/index_build.py +313 -0
  28. sde/index_operator.py +347 -0
  29. sde/infer.py +461 -0
  30. sde/inspection.py +62 -0
  31. sde/internal.py +90 -0
  32. sde/layout.py +669 -0
  33. sde/local_cutover.py +801 -0
  34. sde/logging.py +143 -0
  35. sde/migration.py +856 -0
  36. sde/model.py +482 -0
  37. sde/physical.py +531 -0
  38. sde/placement.py +1010 -0
  39. sde/provisioning.py +63 -0
  40. sde/py.typed +0 -0
  41. sde/query.py +521 -0
  42. sde/routing.py +85 -0
  43. sde/schema.py +466 -0
  44. sde/session.py +993 -0
  45. sde/shapes.py +153 -0
  46. sde/staging.py +264 -0
  47. sde/staging_operator.py +393 -0
  48. sde/telemetry.py +1087 -0
  49. sde/testing/__init__.py +14 -0
  50. sde/testing/loader.py +175 -0
  51. sde/testing/memory.py +331 -0
  52. sde/types.py +228 -0
  53. sde/verification.py +220 -0
  54. sde/watermark.py +222 -0
  55. sde/write_fence.py +283 -0
  56. sde_demo/__init__.py +1 -0
  57. sde_demo/__main__.py +183 -0
  58. sde_demo/diagnostics.py +92 -0
  59. sde_demo/model.py +75 -0
  60. sde_demo/project.py +312 -0
  61. sde_demo/py.typed +0 -0
  62. sde_demo/query_count.py +301 -0
  63. sde_demo/resources.py +969 -0
  64. sde_demo/runtime.py +419 -0
  65. sde_demo/verification.py +242 -0
  66. sde_operator/__init__.py +1 -0
  67. sde_operator/__main__.py +210 -0
  68. smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
  69. smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
  70. smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
  71. smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  72. smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  73. 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
@@ -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)