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