smart-data-engine-sdk 0.1.0__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 +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
sde/index_build.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Load the signed authorization to build indexes on the tables in force, in place.
|
|
2
|
+
|
|
3
|
+
A model's design that only adds indexes to a group's source used to run as a relayout: a fresh copy
|
|
4
|
+
with the index, then a cutover that copies and compares every row while the source's writes are
|
|
5
|
+
frozen. Measured on PostgreSQL 15.19 and ClickHouse 24.8.14.39 (24 September 2026), that pause is
|
|
6
|
+
linear in the table - 22.5 s at 300 000 rows - and a table of about 400 000 rows exceeds the 30 s
|
|
7
|
+
cutover budget and rolls back. Building the index on the live table moves no row and pauses no
|
|
8
|
+
write.
|
|
9
|
+
|
|
10
|
+
This authorization binds that build: the exact map in force and the next map, which differs from it
|
|
11
|
+
only by indexes added to one group's source. Nothing else may change, because nothing else can
|
|
12
|
+
change without a copy - and the tables, the write generation and every running process stay as they
|
|
13
|
+
are, which is what lets the build run without a barrier.
|
|
14
|
+
|
|
15
|
+
Protocol 2 also removes indexes the map in force declares, for a design that drops an index nobody
|
|
16
|
+
reads or replaces one with another. The removed ones leave the next map, the others keep their
|
|
17
|
+
order, and the new ones follow. The operator removes them only after its decision, one resumable
|
|
18
|
+
step each: ``DROP INDEX CONCURRENTLY`` pauses no write (measured, like the build), and a process on
|
|
19
|
+
the map in force that still declares a removed index reports it missing and keeps serving rows.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from copy import deepcopy
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .canonical import CanonicalError, canonical_bytes
|
|
32
|
+
from .cutover import _hex, _record, _signature
|
|
33
|
+
from .errors import MapError, MigrationRefused
|
|
34
|
+
from .generation import GENERATIONS_SINCE, check_map_project, json_numbers
|
|
35
|
+
from .model import LogicalModel
|
|
36
|
+
from .placement import PlacementMap, _verify_signature, load_map
|
|
37
|
+
|
|
38
|
+
INDEX_PROTOCOL = 1
|
|
39
|
+
"""Indexes added to one group's source, built on the tables in force; no copy, no cutover."""
|
|
40
|
+
|
|
41
|
+
INDEX_CHANGE_PROTOCOL = 2
|
|
42
|
+
"""Indexes added to and removed from one group's source, in place; at least one removed."""
|
|
43
|
+
|
|
44
|
+
_PROTOCOLS = (INDEX_PROTOCOL, INDEX_CHANGE_PROTOCOL)
|
|
45
|
+
|
|
46
|
+
MAX_BUILD_BUDGET_MS = 86_400_000
|
|
47
|
+
"""A day. The budget bounds a build on a server that stopped answering; nothing is paused while it
|
|
48
|
+
runs, so it is not a pause budget and it is deliberately far longer than a cutover's."""
|
|
49
|
+
|
|
50
|
+
_FIELDS = {
|
|
51
|
+
"kind",
|
|
52
|
+
"protocol",
|
|
53
|
+
"index_id",
|
|
54
|
+
"project_id",
|
|
55
|
+
"group",
|
|
56
|
+
"current",
|
|
57
|
+
"prepared",
|
|
58
|
+
"build_budget_ms",
|
|
59
|
+
"signature",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_SUBJECT = "index build"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def index_build_name(index_id: str, position: int) -> str:
|
|
67
|
+
"""The physical name of the ``position``-th index an in-place build adds (one-based)."""
|
|
68
|
+
_hex(index_id, 32, "index_id", _SUBJECT)
|
|
69
|
+
if type(position) is not int or not 1 <= position <= 999999:
|
|
70
|
+
raise MigrationRefused("index build position must be an integer from 1 through 999999")
|
|
71
|
+
return f"sde_i_{index_id}_{position:06d}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class IndexPlan:
|
|
76
|
+
index_id: str
|
|
77
|
+
project_id: str
|
|
78
|
+
group: str
|
|
79
|
+
current: PlacementMap
|
|
80
|
+
prepared: PlacementMap
|
|
81
|
+
build_budget_ms: int
|
|
82
|
+
added: tuple[Mapping[str, Any], ...]
|
|
83
|
+
"""The new index definitions, in position order, exactly as the prepared map carries them."""
|
|
84
|
+
verified_with: str | None
|
|
85
|
+
removed: tuple[Mapping[str, Any], ...] = ()
|
|
86
|
+
"""Protocol 2: the definitions in force the next map drops, in their order in force."""
|
|
87
|
+
fingerprint: str | None = field(default=None, init=False)
|
|
88
|
+
_document: bytes = field(default=b"", init=False, repr=False)
|
|
89
|
+
|
|
90
|
+
def _loaded(self) -> None:
|
|
91
|
+
if self.fingerprint is None or not self._document:
|
|
92
|
+
raise MigrationRefused("an index build requires an immutable loaded authorization")
|
|
93
|
+
|
|
94
|
+
def as_record(self) -> dict[str, Any]:
|
|
95
|
+
self._loaded()
|
|
96
|
+
value: dict[str, Any] = json.loads(self._document)
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def protocol(self) -> int:
|
|
101
|
+
return int(self.as_record()["protocol"])
|
|
102
|
+
|
|
103
|
+
def prepared_payload(self) -> bytes:
|
|
104
|
+
return canonical_bytes(self.as_record()["prepared"])
|
|
105
|
+
|
|
106
|
+
def check_current(self, current: PlacementMap) -> None:
|
|
107
|
+
self._loaded()
|
|
108
|
+
check_map_project(current, self.project_id)
|
|
109
|
+
if not current.signed or current.fingerprint != self.current.fingerprint:
|
|
110
|
+
raise MigrationRefused("index build authorization does not name the signed current map")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, init=False)
|
|
114
|
+
class IndexReceipt:
|
|
115
|
+
_payload: bytes
|
|
116
|
+
|
|
117
|
+
def __init__(self, record: Mapping[str, Any]) -> None:
|
|
118
|
+
object.__setattr__(
|
|
119
|
+
self,
|
|
120
|
+
"_payload",
|
|
121
|
+
json.dumps(
|
|
122
|
+
record, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
123
|
+
).encode("utf-8"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def as_record(self) -> dict[str, Any]:
|
|
127
|
+
record: dict[str, Any] = json.loads(self._payload)
|
|
128
|
+
return record
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _names(document: Mapping[str, Any]) -> set[str]:
|
|
132
|
+
"""Every table and index name any layout of a map document uses."""
|
|
133
|
+
found: set[str] = set()
|
|
134
|
+
for raw_group in document["groups"].values():
|
|
135
|
+
for material in (raw_group["source"], *raw_group.get("derived", [])):
|
|
136
|
+
layout = material["layout"]
|
|
137
|
+
found.update(str(table) for table in layout.get("tables", {}).values())
|
|
138
|
+
found.update(str(index["name"]) for index in layout.get("indexes", []) or [])
|
|
139
|
+
return found
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load(
|
|
143
|
+
raw: Mapping[str, Any],
|
|
144
|
+
model: LogicalModel,
|
|
145
|
+
project_id: str,
|
|
146
|
+
public_key: bytes | Mapping[str, bytes],
|
|
147
|
+
) -> IndexPlan:
|
|
148
|
+
body = _record(json_numbers(deepcopy(raw)), "authorization", _SUBJECT)
|
|
149
|
+
if set(body) != _FIELDS:
|
|
150
|
+
raise MigrationRefused("index build authorization has missing or unknown fields")
|
|
151
|
+
if (
|
|
152
|
+
type(body["protocol"]) is not int
|
|
153
|
+
or body["protocol"] not in _PROTOCOLS
|
|
154
|
+
or body["kind"] != "sde-index"
|
|
155
|
+
):
|
|
156
|
+
raise MigrationRefused("unsupported index build authorization kind or protocol")
|
|
157
|
+
protocol = int(body["protocol"])
|
|
158
|
+
identity = _hex(body["index_id"], 32, "index_id", _SUBJECT)
|
|
159
|
+
local = _hex(body["project_id"], 32, "project_id", _SUBJECT)
|
|
160
|
+
if local != project_id:
|
|
161
|
+
raise MigrationRefused("index build authorization belongs to another local project")
|
|
162
|
+
group = body["group"]
|
|
163
|
+
if not isinstance(group, str) or not group:
|
|
164
|
+
raise MigrationRefused("index build group must be a nonempty string")
|
|
165
|
+
budget = body["build_budget_ms"]
|
|
166
|
+
if type(budget) is not int or not 1 <= budget <= MAX_BUILD_BUDGET_MS:
|
|
167
|
+
raise MigrationRefused(
|
|
168
|
+
f"index build budget must be an integer from 1 through {MAX_BUILD_BUDGET_MS} ms"
|
|
169
|
+
)
|
|
170
|
+
_signature(body, _SUBJECT)
|
|
171
|
+
verified = _verify_signature(body, public_key)
|
|
172
|
+
maps = []
|
|
173
|
+
for name in ("current", "prepared"):
|
|
174
|
+
document = _record(body[name], name, _SUBJECT)
|
|
175
|
+
_signature(document, _SUBJECT)
|
|
176
|
+
for raw_group in _record(document.get("groups"), "groups", _SUBJECT).values():
|
|
177
|
+
value = _record(raw_group, "group", _SUBJECT)
|
|
178
|
+
copies = value.get("derived", [])
|
|
179
|
+
if not isinstance(copies, list):
|
|
180
|
+
raise MigrationRefused("index build derived copies must be an array")
|
|
181
|
+
for raw_material in (value.get("source"), *copies):
|
|
182
|
+
raw_layout = _record(
|
|
183
|
+
_record(raw_material, "materialization", _SUBJECT).get("layout"),
|
|
184
|
+
"layout",
|
|
185
|
+
_SUBJECT,
|
|
186
|
+
)
|
|
187
|
+
if raw_layout.get("auto"):
|
|
188
|
+
raise MigrationRefused("index build maps need explicit physical layouts")
|
|
189
|
+
parsed = load_map(document, model=model, public_key=public_key, require_signature=True)
|
|
190
|
+
if parsed.contract < GENERATIONS_SINCE:
|
|
191
|
+
raise MigrationRefused(
|
|
192
|
+
f"index build protocol {protocol} requires map contract "
|
|
193
|
+
f"{GENERATIONS_SINCE} or later"
|
|
194
|
+
)
|
|
195
|
+
check_map_project(parsed, project_id)
|
|
196
|
+
for placement in parsed.groups.values():
|
|
197
|
+
for material in placement.all():
|
|
198
|
+
if not material.layout.tables or not material.layout.columns:
|
|
199
|
+
raise MigrationRefused("index build maps need explicit physical layouts")
|
|
200
|
+
maps.append(parsed)
|
|
201
|
+
current, prepared = maps
|
|
202
|
+
# The prepared map may raise the contract, because an index method first appears here - a
|
|
203
|
+
# BRIN index under a contract-4 current map. It may not lower it: a lower number would tell an
|
|
204
|
+
# older library it may ignore keys that the current map already relies on.
|
|
205
|
+
if prepared.contract < current.contract:
|
|
206
|
+
raise MigrationRefused("an index build cannot lower the placement map contract")
|
|
207
|
+
if current.map_version >= prepared.map_version:
|
|
208
|
+
raise MigrationRefused("an index build must allocate a newer prepared map")
|
|
209
|
+
if group not in current.groups or set(current.groups) != set(prepared.groups):
|
|
210
|
+
raise MigrationRefused("an index build cannot add or remove colocation groups")
|
|
211
|
+
old, new = current.groups[group], prepared.groups[group]
|
|
212
|
+
if old.derived or old.also_write or new.derived or new.also_write:
|
|
213
|
+
raise MigrationRefused("an index build begins and ends with a source-only group")
|
|
214
|
+
if new.write_epoch != old.write_epoch:
|
|
215
|
+
# Nothing is fenced: the tables stay and every running process keeps writing to them.
|
|
216
|
+
raise MigrationRefused("an index build keeps the source's write generation")
|
|
217
|
+
current_raw, prepared_raw = body["current"], body["prepared"]
|
|
218
|
+
old_group, new_group = current_raw["groups"][group], prepared_raw["groups"][group]
|
|
219
|
+
if set(old_group) != {"source", "write_epoch"} or set(new_group) != {"source", "write_epoch"}:
|
|
220
|
+
raise MigrationRefused("an index build group is a source and its write generation only")
|
|
221
|
+
|
|
222
|
+
def without_indexes(material: Mapping[str, Any]) -> dict[str, Any]:
|
|
223
|
+
layout = {key: value for key, value in material["layout"].items() if key != "indexes"}
|
|
224
|
+
return {**material, "layout": layout}
|
|
225
|
+
|
|
226
|
+
if canonical_bytes(without_indexes(old_group["source"])) != canonical_bytes(
|
|
227
|
+
without_indexes(new_group["source"])
|
|
228
|
+
):
|
|
229
|
+
raise MigrationRefused(
|
|
230
|
+
"an index build changes nothing about the source but its indexes; a new key order, "
|
|
231
|
+
"partition, table or engine is a relayout or a move"
|
|
232
|
+
)
|
|
233
|
+
in_force = list(old_group["source"]["layout"].get("indexes", []) or [])
|
|
234
|
+
after = list(new_group["source"]["layout"].get("indexes", []) or [])
|
|
235
|
+
if protocol == INDEX_PROTOCOL:
|
|
236
|
+
kept, removed = in_force, []
|
|
237
|
+
if len(after) <= len(kept) or canonical_bytes(after[: len(kept)]) != canonical_bytes(kept):
|
|
238
|
+
raise MigrationRefused(
|
|
239
|
+
"an index build keeps every index in force, in order, and adds at least one after "
|
|
240
|
+
"them"
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
remaining = {str(index.get("name")) for index in after}
|
|
244
|
+
kept = [index for index in in_force if str(index.get("name")) in remaining]
|
|
245
|
+
removed = [index for index in in_force if str(index.get("name")) not in remaining]
|
|
246
|
+
if not removed:
|
|
247
|
+
raise MigrationRefused("index build protocol 2 removes at least one index in force")
|
|
248
|
+
if canonical_bytes(after[: len(kept)]) != canonical_bytes(kept):
|
|
249
|
+
raise MigrationRefused(
|
|
250
|
+
"an index change keeps the other indexes in force, in order, before the new ones"
|
|
251
|
+
)
|
|
252
|
+
added = after[len(kept) :]
|
|
253
|
+
for position, index in enumerate(added, start=1):
|
|
254
|
+
if index.get("name") != index_build_name(identity, position):
|
|
255
|
+
raise MigrationRefused(
|
|
256
|
+
"new indexes need fresh names bound to the index build id and their position"
|
|
257
|
+
)
|
|
258
|
+
if _names(current_raw) & {str(index["name"]) for index in added}:
|
|
259
|
+
raise MigrationRefused("an index build cannot reuse a name the current map uses")
|
|
260
|
+
stable = {
|
|
261
|
+
key: value
|
|
262
|
+
for key, value in current_raw.items()
|
|
263
|
+
if key not in {"signature", "map_version", "groups", "contract"}
|
|
264
|
+
}
|
|
265
|
+
changed = {
|
|
266
|
+
key: value
|
|
267
|
+
for key, value in prepared_raw.items()
|
|
268
|
+
if key not in {"signature", "map_version", "groups", "contract"}
|
|
269
|
+
}
|
|
270
|
+
if canonical_bytes(stable) != canonical_bytes(changed) or dict(current.routing) != dict(
|
|
271
|
+
prepared.routing
|
|
272
|
+
):
|
|
273
|
+
raise MigrationRefused("an index build cannot change routing or other map attributes")
|
|
274
|
+
for other in current.groups:
|
|
275
|
+
if other != group and canonical_bytes(current_raw["groups"][other]) != canonical_bytes(
|
|
276
|
+
prepared_raw["groups"][other]
|
|
277
|
+
):
|
|
278
|
+
raise MigrationRefused("an index build cannot change an unaffected group")
|
|
279
|
+
gone = {str(index["name"]) for index in removed}
|
|
280
|
+
plan = IndexPlan(
|
|
281
|
+
identity,
|
|
282
|
+
local,
|
|
283
|
+
group,
|
|
284
|
+
current,
|
|
285
|
+
prepared,
|
|
286
|
+
budget,
|
|
287
|
+
# From the loaded maps, which freeze nested structures, not from the caller's dictionaries.
|
|
288
|
+
tuple(new.source.layout.indexes[len(kept) :]),
|
|
289
|
+
verified,
|
|
290
|
+
tuple(index for index in old.source.layout.indexes if str(index["name"]) in gone),
|
|
291
|
+
)
|
|
292
|
+
object.__setattr__(plan, "_document", canonical_bytes(body))
|
|
293
|
+
object.__setattr__(
|
|
294
|
+
plan,
|
|
295
|
+
"fingerprint",
|
|
296
|
+
hashlib.sha256(
|
|
297
|
+
canonical_bytes({key: value for key, value in body.items() if key != "signature"})
|
|
298
|
+
).hexdigest(),
|
|
299
|
+
)
|
|
300
|
+
return plan
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_index_plan(
|
|
304
|
+
raw: Mapping[str, Any],
|
|
305
|
+
*,
|
|
306
|
+
model: LogicalModel,
|
|
307
|
+
project_id: str,
|
|
308
|
+
public_key: bytes | Mapping[str, bytes],
|
|
309
|
+
) -> IndexPlan:
|
|
310
|
+
try:
|
|
311
|
+
return _load(raw, model, project_id, public_key)
|
|
312
|
+
except (MapError, CanonicalError, ValueError, TypeError, KeyError) as exc:
|
|
313
|
+
raise MigrationRefused(f"index build authorization refused: {exc}") from exc
|
sde/index_operator.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Durable in-place index builds using only customer-local connections.
|
|
2
|
+
|
|
3
|
+
The build pauses nothing: writers on the map in force keep writing to the same tables while the
|
|
4
|
+
index is built beside them, and the next map differs only by declaring it. So the operation holds no
|
|
5
|
+
barrier and raises no generation - it refuses to start next to anybody else's barrier instead - and
|
|
6
|
+
its two terminal outcomes are ``built`` (the next map is published) and ``abandoned`` (this build's
|
|
7
|
+
own indexes are removed and the map in force stays). Without a decision a recovery builds on:
|
|
8
|
+
unlike a cutover, where the source stands frozen until a decision, here nothing waits, and an index
|
|
9
|
+
that gets built is harmless.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from functools import partial
|
|
16
|
+
from time import monotonic_ns
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from .errors import MigrationRefused
|
|
20
|
+
from .index_build import IndexPlan, IndexReceipt, load_index_plan
|
|
21
|
+
from .local_cutover import CutoverRecoveryRequired
|
|
22
|
+
from .physical import METHODS_BY_DIALECT, index_method, refuse_findings
|
|
23
|
+
from .placement import WATERMARK_TABLE
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .local_cutover import LocalCutover
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _snapshot(operator: LocalCutover, plan: IndexPlan, state: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
from .engines._index_build import NativeIndexBuild
|
|
31
|
+
|
|
32
|
+
plan.check_current(operator.active_map())
|
|
33
|
+
placement = plan.current.groups[plan.group]
|
|
34
|
+
source = placement.source
|
|
35
|
+
if source.engine not in operator.engines:
|
|
36
|
+
raise MigrationRefused("an index build is missing its configured local engine binding")
|
|
37
|
+
native = operator.native[source.engine]
|
|
38
|
+
for index in plan.added:
|
|
39
|
+
if index_method(index) not in METHODS_BY_DIALECT.get(native.dialect, ()):
|
|
40
|
+
raise MigrationRefused(
|
|
41
|
+
f"{native.dialect} cannot build a {index_method(index)} index in place"
|
|
42
|
+
)
|
|
43
|
+
engine = operator.engines[source.engine]
|
|
44
|
+
# The design in force, read back before any DDL: a build beside a table that already differs
|
|
45
|
+
# from its map would be refused only at publication, after the work.
|
|
46
|
+
keys = {entity: operator.model.entity(entity).key for entity in source.layout.tables}
|
|
47
|
+
refuse_findings(engine.validate_schema(source.layout, keys=keys), MigrationRefused)
|
|
48
|
+
tables: dict[str, dict[str, str]] = {}
|
|
49
|
+
builder = NativeIndexBuild(native)
|
|
50
|
+
for entity, table in sorted(source.layout.tables.items()):
|
|
51
|
+
identity = native.identity(table)
|
|
52
|
+
observed = engine.write_fence(table, project_id=operator.project_id).state()
|
|
53
|
+
if not observed.complete or observed.epoch != placement.write_epoch or observed.holds:
|
|
54
|
+
# A build beside another operation's barrier would publish a map that operation's
|
|
55
|
+
# own next map does not know about.
|
|
56
|
+
raise MigrationRefused(
|
|
57
|
+
"an index build needs the current generation without another barrier"
|
|
58
|
+
)
|
|
59
|
+
tables[entity] = identity.as_record()
|
|
60
|
+
for index in plan.added:
|
|
61
|
+
if index["entity"] == entity:
|
|
62
|
+
status, reason = builder.inspect(identity, index)
|
|
63
|
+
if status == "foreign":
|
|
64
|
+
raise MigrationRefused(reason)
|
|
65
|
+
for index in plan.removed:
|
|
66
|
+
# Before any DDL, as for a build beside a table that differs from its map: removing
|
|
67
|
+
# an index the table does not hold as declared would publish a map about another table.
|
|
68
|
+
if index["entity"] == entity and builder.declared(identity, index) not in (
|
|
69
|
+
"ready",
|
|
70
|
+
"unfinished",
|
|
71
|
+
):
|
|
72
|
+
raise MigrationRefused(
|
|
73
|
+
"an index the map in force declares is not on its table as declared; "
|
|
74
|
+
"inspect the table before changing its indexes"
|
|
75
|
+
)
|
|
76
|
+
allowed: dict[str, set[str]] = {name: {WATERMARK_TABLE} for name in operator.engines}
|
|
77
|
+
for placed in plan.current.groups.values():
|
|
78
|
+
for material in placed.all():
|
|
79
|
+
allowed[material.engine].update(material.layout.tables.values())
|
|
80
|
+
bindings = {}
|
|
81
|
+
for name, driver in operator.native.items():
|
|
82
|
+
value = operator.engines[name].map_watermark()
|
|
83
|
+
if value is not None and value > plan.current.map_version:
|
|
84
|
+
raise MigrationRefused("a newer map was adopted before this index build")
|
|
85
|
+
driver.qualify([WATERMARK_TABLE], sorted(allowed[name]))
|
|
86
|
+
bindings[name] = {
|
|
87
|
+
"endpoint": list(driver.endpoint()),
|
|
88
|
+
"watermark_identity": driver.identity(WATERMARK_TABLE).as_record(),
|
|
89
|
+
"principals": dict(driver.principal_ids),
|
|
90
|
+
"allowed_tables": sorted(allowed[name]),
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
"kind": "index",
|
|
94
|
+
"plan_id": plan.index_id,
|
|
95
|
+
"plan_fingerprint": plan.fingerprint,
|
|
96
|
+
"plan": plan.as_record(),
|
|
97
|
+
"phase": "index_prepared",
|
|
98
|
+
"pending_hold": None,
|
|
99
|
+
"decision": None,
|
|
100
|
+
"bindings": bindings,
|
|
101
|
+
"engine": source.engine,
|
|
102
|
+
"epoch": placement.write_epoch,
|
|
103
|
+
"tables": tables,
|
|
104
|
+
"indexes": [
|
|
105
|
+
{"entity": str(index["entity"]), "name": str(index["name"]), "index": dict(index)}
|
|
106
|
+
for index in plan.added
|
|
107
|
+
],
|
|
108
|
+
"removed": [
|
|
109
|
+
{"entity": str(index["entity"]), "name": str(index["name"]), "index": dict(index)}
|
|
110
|
+
for index in plan.removed
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _recheck(operator: LocalCutover, execution: dict[str, Any]) -> None:
|
|
116
|
+
"""Everything a publication stands on: bindings, logins, tables and a generation to itself."""
|
|
117
|
+
from .engines._operator import TableIdentity
|
|
118
|
+
|
|
119
|
+
for name, binding in execution["bindings"].items():
|
|
120
|
+
native = operator.native[name]
|
|
121
|
+
if list(native.endpoint()) != binding["endpoint"]:
|
|
122
|
+
raise MigrationRefused("an index build binding names another native database")
|
|
123
|
+
expected_watermark = TableIdentity(**binding["watermark_identity"])
|
|
124
|
+
if native.identity(WATERMARK_TABLE).physical_key != expected_watermark.physical_key:
|
|
125
|
+
raise MigrationRefused("an index build's namespace or watermark identity changed")
|
|
126
|
+
native.qualify([WATERMARK_TABLE], binding["allowed_tables"])
|
|
127
|
+
if native.principal_ids != binding["principals"]:
|
|
128
|
+
raise MigrationRefused("a runtime login identity changed during the index build")
|
|
129
|
+
engine = operator.engines[execution["engine"]]
|
|
130
|
+
native = operator.native[execution["engine"]]
|
|
131
|
+
for record in execution["tables"].values():
|
|
132
|
+
expected = TableIdentity(**record)
|
|
133
|
+
if native.identity(expected.name).physical_key != expected.physical_key:
|
|
134
|
+
raise MigrationRefused("a table was replaced during its index build")
|
|
135
|
+
observed = engine.write_fence(expected.name, project_id=operator.project_id).state()
|
|
136
|
+
if not observed.complete or observed.epoch != execution["epoch"] or observed.holds:
|
|
137
|
+
raise MigrationRefused("another barrier or generation appeared during the index build")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _finish(
|
|
141
|
+
operator: LocalCutover, state: dict[str, Any], plan: IndexPlan, *, recovered: bool
|
|
142
|
+
) -> IndexReceipt:
|
|
143
|
+
from .engines._index_build import NativeIndexBuild
|
|
144
|
+
from .engines._operator import TableIdentity
|
|
145
|
+
|
|
146
|
+
execution = state["execution"]
|
|
147
|
+
builder = NativeIndexBuild(operator.native[execution["engine"]])
|
|
148
|
+
if execution["decision"] == "abandoned":
|
|
149
|
+
# Removing our own indexes publishes nothing, so it needs only the same database - each
|
|
150
|
+
# drop checks its table's identity - and neither the logins nor a barrier-free table.
|
|
151
|
+
# That is what keeps abandonment available when the build cannot finish.
|
|
152
|
+
binding = execution["bindings"][execution["engine"]]
|
|
153
|
+
if list(operator.native[execution["engine"]].endpoint()) != binding["endpoint"]:
|
|
154
|
+
raise MigrationRefused("an index build binding names another native database")
|
|
155
|
+
else:
|
|
156
|
+
_recheck(operator, execution)
|
|
157
|
+
rows = [
|
|
158
|
+
(TableIdentity(**execution["tables"][row["entity"]]), row["index"])
|
|
159
|
+
for row in execution["indexes"]
|
|
160
|
+
]
|
|
161
|
+
if execution["decision"] is None:
|
|
162
|
+
for table, index in rows:
|
|
163
|
+
operator._step(
|
|
164
|
+
state, "index_build_" + str(index["name"]), partial(builder.build, table, index)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def qualify() -> None:
|
|
168
|
+
# The whole next layout, read back: a build that left the right index beside a table
|
|
169
|
+
# that no longer matches would still be publishing a map the engine does not hold.
|
|
170
|
+
source = plan.prepared.groups[plan.group].source
|
|
171
|
+
keys = {entity: operator.model.entity(entity).key for entity in source.layout.tables}
|
|
172
|
+
refuse_findings(
|
|
173
|
+
operator.engines[execution["engine"]].validate_schema(source.layout, keys=keys),
|
|
174
|
+
MigrationRefused,
|
|
175
|
+
)
|
|
176
|
+
for table, index in rows:
|
|
177
|
+
if builder.status(table, index) != "ready":
|
|
178
|
+
raise MigrationRefused("an index is not ready for publication")
|
|
179
|
+
_recheck(operator, execution)
|
|
180
|
+
|
|
181
|
+
operator._step(state, "index_qualify", qualify)
|
|
182
|
+
execution["decision"] = "built"
|
|
183
|
+
operator.store.write(state)
|
|
184
|
+
operator._after_step("index_decision")
|
|
185
|
+
if execution["decision"] == "built":
|
|
186
|
+
|
|
187
|
+
def watermarks() -> None:
|
|
188
|
+
for engine in operator.engines.values():
|
|
189
|
+
observed = engine.map_watermark()
|
|
190
|
+
if observed is not None and observed > plan.prepared.map_version:
|
|
191
|
+
raise MigrationRefused("a newer map appeared while finishing the index build")
|
|
192
|
+
engine.record_map_version(
|
|
193
|
+
plan.prepared.map_version, model_version=operator.model.version
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
operator._step(state, "index_watermarks", watermarks)
|
|
197
|
+
|
|
198
|
+
def publish() -> None:
|
|
199
|
+
current = operator.active_map()
|
|
200
|
+
if current.fingerprint not in (plan.current.fingerprint, plan.prepared.fingerprint):
|
|
201
|
+
raise MigrationRefused("another active map replaced the index build instruction")
|
|
202
|
+
operator.store.publish(plan.prepared_payload())
|
|
203
|
+
|
|
204
|
+
operator._step(state, "index_publish", publish)
|
|
205
|
+
# Removals follow the decision and the publication: each is a step of its own, resumed
|
|
206
|
+
# after a crash, and a process still on the map in force only reports a removed index
|
|
207
|
+
# missing. Before this point nothing of the map in force was touched.
|
|
208
|
+
for row in execution.get("removed", ()):
|
|
209
|
+
table = TableIdentity(**execution["tables"][row["entity"]])
|
|
210
|
+
operator._step(
|
|
211
|
+
state,
|
|
212
|
+
"index_remove_" + str(row["name"]),
|
|
213
|
+
partial(builder.remove, table, row["index"]),
|
|
214
|
+
)
|
|
215
|
+
outcome, active = "built", plan.prepared
|
|
216
|
+
else:
|
|
217
|
+
for table, index in rows:
|
|
218
|
+
operator._step(
|
|
219
|
+
state, "index_drop_" + str(index["name"]), partial(builder.drop, table, index)
|
|
220
|
+
)
|
|
221
|
+
if operator.active_map().fingerprint != plan.current.fingerprint:
|
|
222
|
+
raise MigrationRefused("an abandoned index build found another active map")
|
|
223
|
+
outcome, active = "abandoned", plan.current
|
|
224
|
+
receipt: dict[str, Any] = {
|
|
225
|
+
"protocol": plan.protocol,
|
|
226
|
+
"index_id": plan.index_id,
|
|
227
|
+
"index_fingerprint": plan.fingerprint,
|
|
228
|
+
"project_id": operator.project_id,
|
|
229
|
+
"group": plan.group,
|
|
230
|
+
"outcome": outcome,
|
|
231
|
+
"map_version": active.map_version,
|
|
232
|
+
"map_fingerprint": active.fingerprint,
|
|
233
|
+
"indexes": [
|
|
234
|
+
{
|
|
235
|
+
"engine": execution["engine"],
|
|
236
|
+
"entity": row["entity"],
|
|
237
|
+
"name": row["name"],
|
|
238
|
+
"table": execution["tables"][row["entity"]],
|
|
239
|
+
}
|
|
240
|
+
for row in execution["indexes"]
|
|
241
|
+
],
|
|
242
|
+
"elapsed_ms": operator._elapsed(),
|
|
243
|
+
"recovered": recovered,
|
|
244
|
+
}
|
|
245
|
+
if plan.protocol == 2:
|
|
246
|
+
# Abandoned before its decision, a change removed nothing: the rows say what was removed.
|
|
247
|
+
receipt["removed"] = [
|
|
248
|
+
{
|
|
249
|
+
"engine": execution["engine"],
|
|
250
|
+
"entity": row["entity"],
|
|
251
|
+
"name": row["name"],
|
|
252
|
+
"table": execution["tables"][row["entity"]],
|
|
253
|
+
}
|
|
254
|
+
for row in execution.get("removed", ())
|
|
255
|
+
if outcome == "built"
|
|
256
|
+
]
|
|
257
|
+
state["indexes"][plan.index_id] = {
|
|
258
|
+
"plan_fingerprint": plan.fingerprint,
|
|
259
|
+
"plan": plan.as_record(),
|
|
260
|
+
"receipt": receipt,
|
|
261
|
+
}
|
|
262
|
+
if outcome == "built":
|
|
263
|
+
state["active_map"] = json.loads(plan.prepared_payload())
|
|
264
|
+
state["execution"] = None
|
|
265
|
+
operator.store.write(state)
|
|
266
|
+
operator._after_step("index_complete")
|
|
267
|
+
return IndexReceipt(receipt)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _completed(operator: LocalCutover, state: dict[str, Any], plan: IndexPlan) -> IndexReceipt:
|
|
271
|
+
complete = state.get("indexes", {}).get(plan.index_id)
|
|
272
|
+
assert complete is not None
|
|
273
|
+
if complete["plan_fingerprint"] != plan.fingerprint:
|
|
274
|
+
raise MigrationRefused("the completed index build id names another authorization")
|
|
275
|
+
operator.store.confirm()
|
|
276
|
+
return IndexReceipt(complete["receipt"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def execute_index(operator: LocalCutover, plan: IndexPlan) -> IndexReceipt:
|
|
280
|
+
with operator.store.lock():
|
|
281
|
+
state = operator.store.read()
|
|
282
|
+
if plan.index_id in state.get("indexes", {}):
|
|
283
|
+
return _completed(operator, state, plan)
|
|
284
|
+
if state["execution"] is not None:
|
|
285
|
+
raise CutoverRecoveryRequired("an unfinished local operation requires resume")
|
|
286
|
+
execution = _snapshot(operator, plan, state)
|
|
287
|
+
state.setdefault("stages", {})
|
|
288
|
+
state.setdefault("indexes", {})
|
|
289
|
+
state["execution"] = execution
|
|
290
|
+
operator.store.write(state)
|
|
291
|
+
operator._after_step("index_prepared")
|
|
292
|
+
operator._started, operator._enforce_budget = monotonic_ns(), False
|
|
293
|
+
try:
|
|
294
|
+
return _finish(operator, state, plan, recovered=False)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
raise CutoverRecoveryRequired(
|
|
297
|
+
"the local index build needs recovery; preserve its project state"
|
|
298
|
+
) from exc
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _stored_plan(operator: LocalCutover, execution: dict[str, Any]) -> IndexPlan:
|
|
302
|
+
plan = load_index_plan(
|
|
303
|
+
execution["plan"],
|
|
304
|
+
model=operator.model,
|
|
305
|
+
project_id=operator.project_id,
|
|
306
|
+
public_key=operator.keys,
|
|
307
|
+
)
|
|
308
|
+
if plan.fingerprint != execution["plan_fingerprint"]:
|
|
309
|
+
raise MigrationRefused("index build recovery names another signed authorization")
|
|
310
|
+
return plan
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def resume_index(operator: LocalCutover, state: dict[str, Any]) -> IndexReceipt:
|
|
314
|
+
plan = _stored_plan(operator, state["execution"])
|
|
315
|
+
operator._alarm.arm(plan.build_budget_ms)
|
|
316
|
+
operator._started, operator._enforce_budget = monotonic_ns(), False
|
|
317
|
+
try:
|
|
318
|
+
return _finish(operator, state, plan, recovered=True)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
raise CutoverRecoveryRequired(
|
|
321
|
+
"index build recovery remains incomplete; preserve its state"
|
|
322
|
+
) from exc
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def abandon_index(operator: LocalCutover) -> IndexReceipt:
|
|
326
|
+
with operator.store.lock():
|
|
327
|
+
state = operator.store.read()
|
|
328
|
+
execution = state["execution"]
|
|
329
|
+
if execution is None or execution.get("kind") != "index":
|
|
330
|
+
raise MigrationRefused("there is no unfinished index build to abandon")
|
|
331
|
+
if execution["decision"] == "built":
|
|
332
|
+
raise MigrationRefused(
|
|
333
|
+
"a built index cannot be abandoned: its next map is decided; resume to publish it"
|
|
334
|
+
)
|
|
335
|
+
plan = _stored_plan(operator, execution)
|
|
336
|
+
operator._alarm.arm(plan.build_budget_ms)
|
|
337
|
+
if execution["decision"] is None:
|
|
338
|
+
execution["decision"] = "abandoned"
|
|
339
|
+
operator.store.write(state)
|
|
340
|
+
operator._after_step("index_abandoned")
|
|
341
|
+
operator._started, operator._enforce_budget = monotonic_ns(), False
|
|
342
|
+
try:
|
|
343
|
+
return _finish(operator, state, plan, recovered=True)
|
|
344
|
+
except Exception as exc:
|
|
345
|
+
raise CutoverRecoveryRequired(
|
|
346
|
+
"abandoning the index build remains incomplete; preserve its state"
|
|
347
|
+
) from exc
|