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.
Files changed (73) hide show
  1. sde/__init__.py +318 -0
  2. sde/_cutover_project.py +179 -0
  3. sde/_local_state.py +188 -0
  4. sde/_operator_deadline.py +50 -0
  5. sde/_usage.py +314 -0
  6. sde/bulk.py +79 -0
  7. sde/canonical.py +141 -0
  8. sde/capabilities.py +62 -0
  9. sde/cutover.py +286 -0
  10. sde/engines/__init__.py +0 -0
  11. sde/engines/_clickhouse_connection.py +224 -0
  12. sde/engines/_index_build.py +294 -0
  13. sde/engines/_operator.py +394 -0
  14. sde/engines/_staging.py +222 -0
  15. sde/engines/_storage.py +22 -0
  16. sde/engines/_write_fences.py +271 -0
  17. sde/engines/clickhouse.py +1115 -0
  18. sde/engines/orderbook.py +457 -0
  19. sde/engines/postgres.py +967 -0
  20. sde/entity.py +170 -0
  21. sde/errors.py +103 -0
  22. sde/explain.py +300 -0
  23. sde/frozen_verification.py +152 -0
  24. sde/generation.py +131 -0
  25. sde/groups.py +97 -0
  26. sde/hashing.py +242 -0
  27. sde/index_build.py +313 -0
  28. sde/index_operator.py +347 -0
  29. sde/infer.py +461 -0
  30. sde/inspection.py +62 -0
  31. sde/internal.py +90 -0
  32. sde/layout.py +669 -0
  33. sde/local_cutover.py +801 -0
  34. sde/logging.py +143 -0
  35. sde/migration.py +856 -0
  36. sde/model.py +482 -0
  37. sde/physical.py +531 -0
  38. sde/placement.py +1010 -0
  39. sde/provisioning.py +63 -0
  40. sde/py.typed +0 -0
  41. sde/query.py +521 -0
  42. sde/routing.py +85 -0
  43. sde/schema.py +466 -0
  44. sde/session.py +993 -0
  45. sde/shapes.py +153 -0
  46. sde/staging.py +264 -0
  47. sde/staging_operator.py +393 -0
  48. sde/telemetry.py +1087 -0
  49. sde/testing/__init__.py +14 -0
  50. sde/testing/loader.py +175 -0
  51. sde/testing/memory.py +331 -0
  52. sde/types.py +228 -0
  53. sde/verification.py +220 -0
  54. sde/watermark.py +222 -0
  55. sde/write_fence.py +283 -0
  56. sde_demo/__init__.py +1 -0
  57. sde_demo/__main__.py +183 -0
  58. sde_demo/diagnostics.py +92 -0
  59. sde_demo/model.py +75 -0
  60. sde_demo/project.py +312 -0
  61. sde_demo/py.typed +0 -0
  62. sde_demo/query_count.py +301 -0
  63. sde_demo/resources.py +969 -0
  64. sde_demo/runtime.py +419 -0
  65. sde_demo/verification.py +242 -0
  66. sde_operator/__init__.py +1 -0
  67. sde_operator/__main__.py +210 -0
  68. smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
  69. smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
  70. smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
  71. smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  72. smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  73. 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