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/entity.py ADDED
@@ -0,0 +1,170 @@
1
+ """Declaring the logical model: entities, relations, and the four invariants.
2
+
3
+ The client declares entities and relations and nothing about storage. No table name, no column name,
4
+ no engine, no index. That absence is the product: because their code never names a table, we can
5
+ change the table, and move it, without touching their code.
6
+
7
+ What they *do* have to declare is the four things traffic cannot reveal. No amount of watching
8
+ queries tells you that two entities must change atomically, or that a column is personal data, or
9
+ that a row may not leave the EU. Those are stated once, here, next to the model they describe.
10
+
11
+ Annotations are resolved lazily, in :func:`~sde.model.build_model`, not when the decorator runs.
12
+ Client modules routinely use ``from __future__ import annotations``, which makes every annotation a
13
+ string, and forward references between entities are normal - ``Order`` referring to ``Payment``
14
+ declared below it. Resolving at decoration time would make declaration order significant, which is a
15
+ trap nobody expects from a declarative API.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass
23
+ from typing import Any, Generic, TypeVar
24
+
25
+ from .errors import DeclarationError
26
+
27
+ __all__ = ["EntityDecl", "Ref", "clear_registry", "entity", "registry"]
28
+
29
+ T = TypeVar("T")
30
+
31
+
32
+ class Ref(Generic[T]):
33
+ """A reference from one entity to another.
34
+
35
+ ``user: Ref[User]`` declares a relation, not a field. It reaches the physical layout as a
36
+ foreign key column, but the model does not say that and does not need to know it.
37
+
38
+ A relation also has a second, larger effect: it joins the two entities into one colocation
39
+ group, so they are placed in the same engine. That is what makes the group the unit of placement
40
+ rather than the entity - see :mod:`sde.groups` for why that is a feature and not a limitation.
41
+ """
42
+
43
+ __slots__ = ()
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class EntityDecl:
48
+ """What the decorator captured, before annotations are resolved."""
49
+
50
+ name: str
51
+ cls: type
52
+ key: tuple[str, ...] | None
53
+ pii: tuple[str, ...]
54
+ residency: str | None
55
+ atomic_with: tuple[str, ...]
56
+ # Names visible where the class was declared. Annotations are resolved later, and
57
+ # get_type_hints() looks them up in the class's *module* globals - which is wrong for an entity
58
+ # declared inside a function, where the entity it references is a local. That is not just a test
59
+ # artefact: declaring a model inside a factory function is a perfectly ordinary thing to do, and
60
+ # without this it would fail with a NameError pointing at our internals.
61
+ localns: Mapping[str, Any]
62
+
63
+
64
+ _REGISTRY: list[EntityDecl] = []
65
+
66
+
67
+ def registry() -> tuple[EntityDecl, ...]:
68
+ """Everything declared so far, in declaration order."""
69
+ return tuple(_REGISTRY)
70
+
71
+
72
+ def clear_registry() -> None:
73
+ """Empty the registry. For tests, which need isolation between models."""
74
+ _REGISTRY.clear()
75
+
76
+
77
+ def _read_meta(cls: type) -> dict[str, Any]:
78
+ meta = getattr(cls, "Meta", None)
79
+ if meta is None:
80
+ return {}
81
+ known = {"key", "pii", "residency", "atomic_with"}
82
+ out: dict[str, Any] = {}
83
+ for attr in dir(meta):
84
+ if attr.startswith("_"):
85
+ continue
86
+ if attr not in known:
87
+ raise DeclarationError(
88
+ f"{cls.__name__}.Meta declares {attr!r}, which is not one of the four invariants "
89
+ f"({', '.join(sorted(known - {'key'}))}) or 'key'. Everything else about storage "
90
+ "is "
91
+ "our decision, and a fifth knob here would be a change to what the product "
92
+ "promises "
93
+ "rather than a configuration option."
94
+ )
95
+ out[attr] = getattr(meta, attr)
96
+ return out
97
+
98
+
99
+ def _as_names(value: Any, *, what: str, cls: type) -> tuple[str, ...]:
100
+ if value is None:
101
+ return ()
102
+ if isinstance(value, str):
103
+ raise DeclarationError(
104
+ f"{cls.__name__}.Meta.{what} is a single string. Use a list, even for one item, so "
105
+ "that "
106
+ "adding a second one later is not a change of shape."
107
+ )
108
+ names: list[str] = []
109
+ for item in value:
110
+ if isinstance(item, str):
111
+ names.append(item)
112
+ elif isinstance(item, type):
113
+ names.append(item.__name__)
114
+ else:
115
+ raise DeclarationError(
116
+ f"{cls.__name__}.Meta.{what} contains {item!r}; expected an entity class or its "
117
+ "name"
118
+ )
119
+ return tuple(names)
120
+
121
+
122
+ def entity(cls: type) -> type:
123
+ """Declare a class as an entity.
124
+
125
+ The class stays an ordinary class - this decorator records it and returns it unchanged, so
126
+ dataclasses, Pydantic models and plain classes all work and the client keeps whatever
127
+ constructor and validation they already had.
128
+ """
129
+ meta = _read_meta(cls)
130
+
131
+ key = meta.get("key")
132
+ if key is not None:
133
+ if isinstance(key, str):
134
+ raise DeclarationError(
135
+ f"{cls.__name__}.Meta.key is a string. Use a list: a composite key is a list of "
136
+ "fields, and a single-field key is a list of one, so the two cases have one shape."
137
+ )
138
+ key = tuple(str(k) for k in key)
139
+
140
+ try:
141
+ caller = sys._getframe(1)
142
+ localns: Mapping[str, Any] = dict(caller.f_locals)
143
+ except (AttributeError, ValueError): # pragma: no cover - non-CPython
144
+ # Losing this only costs the function-local case; module-level declarations resolve from
145
+ # module globals in any interpreter.
146
+ localns = {}
147
+
148
+ decl = EntityDecl(
149
+ name=cls.__name__,
150
+ cls=cls,
151
+ localns=localns,
152
+ key=key,
153
+ pii=_as_names(meta.get("pii"), what="pii", cls=cls),
154
+ residency=meta.get("residency"),
155
+ atomic_with=_as_names(meta.get("atomic_with"), what="atomic_with", cls=cls),
156
+ )
157
+
158
+ existing = [d for d in _REGISTRY if d.name == decl.name]
159
+ if existing:
160
+ raise DeclarationError(
161
+ f"two entities are called {decl.name!r}. Entity names reach the canonical IR and the "
162
+ "colocation graph, so they have to be unique within a model."
163
+ )
164
+
165
+ # Attached to the class rather than kept only in the registry, so that build_model() can accept
166
+ # entity classes directly - which is what tests do, because a global registry and test isolation
167
+ # do not mix.
168
+ cls.__sde_decl__ = decl # type: ignore[attr-defined]
169
+ _REGISTRY.append(decl)
170
+ return cls
sde/errors.py ADDED
@@ -0,0 +1,88 @@
1
+ """Error hierarchy, organised by *when* the problem is detectable.
2
+
3
+ That grouping is deliberate. A client should be able to tell from the exception type whether the
4
+ problem is in their declaration (found at import time, before anything runs), in the shape of their
5
+ model against a placement (found when the model is planned, still before traffic), or in the world
6
+ (found at runtime, and therefore something their code has to handle).
7
+
8
+ :class:`~sde.canonical.CanonicalError` deliberately does not live here. ``canonical.py`` imports
9
+ nothing from the rest of the package, because it is the module every other language port has to
10
+ reproduce first and a self-contained file is easier to port and to audit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__ = [
16
+ "DeclarationError",
17
+ "EngineError",
18
+ "MapError",
19
+ "MapRolledBack",
20
+ "MigrationRefused",
21
+ "ModelPlanningError",
22
+ "SdeError",
23
+ ]
24
+
25
+
26
+ class SdeError(Exception):
27
+ """Base for everything this library raises on purpose."""
28
+
29
+
30
+ class DeclarationError(SdeError):
31
+ """The declared model is not a model.
32
+
33
+ Raised while the declaration is being read, so at import time in practice: an unmapped Python
34
+ type, a reference to an unknown entity, an atomicity declaration naming something that is not an
35
+ entity. The message names the declaration, never a line inside this library, because the reader
36
+ has to fix their code and our stack frames do not help them.
37
+ """
38
+
39
+
40
+ class ModelPlanningError(SdeError):
41
+ """The model is valid but what is being asked of it is not possible under a placement.
42
+
43
+ A query that would join across engines, or a transaction spanning two colocation groups. Raised
44
+ when the model is planned rather than when the query runs, which is the whole point: this class
45
+ of mistake is a design error and should surface in a test run, not in production at the moment a
46
+ customer triggers that code path. The message says which entities would have to share a group.
47
+ """
48
+
49
+
50
+ class MapError(SdeError):
51
+ """The placement map cannot be trusted or cannot be used.
52
+
53
+ A bad signature, a map produced for a different model version, an unknown contract version. All
54
+ of these refuse rather than degrade: the map decides where data is written, so guessing at a
55
+ difference is the one thing that must never happen.
56
+ """
57
+
58
+
59
+ class MapRolledBack(MapError):
60
+ """This map is older than one already applied against these engines.
61
+
62
+ A subclass rather than a plain :class:`MapError`, because it is the one map refusal a client may
63
+ reasonably want to handle: it says the document is authentic and out of date, not that it is
64
+ wrong. Everything else in this hierarchy says the map cannot be trusted; this one says it can
65
+ be, and that trusting it would undo something.
66
+ """
67
+
68
+
69
+ class EngineError(SdeError):
70
+ """A backend refused or failed, and the client's code has to know.
71
+
72
+ Deliberately not swallowed. Internal problems in this library are swallowed and logged, because
73
+ a profiling or routing bug must not take down someone's application - but a write that did not
74
+ happen is not an internal problem, and reporting success for it would be the worst thing this
75
+ library could do.
76
+ """
77
+
78
+
79
+ class MigrationRefused(SdeError):
80
+ """A migration cannot be performed as asked, and nothing has been copied.
81
+
82
+ Its own class rather than a :class:`ModelPlanningError`, because of when it is raised and what
83
+ the caller does about it. Everything here is refused *before* the first chunk moves - a target
84
+ whose columns are not the source's, an engine whose adapter cannot scan a table in key order, a
85
+ column the target dialect would silently truncate - so the guarantee this type carries is that
86
+ the data is exactly where it was. That is the fact an operator needs first, and a shared
87
+ exception type would not carry it.
88
+ """
sde/explain.py ADDED
@@ -0,0 +1,300 @@
1
+ """Validation against a **live** engine, which is the only kind this library can do.
2
+
3
+ Requirement 19.4 splits the work in two and the split is the interesting part. The control plane
4
+ validates a query against the schema **it authored** - only tables and columns its own map defines,
5
+ and read-only, proved by walking a syntax tree. That is real and it is not enough, because the
6
+ schema it authored and the schema that *exists* are two different things the moment somebody runs
7
+ DDL by hand. Only the engine knows the second one, and only this library has a connection to the
8
+ engine. So this module is the half the control plane deliberately does not pretend to do.
9
+
10
+ **What it does not do is execute the query.** Requirement 19.2: executing an analyst's query would
11
+ put this product in the data path for analytics. ``EXPLAIN`` plans; it does not run. That is a claim
12
+ about somebody else's software, so it is measured rather than believed - see
13
+ ``python/tests/test_explain_live.py``, which puts ``WITH x AS (DELETE FROM t RETURNING *) SELECT *
14
+ FROM x`` through ``explain`` and counts the rows afterwards. They are all there.
15
+
16
+ **And the safety does not rest on that measurement.** This library has **no runtime dependencies**
17
+ and therefore no SQL parser, so it cannot tell a read from a write by looking - the control plane's
18
+ gate needs ``sqlglot`` for exactly that reason, and the case it was built for parses as a
19
+ ``SELECT``. What this module does instead is make the *engine* refuse:
20
+
21
+ * PostgreSQL: the plan is taken inside ``SET TRANSACTION READ ONLY``, and the transaction is rolled
22
+ back. Measured: running that data-modifying CTE for real in such a transaction fails with
23
+ ``cannot execute SELECT in a read-only transaction`` - and the word *SELECT* in PostgreSQL's own
24
+ message is the same evidence the control plane's gate was built on.
25
+ * ClickHouse: every statement is sent with ``readonly=1``, which refuses a mutation (code 164) and
26
+ leaves ``EXPLAIN ESTIMATE`` working. Measured, both halves.
27
+ * The orderbook engine has no query language to plan and refuses by name.
28
+
29
+ That ordering matters: **planning is safe, and if it were not, the transaction would still stop
30
+ it.** Constant folding is the reason the second sentence is not decoration - ``EXPLAIN SELECT 1/0``
31
+ raises ``DivisionByZero`` at plan time, so planning *does* evaluate immutable expressions, and an
32
+ immutable function that lies about being immutable is a thing a client's own database can contain.
33
+
34
+ **The cost travels with its units and what it cannot be compared to.** PostgreSQL's planner cost is
35
+ in arbitrary units that depend on the machine and on ``seq_page_cost``; ClickHouse reports parts,
36
+ rows and marks, which are real units and coarse at small scale - measured, a thousand-row table
37
+ reports the whole thousand and one mark whether or not the key filter applies, because a granule
38
+ is 8192 rows. A number with no statement of what it means is not wrong, it is unfalsifiable, which
39
+ is worse: the same rule that put ``price_basis`` beside every engine price.
40
+
41
+ **There is no timestamp on a plan.** Not an omission: this library reads the wall clock exactly
42
+ once, in ``verify()``, and ``python/tests/test_no_expiry.py`` pins that count - because "what time
43
+ is it" is the first thing an expiry check needs. A caller who wants a plan dated can date it.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from collections.abc import Mapping, Sequence
49
+ from dataclasses import dataclass
50
+ from typing import Any, Protocol, runtime_checkable
51
+
52
+ from .errors import EngineError
53
+
54
+ __all__ = [
55
+ "Cost",
56
+ "Explains",
57
+ "PlanFinding",
58
+ "QueryPlan",
59
+ "QueryPlanRefused",
60
+ "explain",
61
+ ]
62
+
63
+
64
+ class QueryPlanRefused(EngineError):
65
+ """The engine would not plan this query, and that refusal **is** the validation.
66
+
67
+ Carries the engine's own message rather than a summary of it. The commonest cause is the one
68
+ requirement 19.4 exists for - a column the control plane's map says exists and the engine says
69
+ does not, because somebody ran DDL by hand - and the engine's wording names the column.
70
+ """
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Cost:
75
+ """The engine's own numbers, with their units and what they cannot be compared to.
76
+
77
+ ``values`` are strings, like every other number this library stores: a fixed-precision string
78
+ is the same value in every process, and a plan may be written down and read back.
79
+
80
+ ``basis`` is not padding and it is not the same sentence for two engines. PostgreSQL's total
81
+ cost is unitless and depends on the machine and on planner settings, so comparing it to a
82
+ number from another database is meaningless; ClickHouse's parts, rows and marks are real
83
+ counts and are coarse below one granule. A cost with no basis is unfalsifiable, which is worse
84
+ than being wrong.
85
+ """
86
+
87
+ units: str
88
+ basis: str
89
+ values: Mapping[str, str]
90
+
91
+ def __post_init__(self) -> None:
92
+ if not self.units.strip() or not self.basis.strip():
93
+ raise EngineError(
94
+ "a cost estimate needs both its units and its basis. A figure whose meaning is "
95
+ "not stated cannot be argued with, and a decision resting on one is not auditable."
96
+ )
97
+
98
+ def as_record(self) -> dict[str, Any]:
99
+ return {"units": self.units, "basis": self.basis, "values": dict(self.values)}
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class PlanFinding:
104
+ """One thing the plan says that is worth reading, with what would change it.
105
+
106
+ Deliberately few, and every one is a **shape** fact rather than a threshold. A threshold on
107
+ PostgreSQL's cost would be taste in arbitrary units; a sequential scan under a filter is a
108
+ statement about what the engine will do, and it is true at any size.
109
+ """
110
+
111
+ kind: str
112
+ detail: str
113
+
114
+ def as_record(self) -> dict[str, Any]:
115
+ return {"kind": self.kind, "detail": self.detail}
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class QueryPlan:
120
+ """What a live engine says about a query it has not run.
121
+
122
+ ``read_only_enforced`` is reported rather than assumed, for the reason
123
+ ``session.rollback_protection`` is: a guarantee whose state cannot be read is a guarantee taken
124
+ on trust. It says *how* the engine was stopped from writing, so a reader can check the claim
125
+ against their own engine's documentation.
126
+ """
127
+
128
+ engine: str
129
+ dialect: str
130
+ plan: tuple[str, ...]
131
+ cost: Cost | None
132
+ findings: tuple[PlanFinding, ...]
133
+ read_only_enforced: str
134
+
135
+ def __post_init__(self) -> None:
136
+ if not self.plan:
137
+ raise EngineError(
138
+ "a query plan with no plan in it is not a plan. An engine that answered nothing "
139
+ "is a refusal, and a refusal is QueryPlanRefused rather than an empty result."
140
+ )
141
+ if not self.read_only_enforced.strip():
142
+ raise EngineError(
143
+ "a plan has to say how the engine was stopped from writing. This library has no "
144
+ "SQL parser, so 'we checked the query' is not available to it - what is available "
145
+ "is what the engine was told, and that is the thing worth recording."
146
+ )
147
+
148
+ def as_record(self) -> dict[str, Any]:
149
+ return {
150
+ "engine": self.engine,
151
+ "dialect": self.dialect,
152
+ "plan": list(self.plan),
153
+ "cost": None if self.cost is None else self.cost.as_record(),
154
+ "findings": [finding.as_record() for finding in self.findings],
155
+ "read_only_enforced": self.read_only_enforced,
156
+ }
157
+
158
+ def for_a_human(self) -> str:
159
+ """The findings before the plan, and the plan before the numbers.
160
+
161
+ Same ordering rule the issued query follows: the caveat goes above the thing it is about,
162
+ because a caveat printed under forty lines of plan is a caveat nobody read.
163
+ """
164
+ lines = [f"Planned by {self.engine} ({self.dialect}). Not executed."]
165
+ lines.append(f" Write protection: {self.read_only_enforced}")
166
+ if self.findings:
167
+ lines.append("")
168
+ lines.append("Worth reading before you run it:")
169
+ for finding in self.findings:
170
+ lines.append(f" [{finding.kind}] {finding.detail}")
171
+ if self.cost is not None:
172
+ lines.append("")
173
+ lines.append(f"Estimate, in {self.cost.units}:")
174
+ for name in sorted(self.cost.values):
175
+ lines.append(f" {name}: {self.cost.values[name]}")
176
+ lines.append(f" What this number is: {self.cost.basis}")
177
+ else:
178
+ lines.append("")
179
+ lines.append(
180
+ "No estimate: this engine gave a plan and no figure. Reported as absent rather "
181
+ "than as zero - a query with no cost estimate and a free query are not the same "
182
+ "thing."
183
+ )
184
+ lines.append("")
185
+ lines.append("Plan, as the engine rendered it:")
186
+ lines.extend(f" {line}" for line in self.plan)
187
+ return "\n".join(lines)
188
+
189
+
190
+ @runtime_checkable
191
+ class Explains(Protocol):
192
+ """An adapter that can ask its engine to plan a query without running it.
193
+
194
+ A separate protocol rather than a method on :class:`~sde.session.Engine`, and that is a
195
+ compatibility decision. ``Engine`` is what a third party implements to plug their own database
196
+ in; growing it would break every such adapter on upgrade, for a capability a session never
197
+ uses. So this is asked for by name, and an adapter without it gets a refusal that says which
198
+ of the two reasons applies.
199
+
200
+ ``runtime_checkable`` here checks only that the attribute exists. Worth remembering why that is
201
+ not enough on its own: ``isinstance`` against a runtime-checkable protocol resolves members
202
+ through ``hasattr`` up to Python 3.11 and through ``inspect.getattr_static`` from 3.12, and the
203
+ second ignores ``__getattr__`` - so a proxy that forwards calls passes on one interpreter and
204
+ fails on another. :func:`explain` therefore looks the attribute up directly.
205
+ """
206
+
207
+ dialect: str
208
+
209
+ def explain_plan(self, sql: str) -> QueryPlan: ...
210
+
211
+
212
+ def explain(engine: object, sql: str) -> QueryPlan:
213
+ """Ask a live engine to plan this query without running it. Requirement 19.4.
214
+
215
+ Takes an adapter rather than a :class:`~sde.session.Session`, because an analyst's query is not
216
+ an operation shape and there is nothing for a session to route. The engine to use is in the
217
+ query the control plane issued - it names the materialisation and its staleness - so the caller
218
+ already knows which adapter to hand over.
219
+
220
+ Looked up with ``getattr`` rather than ``isinstance``: see :class:`Explains` for the version
221
+ difference that makes the second one answer differently on 3.11 and 3.12.
222
+ """
223
+ if not sql.strip():
224
+ raise EngineError("there is no query here to plan")
225
+ method = getattr(engine, "explain_plan", None)
226
+ if method is None or not callable(method):
227
+ raise EngineError(
228
+ f"{type(engine).__name__} cannot plan a query. That is one of two different things and "
229
+ f"the difference matters: either this engine has no query planner to ask - the "
230
+ f"orderbook engine has a fixed access path and nothing to choose between - or it has "
231
+ f"one and this adapter does not expose it yet. Neither is a reason to run the query "
232
+ f"instead: requirement 19.2 keeps this product out of the data path for analytics, and "
233
+ f"'we could not check it, so we ran it' is the exact opposite of a validation."
234
+ )
235
+ plan: QueryPlan = method(sql)
236
+ return plan
237
+
238
+
239
+ def postgres_findings(node: Mapping[str, Any]) -> tuple[PlanFinding, ...]:
240
+ """Shape facts from a PostgreSQL JSON plan. Walks the whole tree, not just the root.
241
+
242
+ One finding, and the restraint is deliberate. A sequential scan carrying a filter means the
243
+ engine reads every row to throw most of them away, which is true at any size and is the case
244
+ an index addresses - the same reasoning that makes ``latency`` drift point at an index rather
245
+ than at an engine. Everything else the plan offers is either a number in arbitrary units or
246
+ needs ``ANALYZE``, which would execute the query.
247
+ """
248
+ found: list[PlanFinding] = []
249
+
250
+ def walk(current: Mapping[str, Any]) -> None:
251
+ if str(current.get("Node Type")) == "Seq Scan" and current.get("Filter"):
252
+ relation = current.get("Relation Name", "a table")
253
+ found.append(
254
+ PlanFinding(
255
+ kind="full_scan_under_filter",
256
+ detail=(
257
+ f"{relation} is read in full and then filtered on "
258
+ f"{current.get('Filter')}, so every row is fetched to discard most of "
259
+ f"them. The engine estimates {current.get('Plan Rows')} row(s) will "
260
+ f"survive. An index on the filtered column is what changes this; ask for "
261
+ f"one rather than adding it, because a physical schema is ours to own."
262
+ ),
263
+ )
264
+ )
265
+ for child in current.get("Plans") or ():
266
+ if isinstance(child, dict):
267
+ walk(child)
268
+
269
+ walk(node)
270
+ return tuple(found)
271
+
272
+
273
+ def replacing_merge_tree_finding(tables: Sequence[tuple[str, str]]) -> tuple[PlanFinding, ...]:
274
+ """The measured hazard from 9.7, now sayable from a live engine rather than only in prose.
275
+
276
+ A ``ReplacingMergeTree`` keeps both rows written under one key until a merge collapses them, so
277
+ a read without ``FINAL`` counts the row twice. Measured in this product with merges stopped:
278
+ two rows against one. The library's own reads use ``FINAL``; a query an analyst runs by hand
279
+ does not unless it says so.
280
+
281
+ **This is a property of the table, not a defect in the query, and it is phrased that way** -
282
+ because this library has no SQL parser and therefore cannot tell whether the query says
283
+ ``FINAL``. Guessing with a substring search would be worse than not looking: an alias called
284
+ ``final`` would suppress a real warning, which is a check that fails open. So the finding
285
+ states the fact and "I already wrote FINAL" is a satisfying answer to it.
286
+ """
287
+ return tuple(
288
+ PlanFinding(
289
+ kind="replacing_merge_tree",
290
+ detail=(
291
+ f"{table} is a {kind}: two rows written under one key both stay until a merge "
292
+ f"collapses them, and a read without FINAL counts both. Measured here with merges "
293
+ f"stopped: two rows against one. This library's own reads use FINAL. Whether "
294
+ f"yours does is something this check cannot see - there is no SQL parser here, so "
295
+ f"this is a fact about the table rather than a claim about your query."
296
+ ),
297
+ )
298
+ for table, kind in tables
299
+ if kind.startswith("Replacing")
300
+ )
sde/groups.py ADDED
@@ -0,0 +1,97 @@
1
+ """Colocation groups: the unit of placement.
2
+
3
+ Entities that are queried together, or that must change together, live in the same engine. The graph
4
+ has an edge for every relation and for every declared atomicity, and a group is a connected
5
+ component of it.
6
+
7
+ This looks like a limitation and is the opposite. A join across two engines means pulling both sides
8
+ over the network and joining in the client's process: slow, memory-hungry, and the single easiest
9
+ way for a young product to embarrass itself. Making colocation a constraint turns that problem into
10
+ something the planner simply respects, and the consistency contract falls straight out of it - one
11
+ group, one engine, that engine's transaction semantics, and no distributed transactions anywhere.
12
+
13
+ The obvious worry is that every relation being an edge collapses a normalised model into one group,
14
+ leaving nothing to place. In practice it does not, and the reason is worth understanding because it
15
+ is the product's whole thesis. Take a typical application: ``User``, ``Order``, ``OrderLine``,
16
+ ``Product``, ``Event``. The first four are related and become one group. ``Event`` references
17
+ nothing and becomes its own. That is exactly the split that matters: the transactional core belongs
18
+ in a row store, the event stream belongs in a column store, and the entities nobody joins are
19
+ precisely the ones that were sitting in the wrong engine all along.
20
+
21
+ A client who wants two related entities in different engines can have that, and finds out about the
22
+ cost honestly: the relation stops being traversable, and the error at model-planning time says which
23
+ entities would have to share a group for the query to be possible.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+
30
+ from .model import LogicalModel
31
+
32
+ __all__ = ["Group", "colocation_groups", "group_of"]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Group:
37
+ """A set of entities placed together.
38
+
39
+ ``name`` is the alphabetically first member. It exists so that logs, proposals and error
40
+ messages can say ``group "order"`` instead of a hash, and it is only meaningful within one model
41
+ version - change the membership and you have changed the model, which changes its version.
42
+ """
43
+
44
+ name: str
45
+ members: tuple[str, ...]
46
+
47
+ def __contains__(self, entity: str) -> bool:
48
+ return entity in self.members
49
+
50
+
51
+ def colocation_groups(model: LogicalModel) -> tuple[Group, ...]:
52
+ """Connected components of the colocation graph, deterministically ordered.
53
+
54
+ Determinism here is not a nicety. The group name reaches the placement map, the telemetry and
55
+ the planner's decisions, so two runs over the same model have to produce the same names or the
56
+ control plane sees a model whose groups keep being renamed.
57
+ """
58
+ names = sorted(e.name for e in model.entities)
59
+ parent: dict[str, str] = {n: n for n in names}
60
+
61
+ def find(x: str) -> str:
62
+ while parent[x] != x:
63
+ parent[x] = parent[parent[x]]
64
+ x = parent[x]
65
+ return x
66
+
67
+ def union(a: str, b: str) -> None:
68
+ ra, rb = find(a), find(b)
69
+ if ra != rb:
70
+ # Always attach to the alphabetically smaller root, so the representative of a component
71
+ # does not depend on the order edges were visited in.
72
+ parent[max(ra, rb)] = min(ra, rb)
73
+
74
+ for relation in sorted(model.relations, key=lambda r: (r.source, r.name, r.target)):
75
+ union(relation.source, relation.target)
76
+ for atomic in model.atomic:
77
+ first = atomic[0]
78
+ for other in atomic[1:]:
79
+ union(first, other)
80
+
81
+ buckets: dict[str, list[str]] = {}
82
+ for name in names:
83
+ buckets.setdefault(find(name), []).append(name)
84
+
85
+ groups = [
86
+ Group(name=min(members), members=tuple(sorted(members)))
87
+ for members in buckets.values()
88
+ ]
89
+ groups.sort(key=lambda g: g.name)
90
+ return tuple(groups)
91
+
92
+
93
+ def group_of(groups: tuple[Group, ...], entity: str) -> Group:
94
+ for group in groups:
95
+ if entity in group:
96
+ return group
97
+ raise KeyError(f"{entity} is not in any group, which means it is not in the model")