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/shapes.py ADDED
@@ -0,0 +1,153 @@
1
+ """Operation shapes: the finite set of things an application can ask of a model.
2
+
3
+ This is where the entity API pays for itself twice over.
4
+
5
+ First, security. A shape is built from an API call, so there is nowhere for a literal to come from.
6
+ Contrast the SQL route, where you receive a string containing values and have to strip them out with
7
+ a parser you hope covers every dialect corner - and where one missed case means a customer's data in
8
+ our telemetry. Here the value never enters the shape, because the shape is assembled from the
9
+ operation's structure and never sees the arguments.
10
+
11
+ Second, planning. Because the API is finite, the set of shapes is *enumerable from the model*. The
12
+ planner can compute a routing decision for every shape ahead of time and put the answers in the
13
+ placement map, which is what lets the library look routing up instead of deciding it. A library that
14
+ decides is a library whose decisions have to be tested in four languages.
15
+
16
+ The enumeration below is deliberately conservative. It covers what the thin slice can execute and
17
+ nothing more: adding a shape kind is cheap, while shipping a shape the runtime cannot honour means
18
+ the map promises a route for an operation that then fails.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Final
25
+
26
+ from .canonical import digest16
27
+ from .groups import Group, colocation_groups, group_of
28
+ from .model import LogicalModel
29
+
30
+ __all__ = ["SHAPE_KINDS", "WRITE_KINDS", "OperationShape", "enumerate_shapes"]
31
+
32
+ SHAPE_KINDS: Final[tuple[str, ...]] = (
33
+ "point_read",
34
+ "range_read",
35
+ "aggregate",
36
+ "full_scan",
37
+ "relation_walk",
38
+ "write",
39
+ "bulk_write",
40
+ )
41
+
42
+ WRITE_KINDS: Final[frozenset[str]] = frozenset({"write", "bulk_write"})
43
+ """Which of those kinds are writes. One definition, and it decides three separate things.
44
+
45
+ Here rather than in each module that asks, because there were four copies of this set and two of
46
+ them were inside this package - one in ``routing``, deciding whether an operation goes to the
47
+ source, and one in ``telemetry``, deciding whether an operation counts as a write in the features a
48
+ placement is scored on. Two copies of a set in one process is how the same operation comes to be a
49
+ write for routing and a read for scoring, and nothing would have raised.
50
+
51
+ Public because a producer of routing tables needs it. A write shape is never routed - the library
52
+ sends writes to the source unconditionally - so an entry for one is a line in a signed document
53
+ that nothing reads, and the control plane cannot avoid writing one without knowing this set.
54
+ """
55
+
56
+ # Types over which a range predicate is meaningful. Ranges over strings and uuids are legal SQL and
57
+ # almost never what anybody means, so they are not enumerated; if telemetry ever shows one, that is
58
+ # a signal to revisit this list rather than to widen it speculatively.
59
+ _ORDERED_PREFIXES: Final[tuple[str, ...]] = (
60
+ "int32",
61
+ "int64",
62
+ "float32",
63
+ "float64",
64
+ "decimal",
65
+ "date",
66
+ "timestamp",
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class OperationShape:
72
+ """One kind of operation, without any of its values."""
73
+
74
+ group: str
75
+ kind: str
76
+ entity: str
77
+ fields: tuple[str, ...]
78
+ target: str | None = None
79
+
80
+ # Computed once, in __post_init__, and stored. It used to be a property, which meant a SHA-256
81
+ # over a freshly built and canonically encoded dict on every access - and routing reads it up to
82
+ # three times per operation. The overhead test measured 41 microseconds median to resolve one
83
+ # route, sixteen percent of a PostgreSQL round trip, against a budget of one percent. The same
84
+ # mistake as building a key four times per write, which the engine paid for once already.
85
+ id: str = field(init=False, repr=False, compare=False)
86
+
87
+ def __post_init__(self) -> None:
88
+ # object.__setattr__ because the dataclass is frozen. The alternative - a memo keyed by the
89
+ # shape - would put a dictionary lookup back on the hot path to avoid a hash, which is the
90
+ # wrong trade when shapes are enumerated once per model and live for the process.
91
+ object.__setattr__(self, "id", digest16(self.as_ir()))
92
+
93
+ def as_ir(self) -> dict[str, Any]:
94
+ # Sorted fields, explicit nulls: the shape is hashed, so its encoding has to be as stable as
95
+ # the model's.
96
+ return {
97
+ "group": self.group,
98
+ "kind": self.kind,
99
+ "entity": self.entity,
100
+ "fields": list(self.fields),
101
+ "target": self.target,
102
+ }
103
+
104
+
105
+ def _is_ordered(neutral_type: str) -> bool:
106
+ return neutral_type.startswith(_ORDERED_PREFIXES)
107
+
108
+
109
+ def enumerate_shapes(model: LogicalModel) -> tuple[OperationShape, ...]:
110
+ """Every shape the model admits, in a deterministic order.
111
+
112
+ Ordering is by ``(group, entity, kind, fields, target)`` rather than by identifier, so that a
113
+ human reading a placement map or a diff between two of them sees related shapes together instead
114
+ of scattered by hash.
115
+ """
116
+ groups: tuple[Group, ...] = colocation_groups(model)
117
+ shapes: list[OperationShape] = []
118
+
119
+ for spec in model.entities:
120
+ group = group_of(groups, spec.name).name
121
+
122
+ shapes.append(
123
+ OperationShape(
124
+ group=group, kind="point_read", entity=spec.name, fields=tuple(sorted(spec.key))
125
+ )
126
+ )
127
+ shapes.append(OperationShape(group=group, kind="write", entity=spec.name, fields=()))
128
+ shapes.append(OperationShape(group=group, kind="bulk_write", entity=spec.name, fields=()))
129
+ shapes.append(OperationShape(group=group, kind="full_scan", entity=spec.name, fields=()))
130
+ shapes.append(OperationShape(group=group, kind="aggregate", entity=spec.name, fields=()))
131
+
132
+ for spec_field in spec.fields:
133
+ if _is_ordered(spec_field.type):
134
+ shapes.append(
135
+ OperationShape(
136
+ group=group, kind="range_read", entity=spec.name, fields=(spec_field.name,)
137
+ )
138
+ )
139
+
140
+ for relation in model.relations:
141
+ group = group_of(groups, relation.source).name
142
+ shapes.append(
143
+ OperationShape(
144
+ group=group,
145
+ kind="relation_walk",
146
+ entity=relation.source,
147
+ fields=(relation.name,),
148
+ target=relation.target,
149
+ )
150
+ )
151
+
152
+ shapes.sort(key=lambda s: (s.group, s.entity, s.kind, s.fields, s.target or ""))
153
+ return tuple(shapes)
sde/staging.py ADDED
@@ -0,0 +1,264 @@
1
+ """Load the signed authorization to prepare one fresh copy while retaining its source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from copy import deepcopy
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from .canonical import CanonicalError, canonical_bytes
13
+ from .cutover import _hex, _record, _signature
14
+ from .errors import MapError, MigrationRefused
15
+ from .generation import GENERATIONS_SINCE, MAX_EPOCH, check_map_project, json_numbers
16
+ from .groups import colocation_groups
17
+ from .layout import group_columns
18
+ from .model import LogicalModel
19
+ from .placement import PlacementMap, _verify_signature, load_map
20
+
21
+ STAGING_PROTOCOL = 1
22
+ """A move: the fresh copy is prepared in another engine binding than the source."""
23
+ STAGING_RELAYOUT_PROTOCOL = 2
24
+ """A relayout: the fresh copy is prepared in the source's own engine binding, under fresh table
25
+ names, so a new physical design takes over by the same staging and cutover as a move.
26
+
27
+ A protocol of its own rather than protocol 1 with a refusal lifted, because lifting a refusal is a
28
+ change of format: a packet a released library refuses would become one a newer library executes,
29
+ under the same number. Here an older operator refuses a relayout by its protocol, by name, and both
30
+ protocols stay strict - protocol 1 still refuses a copy in the source's binding, protocol 2 refuses
31
+ one anywhere else."""
32
+ _FIELDS = {
33
+ "kind",
34
+ "protocol",
35
+ "stage_id",
36
+ "project_id",
37
+ "group",
38
+ "current",
39
+ "prepared",
40
+ "signature",
41
+ }
42
+
43
+
44
+ def staging_table_name(stage_id: str, position: int) -> str:
45
+ """Portable physical name: fresh stage id plus a one-based, code-point ordered entity index."""
46
+ _hex(stage_id, 32, "stage_id")
47
+ if type(position) is not int or not 1 <= position <= 999999:
48
+ raise MigrationRefused("staging entity position must be an integer from 1 through 999999")
49
+ return f"sde_m_{stage_id}_{position:06d}"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class StagingPlan:
54
+ stage_id: str
55
+ project_id: str
56
+ group: str
57
+ current: PlacementMap
58
+ prepared: PlacementMap
59
+ verified_with: str | None
60
+ fingerprint: str | None = field(default=None, init=False)
61
+ _document: bytes = field(default=b"", init=False, repr=False)
62
+
63
+ def _loaded(self) -> None:
64
+ if self.fingerprint is None or not self._document:
65
+ raise MigrationRefused("staging requires an immutable loaded authorization")
66
+
67
+ def as_record(self) -> dict[str, Any]:
68
+ self._loaded()
69
+ value: dict[str, Any] = json.loads(self._document)
70
+ return value
71
+
72
+ def prepared_payload(self) -> bytes:
73
+ return canonical_bytes(self.as_record()["prepared"])
74
+
75
+ def check_current(self, current: PlacementMap) -> None:
76
+ self._loaded()
77
+ check_map_project(current, self.project_id)
78
+ if not current.signed or current.fingerprint != self.current.fingerprint:
79
+ raise MigrationRefused("staging authorization does not name the signed current map")
80
+
81
+
82
+ @dataclass(frozen=True, init=False)
83
+ class StagingReceipt:
84
+ _payload: bytes
85
+
86
+ def __init__(self, record: Mapping[str, Any]) -> None:
87
+ object.__setattr__(
88
+ self,
89
+ "_payload",
90
+ json.dumps(
91
+ record, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
92
+ ).encode("utf-8"),
93
+ )
94
+
95
+ def as_record(self) -> dict[str, Any]:
96
+ record: dict[str, Any] = json.loads(self._payload)
97
+ return record
98
+
99
+
100
+ def _load(
101
+ raw: Mapping[str, Any],
102
+ model: LogicalModel,
103
+ project_id: str,
104
+ public_key: bytes | Mapping[str, bytes],
105
+ ) -> StagingPlan:
106
+ body = _record(json_numbers(deepcopy(raw)), "staging authorization")
107
+ if set(body) != _FIELDS:
108
+ raise MigrationRefused("staging authorization has missing or unknown fields")
109
+ if (
110
+ type(body["protocol"]) is not int
111
+ or body["protocol"] not in (STAGING_PROTOCOL, STAGING_RELAYOUT_PROTOCOL)
112
+ or body["kind"] != "sde-stage"
113
+ ):
114
+ raise MigrationRefused("unsupported staging authorization kind or protocol")
115
+ protocol: int = body["protocol"]
116
+ identity = _hex(body["stage_id"], 32, "stage_id")
117
+ local = _hex(body["project_id"], 32, "project_id")
118
+ if local != project_id:
119
+ raise MigrationRefused("staging authorization belongs to another local project")
120
+ group = body["group"]
121
+ if not isinstance(group, str) or not group:
122
+ raise MigrationRefused("staging group must be a nonempty string")
123
+ _signature(body)
124
+ verified = _verify_signature(body, public_key)
125
+ maps = []
126
+ for name in ("current", "prepared"):
127
+ document = _record(body[name], name)
128
+ _signature(document)
129
+ for raw_group in _record(document.get("groups"), "groups").values():
130
+ value = _record(raw_group, "group")
131
+ copies = value.get("derived", [])
132
+ if not isinstance(copies, list):
133
+ raise MigrationRefused("staging derived copies must be an array")
134
+ for raw_material in (value.get("source"), *copies):
135
+ raw_layout = _record(
136
+ _record(raw_material, "materialization").get("layout"), "layout"
137
+ )
138
+ if raw_layout.get("auto"):
139
+ raise MigrationRefused("staging maps need explicit physical layouts")
140
+ parsed = load_map(document, model=model, public_key=public_key, require_signature=True)
141
+ # Contract 4 introduced the generations this protocol rests on; 5 adds physical design
142
+ # and keeps them. Anything a newer library would read is refused by load_map already.
143
+ if parsed.contract < GENERATIONS_SINCE:
144
+ raise MigrationRefused(
145
+ f"staging protocol {protocol} requires map contract {GENERATIONS_SINCE} or later"
146
+ )
147
+ check_map_project(parsed, project_id)
148
+ for placement in parsed.groups.values():
149
+ for material in placement.all():
150
+ if not material.layout.tables or not material.layout.columns:
151
+ raise MigrationRefused("staging maps need explicit physical layouts")
152
+ maps.append(parsed)
153
+ current, prepared = maps
154
+ # The prepared map may raise the contract, because the fresh copy is the one place a physical
155
+ # design can first appear - a ClickHouse copy partitioned by month under a contract-4 current
156
+ # map. It may not lower it: a lower number would tell an older library it may ignore keys that
157
+ # the current map already relies on.
158
+ if prepared.contract < current.contract:
159
+ raise MigrationRefused("staging cannot lower the placement map contract")
160
+ if current.map_version >= prepared.map_version:
161
+ raise MigrationRefused("staging must allocate a newer prepared map")
162
+ if group not in current.groups or set(current.groups) != set(prepared.groups):
163
+ raise MigrationRefused("staging cannot add or remove colocation groups")
164
+ old, new = current.groups[group], prepared.groups[group]
165
+ if old.derived or old.also_write:
166
+ raise MigrationRefused("staging begins with a source-only group")
167
+ if len(new.derived) != 1 or new.also_write != new.derived:
168
+ raise MigrationRefused("staging prepares exactly one maintained copy")
169
+ epoch = old.write_epoch
170
+ assert epoch is not None
171
+ if epoch > MAX_EPOCH - 2 or new.write_epoch != epoch:
172
+ raise MigrationRefused(
173
+ "staging retains the source generation and needs two spare generations"
174
+ )
175
+ same = new.derived[0].engine == old.source.engine
176
+ if protocol == STAGING_PROTOCOL and same:
177
+ raise MigrationRefused("staging target must use another engine binding")
178
+ if protocol == STAGING_RELAYOUT_PROTOCOL and not same:
179
+ # The copy's tables are fresh stage names, and the map refuses a copy in the source's
180
+ # engine that reuses a source table - so a relayout cannot be the source under a new name.
181
+ raise MigrationRefused(
182
+ "a relayout (staging protocol 2) prepares its copy in the source's own engine binding"
183
+ )
184
+ current_raw, prepared_raw = body["current"], body["prepared"]
185
+ old_group, new_group = current_raw["groups"][group], prepared_raw["groups"][group]
186
+ if set(old_group) != {"source", "write_epoch"} or set(new_group) != {
187
+ "source",
188
+ "write_epoch",
189
+ "derived",
190
+ "also_write",
191
+ }:
192
+ raise MigrationRefused("staging group shape is not source-only to one maintained copy")
193
+ if canonical_bytes(old_group["source"]) != canonical_bytes(new_group["source"]):
194
+ raise MigrationRefused("staging cannot change the existing source")
195
+ stable = {
196
+ key: value
197
+ for key, value in current_raw.items()
198
+ if key not in {"signature", "map_version", "groups", "contract"}
199
+ }
200
+ after = {
201
+ key: value
202
+ for key, value in prepared_raw.items()
203
+ if key not in {"signature", "map_version", "groups", "contract"}
204
+ }
205
+ if canonical_bytes(stable) != canonical_bytes(after) or dict(current.routing) != dict(
206
+ prepared.routing
207
+ ):
208
+ raise MigrationRefused("staging cannot change routing or other map attributes")
209
+ for other in current.groups:
210
+ if other != group and canonical_bytes(current_raw["groups"][other]) != canonical_bytes(
211
+ prepared_raw["groups"][other]
212
+ ):
213
+ raise MigrationRefused("staging cannot change an unaffected group")
214
+ members = next(value for value in colocation_groups(model) if value.name == group)
215
+ expected = {
216
+ entity: staging_table_name(identity, position)
217
+ for position, entity in enumerate(sorted(members.members), start=1)
218
+ }
219
+ if dict(new.derived[0].layout.tables) != expected:
220
+ raise MigrationRefused(
221
+ "staging needs fresh physical names bound to its stage id and entity order"
222
+ )
223
+ columns = group_columns(model, members)
224
+ for material in (old.source, new.derived[0]):
225
+ if set(material.layout.tables) != set(columns) or set(material.layout.columns) != set(
226
+ columns
227
+ ):
228
+ raise MigrationRefused("staging layouts must cover exactly the group's entities")
229
+ if any(
230
+ set(material.layout.columns[entity]) != set(fields)
231
+ for entity, fields in columns.items()
232
+ ):
233
+ raise MigrationRefused("staging layout columns must match the logical group")
234
+ used = {
235
+ table
236
+ for placed in current.groups.values()
237
+ for material in placed.all()
238
+ for table in material.layout.tables.values()
239
+ }
240
+ if used & set(expected.values()):
241
+ raise MigrationRefused("staging cannot reuse a current physical name")
242
+ plan = StagingPlan(identity, local, group, current, prepared, verified)
243
+ object.__setattr__(plan, "_document", canonical_bytes(body))
244
+ object.__setattr__(
245
+ plan,
246
+ "fingerprint",
247
+ hashlib.sha256(
248
+ canonical_bytes({key: value for key, value in body.items() if key != "signature"})
249
+ ).hexdigest(),
250
+ )
251
+ return plan
252
+
253
+
254
+ def load_staging_plan(
255
+ raw: Mapping[str, Any],
256
+ *,
257
+ model: LogicalModel,
258
+ project_id: str,
259
+ public_key: bytes | Mapping[str, bytes],
260
+ ) -> StagingPlan:
261
+ try:
262
+ return _load(raw, model, project_id, public_key)
263
+ except (MapError, CanonicalError, ValueError, TypeError, KeyError) as exc:
264
+ raise MigrationRefused(f"staging authorization refused: {exc}") from exc