smart-data-engine-sdk 0.1.0.dev0__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/session.py ADDED
@@ -0,0 +1,507 @@
1
+ """A session: a model, a placement and the engines it points at, tied together.
2
+
3
+ Everything below this is plumbing that was proved separately - canonical models, groups, shapes,
4
+ maps, routing, an engine adapter. This is where they become something an application calls, and
5
+ where the one guarantee that cannot be delivered by any of them individually gets enforced.
6
+
7
+ That guarantee is the transaction boundary. One group, one engine, that engine's transaction
8
+ semantics, and nothing wider. A client asking for a transaction across two groups is asking for a
9
+ distributed transaction, and the answer is not a two-phase commit - it is that they should declare
10
+ the atomicity they need, so the planner colocates the entities and the requirement becomes a
11
+ placement constraint instead. The error says exactly that, and it says it when the transaction is
12
+ *opened* rather than when the commit fails, because the second one is a production incident and the
13
+ first one is a test failure.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable, Iterator, Mapping
19
+ from contextlib import contextmanager
20
+ from time import perf_counter_ns
21
+ from types import MappingProxyType
22
+ from typing import Any, Protocol
23
+
24
+ from .errors import EngineError, MigrationRefused, ModelPlanningError
25
+ from .groups import Group, colocation_groups
26
+ from .hashing import NameMap
27
+ from .layout import group_columns
28
+ from .logging import log
29
+ from .migration import precision_refusal
30
+ from .model import LogicalModel
31
+ from .placement import Materialization, PlacementMap
32
+ from .routing import Router
33
+ from .shapes import OperationShape, enumerate_shapes
34
+ from .telemetry import Recorder
35
+ from .watermark import WatermarkCheck, enforce_forward_only
36
+
37
+ __all__ = ["Engine", "Session"]
38
+
39
+
40
+ class Engine(Protocol):
41
+ """What a session needs from an engine adapter, and nothing more.
42
+
43
+ A protocol rather than a base class so that an adapter for another engine - or a fake, in
44
+ somebody else's test suite - does not have to import anything from us to satisfy it.
45
+ """
46
+
47
+ dialect: str
48
+
49
+ def ensure_schema(self, layout: Any, *, keys: Mapping[str, Any]) -> None: ...
50
+ def insert(self, table: str, values: Mapping[str, Any]) -> None: ...
51
+ def get(self, table: str, key: Mapping[str, Any]) -> dict[str, Any] | None: ...
52
+
53
+ @contextmanager
54
+ def transaction(self) -> Iterator[Any]: ...
55
+
56
+
57
+ class Session:
58
+ """Routes operations for one model against one placement.
59
+
60
+ Holds no connection state of its own: engines do that. It exists to answer "where does this
61
+ operation go" and to refuse the operations that cannot be answered - and both of those are pure
62
+ functions of the model and the map, which is why this class has no configuration.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ model: LogicalModel,
68
+ placement: PlacementMap,
69
+ engines: Mapping[str, Engine],
70
+ *,
71
+ recorder: Recorder | None = None,
72
+ names: NameMap | None = None,
73
+ ) -> None:
74
+ # Telemetry is optional and off by default. A library that starts measuring the moment it is
75
+ # imported is a library people are right to be suspicious of; measurement begins when a
76
+ # recorder is handed in, which is a visible line in the client's code.
77
+ self._recorder = recorder
78
+
79
+ # The one place where the client's names and the hashed ones meet. When a model has been
80
+ # hashed, everything downstream of here - the map, the shapes, the tables, the telemetry -
81
+ # speaks digests only, and the application keeps saying `save("User", {"email": ...})`.
82
+ # Without this the mode is unusable: a client would have to write `save("e_546526714dc9",
83
+ # {"f_b1b4a0ec9efb": ...})` in their own code, which nobody will do and which would put the
84
+ # digests in their source anyway.
85
+ self._names = names
86
+ self._reverse_fields: dict[str, dict[str, str]] = {}
87
+ self._declared: tuple[str, ...] = ()
88
+ if names is not None:
89
+ for entity, mapping in names.fields.items():
90
+ self._reverse_fields[names.entity(entity)] = {
91
+ hashed: original for original, hashed in mapping.items()
92
+ }
93
+ self._declared = tuple(sorted(names.entities))
94
+ self._model = model
95
+ self._placement = placement
96
+ self._engines = dict(engines)
97
+ self._router = Router(placement)
98
+ self._groups: tuple[Group, ...] = colocation_groups(model)
99
+ self._shapes = {
100
+ (s.entity, s.kind, s.fields): s for s in enumerate_shapes(model)
101
+ }
102
+ self._in_write_transaction = False
103
+ self._deferred: list[tuple[str, str, str, str, int, dict[str, Any]]] = []
104
+ """Rows waiting for their transaction to commit before reaching a copy.
105
+
106
+ Six-wide rather than three because requirement 5.2 has us report how far behind a copy
107
+ runs, and the honest start of that interval is when the row was **queued** - not when the
108
+ replay ran. Carrying the group and the materialisation id too means the measurement is
109
+ attributed without looking anything up on a path that must not do work.
110
+ """
111
+
112
+ missing = sorted(
113
+ {m.engine for p in placement.groups.values() for m in p.all()} - set(self._engines)
114
+ )
115
+ if missing:
116
+ raise EngineError(
117
+ f"the placement map refers to engines that were not supplied: {missing}. A session "
118
+ "cannot route an operation to an engine it has no adapter for, and guessing at a "
119
+ "connection is not something a library should do."
120
+ )
121
+
122
+ # A copy that silently holds different values from its source is refused before the first
123
+ # write rather than discovered by `verify` at the end of one. The rule is `backfill`'s and
124
+ # it lives in one function, because this is the second door asking it and for a long time
125
+ # only the first one did: measured against live servers, a `timestamptz` written through a
126
+ # fan-out map came back `09:30:15.123456` from PostgreSQL and `09:30:15.123` from
127
+ # ClickHouse, with no error anywhere and `backfill` refusing the very same copy a phase
128
+ # later. Here rather than at `load_map`, and that is forced: a map names engines by name
129
+ # and carries no dialect, deliberately, so the earliest moment this is answerable is the
130
+ # one where the adapters are in hand.
131
+ for group in self._groups:
132
+ spot = placement.groups.get(group.name)
133
+ if spot is None or not spot.also_write:
134
+ continue
135
+ columns = group_columns(model, group)
136
+ source_dialect = self._engines[spot.source.engine].dialect
137
+ for copy in spot.also_write:
138
+ for entity in sorted(columns):
139
+ refusal = precision_refusal(
140
+ group=group.name,
141
+ entity=entity,
142
+ columns=columns[entity],
143
+ source_dialect=source_dialect,
144
+ target_dialect=self._engines[copy.engine].dialect,
145
+ )
146
+ if refusal is not None:
147
+ raise MigrationRefused(
148
+ f"{refusal} This map fans writes out to {copy.engine}, so it would "
149
+ f"happen on every write rather than once during a copy, and nothing "
150
+ f"would report it."
151
+ )
152
+
153
+ # Here rather than in a method somebody has to remember to call, and here rather than in
154
+ # `ensure_schema`, which a deployment past its first release skips. A rolled-back map file
155
+ # is read at process start, so the check has to be on the path every start takes - and this
156
+ # constructor already refuses a map it cannot route, which is the same kind of refusal in
157
+ # the same place. It costs one statement per participating engine, once per process, and
158
+ # nothing at all for an unsigned map.
159
+ self._forward_only = enforce_forward_only(placement, self._engines)
160
+
161
+ # --- what a session is -----------------------------------------------------------------
162
+ #
163
+ # The three things a session was handed, readable. Exposed for `sde.migration`, which needs all
164
+ # three and is deliberately a set of free functions rather than methods here: a call that copies
165
+ # a table for an hour has no business sitting in autocomplete next to `save()`. Reaching into
166
+ # private attributes from a sibling module would have worked and would have made this class a
167
+ # friend of that one, which is a worse arrangement than admitting what a session holds.
168
+
169
+ @property
170
+ def model(self) -> LogicalModel:
171
+ """The model this session routes. Digests rather than the client's names when hashing is on,
172
+ like everything else downstream of that boundary."""
173
+ return self._model
174
+
175
+ @property
176
+ def placement(self) -> PlacementMap:
177
+ """The map in force."""
178
+ return self._placement
179
+
180
+ @property
181
+ def engines(self) -> Mapping[str, Engine]:
182
+ """The adapters, by the names the map uses. A read-only view; the session keeps its own."""
183
+ return MappingProxyType(self._engines)
184
+
185
+ @property
186
+ def rollback_protection(self) -> WatermarkCheck:
187
+ """Whether an older map could be loaded over this one, and why.
188
+
189
+ Public because a protection whose state cannot be read is a protection taken on trust. It
190
+ has three values and the middle one matters: `enforced`, `unavailable` - no engine in this
191
+ map can keep the bookkeeping, which is the case for an engine whose schema is fixed in its
192
+ own source - and `not_applicable` for an unsigned map, which is the client's own document.
193
+ """
194
+ return self._forward_only
195
+
196
+ # --- the hashing boundary --------------------------------------------------------------
197
+ #
198
+ # Four one-line helpers, each guarded by `is None`, because with hashing off the cost of this
199
+ # whole mechanism has to be a single attribute check on the hot path - the overhead test gates
200
+ # the library at one percent of a round trip and routing already spends 0.4% of it.
201
+
202
+ def _entity(self, entity: str) -> str:
203
+ """The client's entity name, as the model knows it."""
204
+ if self._names is None:
205
+ return entity
206
+ return self._names.entity(entity)
207
+
208
+ def _fields_in(self, entity: str, values: Mapping[str, Any]) -> Mapping[str, Any]:
209
+ if self._names is None:
210
+ return values
211
+ mapping = self._names.fields[entity]
212
+ try:
213
+ return {mapping[field]: value for field, value in values.items()}
214
+ except KeyError as exc:
215
+ raise ModelPlanningError(
216
+ f"{entity} has no field {exc.args[0]!r}. With hashed identifiers a field the model "
217
+ "does not declare cannot be translated, so it is refused here rather than sent to "
218
+ "an engine under a name nothing will recognise."
219
+ ) from None
220
+
221
+ def _fields_out(self, entity: str, row: Any) -> Any:
222
+ """Translate a row back, so a client can read their own data.
223
+
224
+ Skipping this would hand back `{"f_b1b4a0ec9efb": "a@b.c"}`. The row would be correct and
225
+ useless: the application would have to know the digests to index it, which is the one thing
226
+ hashing exists to keep out of their code.
227
+ """
228
+ if self._names is None or not isinstance(row, Mapping):
229
+ return row
230
+ reverse = self._reverse_fields.get(self._names.entity(entity), {})
231
+ return {reverse.get(field, field): value for field, value in row.items()}
232
+
233
+ def _client_names(self, entity: str, fields: Iterable[str]) -> list[str]:
234
+ """Hashed field names, back in the client's vocabulary, for an error message.
235
+
236
+ An error that says `['f_7d1d3e8368c8', 'f_d186ef62ff24']` is an error that sends somebody to
237
+ read our source. It costs one dict lookup on a path that is already raising.
238
+ """
239
+ if self._names is None:
240
+ return sorted(fields)
241
+ reverse = self._reverse_fields.get(self._names.entity(entity), {})
242
+ return sorted(reverse.get(field, field) for field in fields)
243
+
244
+ # --- structure -------------------------------------------------------------------------
245
+
246
+ def group_of(self, entity: str) -> Group:
247
+ target = self._entity(entity)
248
+ for group in self._groups:
249
+ if target in group:
250
+ return group
251
+ raise ModelPlanningError(
252
+ f"{entity!r} is not in this model. A session routes what the model declares; an entity "
253
+ "that is not declared has no group, no placement and no table."
254
+ )
255
+
256
+ def _shape(self, entity: str, kind: str, fields: tuple[str, ...] = ()) -> OperationShape:
257
+ try:
258
+ return self._shapes[(entity, kind, fields)]
259
+ except KeyError:
260
+ raise ModelPlanningError(
261
+ f"the model admits no {kind} on {entity} over {list(fields)}. Shapes are "
262
+ "enumerated from the model, so an operation with no shape is one the planner never "
263
+ "saw and therefore never routed, which makes it a modelling gap rather than a "
264
+ "runtime error."
265
+ ) from None
266
+
267
+ def _target(self, shape: OperationShape, *, fresh: bool) -> tuple[Engine, Materialization]:
268
+ materialization = self._router.resolve(
269
+ shape, in_write_transaction=self._in_write_transaction, fresh=fresh
270
+ )
271
+ return self._engines[materialization.engine], materialization
272
+
273
+ # --- schema ----------------------------------------------------------------------------
274
+
275
+ def ensure_schema(self) -> None:
276
+ """Create what each engine is missing for the groups placed in it."""
277
+ for group in self._groups:
278
+ placement = self._placement.placement_of(group.name)
279
+ keys = {name: self._model.entity(name).key for name in group.members}
280
+ for materialization in placement.all():
281
+ engine = self._engines[materialization.engine]
282
+ engine.ensure_schema(materialization.layout, keys=keys)
283
+ log("sde.schema.applied", groups=len(self._groups))
284
+
285
+ # --- data ------------------------------------------------------------------------------
286
+
287
+ def save(self, entity: str, values: Mapping[str, Any]) -> None:
288
+ target = self._entity(entity)
289
+ values = self._fields_in(entity, values)
290
+ shape = self._shape(target, "write")
291
+ engine, materialization = self._target(shape, fresh=False)
292
+ table = materialization.layout.table_for(target)
293
+ started = perf_counter_ns() if self._recorder else 0
294
+ failed = False
295
+ try:
296
+ engine.insert(table, values)
297
+ self._fan_out(target, shape.group, values)
298
+ except BaseException:
299
+ failed = True
300
+ raise
301
+ finally:
302
+ # In a finally block on purpose: a failed write is exactly the operation whose
303
+ # latency and error count matter most to a placement decision, and it is the one an
304
+ # early return would silently omit.
305
+ #
306
+ # The fan-out is inside the timed region, which is a decision. It makes the client's
307
+ # write slower and the telemetry has to say so, or a placement would be scored against
308
+ # a latency the application is not experiencing - and the whole point of the number is
309
+ # that it is what they pay. One consequence is worth knowing: during a migration this
310
+ # inflates write latency against a baseline written before it, so the drift detector
311
+ # sees a real degradation with a known cause.
312
+ self._observe(shape, started, rows=1, failed=failed)
313
+
314
+ # --- dual write ----------------------------------------------------------------------------
315
+
316
+ def _fan_out(self, entity: str, group: str, values: Mapping[str, Any]) -> None:
317
+ """Write the row to every ``also_write`` copy of the group. Additionally, never
318
+ authoritatively.
319
+
320
+ Requirement 9.1 and task 12.3: **a failure here does not interrupt the client's
321
+ operation.** The row is in the source, which is the copy that counts, and turning a
322
+ migration into an application outage would make the safest thing this product does the
323
+ most dangerous. So the divergence is recorded and `VERIFY` is the gate that refuses to
324
+ switch reads while any of them remain.
325
+
326
+ Inside a write transaction the fan-out is **deferred to commit** rather than skipped or
327
+ done inline, and each of those three was considered. Inline is wrong: the target is a
328
+ different engine, so it is outside the source's transaction, and a rolled-back row would
329
+ exist in the copy - which after the switch is a row the client explicitly undid, readable.
330
+ Skipping is wrong for a quieter reason: those rows are above the backfill marker, so
331
+ nothing else copies them, and `VERIFY`'s tail check would refuse the migration of every
332
+ group that uses a transaction. Deferring keeps both properties: the copy only ever receives
333
+ committed rows, and it receives all of them.
334
+ """
335
+ placement = self._placement.placement_of(group)
336
+ if not placement.also_write:
337
+ return
338
+ for copy in placement.also_write:
339
+ table = copy.layout.table_for(entity)
340
+ if self._in_write_transaction:
341
+ self._deferred.append(
342
+ (copy.engine, table, group, copy.id, perf_counter_ns(), dict(values))
343
+ )
344
+ continue
345
+ self._replay_one(copy.engine, table, group, copy.id, perf_counter_ns(), dict(values))
346
+
347
+ def _replay_one(
348
+ self,
349
+ engine_name: str,
350
+ table: str,
351
+ group: str,
352
+ materialization: str,
353
+ queued_ns: int,
354
+ values: dict[str, Any],
355
+ ) -> None:
356
+ """Write one row to one copy, measure how long the copy was behind, and never raise.
357
+
358
+ ``queued_ns`` is when the row was handed to the fan-out, so the interval measured is the
359
+ whole time the copy did not have a row the source did. Outside a transaction that is the
360
+ duration of this write; inside one it also includes the rest of the transaction, which
361
+ **overstates** the staleness - the safe direction for a bound somebody checks a budget
362
+ against.
363
+ """
364
+ failed = False
365
+ try:
366
+ self._engines[engine_name].insert(table, values)
367
+ except Exception as exc:
368
+ # Deliberately swallowed, and the only place in this library that swallows a write
369
+ # failure. The narrow `Exception` rather than `BaseException` matters: a
370
+ # KeyboardInterrupt or a SystemExit during a fan-out is not a divergence, it is a
371
+ # process being told to stop, and treating it as one would log a lie and continue.
372
+ failed = True
373
+ log(
374
+ "sde.migration.divergence",
375
+ engine=engine_name,
376
+ table=table,
377
+ error=type(exc).__name__,
378
+ )
379
+ # After the `except` rather than in a `finally`, and the difference is one case. A failed
380
+ # fan-out must be counted - a copy missing a thousand rows with an excellent p99 is the
381
+ # report this measurement exists to make impossible - and `except Exception` above already
382
+ # falls through to here, so both paths record. What a `finally` would add is the
383
+ # **BaseException** path: a KeyboardInterrupt or SystemExit while the copy is being written
384
+ # would record a fan-out that completed, with `failed=False`, for a row that never landed.
385
+ # The comment above says an interrupt is not a divergence; recording it as a success would
386
+ # be worse than not recording it, so it is not recorded at all.
387
+ if self._recorder is not None:
388
+ self._recorder.record_fan_out(
389
+ group=group,
390
+ materialization=materialization,
391
+ nanoseconds=perf_counter_ns() - queued_ns,
392
+ failed=failed,
393
+ )
394
+
395
+ def get(self, entity: str, key: Mapping[str, Any], *, fresh: bool = False) -> Any:
396
+ target = self._entity(entity)
397
+ given = self._fields_in(entity, key)
398
+ spec = self._model.entity(target)
399
+ expected = tuple(sorted(spec.key))
400
+ if tuple(sorted(given)) != expected:
401
+ # Both lists are put back into the client's vocabulary: with hashing on, an error naming
402
+ # digests tells them nothing about their own code.
403
+ raise ModelPlanningError(
404
+ f"a point read of {entity} needs exactly its key "
405
+ f"{self._client_names(entity, expected)}, and was given "
406
+ f"{self._client_names(entity, given)}. A partial key is a range read, which is a "
407
+ "different shape and may well be routed somewhere else."
408
+ )
409
+ shape = self._shape(target, "point_read", expected)
410
+ engine, materialization = self._target(shape, fresh=fresh)
411
+ table = materialization.layout.table_for(target)
412
+ key = given
413
+ started = perf_counter_ns() if self._recorder else 0
414
+ failed = False
415
+ row: Any = None
416
+ try:
417
+ row = engine.get(table, key)
418
+ except BaseException:
419
+ failed = True
420
+ raise
421
+ finally:
422
+ self._observe(shape, started, rows=0 if row is None else 1, failed=failed)
423
+ return self._fields_out(entity, row)
424
+
425
+ def _observe(self, shape: OperationShape, started: int, *, rows: int, failed: bool) -> None:
426
+ """Hand one observation to the recorder, if there is one.
427
+
428
+ The timing call is skipped entirely when telemetry is off, which is why `started` is
429
+ passed in rather than measured here: with no recorder this method is one attribute check.
430
+ """
431
+ recorder = self._recorder
432
+ if recorder is None:
433
+ return
434
+ recorder.record(
435
+ shape_id=shape.id,
436
+ group=shape.group,
437
+ entity=shape.entity,
438
+ kind=shape.kind,
439
+ nanoseconds=perf_counter_ns() - started,
440
+ rows=rows,
441
+ failed=failed,
442
+ )
443
+
444
+ # --- transactions ----------------------------------------------------------------------
445
+
446
+ @contextmanager
447
+ def transaction(self, *entities: str) -> Iterator[Session]:
448
+ """Open a transaction covering the given entities.
449
+
450
+ They must share a colocation group, because a transaction is one engine's transaction. If
451
+ they do not, this raises before anything is opened and names the fix: declare the atomicity,
452
+ and the planner will colocate them.
453
+
454
+ Called with no entities it covers the whole model, which is only legal when the model has
455
+ one group. That is not a convenience for small models so much as a refusal to let a
456
+ two-group model quietly get a transaction that only covers half of what the caller meant.
457
+ """
458
+ # Entity names in the *client's* vocabulary, so that an error message and the entities they
459
+ # passed are in the same language. With hashing on, `self._model.entities` are digests, and
460
+ # feeding those back through `group_of` would try to hash a hash.
461
+ declared = self._declared or tuple(e.name for e in self._model.entities)
462
+ names = list(entities) if entities else list(declared)
463
+ groups = {self.group_of(name).name for name in names}
464
+ if len(groups) > 1:
465
+ by_group: dict[str, list[str]] = {}
466
+ for name in sorted(names):
467
+ by_group.setdefault(self.group_of(name).name, []).append(name)
468
+ layout = "; ".join(
469
+ f"{g}: {', '.join(members)}" for g, members in sorted(by_group.items())
470
+ )
471
+ raise ModelPlanningError(
472
+ f"a transaction cannot span colocation groups ({layout}). One group is one "
473
+ "engine and one engine's transaction; there is no distributed transaction here and "
474
+ "there will not be one. If these entities have to commit together, declare it with "
475
+ "`atomic_with` on either side, and the planner will place them in the same engine "
476
+ "- which turns the requirement into a placement constraint instead of a two-phase "
477
+ "commit."
478
+ )
479
+
480
+ group = self.group_of(names[0])
481
+ engine = self._engines[self._placement.placement_of(group.name).source.engine]
482
+ previous = self._in_write_transaction
483
+ outer = len(self._deferred)
484
+ self._in_write_transaction = True
485
+ committed = False
486
+ try:
487
+ with engine.transaction():
488
+ yield self
489
+ committed = True
490
+ finally:
491
+ self._in_write_transaction = previous
492
+ pending = self._deferred[outer:]
493
+ del self._deferred[outer:]
494
+ if committed and not previous:
495
+ # Replayed after the source transaction has committed, and only by the outermost
496
+ # one: a nested block that returns to a still-open transaction has not committed
497
+ # anything yet, so its rows go back on the queue rather than to the copy.
498
+ for engine_name, table, group_name, copy_id, queued_ns, values in pending:
499
+ self._replay_one(
500
+ engine_name, table, group_name, copy_id, queued_ns, values
501
+ )
502
+ elif not committed:
503
+ # Rolled back. The rows never existed in the source, so they must never exist in
504
+ # the copy - dropping them is the whole reason the fan-out was deferred.
505
+ pass
506
+ else:
507
+ self._deferred.extend(pending)
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)