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