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/placement.py
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
"""The placement map: where every group lives, and where every operation goes.
|
|
2
|
+
|
|
3
|
+
The map is the only instruction this library takes from outside. It says which engine holds each
|
|
4
|
+
group, what the physical layout there is, and - optionally - which materialisation each operation
|
|
5
|
+
shape should be routed to. Because it decides where data is written, it is the one input that
|
|
6
|
+
refuses rather than degrades: a bad signature, a mismatched model version or an unknown contract
|
|
7
|
+
version all stop the library from starting.
|
|
8
|
+
|
|
9
|
+
Two things about signing are worth stating plainly, because open-sourcing the library changes how
|
|
10
|
+
they read.
|
|
11
|
+
|
|
12
|
+
Publishing this code does not weaken the map. The public key lives here, the private key lives in
|
|
13
|
+
the control plane, and a reader seeing that maps are verified is reassured rather than informed of a
|
|
14
|
+
weakness. A signature is not a secret.
|
|
15
|
+
|
|
16
|
+
And a map with no signature at all is a *valid* map. That is the no-account mode: write a map by
|
|
17
|
+
hand, point the library at it, and everything works with no key, no account and no network. It is a
|
|
18
|
+
documented, supported way to use this library rather than a gap - which is also the honest answer to
|
|
19
|
+
anyone asking what happens if they stop paying us. What is refused is the middle case: a map that
|
|
20
|
+
carries a signature, thereby claiming to come from us, when we have no key to check that claim
|
|
21
|
+
against.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
from collections.abc import Mapping, Sequence
|
|
28
|
+
from dataclasses import dataclass, field, replace
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .canonical import canonical_bytes
|
|
32
|
+
from .errors import MapError
|
|
33
|
+
from .logging import log
|
|
34
|
+
from .model import LogicalModel
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"BACKFILL_TABLE",
|
|
38
|
+
"RESERVED_TABLES",
|
|
39
|
+
"GroupPlacement",
|
|
40
|
+
"Materialization",
|
|
41
|
+
"PhysicalLayout",
|
|
42
|
+
"PlacementMap",
|
|
43
|
+
"load_map",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PhysicalLayout:
|
|
49
|
+
"""What the group looks like inside one engine.
|
|
50
|
+
|
|
51
|
+
This is ours to choose and ours to change, which is the entire reason the client's code never
|
|
52
|
+
names a table. Everything here is derived by the planner and simply obeyed by the library.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
tables: Mapping[str, str]
|
|
56
|
+
columns: Mapping[str, Mapping[str, str]]
|
|
57
|
+
indexes: Sequence[Mapping[str, Any]] = field(default_factory=tuple)
|
|
58
|
+
partition_by: Mapping[str, str] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def table_for(self, entity: str) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return self.tables[entity]
|
|
63
|
+
except KeyError:
|
|
64
|
+
raise MapError(
|
|
65
|
+
f"the layout has no table for {entity!r}. The map claims to place a group that "
|
|
66
|
+
"contains this entity, so this is a defect in the map rather than something the "
|
|
67
|
+
"library can work around."
|
|
68
|
+
) from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Materialization:
|
|
73
|
+
"""One physical copy of a group in one engine."""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
engine: str
|
|
77
|
+
layout: PhysicalLayout
|
|
78
|
+
lag_budget_ms: int | None = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_source(self) -> bool:
|
|
82
|
+
return self.lag_budget_ms is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class GroupPlacement:
|
|
87
|
+
group: str
|
|
88
|
+
source: Materialization
|
|
89
|
+
derived: tuple[Materialization, ...] = ()
|
|
90
|
+
also_write: tuple[Materialization, ...] = ()
|
|
91
|
+
"""Derived copies that writes are **also** sent to, additionally and never authoritatively.
|
|
92
|
+
|
|
93
|
+
This is how a migration reaches the library, and the reason there is no phase name anywhere in
|
|
94
|
+
the map. A library does not need to know what ``DUAL_WRITE`` means; it needs to know where
|
|
95
|
+
writes go and where reads go, and both of those were already things a map says. Putting the
|
|
96
|
+
phase in the document as well would be a second representation of a fact the fan-out and the
|
|
97
|
+
routing table already carry - and a signed document with two representations of one fact is one
|
|
98
|
+
that can contradict itself.
|
|
99
|
+
|
|
100
|
+
Empty for every map that is not mid-migration, and the key is absent rather than empty then, for
|
|
101
|
+
the reason ``partition_by`` is: absent says "not doing this", and an empty list says "considered
|
|
102
|
+
and chose none", which is a stronger claim and a false one.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def all(self) -> tuple[Materialization, ...]:
|
|
106
|
+
return (self.source, *self.derived)
|
|
107
|
+
|
|
108
|
+
def by_id(self, mat_id: str) -> Materialization:
|
|
109
|
+
for mat in self.all():
|
|
110
|
+
if mat.id == mat_id:
|
|
111
|
+
return mat
|
|
112
|
+
raise MapError(
|
|
113
|
+
f"group {self.group!r} has no materialisation {mat_id!r}, but the routing table points "
|
|
114
|
+
"at it. The map is internally inconsistent."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class PlacementMap:
|
|
120
|
+
contract: int
|
|
121
|
+
model_version: str
|
|
122
|
+
map_version: int
|
|
123
|
+
groups: Mapping[str, GroupPlacement]
|
|
124
|
+
routing: Mapping[str, str]
|
|
125
|
+
signed: bool
|
|
126
|
+
verified_with: str | None = None
|
|
127
|
+
"""Which of the caller's public keys verified this map, by the caller's own name for it.
|
|
128
|
+
|
|
129
|
+
``None`` for an unsigned map and also for one verified against a bare key, where there was no
|
|
130
|
+
name to report; :attr:`signed` tells those apart. A default is allowed here because this field
|
|
131
|
+
is *reported* and never acted on - nothing routes or writes differently because of it - unlike
|
|
132
|
+
the dialect in a materialisation plan, which has no default precisely because a wrong one
|
|
133
|
+
produces a document that verifies and cannot be applied.
|
|
134
|
+
|
|
135
|
+
Public because a rotation needs it. Adding a key is safe and reversible; **removing** one is
|
|
136
|
+
the irreversible half, and a client cannot know when the old key is safe to drop without
|
|
137
|
+
seeing which key the maps they are actually receiving were signed with.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def placement_of(self, group: str) -> GroupPlacement:
|
|
141
|
+
try:
|
|
142
|
+
return self.groups[group]
|
|
143
|
+
except KeyError:
|
|
144
|
+
raise MapError(
|
|
145
|
+
f"no placement for group {group!r}. Every group in the model needs one: a group "
|
|
146
|
+
"with "
|
|
147
|
+
"nowhere to live is not a slow path, it is an unanswerable operation."
|
|
148
|
+
) from None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
MAP_CONTRACT = 3
|
|
152
|
+
"""The placement map's format version, which is not the IR's - see :data:`sde.model.CONTRACT`.
|
|
153
|
+
|
|
154
|
+
Two was for ``also_write``, which a contract-1 library would ignore while a contract-2 one honours
|
|
155
|
+
it: the same document, two different sets of engines written to, and the difference decided by
|
|
156
|
+
which version happens to be installed. That is a loosening, and section 11 of the contract says a
|
|
157
|
+
loosening bumps the number.
|
|
158
|
+
|
|
159
|
+
Three since 7 September 2026, and the reason is not a new key. The rendered ClickHouse timestamp
|
|
160
|
+
moved from `DateTime64(3)` to `DateTime64(6)` so that it matches PostgreSQL, which changed what
|
|
161
|
+
this library believes the two dialects keep - and therefore whether it accepts a `also_write` map
|
|
162
|
+
between them. A contract-2 library refuses a fan-out that a contract-3 one performs, on the same
|
|
163
|
+
document, which is the loosening section 11 says bumps the number.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
ALSO_WRITE_SINCE = 2
|
|
167
|
+
"""The contract that introduced ``also_write``.
|
|
168
|
+
|
|
169
|
+
A document declaring an earlier contract and carrying the key is refused. That is a *tightening* -
|
|
170
|
+
no bump - and it exists because of who makes the mistake: a producer that grew the key and forgot to
|
|
171
|
+
raise the number, which is exactly what happened on the control-plane side of the very change that
|
|
172
|
+
added it. Honouring the key anyway would mean an old library ignoring the fan-out while a new one
|
|
173
|
+
performs it, on the same document.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
MAP_CONTRACT_FLOOR = 1
|
|
177
|
+
"""The oldest map format this library still reads.
|
|
178
|
+
|
|
179
|
+
**Backwards compatible, forwards strict**, and the asymmetry is knowledge rather than kindness: we
|
|
180
|
+
know exactly what contract 1 was, and every contract-1 document is a valid contract-2 one with a key
|
|
181
|
+
absent - which reads as "no dual write", a complete and correct meaning. What came *after* this
|
|
182
|
+
library cannot be known, so a higher number is refused rather than interpreted, which is the case
|
|
183
|
+
the original message argued for.
|
|
184
|
+
|
|
185
|
+
The alternative - strict equality, as this library did until contract 2 - couples a library upgrade
|
|
186
|
+
to a control-plane action: a client would have to be issued a new map before they could start. For
|
|
187
|
+
the no-account mode that is worse than inconvenient, because there is nobody to issue them one and
|
|
188
|
+
the promise was that hand-writing a map is enough.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
WATERMARK_TABLE = "sde_map_state"
|
|
192
|
+
"""The table this library keeps its map bookkeeping in - see :mod:`sde.watermark`.
|
|
193
|
+
|
|
194
|
+
Defined here rather than there because the reservation is a property of the map format, and because
|
|
195
|
+
``watermark.py`` imports this module: a layout naming this table is refused when the map is loaded.
|
|
196
|
+
|
|
197
|
+
A client entity called ``SdeMapState`` would derive this table name from the model, and the refusal
|
|
198
|
+
below catches that too - at map load, which for a map we issue means at issuance, since `issue()`
|
|
199
|
+
parses its own output with this parser. The message names the fix, which is to rename the entity.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
BACKFILL_TABLE = "sde_backfill_state"
|
|
203
|
+
"""The table this library keeps its backfill progress in - see :mod:`sde.migration`.
|
|
204
|
+
|
|
205
|
+
The second of these, and the reason the refusal below is written over a set rather than over one
|
|
206
|
+
name: a bookkeeping table whose name is not reserved is a table a client's model can collide with,
|
|
207
|
+
and the collision is silent in the direction that matters - we would read their rows as progress and
|
|
208
|
+
write progress into their table.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
RESERVED_TABLES: Mapping[str, str] = {
|
|
212
|
+
WATERMARK_TABLE: (
|
|
213
|
+
"the highest map version applied against an engine, which is what stops an older map "
|
|
214
|
+
"from being loaded over a newer one"
|
|
215
|
+
),
|
|
216
|
+
BACKFILL_TABLE: (
|
|
217
|
+
"how many rows of each entity a migration has copied into an engine, which is what lets "
|
|
218
|
+
"an interrupted backfill resume instead of starting over"
|
|
219
|
+
),
|
|
220
|
+
}
|
|
221
|
+
"""Table names this library owns, and what each one holds.
|
|
222
|
+
|
|
223
|
+
A mapping rather than a set so the refusal can say what the collision would break. "This name is
|
|
224
|
+
reserved" sends the reader to our source; "this name holds the backfill marker" tells them why
|
|
225
|
+
their entity cannot have it.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
_AUTO = object()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _layout(raw: Mapping[str, Any], where: str) -> PhysicalLayout | object:
|
|
232
|
+
"""Parse a layout, or report that it asked to be derived.
|
|
233
|
+
|
|
234
|
+
``{"auto": true}`` is what makes a hand-written map a few lines rather than a full schema
|
|
235
|
+
written out by hand. Without it the no-account mode from requirement 12.5 would be technically
|
|
236
|
+
true and practically unusable, which is the same as not having it.
|
|
237
|
+
|
|
238
|
+
It derives a **PostgreSQL** layout, always, and that is a limit rather than a default worth
|
|
239
|
+
changing quietly. A layout has no dialect in it and the map names an engine by *name*, not by
|
|
240
|
+
dialect - deliberately, since reasoning from an engine's name is what this library refuses
|
|
241
|
+
everywhere - so there is nothing here from which the right dialect could be known. A
|
|
242
|
+
hand-written map for ClickHouse or for the orderbook engine therefore needs an explicit layout,
|
|
243
|
+
and both of those engines refuse a PostgreSQL-derived one rather than applying it: ClickHouse
|
|
244
|
+
because it carries indexes it has no B-tree for, the orderbook engine because the table is not
|
|
245
|
+
the one name its storage has. Fails closed, in other words, but the message names the symptom.
|
|
246
|
+
Making ``auto`` dialect-aware means a new key in a signed document, which is a loosening of the
|
|
247
|
+
format and so a contract bump in every language at once - see section 11.
|
|
248
|
+
"""
|
|
249
|
+
if not isinstance(raw, dict):
|
|
250
|
+
raise MapError(f"{where}: layout must be an object")
|
|
251
|
+
if raw.get("auto") is True:
|
|
252
|
+
if len(raw) != 1:
|
|
253
|
+
raise MapError(
|
|
254
|
+
f"{where}: a layout is either auto or explicit, not both. Two sources of truth for "
|
|
255
|
+
"a "
|
|
256
|
+
"schema is how a column ends up existing in one place and not the other."
|
|
257
|
+
)
|
|
258
|
+
return _AUTO
|
|
259
|
+
tables = raw.get("tables")
|
|
260
|
+
if not isinstance(tables, dict) or not tables:
|
|
261
|
+
raise MapError(
|
|
262
|
+
f"{where}: layout needs a non-empty 'tables' mapping entity -> table name, "
|
|
263
|
+
'or {"auto": true} to have one derived from the model'
|
|
264
|
+
)
|
|
265
|
+
for name, holds in RESERVED_TABLES.items():
|
|
266
|
+
reserved = sorted(entity for entity, table in tables.items() if str(table) == name)
|
|
267
|
+
if reserved:
|
|
268
|
+
raise MapError(
|
|
269
|
+
f"{where}: {reserved} would be stored in a table called {name!r}, which this "
|
|
270
|
+
f"library keeps its own bookkeeping in - {holds}. A client table under that name "
|
|
271
|
+
f"would be read as bookkeeping and written to as bookkeeping. Rename the table; "
|
|
272
|
+
f"the name is yours to choose everywhere else."
|
|
273
|
+
)
|
|
274
|
+
# `partition_by` is refused rather than ignored, and the refusal is the whole point: this key
|
|
275
|
+
# is parsed here, the control plane emits it when non-empty, and **no renderer has ever applied
|
|
276
|
+
# it** - a layout declaring it produced an unpartitioned table and said nothing. Nothing
|
|
277
|
+
# populates it today, so no issued map has carried one, but a hand-written map legally may and
|
|
278
|
+
# the no-account mode is a documented mode. Rendering it instead would mean designing two
|
|
279
|
+
# dialect-specific features with no requirement behind them and interpolating a caller's SQL
|
|
280
|
+
# fragment into DDL. Fails closed until partitioning exists; §7a and `errors/037`.
|
|
281
|
+
if raw.get("partition_by"):
|
|
282
|
+
raise MapError(
|
|
283
|
+
f"{where}: this layout declares partition_by, and no library renders it - the table "
|
|
284
|
+
f"would be created unpartitioned and nothing would say so. A storage decision in a "
|
|
285
|
+
f"signed document that is silently dropped is worse than a refusal, so this refuses "
|
|
286
|
+
f"until partitioning is implemented. Remove the key to apply the rest of the layout."
|
|
287
|
+
)
|
|
288
|
+
return PhysicalLayout(
|
|
289
|
+
tables=dict(tables),
|
|
290
|
+
columns={k: dict(v) for k, v in (raw.get("columns") or {}).items()},
|
|
291
|
+
indexes=tuple(raw.get("indexes") or ()),
|
|
292
|
+
partition_by=dict(raw.get("partition_by") or {}),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _also_write(
|
|
297
|
+
raw: Any,
|
|
298
|
+
where: str,
|
|
299
|
+
*,
|
|
300
|
+
contract: int,
|
|
301
|
+
source: Materialization,
|
|
302
|
+
derived: tuple[Materialization, ...],
|
|
303
|
+
) -> tuple[Materialization, ...]:
|
|
304
|
+
"""The derived copies writes are additionally sent to. Four refusals, each with its failure.
|
|
305
|
+
|
|
306
|
+
Validated here rather than at the first write, for the reason the routing table was moved here:
|
|
307
|
+
a map is a document handed over once and obeyed for months, so a defect in it should be found
|
|
308
|
+
when it arrives and not by the request that happens to touch it. During a migration that request
|
|
309
|
+
is a write, and the failure mode is a write that goes to one engine when the map says two.
|
|
310
|
+
"""
|
|
311
|
+
if raw is None:
|
|
312
|
+
return ()
|
|
313
|
+
if contract < ALSO_WRITE_SINCE:
|
|
314
|
+
raise MapError(
|
|
315
|
+
f"{where}: this map declares format contract {contract} and uses 'also_write', which "
|
|
316
|
+
f"contract {ALSO_WRITE_SINCE} introduced. Refused rather than honoured, and the reason "
|
|
317
|
+
f"is who makes this mistake: a producer that grew the key and forgot to raise the "
|
|
318
|
+
f"number. Honouring it would mean a contract-1 library ignoring the fan-out while a "
|
|
319
|
+
f"contract-2 one performs it - the same document, two sets of engines written to - "
|
|
320
|
+
f"which is the whole failure the version exists to prevent."
|
|
321
|
+
)
|
|
322
|
+
if not isinstance(raw, list) or not raw:
|
|
323
|
+
raise MapError(
|
|
324
|
+
f"{where}: 'also_write' is a non-empty list of materialisation ids, or absent. Absent "
|
|
325
|
+
f"means writes go to the source alone; an empty list would be a claim that fan-out was "
|
|
326
|
+
f"considered and none chosen, which is a stronger thing to say and not what the "
|
|
327
|
+
f"planner means when it omits the key."
|
|
328
|
+
)
|
|
329
|
+
by_id = {m.id: m for m in derived}
|
|
330
|
+
out: list[Materialization] = []
|
|
331
|
+
seen: set[str] = set()
|
|
332
|
+
for entry in raw:
|
|
333
|
+
if not isinstance(entry, str):
|
|
334
|
+
raise MapError(f"{where}: 'also_write' holds materialisation ids, not {entry!r}")
|
|
335
|
+
if entry == source.id:
|
|
336
|
+
raise MapError(
|
|
337
|
+
f"{where}: 'also_write' names the source {entry!r}. The source is where writes "
|
|
338
|
+
f"already land - listing it would either write the row twice or read as though the "
|
|
339
|
+
f"source were somehow optional, and both are worse than the refusal."
|
|
340
|
+
)
|
|
341
|
+
if entry in seen:
|
|
342
|
+
raise MapError(
|
|
343
|
+
f"{where}: 'also_write' names {entry!r} twice. Two identical fan-out targets is "
|
|
344
|
+
f"either a duplicated row or a document nobody meant to write."
|
|
345
|
+
)
|
|
346
|
+
if entry not in by_id:
|
|
347
|
+
raise MapError(
|
|
348
|
+
f"{where}: 'also_write' names {entry!r}, which is not a derived materialisation of "
|
|
349
|
+
f"this group. It has {sorted(by_id)}. A fan-out target that does not exist is a "
|
|
350
|
+
f"write with nowhere to go, and during a migration that is a row the copy never "
|
|
351
|
+
f"receives."
|
|
352
|
+
)
|
|
353
|
+
seen.add(entry)
|
|
354
|
+
out.append(by_id[entry])
|
|
355
|
+
return tuple(out)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _materialization(raw: Mapping[str, Any], where: str, *, source: bool) -> Materialization:
|
|
359
|
+
for required in ("id", "engine", "layout"):
|
|
360
|
+
if required not in raw:
|
|
361
|
+
raise MapError(f"{where}: materialisation is missing {required!r}")
|
|
362
|
+
lag = raw.get("lag_budget_ms")
|
|
363
|
+
if source and lag is not None:
|
|
364
|
+
raise MapError(
|
|
365
|
+
f"{where}: the source materialisation cannot have a lag budget. The source is where "
|
|
366
|
+
"writes land, so it is by definition not behind anything."
|
|
367
|
+
)
|
|
368
|
+
if not source and lag is None:
|
|
369
|
+
raise MapError(
|
|
370
|
+
f"{where}: a derived materialisation needs lag_budget_ms. Without it nobody - not the "
|
|
371
|
+
"client, not the monitoring - can tell whether it is healthy or hours behind."
|
|
372
|
+
)
|
|
373
|
+
layout = _layout(raw["layout"], where)
|
|
374
|
+
return Materialization(
|
|
375
|
+
id=str(raw["id"]),
|
|
376
|
+
engine=str(raw["engine"]),
|
|
377
|
+
layout=layout, # type: ignore[arg-type]
|
|
378
|
+
lag_budget_ms=None if lag is None else int(lag),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _key_set(public_key: bytes | Mapping[str, bytes]) -> tuple[tuple[str, bytes], ...]:
|
|
383
|
+
"""Normalise what the caller supplied into named keys, refusing what cannot be a key.
|
|
384
|
+
|
|
385
|
+
A bare ``bytes`` is the ordinary case and keeps the empty name, because the caller gave us no
|
|
386
|
+
name to report back. A mapping is the rotation case, and the names are the caller's own: what a
|
|
387
|
+
rotation runbook needs is not a key but something to *remove*, and "remove k1" is only an
|
|
388
|
+
instruction if k1 is what they wrote in their configuration.
|
|
389
|
+
|
|
390
|
+
A key of the wrong length is refused here rather than left to fail verification. It is the
|
|
391
|
+
client's configuration, and a rotation that silently did not take effect - because the new key
|
|
392
|
+
was pasted a byte short - is the failure mode this whole mechanism exists to avoid. Before this
|
|
393
|
+
it raised a bare ``ValueError`` out of ``load_map`` on the application's start path.
|
|
394
|
+
"""
|
|
395
|
+
pairs: tuple[tuple[str, bytes], ...]
|
|
396
|
+
if isinstance(public_key, (bytes, bytearray)):
|
|
397
|
+
pairs = (("", bytes(public_key)),)
|
|
398
|
+
else:
|
|
399
|
+
if not public_key:
|
|
400
|
+
raise MapError(
|
|
401
|
+
"an empty set of public keys was supplied. That is not the no-account mode - a map "
|
|
402
|
+
"with no signature is - it is a configuration that can verify nothing. Pass the "
|
|
403
|
+
"keys, or load an unsigned map."
|
|
404
|
+
)
|
|
405
|
+
pairs = tuple(sorted((str(k), bytes(v)) for k, v in public_key.items()))
|
|
406
|
+
for name, value in pairs:
|
|
407
|
+
if len(value) != 32:
|
|
408
|
+
raise MapError(
|
|
409
|
+
f"the public key {name or '(unnamed)'!r} is {len(value)} bytes and an Ed25519 "
|
|
410
|
+
f"public key is 32. Refused here rather than at verification: a key pasted a byte "
|
|
411
|
+
f"short would look like a map that does not verify, and the two have completely "
|
|
412
|
+
f"different fixes."
|
|
413
|
+
)
|
|
414
|
+
return pairs
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _verify_signature(
|
|
418
|
+
raw: Mapping[str, Any], public_key: bytes | Mapping[str, bytes]
|
|
419
|
+
) -> str | None:
|
|
420
|
+
"""Verify, and report **which** of the caller's keys did it.
|
|
421
|
+
|
|
422
|
+
Returns the caller's own name for the key that verified, or ``None`` when a bare key was
|
|
423
|
+
supplied and there was no name to report. That does not collide with an unsigned map:
|
|
424
|
+
:attr:`PlacementMap.signed` tells those two apart, and one field meaning two things is how a
|
|
425
|
+
value ends up standing for four (see the coordinator's three-state answer in the engine).
|
|
426
|
+
|
|
427
|
+
**Every key is tried, and ``key_id`` only decides the order.** The signature block is excluded
|
|
428
|
+
from the signed payload in its entirety, so ``key_id`` is not covered by the signature and
|
|
429
|
+
anybody can edit it. Making it authoritative would therefore be trusting an attacker's
|
|
430
|
+
annotation; making it *authenticated* would mean changing what the payload is, which recomputes
|
|
431
|
+
every signature ever issued and is a contract bump in every language at once. As an ordering it
|
|
432
|
+
costs nothing to be wrong about: a bad hint makes verification try the keys in a worse order
|
|
433
|
+
and reach the same answer.
|
|
434
|
+
|
|
435
|
+
The cost of accepting a set is real and belongs written down here rather than in a document:
|
|
436
|
+
**an old key verifies for as long as the client keeps it.** This library has no clock - it is
|
|
437
|
+
never told when a map was issued, and reads the wall clock exactly once in the whole package -
|
|
438
|
+
so it cannot expire a key, and that same absence is what makes "you stop paying and nothing
|
|
439
|
+
breaks" true. Revocation is therefore the client removing a key from their own configuration,
|
|
440
|
+
which is why the id that verified is reported: a client who cannot see which key is in use
|
|
441
|
+
cannot know when the old one is safe to drop.
|
|
442
|
+
"""
|
|
443
|
+
try:
|
|
444
|
+
from cryptography.exceptions import InvalidSignature
|
|
445
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
446
|
+
except ImportError as exc: # pragma: no cover - depends on the install extra
|
|
447
|
+
raise MapError(
|
|
448
|
+
"this map is signed, but signature verification needs the 'signed' extra: "
|
|
449
|
+
"pip install 'smart-data-engine-sdk[signed]'. The base install stays dependency-free "
|
|
450
|
+
"on purpose, because a library that goes into someone's application should not drag in "
|
|
451
|
+
"cryptography unless it is actually verifying something."
|
|
452
|
+
) from exc
|
|
453
|
+
|
|
454
|
+
signature = raw["signature"]
|
|
455
|
+
if not isinstance(signature, dict) or signature.get("alg") != "ed25519":
|
|
456
|
+
raise MapError("only ed25519 signatures are understood")
|
|
457
|
+
try:
|
|
458
|
+
value = base64.b64decode(signature["value"], validate=True)
|
|
459
|
+
except Exception as exc:
|
|
460
|
+
raise MapError("the signature is not valid base64") from exc
|
|
461
|
+
|
|
462
|
+
claimed = signature.get("key_id")
|
|
463
|
+
claimed = str(claimed) if isinstance(claimed, str) else None
|
|
464
|
+
keys = _key_set(public_key)
|
|
465
|
+
if claimed is None:
|
|
466
|
+
ordered = list(keys)
|
|
467
|
+
else:
|
|
468
|
+
hinted = [pair for pair in keys if pair[0] == claimed]
|
|
469
|
+
ordered = hinted + [pair for pair in keys if pair[0] != claimed]
|
|
470
|
+
|
|
471
|
+
payload = canonical_bytes({k: v for k, v in raw.items() if k != "signature"})
|
|
472
|
+
for name, key in ordered:
|
|
473
|
+
try:
|
|
474
|
+
Ed25519PublicKey.from_public_bytes(key).verify(value, payload)
|
|
475
|
+
except InvalidSignature:
|
|
476
|
+
continue
|
|
477
|
+
return name or None
|
|
478
|
+
|
|
479
|
+
tried = [name or "(unnamed)" for name, _ in ordered]
|
|
480
|
+
raise MapError(
|
|
481
|
+
f"the map's signature does not verify against any of the {len(ordered)} key(s) supplied "
|
|
482
|
+
f"({tried}); the map says it was signed with {claimed!r}. This is refused rather than "
|
|
483
|
+
f"warned about: the map decides where your data is written. If a rotation is in progress, "
|
|
484
|
+
f"the key named above is the one to add - and while both are configured, maps signed with "
|
|
485
|
+
f"either are accepted."
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def load_map(
|
|
490
|
+
raw: Mapping[str, Any],
|
|
491
|
+
*,
|
|
492
|
+
model: LogicalModel | None = None,
|
|
493
|
+
public_key: bytes | Mapping[str, bytes] | None = None,
|
|
494
|
+
require_signature: bool = False,
|
|
495
|
+
) -> PlacementMap:
|
|
496
|
+
"""Parse and validate a placement map, and put the outcome on the record either way.
|
|
497
|
+
|
|
498
|
+
``model`` is optional only so that the conformance vectors can exercise parsing on its own; in
|
|
499
|
+
an application it is always passed, because a map for a different model version has to be
|
|
500
|
+
refused rather than half-applied.
|
|
501
|
+
|
|
502
|
+
Both outcomes are logged, and the pair is the point. Until this wrapper existed the only line
|
|
503
|
+
a map load produced was ``sde.map.unsigned``, which fired **only** when the map had no
|
|
504
|
+
signature - so the one thing this library said about a map was said exclusively to clients
|
|
505
|
+
without an account. That is the shape requirement 12.6 forbids, arrived at from the other
|
|
506
|
+
direction: not a complaint anybody wrote, but a line that exists in one mode because nobody
|
|
507
|
+
wrote the other one.
|
|
508
|
+
|
|
509
|
+
So the event is the same in both modes and the mode is a **field**. That is what the closed
|
|
510
|
+
vocabulary is for - structured fields, and no interpolated values in the event name - and it
|
|
511
|
+
means the account-free mode emits no event the account mode does not. ``forward_only`` is the
|
|
512
|
+
single thing that actually differs; the sentence explaining it lives on
|
|
513
|
+
:attr:`Session.rollback_protection`, where a client can read it rather than grep for it.
|
|
514
|
+
|
|
515
|
+
A refusal is logged too, because ``MapError`` reaches the application while the log reaches the
|
|
516
|
+
operator, and those are different people at four in the morning. Its ``reason`` is the
|
|
517
|
+
engine-facing message, which in this library is always about structure - contract versions,
|
|
518
|
+
model versions, group, engine, table and materialisation names - and never about a value: there
|
|
519
|
+
is no code path by which one of the client's rows reaches a map.
|
|
520
|
+
"""
|
|
521
|
+
try:
|
|
522
|
+
placement = _parse_map(
|
|
523
|
+
raw, model=model, public_key=public_key, require_signature=require_signature
|
|
524
|
+
)
|
|
525
|
+
except MapError as exc:
|
|
526
|
+
log("sde.map.rejected", error=type(exc).__name__, reason=str(exc)[:200])
|
|
527
|
+
raise
|
|
528
|
+
log(
|
|
529
|
+
"sde.map.loaded",
|
|
530
|
+
model_version=placement.model_version,
|
|
531
|
+
map_version=placement.map_version,
|
|
532
|
+
signed=placement.signed,
|
|
533
|
+
forward_only=placement.signed,
|
|
534
|
+
key=placement.verified_with,
|
|
535
|
+
)
|
|
536
|
+
return placement
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _parse_map(
|
|
540
|
+
raw: Mapping[str, Any],
|
|
541
|
+
*,
|
|
542
|
+
model: LogicalModel | None = None,
|
|
543
|
+
public_key: bytes | Mapping[str, bytes] | None = None,
|
|
544
|
+
require_signature: bool = False,
|
|
545
|
+
) -> PlacementMap:
|
|
546
|
+
"""The parse itself. Separate so that the reporting above wraps every refusal in it, including
|
|
547
|
+
the ones added later by somebody who has not read this file."""
|
|
548
|
+
if not isinstance(raw, dict):
|
|
549
|
+
raise MapError("a placement map is an object")
|
|
550
|
+
|
|
551
|
+
contract = raw.get("contract")
|
|
552
|
+
if not isinstance(contract, int) or isinstance(contract, bool):
|
|
553
|
+
raise MapError(
|
|
554
|
+
f"this map declares format contract {contract!r}, which is not a version number. "
|
|
555
|
+
f"This library reads {MAP_CONTRACT_FLOOR} to {MAP_CONTRACT}."
|
|
556
|
+
)
|
|
557
|
+
if contract > MAP_CONTRACT:
|
|
558
|
+
raise MapError(
|
|
559
|
+
f"this map declares format contract {contract} and this library implements "
|
|
560
|
+
f"{MAP_CONTRACT}. Refusing rather than guessing: a version this library does not know "
|
|
561
|
+
f"may say something with a key it has never heard of, and the difference between two "
|
|
562
|
+
f"contract versions is exactly the kind of thing that would otherwise be interpreted "
|
|
563
|
+
f"as a missing field meaning zero. Upgrade the library."
|
|
564
|
+
)
|
|
565
|
+
if contract < MAP_CONTRACT_FLOOR:
|
|
566
|
+
raise MapError(
|
|
567
|
+
f"this map declares format contract {contract} and the oldest this library still "
|
|
568
|
+
f"reads is {MAP_CONTRACT_FLOOR}."
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
model_version = raw.get("model_version")
|
|
572
|
+
if not isinstance(model_version, str) or not model_version:
|
|
573
|
+
raise MapError("the map does not say which model version it is for")
|
|
574
|
+
if model is not None and model.version != model_version:
|
|
575
|
+
raise MapError(
|
|
576
|
+
f"this map is for model version {model_version} and your declared model is "
|
|
577
|
+
f"{model.version}. Something changed in your entities; ask for a new map rather than "
|
|
578
|
+
"running against this one, because the difference cannot be guessed."
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
map_version = raw.get("map_version")
|
|
582
|
+
if not isinstance(map_version, int) or isinstance(map_version, bool) or map_version < 1:
|
|
583
|
+
raise MapError(
|
|
584
|
+
f"this map declares map_version {map_version!r}, which is not a version number. It "
|
|
585
|
+
"used to be read as int(document.get('map_version', 0)), so a document without one "
|
|
586
|
+
"loaded as version 0 - and this is the number that decides whether an older map is "
|
|
587
|
+
"being replayed over a newer one against the client's own engines."
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
signature_present = "signature" in raw and raw["signature"] is not None
|
|
591
|
+
if require_signature and not signature_present:
|
|
592
|
+
raise MapError("a signature was required and this map has none")
|
|
593
|
+
if signature_present:
|
|
594
|
+
if public_key is None:
|
|
595
|
+
raise MapError(
|
|
596
|
+
"this map is signed, which is a claim that it came from us, and no public key was "
|
|
597
|
+
"provided to check that claim. Either pass the key, or use an unsigned map - an "
|
|
598
|
+
"unsigned map is a supported mode, an unverifiable claim is not."
|
|
599
|
+
)
|
|
600
|
+
verified_with = _verify_signature(raw, public_key)
|
|
601
|
+
else:
|
|
602
|
+
verified_with = None
|
|
603
|
+
|
|
604
|
+
groups_raw = raw.get("groups")
|
|
605
|
+
if not isinstance(groups_raw, dict) or not groups_raw:
|
|
606
|
+
raise MapError("the map places no groups")
|
|
607
|
+
|
|
608
|
+
groups: dict[str, GroupPlacement] = {}
|
|
609
|
+
# By name, not in the document's order. A map with two defects has to refuse the same way in
|
|
610
|
+
# every language (format-contract §8a): `errors/019` carries a reserved table name in one group
|
|
611
|
+
# and an auto-plus-explicit layout in another, and it pinned the first only because that group
|
|
612
|
+
# is written earlier in the file and both of our runtimes iterate an object in insertion order.
|
|
613
|
+
# A third implementation in Go, whose maps iterate in a randomised order, failed that vector in
|
|
614
|
+
# 5 of 20 runs. The routing loop below has sorted since it was written; this one had not.
|
|
615
|
+
for name in sorted(groups_raw):
|
|
616
|
+
body = groups_raw[name]
|
|
617
|
+
where = f"group {name!r}"
|
|
618
|
+
if not isinstance(body, dict) or "source" not in body:
|
|
619
|
+
raise MapError(f"{where}: needs a 'source' materialisation")
|
|
620
|
+
source = _materialization(body["source"], f"{where}.source", source=True)
|
|
621
|
+
derived = tuple(
|
|
622
|
+
_materialization(d, f"{where}.derived[{i}]", source=False)
|
|
623
|
+
for i, d in enumerate(body.get("derived") or ())
|
|
624
|
+
)
|
|
625
|
+
ids = [m.id for m in (source, *derived)]
|
|
626
|
+
if len(set(ids)) != len(ids):
|
|
627
|
+
raise MapError(f"{where}: two materialisations share an id")
|
|
628
|
+
|
|
629
|
+
groups[name] = GroupPlacement(
|
|
630
|
+
group=name,
|
|
631
|
+
source=source,
|
|
632
|
+
derived=derived,
|
|
633
|
+
also_write=_also_write(
|
|
634
|
+
body.get("also_write"),
|
|
635
|
+
where,
|
|
636
|
+
contract=contract,
|
|
637
|
+
source=source,
|
|
638
|
+
derived=derived,
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
routing = raw.get("routing") or {}
|
|
643
|
+
if not isinstance(routing, dict):
|
|
644
|
+
raise MapError("'routing' must be a mapping from shape id to materialisation id")
|
|
645
|
+
|
|
646
|
+
if model is not None:
|
|
647
|
+
model_groups = {g.name: g for g in _model_groups(model)}
|
|
648
|
+
missing = sorted(set(model_groups) - set(groups))
|
|
649
|
+
if missing:
|
|
650
|
+
raise MapError(
|
|
651
|
+
f"the map does not place these groups: {missing}. Every group in the model needs a "
|
|
652
|
+
"home before anything can run."
|
|
653
|
+
)
|
|
654
|
+
# And the other direction. Checking only one of the two is how a map placing a group this
|
|
655
|
+
# model does not have reached `model_groups[name]` below and came out as a bare KeyError -
|
|
656
|
+
# a library exception with no explanation, on the path whose entire job is to explain.
|
|
657
|
+
unknown = sorted(set(groups) - set(model_groups))
|
|
658
|
+
if unknown:
|
|
659
|
+
raise MapError(
|
|
660
|
+
f"the map places groups this model does not have: {unknown}. The model version "
|
|
661
|
+
"matched, so this is not a stale map: the two sides derived colocation groups "
|
|
662
|
+
"differently, and a group nobody declared has no entities to hold."
|
|
663
|
+
)
|
|
664
|
+
groups = {
|
|
665
|
+
name: _resolve_auto(placement, model, model_groups[name])
|
|
666
|
+
for name, placement in groups.items()
|
|
667
|
+
}
|
|
668
|
+
for placement in groups.values():
|
|
669
|
+
_refuse_shadowing(placement)
|
|
670
|
+
_check_routing_targets(routing, groups, model)
|
|
671
|
+
elif any(m.layout is _AUTO for p in groups.values() for m in p.all()):
|
|
672
|
+
raise MapError(
|
|
673
|
+
'a layout asked to be derived with {"auto": true}, but no model was supplied to derive '
|
|
674
|
+
"it from. Pass model= to load_map()."
|
|
675
|
+
)
|
|
676
|
+
else:
|
|
677
|
+
_check_routing_targets(routing, groups, None)
|
|
678
|
+
|
|
679
|
+
return PlacementMap(
|
|
680
|
+
contract=contract,
|
|
681
|
+
model_version=model_version,
|
|
682
|
+
map_version=map_version,
|
|
683
|
+
groups=groups,
|
|
684
|
+
routing={str(k): str(v) for k, v in routing.items()},
|
|
685
|
+
signed=signature_present,
|
|
686
|
+
verified_with=verified_with,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _refuse_shadowing(placement: GroupPlacement) -> None:
|
|
691
|
+
"""Refuse a derived copy that is secretly the source.
|
|
692
|
+
|
|
693
|
+
Two materialisations of one group in the same engine must not name the same tables. Found by a
|
|
694
|
+
test rather than by thinking: with an auto layout on both, the source and a derived copy derive
|
|
695
|
+
identical table names, so a copy placed in the same engine simply *is* the source. Reads would
|
|
696
|
+
appear to work, the lag would always measure zero, and the second copy would exist only in the
|
|
697
|
+
map. Refused rather than warned about, precisely because it looks like it works.
|
|
698
|
+
|
|
699
|
+
Checked after auto layouts are resolved, since before that there are no table names to compare.
|
|
700
|
+
"""
|
|
701
|
+
source_tables = set(placement.source.layout.tables.values())
|
|
702
|
+
for candidate in placement.derived:
|
|
703
|
+
if candidate.engine != placement.source.engine:
|
|
704
|
+
continue
|
|
705
|
+
shared = sorted(source_tables & set(candidate.layout.tables.values()))
|
|
706
|
+
if shared:
|
|
707
|
+
raise MapError(
|
|
708
|
+
f"group {placement.group!r}: materialisation {candidate.id!r} is in the same "
|
|
709
|
+
"engine "
|
|
710
|
+
f"as the source and reuses its tables {shared}. That is not a copy of the group, "
|
|
711
|
+
"it is the original with a second name in the map, so its lag would always read as "
|
|
712
|
+
"zero and a read routed to it would silently be a read of the source."
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _resolve_auto(placement: GroupPlacement, model: LogicalModel, group: Any) -> GroupPlacement:
|
|
717
|
+
"""Fill in any layout that asked to be derived.
|
|
718
|
+
|
|
719
|
+
Derivation lives in :mod:`sde.layout` and is the boring total function from a model to a schema
|
|
720
|
+
that stores it. The interesting choices - indexes worth their cost, partitioning, a second
|
|
721
|
+
materialisation - are the planner's and are not in this repository.
|
|
722
|
+
"""
|
|
723
|
+
from .layout import default_layout
|
|
724
|
+
|
|
725
|
+
def fill(mat: Materialization) -> Materialization:
|
|
726
|
+
if mat.layout is not _AUTO:
|
|
727
|
+
return mat
|
|
728
|
+
return Materialization(
|
|
729
|
+
id=mat.id,
|
|
730
|
+
engine=mat.engine,
|
|
731
|
+
layout=default_layout(model, group),
|
|
732
|
+
lag_budget_ms=mat.lag_budget_ms,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
# `replace` rather than a fresh GroupPlacement, and this is a fix rather than a preference.
|
|
736
|
+
# Reconstructing it listed the fields that existed when it was written, so `also_write` was
|
|
737
|
+
# dropped here the moment it was added: the map parsed, the fan-out came back empty, and writes
|
|
738
|
+
# went to one engine while the signed document said two. Silently, on the write path, during a
|
|
739
|
+
# migration - the worst failure this feature has. It is the third time in this project that a
|
|
740
|
+
# value has been computed and lost at a boundary (`provisional`, then `basis`), so the shape of
|
|
741
|
+
# the fix matters more than the fix: with `replace`, a field added later travels whether or not
|
|
742
|
+
# anybody remembers this function.
|
|
743
|
+
return replace(
|
|
744
|
+
placement,
|
|
745
|
+
source=fill(placement.source),
|
|
746
|
+
derived=tuple(fill(m) for m in placement.derived),
|
|
747
|
+
also_write=tuple(fill(m) for m in placement.also_write),
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _model_groups(model: LogicalModel) -> tuple[Any, ...]:
|
|
752
|
+
# Imported late: groups imports model, and model must not import groups.
|
|
753
|
+
from .groups import colocation_groups
|
|
754
|
+
|
|
755
|
+
return colocation_groups(model)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _model_shapes(model: LogicalModel) -> tuple[Any, ...]:
|
|
759
|
+
# Late for the same reason: shapes imports groups, which imports model.
|
|
760
|
+
from .shapes import enumerate_shapes
|
|
761
|
+
|
|
762
|
+
return enumerate_shapes(model)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _check_routing_targets(
|
|
766
|
+
routing: Mapping[str, Any],
|
|
767
|
+
groups: Mapping[str, GroupPlacement],
|
|
768
|
+
model: LogicalModel | None,
|
|
769
|
+
) -> None:
|
|
770
|
+
"""Every routing entry must name a materialisation that exists, in the shape's own group.
|
|
771
|
+
|
|
772
|
+
This used to be checked in ``GroupPlacement.by_id`` - that is, at the first read that routed
|
|
773
|
+
through the broken entry. Deferring it there turns a mistake in a document we hand over into an
|
|
774
|
+
error inside the client's request path, at a moment nobody can predict: only the shapes routing
|
|
775
|
+
through that entry fail, so a staging run that never issues those operations is green and the
|
|
776
|
+
map looks applied. Everything here is decidable at load, so it is decided at load.
|
|
777
|
+
|
|
778
|
+
Two levels, because ``model`` is optional. Without a model: the target has to be an id declared
|
|
779
|
+
somewhere in this map. With one: it has to be declared in the group the shape belongs to, which
|
|
780
|
+
is the check that matters - ids are only unique *within* a group, so a target that exists in
|
|
781
|
+
some other group would otherwise read the entity out of a copy that does not hold it.
|
|
782
|
+
"""
|
|
783
|
+
declared = {name: {m.id for m in placement.all()} for name, placement in groups.items()}
|
|
784
|
+
everywhere = {mat_id for ids in declared.values() for mat_id in ids}
|
|
785
|
+
|
|
786
|
+
for shape_id, target in sorted(routing.items()):
|
|
787
|
+
if not isinstance(target, str):
|
|
788
|
+
raise MapError(
|
|
789
|
+
f"the routing entry for shape {shape_id!r} is not a materialisation id. Routing "
|
|
790
|
+
"maps a shape id to one id, and anything else is a table nobody can look up."
|
|
791
|
+
)
|
|
792
|
+
if target not in everywhere:
|
|
793
|
+
raise MapError(
|
|
794
|
+
f"the routing table sends shape {shape_id!r} to materialisation {target!r}, and no "
|
|
795
|
+
f"group in this map declares one with that id. The map is internally inconsistent: "
|
|
796
|
+
f"declared ids are {sorted(everywhere)}."
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
if model is None:
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
shapes = {shape.id: shape for shape in _model_shapes(model)}
|
|
803
|
+
for shape_id, target in sorted(routing.items()):
|
|
804
|
+
shape = shapes.get(shape_id)
|
|
805
|
+
if shape is None:
|
|
806
|
+
raise MapError(
|
|
807
|
+
f"the routing table has an entry for shape {shape_id!r}, and this model does not "
|
|
808
|
+
"produce that shape. The model version matched, so the two sides enumerated shapes "
|
|
809
|
+
"differently - which is the divergence that puts one library's write in a table "
|
|
810
|
+
"another library never looks at."
|
|
811
|
+
)
|
|
812
|
+
if target not in declared.get(shape.group, frozenset()):
|
|
813
|
+
raise MapError(
|
|
814
|
+
f"the routing table sends shape {shape_id!r} - which belongs to group "
|
|
815
|
+
f"{shape.group!r} - to materialisation {target!r}, which that group does not "
|
|
816
|
+
f"declare. Materialisation ids are unique only within a group, so this would read "
|
|
817
|
+
f"{shape.entity} out of a copy that does not hold it."
|
|
818
|
+
)
|