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
@@ -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