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/types.py ADDED
@@ -0,0 +1,228 @@
1
+ """The neutral type vocabulary, and the mapping from Python's types onto it.
2
+
3
+ Why a vocabulary at all: ``Decimal`` in Python and ``BigDecimal`` in Java have to land on the same
4
+ bytes in the canonical IR, or the same model gets two versions and the control plane sees two
5
+ models. So no language's own type names ever reach the IR. Each library maps its host language onto
6
+ this closed set, and the mapping is part of the published contract rather than an implementation
7
+ detail.
8
+
9
+ The set:
10
+
11
+ bool int32 int64 float32 float64 decimal(p,s) string bytes uuid date timestamp timestamptz json
12
+
13
+ ``decimal`` is the only parameterised member, written ``decimal(12,2)`` - precision then scale, no
14
+ spaces. It is parameterised because a decimal without precision is not a storable type in any of the
15
+ engines we place data in, and asking the engine to pick would make the physical schema depend on
16
+ something the model did not say.
17
+
18
+ **Floats are in the vocabulary, and this needed a correction to the specification.** The first draft
19
+ of the format contract said "no floating point anywhere", conflating two different things. Canonical
20
+ *encoding* must contain no float literals, because their textual form differs between languages -
21
+ that rule stands and ``canonical.py`` enforces it. But a *field* may perfectly well be a float:
22
+ sensor readings are the obvious case, and IoT at scale is one of the segments this product is aimed
23
+ at. A field of type ``float64`` is recorded in the IR as the string ``"float64"``, so there is no
24
+ float literal involved and no conflict. Forbidding the type would have meant telling a client to
25
+ store temperatures as decimals, which is worse engineering than the rule was worth.
26
+
27
+ Two mappings are defaults rather than one-to-one, and both are documented here because a silent
28
+ default in a type mapping is the kind of thing that surfaces two years later as a timezone bug:
29
+
30
+ * ``int`` maps to ``int64``. Python's integers are unbounded, so any choice is a narrowing; 64 bits
31
+ is what every target engine has a native type for. Use :data:`Int32` to say so explicitly.
32
+ * ``datetime`` maps to ``timestamptz``. A naive timestamp is a latent bug in a system that may move
33
+ data between engines and regions, so the safe reading is the default and the narrow one has to be
34
+ asked for by name with :data:`Timestamp`.
35
+
36
+ Anything with no mapping - a bare ``Decimal``, a custom class, ``complex`` - is a
37
+ :class:`~sde.errors.DeclarationError`. Guessing would produce a physical schema the model did not
38
+ ask for.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import datetime as _dt
44
+ import decimal as _decimal
45
+ import types as _pytypes
46
+ import typing
47
+ import uuid as _uuid
48
+ from dataclasses import dataclass
49
+ from typing import Annotated, Any, Final, get_args, get_origin
50
+
51
+ from .errors import DeclarationError
52
+
53
+ __all__ = [
54
+ "NEUTRAL_TYPES",
55
+ "Float32",
56
+ "Int32",
57
+ "Json",
58
+ "Timestamp",
59
+ "precision",
60
+ "resolve_type",
61
+ ]
62
+
63
+ NEUTRAL_TYPES: Final[frozenset[str]] = frozenset(
64
+ {
65
+ "bool",
66
+ "int32",
67
+ "int64",
68
+ "float32",
69
+ "float64",
70
+ "string",
71
+ "bytes",
72
+ "uuid",
73
+ "date",
74
+ "timestamp",
75
+ "timestamptz",
76
+ "json",
77
+ # decimal is parameterised and validated by pattern, not by membership here.
78
+ }
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _Marker:
84
+ """Base for the annotations that disambiguate a mapping."""
85
+
86
+ kind: str
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class _Precision(_Marker):
91
+ digits: int
92
+ scale: int
93
+
94
+
95
+ def precision(digits: int, scale: int) -> _Precision:
96
+ """Annotate a ``Decimal`` field with its precision and scale.
97
+
98
+ ``total: Annotated[Decimal, precision(12, 2)]``
99
+
100
+ Both are required. A decimal without them is not a storable type, and letting the engine choose
101
+ would make the physical schema depend on something the model never stated.
102
+ """
103
+ if digits < 1 or scale < 0 or scale > digits:
104
+ raise DeclarationError(
105
+ f"precision({digits}, {scale}) is not a usable decimal: digits must be at least 1 and "
106
+ "scale must be between 0 and digits"
107
+ )
108
+ return _Precision(kind="precision", digits=digits, scale=scale)
109
+
110
+
111
+ Int32 = Annotated[int, _Marker(kind="int32")]
112
+ """A 32-bit integer, said explicitly. Bare ``int`` maps to ``int64``."""
113
+
114
+ Float32 = Annotated[float, _Marker(kind="float32")]
115
+ """A single-precision float. Bare ``float`` maps to ``float64``."""
116
+
117
+ Timestamp = Annotated[_dt.datetime, _Marker(kind="naive")]
118
+ """A timestamp without a zone. Bare ``datetime`` maps to ``timestamptz`` on purpose."""
119
+
120
+ Json = Annotated[object, _Marker(kind="json")]
121
+ """An opaque JSON document. Use when the shape genuinely is not fixed, not to avoid declaring it."""
122
+
123
+ _SIMPLE: Final[dict[Any, str]] = {
124
+ bool: "bool",
125
+ int: "int64",
126
+ float: "float64",
127
+ str: "string",
128
+ bytes: "bytes",
129
+ _uuid.UUID: "uuid",
130
+ _dt.date: "date",
131
+ _dt.datetime: "timestamptz",
132
+ dict: "json",
133
+ list: "json",
134
+ }
135
+
136
+
137
+ def _markers(annotation: object) -> tuple[object, tuple[_Marker, ...]]:
138
+ """Peel ``Annotated`` and ``Optional`` off an annotation.
139
+
140
+ Returns the bare type plus any markers found. Nullability is handled by the caller, which needs
141
+ to record it separately in the IR rather than as part of the type name.
142
+ """
143
+ markers: list[_Marker] = []
144
+ current = annotation
145
+ while get_origin(current) is Annotated:
146
+ args = get_args(current)
147
+ current = args[0]
148
+ markers.extend(m for m in args[1:] if isinstance(m, _Marker))
149
+ return current, tuple(markers)
150
+
151
+
152
+ def _is_optional(annotation: object) -> tuple[bool, object]:
153
+ """Peel ``| None`` off, and refuse anything wider.
154
+
155
+ ``str | None`` is a nullable string. ``int | str`` is not a field: it has no single physical
156
+ representation, so there is no column we could create for it and no index we could put on it.
157
+ Refusing here rather than falling through to the vocabulary check matters only for the error
158
+ message, and the error message is the whole product for someone who mistyped an annotation.
159
+ """
160
+ origin = get_origin(annotation)
161
+ if origin is typing.Union or origin is _pytypes.UnionType:
162
+ args = get_args(annotation)
163
+ non_none = [a for a in args if a is not type(None)]
164
+ if len(non_none) != 1:
165
+ raise DeclarationError(
166
+ f"{annotation!r} is a union of several types. A field has one type; a union of two "
167
+ "real types has no single physical representation, so it cannot be placed. Model "
168
+ "it "
169
+ "as separate nullable fields, or as json if the shape really is not fixed."
170
+ )
171
+ return len(non_none) != len(args), non_none[0]
172
+ return False, annotation
173
+
174
+
175
+ def resolve_type(annotation: object, *, field: str, entity: str) -> tuple[str, bool]:
176
+ """Map a Python annotation onto ``(neutral_type, nullable)``.
177
+
178
+ Raises :class:`~sde.errors.DeclarationError` naming the field, because the reader has to fix
179
+ their declaration and a traceback into this module tells them nothing.
180
+ """
181
+ nullable, inner = _is_optional(annotation)
182
+ bare, markers = _markers(inner)
183
+ # Optional may sit inside Annotated as well: Annotated[int | None, ...].
184
+ nested_nullable, bare = _is_optional(bare)
185
+ nullable = nullable or nested_nullable
186
+
187
+ kinds = {m.kind for m in markers}
188
+
189
+ if bare is _decimal.Decimal:
190
+ found = [m for m in markers if isinstance(m, _Precision)]
191
+ if not found:
192
+ raise DeclarationError(
193
+ f"{entity}.{field} is a Decimal without precision. Write "
194
+ f"Annotated[Decimal, precision(digits, scale)] - a decimal without precision is "
195
+ "not "
196
+ "a storable type in any engine we place data in, and choosing for you would make "
197
+ "the physical schema depend on something your model did not say."
198
+ )
199
+ p = found[0]
200
+ return f"decimal({p.digits},{p.scale})", nullable
201
+
202
+ if "json" in kinds:
203
+ return "json", nullable
204
+ if "int32" in kinds:
205
+ return "int32", nullable
206
+ if "float32" in kinds:
207
+ return "float32", nullable
208
+ if "naive" in kinds:
209
+ if bare is not _dt.datetime:
210
+ raise DeclarationError(
211
+ f"{entity}.{field} is annotated as a naive timestamp but is not a datetime"
212
+ )
213
+ return "timestamp", nullable
214
+
215
+ origin = get_origin(bare)
216
+ if origin in (dict, list):
217
+ return "json", nullable
218
+
219
+ mapped = _SIMPLE.get(bare)
220
+ if mapped is not None:
221
+ return mapped, nullable
222
+
223
+ raise DeclarationError(
224
+ f"{entity}.{field} has type {bare!r}, which has no place in the neutral type vocabulary "
225
+ f"({', '.join(sorted(NEUTRAL_TYPES))}, decimal(p,s)). Map it yourself - to a string, to "
226
+ "json, to a decimal with stated precision - so that the choice is visible in the model "
227
+ "instead of being guessed here."
228
+ )
sde/verification.py ADDED
@@ -0,0 +1,220 @@
1
+ """Bind a comparison to one request, project and exact placement map, without carrying rows.
2
+
3
+ A matching row count is not evidence about a particular migration. The request is persisted by
4
+ the controller before comparison and echoed by the verifier only after checking its local session.
5
+ Its unpredictable id distinguishes repeated verification rounds; the project id comes from local
6
+ client configuration, so identical models/maps in two projects are not interchangeable evidence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from .errors import MigrationRefused
18
+
19
+ if TYPE_CHECKING:
20
+ from .placement import PlacementMap
21
+
22
+ REQUEST_PROTOCOL = 1
23
+
24
+
25
+ def aware_time(value: str) -> datetime:
26
+ pattern = (
27
+ r"\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d"
28
+ r"(?:\.\d{1,6})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)"
29
+ )
30
+ if not isinstance(value, str) or re.fullmatch(pattern, value) is None:
31
+ raise MigrationRefused("verification time must be an ISO timestamp with an offset")
32
+ try:
33
+ result = datetime.fromisoformat(value[:-1] + "Z" if value.endswith("z") else value)
34
+ except (TypeError, ValueError) as exc:
35
+ raise MigrationRefused("verification time must be an ISO timestamp with an offset") from exc
36
+ if result.tzinfo is None or result.utcoffset() is None:
37
+ raise MigrationRefused("verification time must include its UTC offset")
38
+ return result
39
+
40
+
41
+ def _hex(value: str, width: int, field: str) -> None:
42
+ if not isinstance(value, str) or re.fullmatch(f"[0-9a-f]{{{width}}}", value) is None:
43
+ raise MigrationRefused(f"verification {field} must be {width} lowercase hexadecimal digits")
44
+
45
+
46
+ def _name(value: str) -> None:
47
+ if not isinstance(value, str) or not value:
48
+ raise MigrationRefused("verification names must be nonempty strings")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class VerificationRequest:
53
+ request_id: str
54
+ project_id: str
55
+ model_version: str
56
+ map_version: int
57
+ map_fingerprint: str
58
+ group: str
59
+ source_engine: str
60
+ source_id: str
61
+ targets: tuple[tuple[str, str], ...]
62
+ requested_at: str
63
+ requires_signature: bool
64
+
65
+ def __post_init__(self) -> None:
66
+ for value, width, name in (
67
+ (self.request_id, 32, "request_id"),
68
+ (self.project_id, 32, "project_id"),
69
+ (self.model_version, 16, "model_version"),
70
+ (self.map_fingerprint, 64, "map_fingerprint"),
71
+ ):
72
+ _hex(value, width, name)
73
+ if type(self.map_version) is not int or not 1 <= self.map_version <= 9_007_199_254_740_991:
74
+ raise MigrationRefused("verification map_version must be a positive integer")
75
+ if type(self.requires_signature) is not bool:
76
+ raise MigrationRefused("verification requires_signature must be a boolean")
77
+ for name in (self.group, self.source_engine, self.source_id):
78
+ _name(name)
79
+ if (
80
+ not isinstance(self.targets, tuple)
81
+ or not self.targets
82
+ or any(not isinstance(target, tuple) or len(target) != 2 for target in self.targets)
83
+ ):
84
+ raise MigrationRefused("verification targets must be nonempty engine/id pairs")
85
+ for engine, identity in self.targets:
86
+ _name(engine)
87
+ _name(identity)
88
+ if identity == self.source_id:
89
+ raise MigrationRefused("verification source cannot also be a target")
90
+ if len({identity for _, identity in self.targets}) != len(self.targets):
91
+ raise MigrationRefused("verification target ids must be unique")
92
+ if self.targets != tuple(sorted(self.targets)):
93
+ raise MigrationRefused("verification targets must be sorted by engine and id")
94
+ aware_time(self.requested_at)
95
+
96
+ def as_record(self) -> dict[str, Any]:
97
+ return {
98
+ "protocol": REQUEST_PROTOCOL,
99
+ "request_id": self.request_id,
100
+ "project_id": self.project_id,
101
+ "model_version": self.model_version,
102
+ "map_version": self.map_version,
103
+ "map_fingerprint": self.map_fingerprint,
104
+ "group": self.group,
105
+ "source": {"engine": self.source_engine, "id": self.source_id},
106
+ "targets": [{"engine": engine, "id": identity} for engine, identity in self.targets],
107
+ "requested_at": self.requested_at,
108
+ "requires_signature": self.requires_signature,
109
+ }
110
+
111
+ @classmethod
112
+ def from_record(cls, record: Mapping[str, Any]) -> VerificationRequest:
113
+ fields = {
114
+ "protocol",
115
+ "request_id",
116
+ "project_id",
117
+ "model_version",
118
+ "map_version",
119
+ "map_fingerprint",
120
+ "group",
121
+ "source",
122
+ "targets",
123
+ "requested_at",
124
+ "requires_signature",
125
+ }
126
+ if not isinstance(record, Mapping) or set(record) != fields:
127
+ raise MigrationRefused("verification request has missing or unknown fields")
128
+ if type(record["protocol"]) not in (int, float) or record["protocol"] != REQUEST_PROTOCOL:
129
+ raise MigrationRefused("unsupported verification request protocol")
130
+ source, targets = record["source"], record["targets"]
131
+ if not isinstance(source, Mapping) or set(source) != {"engine", "id"}:
132
+ raise MigrationRefused("verification source must contain exactly engine and id")
133
+ if not isinstance(targets, list) or any(
134
+ not isinstance(target, Mapping) or set(target) != {"engine", "id"} for target in targets
135
+ ):
136
+ raise MigrationRefused("verification targets must contain exactly engine and id")
137
+ return cls(
138
+ request_id=record["request_id"],
139
+ project_id=record["project_id"],
140
+ model_version=record["model_version"],
141
+ map_version=_integer(record["map_version"], "map_version"),
142
+ map_fingerprint=record["map_fingerprint"],
143
+ group=record["group"],
144
+ source_engine=source["engine"],
145
+ source_id=source["id"],
146
+ targets=tuple((target["engine"], target["id"]) for target in targets),
147
+ requested_at=record["requested_at"],
148
+ requires_signature=record["requires_signature"],
149
+ )
150
+
151
+ def check_session(self, placement: PlacementMap, *, project_id: str | None, group: str) -> None:
152
+ if project_id != self.project_id:
153
+ raise MigrationRefused(
154
+ "verification request names another project, or this session has no project_id. "
155
+ "Configure the project id from the client's enrollment, not from the request."
156
+ )
157
+ if group != self.group:
158
+ raise MigrationRefused("verification request names another group")
159
+ expected = verification_request(
160
+ placement,
161
+ group=group,
162
+ project_id=project_id,
163
+ request_id=self.request_id,
164
+ requested_at=self.requested_at,
165
+ )
166
+ if self != expected:
167
+ raise MigrationRefused(
168
+ "verification request does not match this session's model, map, source or targets; "
169
+ "no comparison was started"
170
+ )
171
+
172
+ def check_time(self, at: str) -> None:
173
+ if aware_time(at) < aware_time(self.requested_at):
174
+ raise MigrationRefused(
175
+ "verification predates its request; check the verifier's clock and run the "
176
+ "comparison for the current request"
177
+ )
178
+
179
+
180
+ def verification_request(
181
+ placement: PlacementMap,
182
+ *,
183
+ group: str,
184
+ project_id: str,
185
+ request_id: str,
186
+ requested_at: str,
187
+ ) -> VerificationRequest:
188
+ if placement.fingerprint is None:
189
+ raise MigrationRefused("verification needs a loaded, canonically encodable placement map")
190
+ spot = placement.placement_of(group)
191
+ return VerificationRequest(
192
+ request_id=request_id,
193
+ project_id=project_id,
194
+ model_version=placement.model_version,
195
+ map_version=placement.map_version,
196
+ map_fingerprint=placement.fingerprint,
197
+ group=group,
198
+ source_engine=spot.source.engine,
199
+ source_id=spot.source.id,
200
+ targets=tuple(sorted((target.engine, target.id) for target in spot.also_write)),
201
+ requested_at=requested_at,
202
+ requires_signature=placement.signed,
203
+ )
204
+
205
+
206
+ def check_project_id(value: str | None) -> None:
207
+ if value is not None:
208
+ _hex(value, 32, "project_id")
209
+
210
+
211
+ def _integer(value: Any, field: str) -> int:
212
+ # JSON Schema integers include an integral JSON number such as 1.0. JavaScript has one
213
+ # number type, so normalize that value here without ever truncating a fractional value.
214
+ if (
215
+ type(value) not in (int, float)
216
+ or not 1 <= value <= 9_007_199_254_740_991
217
+ or int(value) != value
218
+ ):
219
+ raise MigrationRefused(f"verification {field} must be a positive safe integer")
220
+ return int(value)
sde/watermark.py ADDED
@@ -0,0 +1,222 @@
1
+ """Refusing a placement map that goes backwards, and the durable state that makes it possible.
2
+
3
+ A signed map for version 3 verifies correctly forever - that is what a signature is. So replacing
4
+ the client's map file with an older signed one loads cleanly, routes writes to the previous
5
+ placement, and **nothing protests**. Today that costs a client a stale schema. Once the migration
6
+ state travels in the map, it costs them writes: a library reverted from dual-write to single-write
7
+ in the middle of a migration drops exactly the rows the migration exists not to drop.
8
+
9
+ Refusing it needs one thing the library has never had: **memory**. Everything else here is a pure
10
+ function of a document, a model and a key, which is why it can be verified by reading it. This
11
+ module is the exception, and each of the three obvious places to keep that memory is worse than the
12
+ one chosen:
13
+
14
+ - **in the process** protects until the first restart, and a restart is when a swapped file is
15
+ read. A protection that lapses exactly when it is needed;
16
+ - **in a file** needs a configured path, and in a container that path is usually ephemeral - so it
17
+ degrades to the first option while continuing to look like the third. The worst property
18
+ available: a guarantee that is present in the code and absent in production;
19
+ - **with us** would mean the library asking our service whether it may start, which is the one
20
+ thing this product promises it will never need to do. Our outage would become the client's.
21
+
22
+ So it lives **in the client's own engines**, in a table this library owns. The library already
23
+ creates tables there; this is one more, it holds no client data, and we still never see a row of it.
24
+
25
+ Four properties, and the first two are what make it safe rather than merely present.
26
+
27
+ **Append-only, and the watermark is `max(map_version)`.** No update, no key enforcement, no
28
+ row-level contention - and therefore identical semantics in PostgreSQL and in ClickHouse, which is
29
+ the engine that has no unique constraint to offer. A stale row can never lower the bar. It also
30
+ leaves an audit trail for free: which map versions this deployment has seen, and when.
31
+
32
+ **Every participating engine is written, and the watermark is the maximum over all of them.**
33
+ Losing an engine cannot lose the protection, and one engine lagging cannot weaken it.
34
+
35
+ **An engine that cannot store it does not participate, and that is reported rather than hidden.**
36
+ The orderbook engine has a schema fixed in its own source and no room for bookkeeping, so a client
37
+ whose only engine is that one has no rollback protection and cannot have any. The honest maximum is
38
+ to say so - :class:`WatermarkCheck` carries it and ``Session`` exposes it, because a protection
39
+ whose state cannot be read is a protection taken on trust.
40
+
41
+ **Only signed maps are checked.** An unsigned map is the client's own document: hand-writing one and
42
+ pointing a library at it is the no-account mode, and their business what they replace it with. A
43
+ signed map is one we issued, which is precisely when we are the authority on what the newest version
44
+ is. In pure no-account mode this module does nothing at all - no table, no query, no cost.
45
+
46
+ The escape hatch is deliberately not a parameter. A legitimate rollback - we issued a bad map -
47
+ means clearing the bookkeeping, and the refusal says how. A parameter called ``allow_rollback``
48
+ would be set once during an incident and left set.
49
+
50
+ **A limitation worth stating.** The watermark is per engine and the format has no field naming which
51
+ stream of maps a document belongs to, so an engine shared by two independent map streams would have
52
+ the higher one refusing the lower. The fix is a separate database per stream, which a shared engine
53
+ wants regardless; inventing a stream identifier would mean a new key in a signed document, which is
54
+ a loosening of the format and a contract bump in every language at once.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from collections.abc import Mapping
60
+ from dataclasses import dataclass
61
+ from typing import Any, Literal, Protocol, runtime_checkable
62
+
63
+ from .capabilities import satisfies
64
+ from .errors import MapRolledBack
65
+ from .logging import log
66
+ from .placement import WATERMARK_TABLE, PlacementMap
67
+
68
+ __all__ = [
69
+ "WATERMARK_TABLE",
70
+ "Protection",
71
+ "WatermarkCheck",
72
+ "WatermarkStore",
73
+ "enforce_forward_only",
74
+ ]
75
+
76
+ Protection = Literal["enforced", "unavailable", "not_applicable"]
77
+
78
+
79
+ @runtime_checkable
80
+ class WatermarkStore(Protocol):
81
+ """What an engine adapter needs to offer to take part.
82
+
83
+ A separate protocol from :class:`~sde.session.Engine`, and optional. Adding two methods to
84
+ ``Engine`` would break every adapter anybody has written against it - including the fakes in
85
+ somebody else's test suite - for a capability one of our own three engines cannot provide
86
+ anyway. So participation is discovered rather than required, and non-participation is a
87
+ reportable state instead of a crash.
88
+ """
89
+
90
+ def map_watermark(self) -> int | None: ...
91
+ def record_map_version(self, version: int, *, model_version: str) -> None: ...
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class WatermarkCheck:
96
+ """What the check did, in a form a client can assert on.
97
+
98
+ Exposed rather than kept private on purpose. A protection whose state cannot be read is a
99
+ protection taken on trust, and this product's whole argument is that its guarantees are
100
+ checkable by reading the code and now by reading this.
101
+ """
102
+
103
+ protection: Protection
104
+ map_version: int
105
+ highest_seen: int | None
106
+ participating: tuple[str, ...]
107
+ unable: tuple[str, ...]
108
+ why: str
109
+
110
+ def as_record(self) -> dict[str, Any]:
111
+ return {
112
+ "protection": self.protection,
113
+ "map_version": self.map_version,
114
+ "highest_seen": self.highest_seen,
115
+ "participating": list(self.participating),
116
+ "unable": list(self.unable),
117
+ "why": self.why,
118
+ }
119
+
120
+
121
+ def _split(engines: Mapping[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
122
+ """Which engines can keep the bookkeeping and which cannot, both sorted.
123
+
124
+ Asked with :func:`sde.capabilities.satisfies` rather than `isinstance`, because a
125
+ runtime_checkable protocol resolves members without consulting `__getattr__` - so a client
126
+ wrapping one of our adapters for metrics or retries would be reported here as an engine with
127
+ nowhere to keep the bookkeeping, and would silently lose rollback protection under a message
128
+ blaming their engine's schema.
129
+ """
130
+ able = sorted(name for name, engine in engines.items() if satisfies(engine, WatermarkStore))
131
+ unable = sorted(set(engines) - set(able))
132
+ return tuple(able), tuple(unable)
133
+
134
+
135
+ def enforce_forward_only(
136
+ placement: PlacementMap, engines: Mapping[str, Any]
137
+ ) -> WatermarkCheck:
138
+ """Refuse a signed map older than the newest one these engines have seen.
139
+
140
+ Raises :class:`~sde.errors.MapRolledBack`. Equal is allowed - restarting a process against the
141
+ same map is the ordinary case - and only strictly lower is refused.
142
+ """
143
+ if not placement.signed:
144
+ return WatermarkCheck(
145
+ protection="not_applicable",
146
+ map_version=placement.map_version,
147
+ highest_seen=None,
148
+ participating=(),
149
+ unable=tuple(sorted(engines)),
150
+ why=(
151
+ "this map is unsigned, so it is your own document rather than one we issued. "
152
+ "Replacing it with another is the no-account mode working as documented, and there "
153
+ "is no newest version for us to be the authority on."
154
+ ),
155
+ )
156
+
157
+ able, unable = _split(engines)
158
+ if not able:
159
+ check = WatermarkCheck(
160
+ protection="unavailable",
161
+ map_version=placement.map_version,
162
+ highest_seen=None,
163
+ participating=(),
164
+ unable=unable,
165
+ why=(
166
+ f"none of the engines in this map can keep bookkeeping ({list(unable)}), so a map "
167
+ f"that goes backwards cannot be recognised. An engine whose schema is fixed in its "
168
+ f"own source - ours is - has nowhere to put it. Nothing is wrong with your "
169
+ f"configuration; this protection simply does not exist for it."
170
+ ),
171
+ )
172
+ log(
173
+ "sde.map.rollback_unprotected",
174
+ map_version=placement.map_version,
175
+ engines=len(unable),
176
+ )
177
+ return check
178
+
179
+ seen = [store.map_watermark() for store in (engines[name] for name in able)]
180
+ known = [value for value in seen if value is not None]
181
+ highest = max(known) if known else None
182
+
183
+ if highest is not None and placement.map_version < highest:
184
+ raise MapRolledBack(
185
+ f"this map is version {placement.map_version} and version {highest} has already been "
186
+ f"applied against these engines. Refusing to go backwards: an older signed map "
187
+ f"verifies perfectly - that is what a signature is - so nothing else here would notice "
188
+ f"that the file was replaced, and the writes would go to the previous placement. If "
189
+ f"this is deliberate, because the newer map was wrong, clear the bookkeeping: "
190
+ f"DELETE FROM {WATERMARK_TABLE} WHERE map_version > {placement.map_version}; in "
191
+ f"{list(able)}, and on an engine that deletes asynchronously, let the deletion finish "
192
+ f"before restarting. That is a deliberate act with a stated consequence, which is why "
193
+ f"it is not a flag."
194
+ )
195
+
196
+ if highest is None or placement.map_version > highest:
197
+ # Written only when it moves. Recording every start would grow the table by one row per
198
+ # process restart, and the watermark would say nothing more than it does now.
199
+ for name in able:
200
+ store: WatermarkStore = engines[name]
201
+ store.record_map_version(
202
+ placement.map_version, model_version=placement.model_version
203
+ )
204
+
205
+ log(
206
+ "sde.map.forward_only",
207
+ map_version=placement.map_version,
208
+ highest_seen=highest,
209
+ engines=len(able),
210
+ )
211
+ return WatermarkCheck(
212
+ protection="enforced",
213
+ map_version=placement.map_version,
214
+ highest_seen=highest,
215
+ participating=able,
216
+ unable=unable,
217
+ why=(
218
+ f"the highest map version applied against these engines is "
219
+ f"{highest if highest is not None else placement.map_version}, kept in "
220
+ f"{WATERMARK_TABLE} in {list(able)}. A map older than that is refused."
221
+ ),
222
+ )