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.
- sde/__init__.py +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
|