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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Build, confirm and drop one index on a live table, through a dedicated operator connection.
|
|
2
|
+
|
|
3
|
+
Nothing here trusts ``IF NOT EXISTS``, because both engines were measured keeping the wrong object
|
|
4
|
+
under it (PostgreSQL 15.19, ClickHouse 24.8.14.39, 24 September 2026):
|
|
5
|
+
|
|
6
|
+
- a failed ``CREATE INDEX CONCURRENTLY`` leaves its index in the catalogue, neither valid nor ready,
|
|
7
|
+
and ``CREATE INDEX CONCURRENTLY IF NOT EXISTS`` over it succeeds with only a notice;
|
|
8
|
+
- ``ALTER TABLE ... ADD INDEX IF NOT EXISTS`` with another type keeps the index that is there.
|
|
9
|
+
|
|
10
|
+
So every step reads the catalogue first and acts on what it found: an index of this name on this
|
|
11
|
+
table and of this shape is ours; anything else under the name is somebody else's object. A build
|
|
12
|
+
refuses it and an abandonment leaves it alone - neither adopts it or removes it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from .._operator_deadline import DeadlineInterrupt
|
|
23
|
+
from ..errors import MigrationRefused
|
|
24
|
+
from ..physical import CLICKHOUSE_METHODS, POSTGRES_METHODS, index_method
|
|
25
|
+
from ..schema import _skip_index_type, clickhouse_index_clause, postgres_index_target
|
|
26
|
+
from ._operator import NativeOperator, TableIdentity
|
|
27
|
+
|
|
28
|
+
Status = Literal["absent", "unfinished", "ready", "foreign"]
|
|
29
|
+
|
|
30
|
+
_NAME = re.compile(r"sde_i_[0-9a-f]{32}_[0-9]{6}")
|
|
31
|
+
"""The only names an in-place build creates. Plain identifiers, so ClickHouse records a mutation
|
|
32
|
+
over one as ``MATERIALIZE INDEX <name>`` with the name unquoted - measured, and relied on to find a
|
|
33
|
+
materialization again instead of starting a second one."""
|
|
34
|
+
|
|
35
|
+
POLL_SECONDS = 0.2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NativeIndexBuild:
|
|
39
|
+
def __init__(self, native: NativeOperator) -> None:
|
|
40
|
+
self.native = native
|
|
41
|
+
self.dialect = native.dialect
|
|
42
|
+
self.quote = native.quote
|
|
43
|
+
|
|
44
|
+
# --- shared ------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _check(self, table: TableIdentity, index: Mapping[str, Any]) -> str:
|
|
47
|
+
name = str(index["name"])
|
|
48
|
+
if _NAME.fullmatch(name) is None:
|
|
49
|
+
raise MigrationRefused("an in-place build creates only its own bound index names")
|
|
50
|
+
allowed = POSTGRES_METHODS if self.dialect == "postgres" else CLICKHOUSE_METHODS
|
|
51
|
+
if index_method(index) not in allowed:
|
|
52
|
+
raise MigrationRefused(
|
|
53
|
+
f"{self.dialect} cannot build a {index_method(index)} index in place"
|
|
54
|
+
)
|
|
55
|
+
if self.native.identity(table.name).physical_key != table.physical_key:
|
|
56
|
+
raise MigrationRefused("the table an index build names was replaced")
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
def inspect(self, table: TableIdentity, index: Mapping[str, Any]) -> tuple[Status, str]:
|
|
60
|
+
"""Where the build of this index stands, and for a foreign object, whose it seems to be."""
|
|
61
|
+
name = self._check(table, index)
|
|
62
|
+
if self.dialect == "postgres":
|
|
63
|
+
return self._pg_status(table, index, name)
|
|
64
|
+
return self._ch_status(table, index, name)
|
|
65
|
+
|
|
66
|
+
def status(self, table: TableIdentity, index: Mapping[str, Any]) -> Status:
|
|
67
|
+
return self.inspect(table, index)[0]
|
|
68
|
+
|
|
69
|
+
def build(self, table: TableIdentity, index: Mapping[str, Any]) -> None:
|
|
70
|
+
"""Bring the index to ``ready`` without pausing the table's writers."""
|
|
71
|
+
status, reason = self.inspect(table, index)
|
|
72
|
+
if status == "foreign":
|
|
73
|
+
raise MigrationRefused(reason)
|
|
74
|
+
if self.dialect == "postgres":
|
|
75
|
+
self._pg_build(table, index, status)
|
|
76
|
+
else:
|
|
77
|
+
self._ch_build(table, index, status)
|
|
78
|
+
if self.status(table, index) != "ready":
|
|
79
|
+
raise MigrationRefused("the index build did not leave a ready index")
|
|
80
|
+
|
|
81
|
+
def declared(self, table: TableIdentity, index: Mapping[str, Any]) -> Status:
|
|
82
|
+
"""Where an index the map in force declares stands on its table, whatever its name.
|
|
83
|
+
|
|
84
|
+
The indexes an index change removes were named by a design or by an earlier build, so the
|
|
85
|
+
bound-name rule of :meth:`inspect` does not apply; the table and the shape still do.
|
|
86
|
+
"""
|
|
87
|
+
if self.native.identity(table.name).physical_key != table.physical_key:
|
|
88
|
+
raise MigrationRefused("the table an index change names was replaced")
|
|
89
|
+
name = str(index["name"])
|
|
90
|
+
if self.dialect == "postgres":
|
|
91
|
+
return self._pg_status(table, index, name)[0]
|
|
92
|
+
return self._ch_status(table, index, name)[0]
|
|
93
|
+
|
|
94
|
+
def remove(self, table: TableIdentity, index: Mapping[str, Any]) -> None:
|
|
95
|
+
"""Remove an index the map in force declares; run after the decision, so resumably.
|
|
96
|
+
|
|
97
|
+
An index of the declared shape goes, finished or not - a PostgreSQL drop that was stopped
|
|
98
|
+
leaves it invalid yet still maintained (measured), and dropping again removes it. Absent,
|
|
99
|
+
or another object under the name, means ours is already gone; the other object stays.
|
|
100
|
+
"""
|
|
101
|
+
status = self.declared(table, index)
|
|
102
|
+
if status in ("absent", "foreign"):
|
|
103
|
+
return
|
|
104
|
+
name = str(index["name"])
|
|
105
|
+
if self.dialect == "postgres":
|
|
106
|
+
# IF EXISTS only for an index that goes while this drop waits for its lock: an earlier
|
|
107
|
+
# drop whose client the budget closed runs on in the server until the transaction it
|
|
108
|
+
# waits for ends. What is left afterwards is read back below, as always.
|
|
109
|
+
self.native.command(f"DROP INDEX CONCURRENTLY IF EXISTS {self.quote(name)}")
|
|
110
|
+
else:
|
|
111
|
+
for mutation_id, is_done, _failure in self._ch_mutations(table, name):
|
|
112
|
+
if not is_done:
|
|
113
|
+
self.native.command(
|
|
114
|
+
"KILL MUTATION WHERE database = currentDatabase() "
|
|
115
|
+
f"AND table = '{self._literal(table.name)}' "
|
|
116
|
+
f"AND mutation_id = '{self._literal(mutation_id)}'"
|
|
117
|
+
)
|
|
118
|
+
self.native.command(
|
|
119
|
+
f"ALTER TABLE {self.quote(table.name)} DROP INDEX {self.quote(name)} "
|
|
120
|
+
"SETTINGS alter_sync = 0"
|
|
121
|
+
)
|
|
122
|
+
if self.declared(table, index) in ("unfinished", "ready"):
|
|
123
|
+
raise MigrationRefused("a removed index is still in the catalogue")
|
|
124
|
+
|
|
125
|
+
def drop(self, table: TableIdentity, index: Mapping[str, Any]) -> None:
|
|
126
|
+
"""Remove this build's own index, finished or not, and anything still materializing it.
|
|
127
|
+
|
|
128
|
+
A foreign object under the name means ours is not there: it is left as it is.
|
|
129
|
+
"""
|
|
130
|
+
status, _ = self.inspect(table, index) # checks the name and the table first
|
|
131
|
+
if status == "foreign":
|
|
132
|
+
return
|
|
133
|
+
name = str(index["name"])
|
|
134
|
+
if self.dialect == "postgres":
|
|
135
|
+
if status != "absent":
|
|
136
|
+
self.native.command(f"DROP INDEX CONCURRENTLY {self.quote(name)}")
|
|
137
|
+
else:
|
|
138
|
+
for mutation_id, is_done, _failure in self._ch_mutations(table, name):
|
|
139
|
+
if not is_done:
|
|
140
|
+
self.native.command(
|
|
141
|
+
"KILL MUTATION WHERE database = currentDatabase() "
|
|
142
|
+
f"AND table = '{self._literal(table.name)}' "
|
|
143
|
+
f"AND mutation_id = '{self._literal(mutation_id)}'"
|
|
144
|
+
)
|
|
145
|
+
if status != "absent":
|
|
146
|
+
# DROP INDEX is itself a mutation, and by default the ALTER waits for it on the
|
|
147
|
+
# merge pool - measured, it waits forever while merges are stopped, which is the
|
|
148
|
+
# very case an abandoned build is likely to be in. With alter_sync=0 the index
|
|
149
|
+
# leaves the catalogue at once and the pool removes its files later.
|
|
150
|
+
self.native.command(
|
|
151
|
+
f"ALTER TABLE {self.quote(table.name)} DROP INDEX {self.quote(name)} "
|
|
152
|
+
"SETTINGS alter_sync = 0"
|
|
153
|
+
)
|
|
154
|
+
if self.status(table, index) in ("unfinished", "ready"):
|
|
155
|
+
raise MigrationRefused("an abandoned index is still in the catalogue")
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _literal(value: str) -> str:
|
|
159
|
+
if re.fullmatch(r"[A-Za-z0-9_.\-]+", value) is None:
|
|
160
|
+
raise MigrationRefused("an index build refuses an identifier it cannot quote as data")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
# --- PostgreSQL --------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _pg_status(
|
|
166
|
+
self, table: TableIdentity, index: Mapping[str, Any], name: str
|
|
167
|
+
) -> tuple[Status, str]:
|
|
168
|
+
rows = self.native.rows(
|
|
169
|
+
"SELECT i.indrelid::text,i.indisunique,i.indisvalid AND i.indisready,"
|
|
170
|
+
"i.indpred IS NULL AND i.indexprs IS NULL AND i.indnkeyatts=i.indnatts,a.amname,"
|
|
171
|
+
"ARRAY(SELECT p.attname FROM unnest(i.indkey) WITH ORDINALITY k(num,pos) "
|
|
172
|
+
"JOIN pg_attribute p ON p.attrelid=i.indrelid AND p.attnum=k.num ORDER BY k.pos),"
|
|
173
|
+
"i.indoption::smallint[] FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid "
|
|
174
|
+
"JOIN pg_am a ON a.oid=c.relam WHERE c.oid=to_regclass(%s)",
|
|
175
|
+
[self.quote(name)],
|
|
176
|
+
)
|
|
177
|
+
if not rows:
|
|
178
|
+
# Index names share PostgreSQL's relation namespace: a table or view of this name
|
|
179
|
+
# would make CREATE INDEX fail, and it is not ours to remove.
|
|
180
|
+
if self.native.rows("SELECT to_regclass(%s)", [self.quote(name)])[0][0] is not None:
|
|
181
|
+
return "foreign", "another relation holds this index build's name"
|
|
182
|
+
return "absent", ""
|
|
183
|
+
target, unique, usable, simple, method, columns, options = rows[0]
|
|
184
|
+
if (
|
|
185
|
+
str(target) != table.object
|
|
186
|
+
or unique
|
|
187
|
+
or not simple
|
|
188
|
+
or method != index_method(index)
|
|
189
|
+
or list(columns) != [str(column) for column in index["columns"]]
|
|
190
|
+
or any(options)
|
|
191
|
+
):
|
|
192
|
+
return (
|
|
193
|
+
"foreign",
|
|
194
|
+
"an index of this build's name exists with another table or shape; it is not ours",
|
|
195
|
+
)
|
|
196
|
+
return ("ready" if usable else "unfinished"), ""
|
|
197
|
+
|
|
198
|
+
def _pg_build(self, table: TableIdentity, index: Mapping[str, Any], status: Status) -> None:
|
|
199
|
+
if status == "ready":
|
|
200
|
+
return
|
|
201
|
+
if status == "unfinished":
|
|
202
|
+
# Our own leftover of an interrupted build: never valid again by itself, and kept by
|
|
203
|
+
# IF NOT EXISTS. Removed without blocking writers, then built again.
|
|
204
|
+
self.native.command(f"DROP INDEX CONCURRENTLY {self.quote(str(index['name']))}")
|
|
205
|
+
self.native.command(f"CREATE INDEX CONCURRENTLY {postgres_index_target(index, table.name)}")
|
|
206
|
+
|
|
207
|
+
# --- ClickHouse --------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _ch_index(self, table: TableIdentity, name: str) -> tuple[str, str, int] | None:
|
|
210
|
+
rows = self.native.rows(
|
|
211
|
+
"SELECT type_full, expr, granularity FROM system.data_skipping_indices "
|
|
212
|
+
"WHERE database = currentDatabase() AND table = {table:String} "
|
|
213
|
+
"AND name = {name:String}",
|
|
214
|
+
{"table": table.name, "name": name},
|
|
215
|
+
)
|
|
216
|
+
if not rows:
|
|
217
|
+
return None
|
|
218
|
+
kind, expr, granularity = rows[0]
|
|
219
|
+
return str(kind), str(expr), int(granularity)
|
|
220
|
+
|
|
221
|
+
def _ch_mutations(self, table: TableIdentity, name: str) -> list[tuple[str, bool, str]]:
|
|
222
|
+
# KILL MUTATION removes the mutation from this table - measured - so what is listed here
|
|
223
|
+
# is either done or still to be done.
|
|
224
|
+
rows = self.native.rows(
|
|
225
|
+
"SELECT mutation_id, is_done, latest_fail_reason FROM system.mutations "
|
|
226
|
+
"WHERE database = currentDatabase() AND table = {table:String} "
|
|
227
|
+
"AND command = {command:String} AND NOT is_killed ORDER BY create_time",
|
|
228
|
+
{"table": table.name, "command": f"MATERIALIZE INDEX {name}"},
|
|
229
|
+
)
|
|
230
|
+
return [(str(mutation_id), bool(done), str(failure)) for mutation_id, done, failure in rows]
|
|
231
|
+
|
|
232
|
+
def _ch_status(
|
|
233
|
+
self, table: TableIdentity, index: Mapping[str, Any], name: str
|
|
234
|
+
) -> tuple[Status, str]:
|
|
235
|
+
from ..physical import parse_identifier_list
|
|
236
|
+
|
|
237
|
+
found = self._ch_index(table, name)
|
|
238
|
+
if found is None:
|
|
239
|
+
return "absent", ""
|
|
240
|
+
kind, expr, granularity = found
|
|
241
|
+
try:
|
|
242
|
+
columns = parse_identifier_list(expr)
|
|
243
|
+
except ValueError:
|
|
244
|
+
columns = ()
|
|
245
|
+
if (
|
|
246
|
+
kind != _skip_index_type(index)
|
|
247
|
+
or list(columns) != [str(column) for column in index["columns"]]
|
|
248
|
+
or granularity != index["granularity"]
|
|
249
|
+
):
|
|
250
|
+
return (
|
|
251
|
+
"foreign",
|
|
252
|
+
"a data-skipping index of this build's name has another shape; it is not ours",
|
|
253
|
+
)
|
|
254
|
+
# Parts written after ADD INDEX carry the index; parts before it only once the
|
|
255
|
+
# materialization is done. The catalogue lists the index from the ADD on, so it alone
|
|
256
|
+
# does not say the index covers the table. A finished mutation is the evidence - and when
|
|
257
|
+
# the server has forgotten it (it keeps the last finished_mutations_to_keep), the index
|
|
258
|
+
# reads as unfinished and is materialized once more: slower, never falsely ready.
|
|
259
|
+
done = any(is_done for _, is_done, _ in self._ch_mutations(table, name))
|
|
260
|
+
return ("ready" if done else "unfinished"), ""
|
|
261
|
+
|
|
262
|
+
def _ch_build(self, table: TableIdentity, index: Mapping[str, Any], status: Status) -> None:
|
|
263
|
+
name = str(index["name"])
|
|
264
|
+
if status == "ready":
|
|
265
|
+
return
|
|
266
|
+
if status == "absent":
|
|
267
|
+
self.native.command(
|
|
268
|
+
f"ALTER TABLE {self.quote(table.name)} ADD {clickhouse_index_clause(index)}"
|
|
269
|
+
)
|
|
270
|
+
added, reason = self._ch_status(table, index, name)
|
|
271
|
+
if added == "foreign":
|
|
272
|
+
raise MigrationRefused(reason)
|
|
273
|
+
if not self._ch_mutations(table, name):
|
|
274
|
+
# Asynchronous on purpose: mutations_sync would hold one HTTP request for the whole
|
|
275
|
+
# rewrite. Found again by its recorded command after a restart, not started twice.
|
|
276
|
+
self.native.command(
|
|
277
|
+
f"ALTER TABLE {self.quote(table.name)} MATERIALIZE INDEX {self.quote(name)}"
|
|
278
|
+
)
|
|
279
|
+
failure = ""
|
|
280
|
+
try:
|
|
281
|
+
while True:
|
|
282
|
+
mutations = self._ch_mutations(table, name)
|
|
283
|
+
if any(is_done for _, is_done, _ in mutations):
|
|
284
|
+
return
|
|
285
|
+
# The server retries a failed mutation by itself; the reason is kept for the one
|
|
286
|
+
# thing that ends this wait without success, the signed build budget.
|
|
287
|
+
failure = next((reason for _, _, reason in mutations if reason), failure)
|
|
288
|
+
time.sleep(POLL_SECONDS)
|
|
289
|
+
except DeadlineInterrupt as exc:
|
|
290
|
+
if not failure:
|
|
291
|
+
raise
|
|
292
|
+
raise DeadlineInterrupt(
|
|
293
|
+
f"the index materialization kept failing until the build budget: {failure}"
|
|
294
|
+
) from exc
|