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/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
+ )