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/session.py ADDED
@@ -0,0 +1,993 @@
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 Callable, Iterable, Iterator, Mapping, Sequence
19
+ from contextlib import contextmanager
20
+ from threading import Lock
21
+ from time import perf_counter_ns
22
+ from types import MappingProxyType
23
+ from typing import Any, Protocol
24
+
25
+ from ._usage import SessionUsage, session_call, session_owner
26
+ from .bulk import batch_columns, bulk_writer, snapshot_rows
27
+ from .errors import (
28
+ BulkWriteRefused,
29
+ EngineError,
30
+ MigrationRefused,
31
+ ModelPlanningError,
32
+ ResourceBusy,
33
+ ResourceClosed,
34
+ )
35
+ from .generation import logical_row, stamp_values, validate_generations
36
+ from .groups import Group, colocation_groups
37
+ from .hashing import NameMap
38
+ from .layout import group_columns
39
+ from .logging import log
40
+ from .migration import precision_refusal
41
+ from .model import LogicalModel
42
+ from .physical import PhysicalFinding
43
+ from .placement import Materialization, PlacementMap
44
+ from .query import (
45
+ NumericSummary,
46
+ QueryRefused,
47
+ Range,
48
+ ReadColumn,
49
+ ReadPlan,
50
+ ScanPage,
51
+ numeric_summary,
52
+ plan_read,
53
+ query_engine,
54
+ summary_engine,
55
+ summary_scale,
56
+ )
57
+ from .routing import Router
58
+ from .shapes import OperationShape, enumerate_shapes
59
+ from .telemetry import Recorder, StorageMeasurement, StorageSize
60
+ from .verification import check_project_id
61
+ from .watermark import WatermarkCheck, enforce_forward_only
62
+
63
+ __all__ = ["Engine", "ManagedEngine", "Session"]
64
+
65
+ _WriteValues = dict[str, Any] | tuple[dict[str, Any], ...]
66
+
67
+
68
+ class Engine(Protocol):
69
+ """What a session needs from an engine adapter, and nothing more.
70
+
71
+ A protocol rather than a base class so that an adapter for another engine - or a fake, in
72
+ somebody else's test suite - does not have to import anything from us to satisfy it.
73
+ """
74
+
75
+ dialect: str
76
+
77
+ def ensure_schema(self, layout: Any, *, keys: Mapping[str, Any]) -> Any: ...
78
+ def insert(self, table: str, values: Mapping[str, Any]) -> None: ...
79
+ def get(self, table: str, key: Mapping[str, Any]) -> dict[str, Any] | None: ...
80
+
81
+ @contextmanager
82
+ def transaction(self) -> Iterator[Any]: ...
83
+
84
+
85
+ class ManagedEngine(Engine, Protocol):
86
+ def connect(self) -> None: ...
87
+ def close(self) -> None: ...
88
+
89
+
90
+ def _storage_refusal(exc: BaseException) -> str:
91
+ """``refused`` when the catalogue denied the login, ``failed`` for any other engine error.
92
+
93
+ Read from the driver's own error beneath the adapter's: PostgreSQL's SQLSTATE 42501,
94
+ ClickHouse's access error 497 - what a login without the ``system.parts`` grant receives
95
+ (measured on 24.8).
96
+ """
97
+ cause = exc.__cause__ if exc.__cause__ is not None else exc
98
+ if getattr(cause, "sqlstate", None) == "42501" or "Code: 497" in str(cause):
99
+ return "refused"
100
+ return "failed"
101
+
102
+
103
+ class Session:
104
+ """Routes operations for one model against one placement.
105
+
106
+ Holds no connection state of its own: engines do that. It exists to answer "where does this
107
+ operation go" and to refuse the operations that cannot be answered - and both of those are pure
108
+ functions of the model and the map, which is why this class has no configuration.
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ model: LogicalModel,
114
+ placement: PlacementMap,
115
+ engines: Mapping[str, Engine],
116
+ *,
117
+ recorder: Recorder | None = None,
118
+ names: NameMap | None = None,
119
+ project_id: str | None = None,
120
+ ) -> None:
121
+ # Telemetry is optional and off by default. A library that starts measuring the moment it is
122
+ # imported is a library people are right to be suspicious of; measurement begins when a
123
+ # recorder is handed in, which is a visible line in the client's code.
124
+ self._session_usage = SessionUsage(self)
125
+ self._owned_engines: list[ManagedEngine] = []
126
+ self._cleanup_lock = Lock()
127
+ check_project_id(project_id)
128
+ self._project_id = project_id
129
+ self._recorder = recorder
130
+
131
+ # The one place where the client's names and the hashed ones meet. When a model has been
132
+ # hashed, everything downstream of here - the map, the shapes, the tables, the telemetry -
133
+ # speaks digests only, and the application keeps saying `save("User", {"email": ...})`.
134
+ # Without this the mode is unusable: a client would have to write `save("e_546526714dc9",
135
+ # {"f_b1b4a0ec9efb": ...})` in their own code, which nobody will do and which would put the
136
+ # digests in their source anyway.
137
+ self._names = names
138
+ self._reverse_fields: dict[str, dict[str, str]] = {}
139
+ self._input_fields: dict[str, dict[str, str]] = {}
140
+ self._declared: tuple[str, ...] = ()
141
+ if names is not None:
142
+ original_entities = {hashed: original for original, hashed in names.entities.items()}
143
+ for entity, declared_fields in names.fields.items():
144
+ mapping = dict(declared_fields)
145
+ for original_relation, hashed_relation in names.relations.get(entity, {}).items():
146
+ relation = next(
147
+ r for r in model.relations
148
+ if r.source == names.entity(entity) and r.name == hashed_relation
149
+ )
150
+ target = model.entity(relation.target)
151
+ target_fields = names.fields[original_entities[target.name]]
152
+ for original_key, hashed_key in target_fields.items():
153
+ if hashed_key in target.key:
154
+ mapping[f"{original_relation}_{original_key}"] = (
155
+ f"{hashed_relation}_{hashed_key}"
156
+ )
157
+ self._input_fields[entity] = mapping
158
+ self._reverse_fields[names.entity(entity)] = {
159
+ hashed: original for original, hashed in mapping.items()
160
+ }
161
+ self._declared = tuple(sorted(names.entities))
162
+ self._model = model
163
+ self._placement = placement
164
+ self._engines = dict(engines)
165
+ self._router = Router(placement)
166
+ self._groups: tuple[Group, ...] = colocation_groups(model)
167
+ self._shapes = {(s.entity, s.kind, s.fields): s for s in enumerate_shapes(model)}
168
+ self._in_write_transaction = False
169
+ self._deferred: list[tuple[str, str, str, str, int, _WriteValues]] = []
170
+ """Rows waiting for their transaction to commit before reaching a copy.
171
+
172
+ Six-wide rather than three because requirement 5.2 has us report how far behind a copy
173
+ runs, and the honest start of that interval is when the row was **queued** - not when the
174
+ replay ran. Carrying the group and the materialisation id too means the measurement is
175
+ attributed without looking anything up on a path that must not do work.
176
+ """
177
+
178
+ missing = sorted(
179
+ {m.engine for p in placement.groups.values() for m in p.all()} - set(self._engines)
180
+ )
181
+ if missing:
182
+ raise EngineError(
183
+ f"the placement map refers to engines that were not supplied: {missing}. A session "
184
+ "cannot route an operation to an engine it has no adapter for, and guessing at a "
185
+ "connection is not something a library should do."
186
+ )
187
+
188
+ # A copy that silently holds different values from its source is refused before the first
189
+ # write rather than discovered by `verify` at the end of one. The rule is `backfill`'s and
190
+ # it lives in one function, because this is the second door asking it and for a long time
191
+ # only the first one did: measured against live servers, a `timestamptz` written through a
192
+ # fan-out map came back `09:30:15.123456` from PostgreSQL and `09:30:15.123` from
193
+ # ClickHouse, with no error anywhere and `backfill` refusing the very same copy a phase
194
+ # later. Here rather than at `load_map`, and that is forced: a map names engines by name
195
+ # and carries no dialect, deliberately, so the earliest moment this is answerable is the
196
+ # one where the adapters are in hand.
197
+ for group in self._groups:
198
+ spot = placement.groups.get(group.name)
199
+ if spot is None or not spot.also_write:
200
+ continue
201
+ columns = group_columns(model, group)
202
+ source_dialect = self._engines[spot.source.engine].dialect
203
+ for copy in spot.also_write:
204
+ for entity in sorted(columns):
205
+ refusal = precision_refusal(
206
+ group=group.name,
207
+ entity=entity,
208
+ columns=columns[entity],
209
+ source_dialect=source_dialect,
210
+ target_dialect=self._engines[copy.engine].dialect,
211
+ )
212
+ if refusal is not None:
213
+ raise MigrationRefused(
214
+ f"{refusal} This map fans writes out to {copy.engine}, so it would "
215
+ f"happen on every write rather than once during a copy, and nothing "
216
+ f"would report it."
217
+ )
218
+
219
+ # Here rather than in a method somebody has to remember to call, and here rather than in
220
+ # `ensure_schema`, which a deployment past its first release skips. A rolled-back map file
221
+ # is read at process start, so the check has to be on the path every start takes - and this
222
+ # constructor already refuses a map it cannot route, which is the same kind of refusal in
223
+ # the same place. It costs one statement per participating engine, once per process, and
224
+ # nothing at all for an unsigned map.
225
+ with session_owner(self):
226
+ self._physical = validate_generations(model, placement, self._engines, project_id)
227
+ self._report_physical()
228
+ self._forward_only = enforce_forward_only(placement, self._engines)
229
+
230
+ @classmethod
231
+ def connect(
232
+ cls,
233
+ model: LogicalModel,
234
+ placement: PlacementMap,
235
+ factories: Mapping[str, Callable[[], ManagedEngine]],
236
+ *,
237
+ recorder: Recorder | None = None,
238
+ names: NameMap | None = None,
239
+ project_id: str | None = None,
240
+ ) -> Session:
241
+ """Create, connect and own fresh adapters; clean up partial startup on any failure."""
242
+ created: list[ManagedEngine] = []
243
+ with session_owner(object()):
244
+ try:
245
+ engines: dict[str, ManagedEngine] = {}
246
+ for name, factory in sorted(factories.items()):
247
+ engine = factory()
248
+ if any(engine is previous for previous in created):
249
+ raise EngineError(
250
+ "owned session factories must provide distinct fresh adapters"
251
+ )
252
+ created.append(engine)
253
+ engines[name] = engine
254
+ engine.connect()
255
+ session = cls(
256
+ model, placement, engines, recorder=recorder, names=names, project_id=project_id
257
+ )
258
+ session._owned_engines = created
259
+ return session
260
+ except BaseException as exc:
261
+ for engine in reversed(created):
262
+ try:
263
+ engine.close()
264
+ except BaseException:
265
+ exc.add_note(
266
+ "An owned adapter also failed to close during startup cleanup."
267
+ )
268
+ raise
269
+
270
+ def close(self) -> None:
271
+ """End this unit of work and close only the adapters created by Session.connect."""
272
+ self._session_usage.close()
273
+ if not self._cleanup_lock.acquire(blocking=False):
274
+ raise ResourceBusy("session cleanup is already in progress")
275
+ try:
276
+ failed: list[ManagedEngine] = []
277
+ failures: list[BaseException] = []
278
+ for engine in reversed(self._owned_engines):
279
+ try:
280
+ engine.close()
281
+ except BaseException as exc:
282
+ failed.append(engine)
283
+ failures.append(exc)
284
+ self._owned_engines = failed
285
+ if failures:
286
+ raise failures[0]
287
+ finally:
288
+ self._cleanup_lock.release()
289
+
290
+ def __enter__(self) -> Session:
291
+ self._session_usage.check()
292
+ return self
293
+
294
+ def __exit__(self, _kind: object, error: BaseException | None, _traceback: object) -> None:
295
+ try:
296
+ self.close()
297
+ except BaseException:
298
+ if error is None:
299
+ raise
300
+ error.add_note("Owned adapter cleanup also failed; retry Session.close().")
301
+
302
+ # --- what a session is -----------------------------------------------------------------
303
+ #
304
+ # The three things a session was handed, readable. Exposed for `sde.migration`, which needs all
305
+ # three and is deliberately a set of free functions rather than methods here: a call that copies
306
+ # a table for an hour has no business sitting in autocomplete next to `save()`. Reaching into
307
+ # private attributes from a sibling module would have worked and would have made this class a
308
+ # friend of that one, which is a worse arrangement than admitting what a session holds.
309
+
310
+ @property
311
+ def project_id(self) -> str | None:
312
+ """The project from local enrollment configuration, never learned from a request."""
313
+ return self._project_id
314
+
315
+ @property
316
+ def model(self) -> LogicalModel:
317
+ """The model this session routes. Digests rather than the client's names when hashing is on,
318
+ like everything else downstream of that boundary."""
319
+ return self._model
320
+
321
+ @property
322
+ def placement(self) -> PlacementMap:
323
+ """The map in force."""
324
+ return self._placement
325
+
326
+ @property
327
+ def engines(self) -> Mapping[str, Engine]:
328
+ """The adapters, by the names the map uses. A read-only view; the session keeps its own."""
329
+ self._session_usage.check()
330
+ return MappingProxyType(self._engines)
331
+
332
+ @property
333
+ def physical(self) -> tuple[PhysicalFinding, ...]:
334
+ """How existing tables differ from the physical design the map declares, if at all.
335
+
336
+ Reported, never refused, because the difference is performance: a table whose sort key,
337
+ partition or index is not the one declared still stores and returns exactly the same rows,
338
+ and turning that into an outage would make this library the thing that broke production
339
+ (requirement 3.6). Provisioning refuses the same differences, which is where a person can
340
+ act on them. Empty until a check has run and when everything matches.
341
+ """
342
+ return self._physical
343
+
344
+ def _report_physical(self) -> None:
345
+ if self._physical:
346
+ log(
347
+ "sde.schema.physical_mismatch",
348
+ findings=len(self._physical),
349
+ tables=sorted({finding.table for finding in self._physical}),
350
+ )
351
+
352
+ @property
353
+ def rollback_protection(self) -> WatermarkCheck:
354
+ """Whether an older map could be loaded over this one, and why.
355
+
356
+ Public because a protection whose state cannot be read is a protection taken on trust. It
357
+ has three values and the middle one matters: `enforced`, `unavailable` - no engine in this
358
+ map can keep the bookkeeping, which is the case for an engine whose schema is fixed in its
359
+ own source - and `not_applicable` for an unsigned map, which is the client's own document.
360
+ """
361
+ return self._forward_only
362
+
363
+ # --- the hashing boundary --------------------------------------------------------------
364
+ #
365
+ # Four one-line helpers, each guarded by `is None`, because with hashing off the cost of this
366
+ # whole mechanism has to be a single attribute check on the hot path - the overhead test gates
367
+ # the library at one percent of a round trip and routing already spends 0.4% of it.
368
+
369
+ def _entity(self, entity: str) -> str:
370
+ """The client's entity name, as the model knows it."""
371
+ if self._names is None:
372
+ return entity
373
+ return self._names.entity(entity)
374
+
375
+ def _fields_in(self, entity: str, values: Mapping[str, Any]) -> Mapping[str, Any]:
376
+ if self._names is None:
377
+ return values
378
+ mapping = self._input_fields[entity]
379
+ try:
380
+ return {mapping[field]: value for field, value in values.items()}
381
+ except KeyError as exc:
382
+ raise ModelPlanningError(
383
+ f"{entity} has no field {exc.args[0]!r}. With hashed identifiers a field the model "
384
+ "does not declare cannot be translated, so it is refused here rather than sent to "
385
+ "an engine under a name nothing will recognise."
386
+ ) from None
387
+
388
+ def _fields_out(self, entity: str, row: Any) -> Any:
389
+ """Translate a row back, so a client can read their own data.
390
+
391
+ Skipping this would hand back `{"f_b1b4a0ec9efb": "a@b.c"}`. The row would be correct and
392
+ useless: the application would have to know the digests to index it, which is the one thing
393
+ hashing exists to keep out of their code.
394
+ """
395
+ if self._names is None or not isinstance(row, Mapping):
396
+ return row
397
+ reverse = self._reverse_fields.get(self._names.entity(entity), {})
398
+ return {reverse.get(field, field): value for field, value in row.items()}
399
+
400
+ def _client_names(self, entity: str, fields: Iterable[str]) -> list[str]:
401
+ """Hashed field names, back in the client's vocabulary, for an error message.
402
+
403
+ An error that says `['f_7d1d3e8368c8', 'f_d186ef62ff24']` is an error that sends somebody to
404
+ read our source. It costs one dict lookup on a path that is already raising.
405
+ """
406
+ if self._names is None:
407
+ return sorted(fields)
408
+ reverse = self._reverse_fields.get(self._names.entity(entity), {})
409
+ return sorted(reverse.get(field, field) for field in fields)
410
+
411
+ # --- structure -------------------------------------------------------------------------
412
+
413
+ def group_of(self, entity: str) -> Group:
414
+ target = self._entity(entity)
415
+ for group in self._groups:
416
+ if target in group:
417
+ return group
418
+ raise ModelPlanningError(
419
+ f"{entity!r} is not in this model. A session routes what the model declares; an entity "
420
+ "that is not declared has no group, no placement and no table."
421
+ )
422
+
423
+ def _shape(self, entity: str, kind: str, fields: tuple[str, ...] = ()) -> OperationShape:
424
+ try:
425
+ return self._shapes[(entity, kind, fields)]
426
+ except KeyError:
427
+ raise ModelPlanningError(
428
+ f"the model admits no {kind} on {entity} over {list(fields)}. Shapes are "
429
+ "enumerated from the model, so an operation with no shape is one the planner never "
430
+ "saw and therefore never routed, which makes it a modelling gap rather than a "
431
+ "runtime error."
432
+ ) from None
433
+
434
+ def _target(self, shape: OperationShape, *, fresh: bool) -> tuple[Engine, Materialization]:
435
+ self._session_usage.group(shape.group)
436
+ materialization = self._router.resolve(
437
+ shape, in_write_transaction=self._in_write_transaction, fresh=fresh
438
+ )
439
+ return self._engines[materialization.engine], materialization
440
+
441
+ # --- schema ----------------------------------------------------------------------------
442
+
443
+ @session_call
444
+ def ensure_schema(self) -> None:
445
+ """Create what each engine is missing for the groups placed in it."""
446
+ if self._placement.contract >= 4:
447
+ self._physical = validate_generations(
448
+ self._model, self._placement, self._engines, self._project_id
449
+ )
450
+ self._report_physical()
451
+ return
452
+ findings: list[PhysicalFinding] = []
453
+ for group in self._groups:
454
+ placement = self._placement.placement_of(group.name)
455
+ keys = {name: self._model.entity(name).key for name in group.members}
456
+ for materialization in placement.all():
457
+ engine = self._engines[materialization.engine]
458
+ # `or ()`: an adapter written before this returned findings returns None.
459
+ findings.extend(engine.ensure_schema(materialization.layout, keys=keys) or ())
460
+ self._physical = tuple(findings)
461
+ self._report_physical()
462
+ log("sde.schema.applied", groups=len(self._groups))
463
+
464
+ # --- data ------------------------------------------------------------------------------
465
+
466
+ @session_call
467
+ def save(self, entity: str, values: Mapping[str, Any]) -> None:
468
+ target = self._entity(entity)
469
+ values = self._fields_in(entity, values)
470
+ shape = self._shape(target, "write")
471
+ engine, materialization = self._target(shape, fresh=False)
472
+ table = materialization.layout.table_for(target)
473
+ started = perf_counter_ns() if self._recorder else 0
474
+ failed = False
475
+ try:
476
+ engine.insert(table, stamp_values(self._placement, shape.group, values))
477
+ self._session_usage.check()
478
+ self._fan_out(target, shape.group, values)
479
+ except BaseException:
480
+ failed = True
481
+ raise
482
+ finally:
483
+ # In a finally block on purpose: a failed write is exactly the operation whose
484
+ # latency and error count matter most to a placement decision, and it is the one an
485
+ # early return would silently omit.
486
+ #
487
+ # The fan-out is inside the timed region, which is a decision. It makes the client's
488
+ # write slower and the telemetry has to say so, or a placement would be scored against
489
+ # a latency the application is not experiencing - and the whole point of the number is
490
+ # that it is what they pay. One consequence is worth knowing: during a migration this
491
+ # inflates write latency against a baseline written before it, so the drift detector
492
+ # sees a real degradation with a known cause.
493
+ self._observe(shape, started, rows=1, failed=failed)
494
+
495
+ @session_call
496
+ def save_many(self, entity: str, rows: Sequence[Mapping[str, Any]]) -> None:
497
+ """Insert one bounded batch. No splitting or retry; see docs/bulk-writes.md."""
498
+ target = self._entity(entity)
499
+ shape = self._shape(target, "bulk_write")
500
+ engine, materialization = self._target(shape, fresh=False)
501
+ spot = self._placement.placement_of(shape.group)
502
+ columns = batch_columns(rows, extra_columns=int(spot.write_epoch is not None))
503
+ if not columns:
504
+ return
505
+ try:
506
+ translated = tuple(self._fields_in(entity, row) for row in rows)
507
+ except ModelPlanningError as exc:
508
+ raise BulkWriteRefused(str(exc)) from exc
509
+ fields = set(translated[0])
510
+ spec = self._model.entity(target)
511
+ declared = set(group_columns(self._model, self.group_of(entity))[target])
512
+ required = {field.name for field in spec.fields if not field.nullable} | set(spec.key)
513
+ if not fields <= declared or not required <= fields:
514
+ raise BulkWriteRefused(
515
+ "batch fields must be declared and include the key and all non-nullable fields"
516
+ )
517
+ writer = bulk_writer(engine)
518
+ for copy in spot.also_write:
519
+ bulk_writer(self._engines[copy.engine])
520
+ snapshot = snapshot_rows(translated)
521
+ stamped = tuple(stamp_values(self._placement, shape.group, row) for row in snapshot)
522
+ table = materialization.layout.table_for(target)
523
+ started = perf_counter_ns() if self._recorder else 0
524
+ failed = False
525
+ try:
526
+ writer.insert_many(table, stamped)
527
+ self._session_usage.check()
528
+ self._fan_out(target, shape.group, snapshot)
529
+ except BaseException:
530
+ failed = True
531
+ raise
532
+ finally:
533
+ self._observe(shape, started, rows=len(snapshot), failed=failed)
534
+
535
+ # --- dual write ----------------------------------------------------------------------------
536
+
537
+ def _fan_out(
538
+ self, entity: str, group: str, values: Mapping[str, Any] | tuple[dict[str, Any], ...]
539
+ ) -> None:
540
+ """Write the row to every ``also_write`` copy of the group. Additionally, never
541
+ authoritatively.
542
+
543
+ Requirement 9.1 and task 12.3: **a failure here does not interrupt the client's
544
+ operation.** The row is in the source, which is the copy that counts, and turning a
545
+ migration into an application outage would make the safest thing this product does the
546
+ most dangerous. So the divergence is recorded and `VERIFY` is the gate that refuses to
547
+ switch reads while any of them remain.
548
+
549
+ Inside a write transaction the fan-out is **deferred to commit** rather than skipped or
550
+ done inline, and each of those three was considered. Inline is wrong: the target is a
551
+ different engine, so it is outside the source's transaction, and a rolled-back row would
552
+ exist in the copy - which after the switch is a row the client explicitly undid, readable.
553
+ Skipping is wrong for a quieter reason: those rows are above the backfill marker, so
554
+ nothing else copies them, and `VERIFY`'s tail check would refuse the migration of every
555
+ group that uses a transaction. Deferring keeps both properties: the copy only ever receives
556
+ committed rows, and it receives all of them.
557
+ """
558
+ placement = self._placement.placement_of(group)
559
+ if not placement.also_write:
560
+ return
561
+ for copy in placement.also_write:
562
+ table = copy.layout.table_for(entity)
563
+ body = dict(values) if isinstance(values, Mapping) else values
564
+ if self._in_write_transaction:
565
+ self._deferred.append(
566
+ (copy.engine, table, group, copy.id, perf_counter_ns(), body)
567
+ )
568
+ continue
569
+ self._replay_one(copy.engine, table, group, copy.id, perf_counter_ns(), body)
570
+
571
+ def _replay_one(
572
+ self,
573
+ engine_name: str,
574
+ table: str,
575
+ group: str,
576
+ materialization: str,
577
+ queued_ns: int,
578
+ values: _WriteValues,
579
+ ) -> None:
580
+ """Write one row to one copy, measure how long the copy was behind, and never raise.
581
+
582
+ ``queued_ns`` is when the row was handed to the fan-out, so the interval measured is the
583
+ whole time the copy did not have a row the source did. Outside a transaction that is the
584
+ duration of this write; inside one it also includes the rest of the transaction, which
585
+ **overstates** the staleness - the safe direction for a bound somebody checks a budget
586
+ against.
587
+ """
588
+ failed = False
589
+ try:
590
+ engine = self._engines[engine_name]
591
+ if isinstance(values, tuple):
592
+ bulk_writer(engine).insert_many(
593
+ table, tuple(stamp_values(self._placement, group, row) for row in values)
594
+ )
595
+ else:
596
+ engine.insert(table, stamp_values(self._placement, group, values))
597
+ except Exception as exc:
598
+ # Deliberately swallowed, and the only place in this library that swallows a write
599
+ # failure. The narrow `Exception` rather than `BaseException` matters: a
600
+ # KeyboardInterrupt or a SystemExit during a fan-out is not a divergence, it is a
601
+ # process being told to stop, and treating it as one would log a lie and continue.
602
+ failed = True
603
+ log(
604
+ "sde.migration.divergence",
605
+ engine=engine_name,
606
+ table=table,
607
+ error=type(exc).__name__,
608
+ )
609
+ # After the `except` rather than in a `finally`, and the difference is one case. A failed
610
+ # fan-out must be counted - a copy missing a thousand rows with an excellent p99 is the
611
+ # report this measurement exists to make impossible - and `except Exception` above already
612
+ # falls through to here, so both paths record. What a `finally` would add is the
613
+ # **BaseException** path: a KeyboardInterrupt or SystemExit while the copy is being written
614
+ # would record a fan-out that completed, with `failed=False`, for a row that never landed.
615
+ # The comment above says an interrupt is not a divergence; recording it as a success would
616
+ # be worse than not recording it, so it is not recorded at all.
617
+ if self._recorder is not None:
618
+ self._recorder.record_fan_out(
619
+ group=group,
620
+ materialization=materialization,
621
+ nanoseconds=perf_counter_ns() - queued_ns,
622
+ failed=failed,
623
+ )
624
+
625
+ @session_call
626
+ def get(self, entity: str, key: Mapping[str, Any], *, fresh: bool = False) -> Any:
627
+ target = self._entity(entity)
628
+ given = self._fields_in(entity, key)
629
+ spec = self._model.entity(target)
630
+ expected = tuple(sorted(spec.key))
631
+ if tuple(sorted(given)) != expected:
632
+ # Both lists are put back into the client's vocabulary: with hashing on, an error naming
633
+ # digests tells them nothing about their own code.
634
+ raise ModelPlanningError(
635
+ f"a point read of {entity} needs exactly its key "
636
+ f"{self._client_names(entity, expected)}, and was given "
637
+ f"{self._client_names(entity, given)}. A partial key is a range read, which is a "
638
+ "different shape and may well be routed somewhere else."
639
+ )
640
+ shape = self._shape(target, "point_read", expected)
641
+ engine, materialization = self._target(shape, fresh=fresh)
642
+ table = materialization.layout.table_for(target)
643
+ key = given
644
+ started = perf_counter_ns() if self._recorder else 0
645
+ failed = False
646
+ row: Any = None
647
+ try:
648
+ row = engine.get(table, key)
649
+ except BaseException:
650
+ failed = True
651
+ raise
652
+ finally:
653
+ self._observe(shape, started, rows=0 if row is None else 1, failed=failed)
654
+ return self._fields_out(entity, logical_row(self._placement, row))
655
+
656
+ def _prepare_read(
657
+ self, entity: str, *, where: Mapping[str, Any] | None, bounds: Range | None,
658
+ order_by: str | None = None, descending: bool = False,
659
+ after: Mapping[str, Any] | None = None, limit: int = 100, paginate: bool = True,
660
+ ) -> tuple[str, ReadPlan]:
661
+ if not isinstance(entity, str):
662
+ raise QueryRefused("a query needs a logical entity name")
663
+ try:
664
+ target = self._entity(entity)
665
+ spec = self._model.entity(target)
666
+ except (KeyError, ModelPlanningError):
667
+ raise QueryRefused("query refers to an entity this model does not declare") from None
668
+
669
+ def translated(values: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
670
+ if values is None:
671
+ return None
672
+ if not isinstance(values, Mapping) or any(not isinstance(name, str) for name in values):
673
+ raise QueryRefused("query arguments must map logical field names to values")
674
+ try:
675
+ return self._fields_in(entity, values)
676
+ except ModelPlanningError as exc:
677
+ raise QueryRefused(str(exc)) from exc
678
+
679
+ def field(name: str) -> str:
680
+ if not isinstance(name, str):
681
+ raise QueryRefused("query fields must be logical field names")
682
+ mapped = translated({name: None})
683
+ assert mapped is not None
684
+ return next(iter(mapped))
685
+
686
+ if bounds is not None:
687
+ if not isinstance(bounds, Range):
688
+ raise QueryRefused("bounds must be a Range")
689
+ bounds = Range(field(bounds.field), bounds.low, bounds.high)
690
+ columns = group_columns(self._model, self.group_of(entity))[target]
691
+ plan = plan_read(
692
+ tuple(ReadColumn(name, kind) for name, kind in sorted(columns.items())), spec.key,
693
+ where=translated(where), bounds=bounds,
694
+ order_by=None if order_by is None else field(order_by), descending=descending,
695
+ after=translated(after), limit=limit, paginate=paginate,
696
+ )
697
+ return target, plan
698
+
699
+ @staticmethod
700
+ def _read_projection(
701
+ material: Materialization, entity: str, plan: ReadPlan, *, count: bool,
702
+ ) -> None:
703
+ required = {predicate.column.name for predicate in plan.filters}
704
+ if not count:
705
+ required.update(column.name for column in plan.columns)
706
+ available = set(material.layout.columns.get(entity, {}))
707
+ if not required <= available:
708
+ raise QueryRefused(
709
+ "the routed materialization does not contain every field this query needs; "
710
+ "request fresh=True to read the source or have the placement revised"
711
+ )
712
+
713
+ @session_call
714
+ def scan(
715
+ self, entity: str, *, where: Mapping[str, Any] | None = None,
716
+ bounds: Range | None = None, order_by: str | None = None, descending: bool = False,
717
+ after: Mapping[str, Any] | None = None, limit: int = 100, fresh: bool = False,
718
+ ) -> ScanPage:
719
+ """Read one bounded page in logical order, independently of migration checkpoints."""
720
+ if not isinstance(fresh, bool):
721
+ raise QueryRefused("fresh must be a bool")
722
+ target, plan = self._prepare_read(
723
+ entity, where=where, bounds=bounds, order_by=order_by, descending=descending,
724
+ after=after, limit=limit,
725
+ )
726
+ if bounds is None:
727
+ shape = self._shape(target, "full_scan")
728
+ else:
729
+ ranged = next(item for item in plan.filters if item.operation in ("ge", "lt"))
730
+ shape = self._shape(target, "range_read", (ranged.column.name,))
731
+ engine, material = self._target(shape, fresh=fresh)
732
+ self._read_projection(material, target, plan, count=False)
733
+ reader = query_engine(engine)
734
+ table = material.layout.table_for(target)
735
+ started = perf_counter_ns() if self._recorder else 0
736
+ failed, returned = False, 0
737
+ try:
738
+ rows = reader.select_rows(table, plan)
739
+ self._session_usage.check()
740
+ visible = rows[:plan.limit]
741
+ position = None
742
+ if len(rows) > plan.limit:
743
+ position = self._fields_out(
744
+ entity, {column.name: visible[-1][column.name] for column in plan.order}
745
+ )
746
+ position = MappingProxyType(snapshot_rows([position])[0])
747
+ result = tuple(self._fields_out(entity, row) for row in visible)
748
+ returned = len(result)
749
+ return ScanPage(result, position)
750
+ except BaseException:
751
+ failed = True
752
+ raise
753
+ finally:
754
+ self._observe(shape, started, rows=returned, failed=failed, plan=plan)
755
+
756
+ @session_call
757
+ def count(
758
+ self, entity: str, *, where: Mapping[str, Any] | None = None,
759
+ bounds: Range | None = None, fresh: bool = False,
760
+ ) -> int:
761
+ """Count matching rows in the engine. The count value itself is never telemetry."""
762
+ if not isinstance(fresh, bool):
763
+ raise QueryRefused("fresh must be a bool")
764
+ target, plan = self._prepare_read(entity, where=where, bounds=bounds, paginate=False)
765
+ shape = self._shape(target, "aggregate")
766
+ engine, material = self._target(shape, fresh=fresh)
767
+ self._read_projection(material, target, plan, count=True)
768
+ reader = query_engine(engine)
769
+ table = material.layout.table_for(target)
770
+ started = perf_counter_ns() if self._recorder else 0
771
+ failed = False
772
+ try:
773
+ result = reader.count_rows(table, plan)
774
+ self._session_usage.check()
775
+ return result
776
+ except BaseException:
777
+ failed = True
778
+ raise
779
+ finally:
780
+ self._observe(shape, started, rows=0 if failed else 1, failed=failed, plan=plan)
781
+
782
+ @session_call
783
+ def measure_storage(self) -> StorageMeasurement:
784
+ """Measure each group's size on its source materialisation, from the engine's catalogue.
785
+
786
+ One catalogue statement per engine, for every table of every group whose source is on it.
787
+ Only the source counts: a copy a staging is still building is not the group's size. Each
788
+ size is recorded into this session's recorder, when it has one, for the window's
789
+ ``total_bytes``, ``index_to_table_ratio`` and ``daily_growth_bytes``.
790
+
791
+ **It never raises because an engine could not answer.** An adapter with no catalogue, a
792
+ refused read, a failed one or a table the map names that does not exist leaves that group's
793
+ size unknown - in ``unavailable`` and in an ``sde.telemetry.storage_unavailable`` line with
794
+ the engine and the class of the reason, never the engine's message. Using a closed session
795
+ or another owner's still raises, as every method of a session does.
796
+ """
797
+ by_engine: dict[str, list[tuple[str, Materialization]]] = {}
798
+ for name in sorted(self._placement.groups):
799
+ source = self._placement.groups[name].source
800
+ by_engine.setdefault(source.engine, []).append((name, source))
801
+ sizes: list[StorageSize] = []
802
+ unavailable: dict[str, str] = {}
803
+ for engine_name in sorted(by_engine):
804
+ members = by_engine[engine_name]
805
+ reader = getattr(self._engines[engine_name], "storage_sizes", None)
806
+ if not callable(reader):
807
+ unavailable.update((name, "unsupported") for name, _ in members)
808
+ continue
809
+ tables = sorted(
810
+ {table for _, source in members for table in source.layout.tables.values()}
811
+ )
812
+ try:
813
+ found: Mapping[str, tuple[int, int]] = reader(tables)
814
+ except (ResourceBusy, ResourceClosed):
815
+ raise # misuse of an adapter is the caller's to see, not an unknown size
816
+ except Exception as exc:
817
+ reason = _storage_refusal(exc)
818
+ unavailable.update((name, reason) for name, _ in members)
819
+ continue
820
+ self._session_usage.check()
821
+ for name, source in members:
822
+ named = list(source.layout.tables.values())
823
+ if any(table not in found for table in named):
824
+ unavailable[name] = "missing_table"
825
+ continue
826
+ size = StorageSize(
827
+ group=name,
828
+ materialization=source.id,
829
+ engine=engine_name,
830
+ total_bytes=sum(found[table][0] for table in named),
831
+ secondary_index_bytes=sum(found[table][1] for table in named),
832
+ )
833
+ sizes.append(size)
834
+ if self._recorder is not None:
835
+ self._recorder.record_storage(
836
+ group=name,
837
+ total_bytes=size.total_bytes,
838
+ secondary_index_bytes=size.secondary_index_bytes,
839
+ )
840
+ for name in sorted(unavailable):
841
+ source = self._placement.groups[name].source
842
+ log(
843
+ "sde.telemetry.storage_unavailable",
844
+ engine=source.engine,
845
+ reason=unavailable[name],
846
+ )
847
+ return StorageMeasurement(
848
+ tuple(sorted(sizes, key=lambda size: size.group)),
849
+ MappingProxyType(dict(sorted(unavailable.items()))),
850
+ )
851
+
852
+ @session_call
853
+ def summarize(
854
+ self, entity: str, field: str, *, where: Mapping[str, Any] | None = None,
855
+ bounds: Range | None = None, mean_scale: int = 6, fresh: bool = False,
856
+ ) -> NumericSummary:
857
+ """Exact integer/decimal statistics; half-even mean at an explicit decimal scale."""
858
+ if not isinstance(fresh, bool):
859
+ raise QueryRefused("fresh must be a bool")
860
+ target, plan = self._prepare_read(entity, where=where, bounds=bounds, paginate=False)
861
+ if not isinstance(field, str):
862
+ raise QueryRefused("a summary needs a logical field name")
863
+ try:
864
+ name = next(iter(self._fields_in(entity, {field: None})))
865
+ except ModelPlanningError as exc:
866
+ raise QueryRefused(str(exc)) from exc
867
+ column = next((item for item in plan.columns if item.name == name), None)
868
+ if column is None:
869
+ raise QueryRefused("summary refers to a field this entity does not declare")
870
+ summary_scale(column, mean_scale)
871
+ shape = self._shape(target, "aggregate")
872
+ engine, material = self._target(shape, fresh=fresh)
873
+ self._read_projection(material, target, plan, count=True)
874
+ if name not in material.layout.columns.get(target, {}):
875
+ raise QueryRefused("the routed materialization does not contain the summary field")
876
+ reader = summary_engine(engine)
877
+ table = material.layout.table_for(target)
878
+ started = perf_counter_ns() if self._recorder else 0
879
+ failed = False
880
+ try:
881
+ raw = reader.summarize_rows(table, plan, column)
882
+ self._session_usage.check()
883
+ return numeric_summary(raw, column, mean_scale)
884
+ except BaseException:
885
+ failed = True
886
+ raise
887
+ finally:
888
+ self._observe(shape, started, rows=0 if failed else 1, failed=failed, plan=plan)
889
+
890
+ def _observe(
891
+ self,
892
+ shape: OperationShape,
893
+ started: int,
894
+ *,
895
+ rows: int,
896
+ failed: bool,
897
+ plan: ReadPlan | None = None,
898
+ ) -> None:
899
+ """Hand one observation to the recorder, if there is one.
900
+
901
+ The timing call is skipped entirely when telemetry is off, which is why `started` is
902
+ passed in rather than measured here: with no recorder this method is one attribute check.
903
+ """
904
+ recorder = self._recorder
905
+ if recorder is None:
906
+ return
907
+ recorder.record(
908
+ shape_id=shape.id,
909
+ group=shape.group,
910
+ entity=shape.entity,
911
+ kind=shape.kind,
912
+ nanoseconds=perf_counter_ns() - started,
913
+ rows=rows,
914
+ failed=failed,
915
+ **({} if plan is None else _predicates(plan)),
916
+ )
917
+
918
+ # --- transactions ----------------------------------------------------------------------
919
+
920
+ @contextmanager
921
+ def transaction(self, *entities: str) -> Iterator[Session]:
922
+ """Open a transaction covering the given entities.
923
+
924
+ They must share a colocation group, because a transaction is one engine's transaction. If
925
+ they do not, this raises before anything is opened and names the fix: declare the atomicity,
926
+ and the planner will colocate them.
927
+
928
+ Called with no entities it covers the whole model, which is only legal when the model has
929
+ one group. That is not a convenience for small models so much as a refusal to let a
930
+ two-group model quietly get a transaction that only covers half of what the caller meant.
931
+ """
932
+ # Entity names in the *client's* vocabulary, so that an error message and the entities they
933
+ # passed are in the same language. With hashing on, `self._model.entities` are digests, and
934
+ # feeding those back through `group_of` would try to hash a hash.
935
+ declared = self._declared or tuple(e.name for e in self._model.entities)
936
+ names = list(entities) if entities else list(declared)
937
+ groups = {self.group_of(name).name for name in names}
938
+ if len(groups) > 1:
939
+ by_group: dict[str, list[str]] = {}
940
+ for name in sorted(names):
941
+ by_group.setdefault(self.group_of(name).name, []).append(name)
942
+ layout = "; ".join(
943
+ f"{g}: {', '.join(members)}" for g, members in sorted(by_group.items())
944
+ )
945
+ raise ModelPlanningError(
946
+ f"a transaction cannot span colocation groups ({layout}). One group is one "
947
+ "engine and one engine's transaction; there is no distributed transaction here and "
948
+ "there will not be one. If these entities have to commit together, declare it with "
949
+ "`atomic_with` on either side, and the planner will place them in the same engine "
950
+ "- which turns the requirement into a placement constraint instead of a two-phase "
951
+ "commit."
952
+ )
953
+
954
+ group = self.group_of(names[0])
955
+ with self._session_usage.transaction(group.name):
956
+ engine = self._engines[self._placement.placement_of(group.name).source.engine]
957
+ previous = self._in_write_transaction
958
+ outer = len(self._deferred)
959
+ self._in_write_transaction = True
960
+ committed = False
961
+ try:
962
+ with engine.transaction():
963
+ yield self
964
+ self._session_usage.idle()
965
+ self._session_usage.seal()
966
+ committed = True
967
+ finally:
968
+ self._in_write_transaction = previous
969
+ pending = self._deferred[outer:]
970
+ del self._deferred[outer:]
971
+ if committed and not previous:
972
+ # Replayed after the source transaction has committed, and only by the outermost
973
+ # one: a nested block that returns to a still-open transaction has not committed
974
+ # anything yet, so its rows go back on the queue rather than to the copy.
975
+ for engine_name, table, group_name, copy_id, queued_ns, values in pending:
976
+ self._replay_one(engine_name, table, group_name, copy_id, queued_ns, values)
977
+ elif not committed:
978
+ # Rolled back. The rows never existed in the source, so they must never exist in
979
+ # the copy - dropping them is the whole reason the fan-out was deferred.
980
+ pass
981
+ else:
982
+ self._deferred.extend(pending)
983
+
984
+
985
+ def _predicates(plan: ReadPlan) -> dict[str, Any]:
986
+ """What a read filtered on, for telemetry: field names only, never a value.
987
+
988
+ The names are the model's own - digests when the client hashes them, like the shape's fields.
989
+ """
990
+ equal = sorted({item.column.name for item in plan.filters if item.operation == "eq"})
991
+ bounded = sorted({item.column.name for item in plan.filters if item.operation != "eq"})
992
+ return {"equal": equal, "ranged": bounded[0] if bounded else None}
993
+