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/engines/_operator.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Native operations for the local cutover executor. Credentials and all rows stay local."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..errors import EngineError, MigrationRefused
|
|
10
|
+
from ..schema import QUOTE
|
|
11
|
+
from ._storage import STORAGE_COLUMNS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TableIdentity:
|
|
16
|
+
dialect: str
|
|
17
|
+
server: str
|
|
18
|
+
database: str
|
|
19
|
+
object: str
|
|
20
|
+
namespace: str
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def physical_key(self) -> tuple[str, str, str, str]:
|
|
25
|
+
return self.dialect, self.server, self.database, self.object
|
|
26
|
+
|
|
27
|
+
def as_record(self) -> dict[str, str]:
|
|
28
|
+
return {
|
|
29
|
+
"dialect": self.dialect,
|
|
30
|
+
"server": self.server,
|
|
31
|
+
"database": self.database,
|
|
32
|
+
"object": self.object,
|
|
33
|
+
"namespace": self.namespace,
|
|
34
|
+
"name": self.name,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NativeOperator:
|
|
39
|
+
"""A dedicated stock adapter plus dedicated read-only probes for declared runtime logins."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, engine: Any, runtime: Sequence[Any]) -> None:
|
|
42
|
+
from .clickhouse import ClickHouseEngine
|
|
43
|
+
from .postgres import PostgresEngine
|
|
44
|
+
|
|
45
|
+
if not isinstance(engine, (PostgresEngine, ClickHouseEngine)) or not runtime:
|
|
46
|
+
raise MigrationRefused(
|
|
47
|
+
"local cutover requires stock operator adapters and runtime probes"
|
|
48
|
+
)
|
|
49
|
+
if any(
|
|
50
|
+
type(probe) is not type(engine) or probe is engine or probe._cx is engine._cx
|
|
51
|
+
for probe in runtime
|
|
52
|
+
):
|
|
53
|
+
raise MigrationRefused(
|
|
54
|
+
"runtime probes must be separate connections of the same dialect"
|
|
55
|
+
)
|
|
56
|
+
self.engine = engine
|
|
57
|
+
self.runtime = tuple(runtime)
|
|
58
|
+
self.dialect = engine.dialect
|
|
59
|
+
self.quote = QUOTE[self.dialect]
|
|
60
|
+
self.principals: tuple[str, ...] = ()
|
|
61
|
+
self.principal_ids: dict[str, str] = {}
|
|
62
|
+
if self.dialect == "postgres" and int(engine._cx.info.transaction_status) != 0:
|
|
63
|
+
raise MigrationRefused("operator connection must not have an open transaction")
|
|
64
|
+
if self.dialect == "clickhouse":
|
|
65
|
+
# Every acknowledged repair/metadata INSERT must have reached the engine.
|
|
66
|
+
engine._cx.set_client_setting("async_insert", 0)
|
|
67
|
+
engine._cx.set_client_setting("wait_for_async_insert", 1)
|
|
68
|
+
|
|
69
|
+
def rows(self, sql: str, params: Any = None, *, engine: Any = None) -> list[Any]:
|
|
70
|
+
adapter = self.engine if engine is None else engine
|
|
71
|
+
try:
|
|
72
|
+
if self.dialect == "postgres":
|
|
73
|
+
with adapter._cx.cursor() as cursor:
|
|
74
|
+
cursor.execute(sql, params)
|
|
75
|
+
return list(cursor.fetchall()) if cursor.description else []
|
|
76
|
+
return list(adapter._cx.query(sql, parameters=params).result_rows)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise EngineError(f"local operator metadata query failed: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
def command(self, sql: str) -> None:
|
|
81
|
+
if self.dialect == "postgres":
|
|
82
|
+
self.rows(sql)
|
|
83
|
+
else:
|
|
84
|
+
try:
|
|
85
|
+
self.engine._cx.command(sql)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise EngineError(
|
|
88
|
+
f"local operator command has an uncertain outcome: {exc}"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
def endpoint(self, engine: Any = None) -> tuple[str, str, str]:
|
|
92
|
+
if self.dialect == "postgres":
|
|
93
|
+
rows = self.rows(
|
|
94
|
+
"SELECT c.system_identifier::text, d.oid::text, current_database() "
|
|
95
|
+
"FROM pg_control_system() c CROSS JOIN pg_database d "
|
|
96
|
+
"WHERE d.datname=current_database()",
|
|
97
|
+
engine=engine,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
rows = self.rows(
|
|
101
|
+
"SELECT toString(serverUUID()), toString(uuid), name "
|
|
102
|
+
"FROM system.databases WHERE name=currentDatabase()",
|
|
103
|
+
engine=engine,
|
|
104
|
+
)
|
|
105
|
+
if len(rows) != 1 or any(not str(value) for value in rows[0]):
|
|
106
|
+
raise MigrationRefused("native endpoint identity is unavailable")
|
|
107
|
+
return tuple(str(value) for value in rows[0]) # type: ignore[return-value]
|
|
108
|
+
|
|
109
|
+
def identity(self, table: str, *, engine: Any = None) -> TableIdentity:
|
|
110
|
+
server, database, database_name = self.endpoint(engine)
|
|
111
|
+
if self.dialect == "postgres":
|
|
112
|
+
rows = self.rows(
|
|
113
|
+
"SELECT c.oid::text,n.nspname,c.relname,c.relkind,c.relpersistence,"
|
|
114
|
+
"c.relrowsecurity,c.relforcerowsecurity,"
|
|
115
|
+
"EXISTS(SELECT 1 FROM pg_inherits i WHERE i.inhrelid=c.oid OR i.inhparent=c.oid),"
|
|
116
|
+
"EXISTS(SELECT 1 FROM pg_trigger t WHERE t.tgrelid=c.oid AND NOT t.tgisinternal),"
|
|
117
|
+
"EXISTS(SELECT 1 FROM pg_rewrite r WHERE r.ev_class=c.oid) "
|
|
118
|
+
"FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace "
|
|
119
|
+
"WHERE c.oid=to_regclass(%s)",
|
|
120
|
+
[self.quote(table)],
|
|
121
|
+
engine=engine,
|
|
122
|
+
)
|
|
123
|
+
if len(rows) != 1:
|
|
124
|
+
raise MigrationRefused("cutover table is missing or invisible")
|
|
125
|
+
oid, namespace, actual, kind, persistence, *unsupported = rows[0]
|
|
126
|
+
if kind != "r" or persistence != "p" or any(unsupported):
|
|
127
|
+
raise MigrationRefused(
|
|
128
|
+
"cutover requires persistent ordinary tables "
|
|
129
|
+
"without RLS, triggers, rules or inheritance"
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
rows = self.rows(
|
|
133
|
+
"SELECT toString(uuid),database,name,engine,length(dependencies_table) "
|
|
134
|
+
"FROM system.tables WHERE database=currentDatabase() AND name={table:String}",
|
|
135
|
+
{"table": table},
|
|
136
|
+
engine=engine,
|
|
137
|
+
)
|
|
138
|
+
if len(rows) != 1:
|
|
139
|
+
raise MigrationRefused("cutover table is missing or invisible")
|
|
140
|
+
oid, namespace, actual, kind, dependencies = rows[0]
|
|
141
|
+
if kind not in ("MergeTree", "ReplacingMergeTree") or dependencies:
|
|
142
|
+
raise MigrationRefused(
|
|
143
|
+
"cutover requires local MergeTree tables without dependent views"
|
|
144
|
+
)
|
|
145
|
+
if self.rows(
|
|
146
|
+
"SELECT count() FROM system.row_policies WHERE database={database:String} "
|
|
147
|
+
"AND table={table:String}",
|
|
148
|
+
{"database": database_name, "table": table},
|
|
149
|
+
)[0][0]:
|
|
150
|
+
raise MigrationRefused("cutover does not transfer row policies")
|
|
151
|
+
return TableIdentity(self.dialect, server, database, str(oid), str(namespace), str(actual))
|
|
152
|
+
|
|
153
|
+
def qualify(
|
|
154
|
+
self, tables: Sequence[str], allowed_tables: Sequence[str], *, required_access: bool = True
|
|
155
|
+
) -> tuple[str, ...]:
|
|
156
|
+
"""Require direct restricted logins whose table grants can be removed independently."""
|
|
157
|
+
endpoint = self.endpoint()
|
|
158
|
+
principals: list[str] = []
|
|
159
|
+
for probe in self.runtime:
|
|
160
|
+
if self.endpoint(probe) != endpoint:
|
|
161
|
+
raise MigrationRefused(
|
|
162
|
+
"runtime and operator connections name different native databases"
|
|
163
|
+
)
|
|
164
|
+
if self.dialect == "postgres":
|
|
165
|
+
rows = self.rows(
|
|
166
|
+
"SELECT current_user,session_user,r.oid::text,r.rolsuper,r.rolcreaterole,"
|
|
167
|
+
"r.rolcreatedb,r.rolreplication,r.rolbypassrls,"
|
|
168
|
+
"EXISTS(SELECT 1 FROM pg_auth_members m WHERE m.member=r.oid) "
|
|
169
|
+
"FROM pg_roles r WHERE r.rolname=current_user",
|
|
170
|
+
engine=probe,
|
|
171
|
+
)
|
|
172
|
+
user, session_user, oid, *powers = rows[0]
|
|
173
|
+
if user != session_user or any(powers):
|
|
174
|
+
raise MigrationRefused(
|
|
175
|
+
"cutover runtime must be an unprivileged direct login "
|
|
176
|
+
"without role memberships"
|
|
177
|
+
)
|
|
178
|
+
for table in tables:
|
|
179
|
+
if (
|
|
180
|
+
self.identity(table, engine=probe).physical_key
|
|
181
|
+
!= self.identity(table).physical_key
|
|
182
|
+
):
|
|
183
|
+
raise MigrationRefused("runtime resolves a different physical table")
|
|
184
|
+
rights = self.rows(
|
|
185
|
+
"SELECT c.relowner=%s::oid,has_schema_privilege(%s,n.oid,'CREATE'),"
|
|
186
|
+
"has_table_privilege(%s,c.oid,'SELECT'),has_table_privilege(%s,c.oid,'INSERT'),"
|
|
187
|
+
"has_table_privilege(%s,c.oid,'UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') "
|
|
188
|
+
"FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace "
|
|
189
|
+
"WHERE c.oid=to_regclass(%s)",
|
|
190
|
+
[oid, user, user, user, user, self.quote(table)],
|
|
191
|
+
)[0]
|
|
192
|
+
if (
|
|
193
|
+
rights[0]
|
|
194
|
+
or rights[1]
|
|
195
|
+
or rights[4]
|
|
196
|
+
or (required_access and (not rights[2] or not rights[3]))
|
|
197
|
+
):
|
|
198
|
+
raise MigrationRefused(
|
|
199
|
+
"runtime needs only SELECT/INSERT table rights "
|
|
200
|
+
"and no ownership or schema CREATE"
|
|
201
|
+
)
|
|
202
|
+
shared = self.rows(
|
|
203
|
+
"SELECT count(*) FROM pg_class c, LATERAL aclexplode(c.relacl) a "
|
|
204
|
+
"WHERE c.oid=to_regclass(%s) AND (a.grantee=0 OR "
|
|
205
|
+
"(a.grantee=%s::oid AND a.is_grantable))",
|
|
206
|
+
[self.quote(table), oid],
|
|
207
|
+
)[0][0]
|
|
208
|
+
columns = self.rows(
|
|
209
|
+
"SELECT count(*) FROM pg_attribute c, LATERAL aclexplode(c.attacl) a "
|
|
210
|
+
"WHERE c.attrelid=to_regclass(%s) AND a.grantee IN (0,%s::oid)",
|
|
211
|
+
[self.quote(table), oid],
|
|
212
|
+
)[0][0]
|
|
213
|
+
if shared or columns:
|
|
214
|
+
raise MigrationRefused(
|
|
215
|
+
"PUBLIC, grant-option or column grants "
|
|
216
|
+
"prevent isolated runtime revocation"
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
user = str(self.rows("SELECT currentUser()", engine=probe)[0][0])
|
|
220
|
+
if self.rows(
|
|
221
|
+
"SELECT count() FROM system.role_grants WHERE user_name={user:String}",
|
|
222
|
+
{"user": user},
|
|
223
|
+
)[0][0]:
|
|
224
|
+
raise MigrationRefused(
|
|
225
|
+
"cutover runtime must use direct grants without role memberships"
|
|
226
|
+
)
|
|
227
|
+
rows = self.rows(
|
|
228
|
+
"SELECT storage,toString(id) FROM system.users WHERE name={user:String}",
|
|
229
|
+
{"user": user},
|
|
230
|
+
)
|
|
231
|
+
if len(rows) != 1 or rows[0][0] != "local_directory":
|
|
232
|
+
raise MigrationRefused("cutover runtime must be a local SQL-managed user")
|
|
233
|
+
oid = str(rows[0][1])
|
|
234
|
+
grants = self.rows(
|
|
235
|
+
"SELECT access_type,database,table,column,is_partial_revoke,grant_option "
|
|
236
|
+
"FROM system.grants WHERE user_name={user:String}",
|
|
237
|
+
{"user": user},
|
|
238
|
+
)
|
|
239
|
+
for access, database, table, column, partial, option in grants:
|
|
240
|
+
ordinary = (
|
|
241
|
+
database == endpoint[2]
|
|
242
|
+
and table in allowed_tables
|
|
243
|
+
and access in ("SELECT", "INSERT")
|
|
244
|
+
)
|
|
245
|
+
# Two whole system tables: settings, which the Python driver reads on connect,
|
|
246
|
+
# and data_skipping_indices, the optional grant docs/runtime-roles.md offers
|
|
247
|
+
# for verifying declared indexes - it shows a login its own tables' indexes
|
|
248
|
+
# only (measured on 24.8).
|
|
249
|
+
settings = (
|
|
250
|
+
database == "system"
|
|
251
|
+
and table in ("settings", "data_skipping_indices")
|
|
252
|
+
and access == "SELECT"
|
|
253
|
+
)
|
|
254
|
+
# The one column grant admitted: what a storage measurement reads of
|
|
255
|
+
# `system.parts`, which shows a login the parts of its own tables only
|
|
256
|
+
# (measured) and says nothing about a migrated table's access.
|
|
257
|
+
storage = (
|
|
258
|
+
database == "system"
|
|
259
|
+
and table == "parts"
|
|
260
|
+
and access == "SELECT"
|
|
261
|
+
and column in STORAGE_COLUMNS
|
|
262
|
+
)
|
|
263
|
+
whole = (ordinary or settings) and column is None
|
|
264
|
+
if not (whole or storage) or partial or option:
|
|
265
|
+
raise MigrationRefused(
|
|
266
|
+
"runtime grants must be direct SELECT/INSERT on declared tables, "
|
|
267
|
+
"plus the storage columns of system.parts"
|
|
268
|
+
)
|
|
269
|
+
for table in tables:
|
|
270
|
+
if required_access and (
|
|
271
|
+
self.identity(table, engine=probe).physical_key
|
|
272
|
+
!= self.identity(table).physical_key
|
|
273
|
+
):
|
|
274
|
+
raise MigrationRefused("runtime resolves a different physical table")
|
|
275
|
+
rights = {(str(row[0]), row[1], row[2]) for row in grants}
|
|
276
|
+
if required_access and any(
|
|
277
|
+
(kind, endpoint[2], table) not in rights for kind in ("SELECT", "INSERT")
|
|
278
|
+
):
|
|
279
|
+
raise MigrationRefused("runtime is missing direct SELECT/INSERT grants")
|
|
280
|
+
principals.append(str(user))
|
|
281
|
+
self.principal_ids[str(user)] = str(oid)
|
|
282
|
+
if len(set(principals)) != len(principals):
|
|
283
|
+
raise MigrationRefused("runtime probes repeat the same login")
|
|
284
|
+
self._qualify_readers(tables, principals)
|
|
285
|
+
self.principals = tuple(sorted(principals))
|
|
286
|
+
return self.principals
|
|
287
|
+
|
|
288
|
+
def _qualify_readers(self, tables: Sequence[str], principals: Sequence[str]) -> None:
|
|
289
|
+
"""A missing runtime principal would keep reading the retired authority after cutover."""
|
|
290
|
+
if self.dialect == "postgres":
|
|
291
|
+
operator = str(self.rows("SELECT current_user")[0][0])
|
|
292
|
+
allowed = {*principals, operator}
|
|
293
|
+
for table in tables:
|
|
294
|
+
grants = self.rows(
|
|
295
|
+
"SELECT pg_get_userbyid(a.grantee) FROM pg_class c, "
|
|
296
|
+
"LATERAL aclexplode(c.relacl) a WHERE c.oid=to_regclass(%s) "
|
|
297
|
+
"AND a.grantee <> c.relowner UNION "
|
|
298
|
+
"SELECT pg_get_userbyid(a.grantee) FROM pg_attribute p "
|
|
299
|
+
"JOIN pg_class c ON c.oid=p.attrelid, LATERAL aclexplode(p.attacl) a "
|
|
300
|
+
"WHERE c.oid=to_regclass(%s) AND a.grantee <> c.relowner",
|
|
301
|
+
[self.quote(table), self.quote(table)],
|
|
302
|
+
)
|
|
303
|
+
if any(str(row[0]) not in allowed for row in grants):
|
|
304
|
+
raise MigrationRefused("cutover table has an undeclared runtime grantee")
|
|
305
|
+
else:
|
|
306
|
+
operator = str(self.rows("SELECT currentUser()")[0][0])
|
|
307
|
+
database = self.endpoint()[2]
|
|
308
|
+
allowed = {*principals, operator}
|
|
309
|
+
for table in tables:
|
|
310
|
+
grants = self.rows(
|
|
311
|
+
"SELECT user_name,role_name FROM system.grants "
|
|
312
|
+
"WHERE (database IS NULL OR database='' OR database={database:String}) "
|
|
313
|
+
"AND (table IS NULL OR table='' OR table={table:String})",
|
|
314
|
+
{"database": database, "table": table},
|
|
315
|
+
)
|
|
316
|
+
if any(role is not None or user not in allowed for user, role in grants):
|
|
317
|
+
raise MigrationRefused("cutover table has an undeclared runtime grantee")
|
|
318
|
+
|
|
319
|
+
def access(self, tables: Sequence[TableIdentity], *, enabled: bool) -> None:
|
|
320
|
+
if not self.principals:
|
|
321
|
+
raise MigrationRefused("runtime principals were not qualified")
|
|
322
|
+
for table in tables:
|
|
323
|
+
if self.identity(table.name).physical_key != table.physical_key:
|
|
324
|
+
raise MigrationRefused("physical table identity changed before access transition")
|
|
325
|
+
qualified = self.quote(table.namespace) + "." + self.quote(table.name)
|
|
326
|
+
for name in self.principals:
|
|
327
|
+
action, direction = ("GRANT", "TO") if enabled else ("REVOKE", "FROM")
|
|
328
|
+
self.command(
|
|
329
|
+
f"{action} SELECT, INSERT ON {qualified} {direction} {self.quote(name)}"
|
|
330
|
+
)
|
|
331
|
+
for table in tables:
|
|
332
|
+
qualified = self.quote(table.namespace) + "." + self.quote(table.name)
|
|
333
|
+
for name in self.principals:
|
|
334
|
+
if self.dialect == "postgres":
|
|
335
|
+
rights = self.rows(
|
|
336
|
+
"SELECT has_table_privilege(%s,to_regclass(%s),'SELECT'),"
|
|
337
|
+
"has_table_privilege(%s,to_regclass(%s),'INSERT')",
|
|
338
|
+
[name, qualified, name, qualified],
|
|
339
|
+
)[0]
|
|
340
|
+
else:
|
|
341
|
+
memberships = self.rows(
|
|
342
|
+
"SELECT count() FROM system.role_grants WHERE user_name={user:String}",
|
|
343
|
+
{"user": name},
|
|
344
|
+
)[0][0]
|
|
345
|
+
if memberships:
|
|
346
|
+
raise MigrationRefused(
|
|
347
|
+
"runtime memberships changed during access transition"
|
|
348
|
+
)
|
|
349
|
+
grants = self.rows(
|
|
350
|
+
"SELECT access_type FROM system.grants WHERE user_name={user:String} "
|
|
351
|
+
"AND database={database:String} AND table={table:String} "
|
|
352
|
+
"AND column IS NULL AND is_partial_revoke=0",
|
|
353
|
+
{"user": name, "database": table.namespace, "table": table.name},
|
|
354
|
+
)
|
|
355
|
+
present = {str(row[0]) for row in grants}
|
|
356
|
+
rights = [kind in present for kind in ("SELECT", "INSERT")]
|
|
357
|
+
if list(rights) != [enabled, enabled]:
|
|
358
|
+
raise MigrationRefused(
|
|
359
|
+
"runtime SELECT/INSERT grants did not reach the expected state"
|
|
360
|
+
)
|
|
361
|
+
for probe in self.runtime:
|
|
362
|
+
for table in tables:
|
|
363
|
+
query = f"SELECT 1 FROM {self.quote(table.name)} LIMIT 0"
|
|
364
|
+
try:
|
|
365
|
+
if self.dialect == "postgres":
|
|
366
|
+
probe._cx.execute(query)
|
|
367
|
+
else:
|
|
368
|
+
probe._cx.query(query)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
denied = (
|
|
371
|
+
getattr(exc, "sqlstate", None) == "42501"
|
|
372
|
+
if self.dialect == "postgres"
|
|
373
|
+
else getattr(exc, "code", None) == 497
|
|
374
|
+
)
|
|
375
|
+
if enabled or not denied:
|
|
376
|
+
raise EngineError(
|
|
377
|
+
"runtime access probe did not establish the expected permissions"
|
|
378
|
+
) from exc
|
|
379
|
+
else:
|
|
380
|
+
if not enabled:
|
|
381
|
+
raise MigrationRefused(
|
|
382
|
+
"runtime still reads a table after its grants were revoked"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def truncate(self, tables: Sequence[TableIdentity]) -> None:
|
|
386
|
+
for table in tables:
|
|
387
|
+
if self.identity(table.name).physical_key != table.physical_key:
|
|
388
|
+
raise MigrationRefused("physical target identity changed before repair")
|
|
389
|
+
names = [self.quote(table.namespace) + "." + self.quote(table.name) for table in tables]
|
|
390
|
+
if self.dialect == "postgres":
|
|
391
|
+
self.command("TRUNCATE TABLE " + ", ".join(names))
|
|
392
|
+
else:
|
|
393
|
+
for name in names:
|
|
394
|
+
self.command("TRUNCATE TABLE " + name + " SYNC")
|
sde/engines/_staging.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Create and recognize stage-owned objects through dedicated native operator connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..errors import EngineError, MigrationRefused
|
|
11
|
+
from ..physical import POSTGRES_METHODS, index_method
|
|
12
|
+
from ..placement import PhysicalLayout
|
|
13
|
+
from ..schema import schema_statements
|
|
14
|
+
from ._operator import NativeOperator, TableIdentity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def creation_marker(project_id: str, stage_id: str, table: str) -> str:
|
|
18
|
+
suffix = hashlib.sha256(table.encode("utf-8")).hexdigest()[:16]
|
|
19
|
+
return f"sde.stage1.{project_id}.{stage_id}.{suffix}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NativeStaging:
|
|
23
|
+
def __init__(self, native: NativeOperator) -> None:
|
|
24
|
+
self.native = native
|
|
25
|
+
self.engine = native.engine
|
|
26
|
+
self.dialect = native.dialect
|
|
27
|
+
self.quote = native.quote
|
|
28
|
+
|
|
29
|
+
def marker(self, table: str) -> tuple[str, str | None] | None:
|
|
30
|
+
if self.dialect == "postgres":
|
|
31
|
+
rows = self.native.rows(
|
|
32
|
+
"SELECT c.oid::text,obj_description(c.oid,'pg_class') FROM pg_class c "
|
|
33
|
+
"WHERE c.oid=to_regclass(%s)",
|
|
34
|
+
[self.quote(table)],
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
rows = self.native.rows(
|
|
38
|
+
"SELECT toString(uuid),comment FROM system.tables "
|
|
39
|
+
"WHERE database=currentDatabase() AND name={table:String}",
|
|
40
|
+
{"table": table},
|
|
41
|
+
)
|
|
42
|
+
if not rows:
|
|
43
|
+
return None
|
|
44
|
+
if len(rows) != 1:
|
|
45
|
+
raise MigrationRefused("staging table name has an ambiguous native identity")
|
|
46
|
+
return str(rows[0][0]), None if rows[0][1] is None else str(rows[0][1])
|
|
47
|
+
|
|
48
|
+
def preflight(self, layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]) -> None:
|
|
49
|
+
schema_statements(layout, keys=keys, dialect=self.dialect)
|
|
50
|
+
for table in layout.tables.values():
|
|
51
|
+
if self.marker(table) is not None:
|
|
52
|
+
raise MigrationRefused("a fresh staging name already exists in the target")
|
|
53
|
+
if self.dialect == "postgres":
|
|
54
|
+
identifiers = [
|
|
55
|
+
*layout.tables.values(),
|
|
56
|
+
*(name for columns in layout.columns.values() for name in columns),
|
|
57
|
+
*(str(index["name"]) for index in layout.indexes),
|
|
58
|
+
]
|
|
59
|
+
if any(len(name.encode("utf-8")) > 63 for name in identifiers):
|
|
60
|
+
raise MigrationRefused("staging identifiers cannot be truncated by PostgreSQL")
|
|
61
|
+
for index in layout.indexes:
|
|
62
|
+
found = self.native.rows("SELECT to_regclass(%s)", [self.quote(str(index["name"]))])
|
|
63
|
+
if found[0][0] is not None:
|
|
64
|
+
raise MigrationRefused(
|
|
65
|
+
"a staging index name already exists in the target namespace"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def create_table(
|
|
69
|
+
self, *, entity: str, layout: PhysicalLayout, keys: Mapping[str, Sequence[str]], marker: str
|
|
70
|
+
) -> TableIdentity:
|
|
71
|
+
if re.fullmatch(r"sde\.stage1\.[0-9a-f]{32}\.[0-9a-f]{32}\.[0-9a-f]{16}", marker) is None:
|
|
72
|
+
raise MigrationRefused("staging creation marker is not valid protocol metadata")
|
|
73
|
+
table = layout.tables[entity]
|
|
74
|
+
present = self.marker(table)
|
|
75
|
+
if present is not None:
|
|
76
|
+
if present[1] != marker:
|
|
77
|
+
raise MigrationRefused("staging refuses a table without its exact creation marker")
|
|
78
|
+
return self.native.identity(table)
|
|
79
|
+
# The whole physical design of this one table: key order, partition and - in ClickHouse,
|
|
80
|
+
# where `CREATE TABLE` is the only moment an index can be declared without a mutation over
|
|
81
|
+
# every part - its data-skipping indexes. An earlier version passed the partition alone,
|
|
82
|
+
# so a staged copy would have silently lost the key order and the indexes its signed map
|
|
83
|
+
# authorized. PostgreSQL indexes follow separately in `create_indexes`.
|
|
84
|
+
single = PhysicalLayout(
|
|
85
|
+
tables={entity: table},
|
|
86
|
+
columns={entity: layout.columns[entity]},
|
|
87
|
+
partition_by={entity: layout.partition_by[entity]}
|
|
88
|
+
if entity in layout.partition_by
|
|
89
|
+
else {},
|
|
90
|
+
key_order={entity: layout.key_order[entity]} if entity in layout.key_order else {},
|
|
91
|
+
indexes=tuple(
|
|
92
|
+
index
|
|
93
|
+
for index in layout.indexes
|
|
94
|
+
if self.dialect == "clickhouse" and str(index["entity"]) == entity
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
statements = schema_statements(single, keys=keys, dialect=self.dialect)
|
|
98
|
+
if len(statements) != 1 or not statements[0].startswith("CREATE TABLE IF NOT EXISTS "):
|
|
99
|
+
raise MigrationRefused("staging requires the SDK's ordinary table creation statement")
|
|
100
|
+
statement = statements[0].replace("CREATE TABLE IF NOT EXISTS ", "CREATE TABLE ", 1)
|
|
101
|
+
try:
|
|
102
|
+
if self.dialect == "postgres":
|
|
103
|
+
from psycopg import sql
|
|
104
|
+
|
|
105
|
+
with self.engine._cx.transaction():
|
|
106
|
+
self.engine._cx.execute(statement)
|
|
107
|
+
self.engine._cx.execute(
|
|
108
|
+
sql.SQL("COMMENT ON TABLE {} IS {}").format(
|
|
109
|
+
sql.Identifier(table), sql.Literal(marker)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# The marker is fixed ASCII protocol metadata, never a credential or a row value.
|
|
114
|
+
self.engine._cx.command(statement + " COMMENT '" + marker + "'")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
raise EngineError(
|
|
117
|
+
"staging table creation has an uncertain outcome; resume its intent"
|
|
118
|
+
) from exc
|
|
119
|
+
found = self.marker(table)
|
|
120
|
+
if found is None or found[1] != marker:
|
|
121
|
+
raise MigrationRefused("staging creation marker was not established with the table")
|
|
122
|
+
return self.native.identity(table)
|
|
123
|
+
|
|
124
|
+
def owned(
|
|
125
|
+
self, table: str, marker: str, identity: TableIdentity | None
|
|
126
|
+
) -> TableIdentity | None:
|
|
127
|
+
"""This staging's own table under ``table``, or ``None`` when it is absent or not ours.
|
|
128
|
+
|
|
129
|
+
Ours means the exact creation marker and, once the identity was recorded, that native
|
|
130
|
+
object. Anything else under the name - another marker, none, another object - belongs to
|
|
131
|
+
somebody else and is left alone by an abandonment.
|
|
132
|
+
"""
|
|
133
|
+
present = self.marker(table)
|
|
134
|
+
if present is None or present[1] != marker:
|
|
135
|
+
return None
|
|
136
|
+
if identity is not None:
|
|
137
|
+
return identity if present[0] == identity.object else None
|
|
138
|
+
return self.native.identity(table)
|
|
139
|
+
|
|
140
|
+
def drop_owned(self, table: TableIdentity, marker: str) -> None:
|
|
141
|
+
"""Drop this staging's own table; its indexes and generation constraints go with it."""
|
|
142
|
+
present = self.marker(table.name)
|
|
143
|
+
if present is None:
|
|
144
|
+
return # dropped before a crash; nothing is left to remove
|
|
145
|
+
if present[1] != marker or present[0] != table.object:
|
|
146
|
+
raise MigrationRefused("the table to abandon is no longer this staging's own")
|
|
147
|
+
# SYNC: in an Atomic database a dropped table otherwise lingers until the server removes
|
|
148
|
+
# it, and its name and data with it.
|
|
149
|
+
suffix = " SYNC" if self.dialect == "clickhouse" else ""
|
|
150
|
+
self.native.command(f"DROP TABLE {self.quote(table.name)}{suffix}")
|
|
151
|
+
if self.marker(table.name) is not None:
|
|
152
|
+
raise MigrationRefused("an abandoned staging table is still in the catalogue")
|
|
153
|
+
|
|
154
|
+
def revoke_runtime(self, tables: Sequence[TableIdentity], principals: Sequence[str]) -> None:
|
|
155
|
+
"""Take back the runtime grants on dropped tables; ClickHouse keeps them after DROP TABLE.
|
|
156
|
+
|
|
157
|
+
Measured on 24.8.14.39: ``system.grants`` still lists a table's grants once the table is
|
|
158
|
+
gone, and ``REVOKE`` on the dropped table is accepted and removes them. PostgreSQL removes a
|
|
159
|
+
table's privileges with the table, so there is nothing to take back there.
|
|
160
|
+
"""
|
|
161
|
+
if self.dialect != "clickhouse":
|
|
162
|
+
return
|
|
163
|
+
for table in tables:
|
|
164
|
+
qualified = self.quote(table.namespace) + "." + self.quote(table.name)
|
|
165
|
+
for name in principals:
|
|
166
|
+
self.native.command(f"REVOKE SELECT, INSERT ON {qualified} FROM {self.quote(name)}")
|
|
167
|
+
for table in tables:
|
|
168
|
+
for name in principals:
|
|
169
|
+
rows = self.native.rows(
|
|
170
|
+
"SELECT count() FROM system.grants WHERE user_name={user:String} "
|
|
171
|
+
"AND database={database:String} AND table={table:String}",
|
|
172
|
+
{"user": name, "database": table.namespace, "table": table.name},
|
|
173
|
+
)
|
|
174
|
+
if rows[0][0]:
|
|
175
|
+
raise MigrationRefused("a runtime grant on an abandoned staging table remains")
|
|
176
|
+
|
|
177
|
+
def create_indexes(self, layout: PhysicalLayout) -> None:
|
|
178
|
+
if self.dialect != "postgres":
|
|
179
|
+
# ClickHouse data-skipping indexes were declared inside `CREATE TABLE`; qualification
|
|
180
|
+
# reads them back from `system.data_skipping_indices` before the stage is accepted.
|
|
181
|
+
return
|
|
182
|
+
for index in layout.indexes:
|
|
183
|
+
name, entity = str(index["name"]), str(index["entity"])
|
|
184
|
+
declared_method = index_method(index)
|
|
185
|
+
if declared_method not in POSTGRES_METHODS:
|
|
186
|
+
raise MigrationRefused(f"PostgreSQL cannot create a {declared_method} index")
|
|
187
|
+
table = self.native.identity(layout.tables[entity])
|
|
188
|
+
columns = [str(column) for column in index["columns"]]
|
|
189
|
+
rows = self._index(name)
|
|
190
|
+
if not rows:
|
|
191
|
+
column_sql = ", ".join(self.quote(column) for column in columns)
|
|
192
|
+
using = "" if declared_method == "btree" else f"USING {declared_method} "
|
|
193
|
+
self.native.command(
|
|
194
|
+
f"CREATE INDEX {self.quote(name)} ON {self.quote(table.name)} "
|
|
195
|
+
f"{using}({column_sql})"
|
|
196
|
+
)
|
|
197
|
+
rows = self._index(name)
|
|
198
|
+
if len(rows) != 1:
|
|
199
|
+
raise MigrationRefused("staging index identity could not be established")
|
|
200
|
+
target, unique, valid, ready, simple, method, actual, options = rows[0]
|
|
201
|
+
if (
|
|
202
|
+
str(target) != table.object
|
|
203
|
+
or unique
|
|
204
|
+
or not valid
|
|
205
|
+
or not ready
|
|
206
|
+
or not simple
|
|
207
|
+
or method != declared_method
|
|
208
|
+
or list(actual) != columns
|
|
209
|
+
or any(options)
|
|
210
|
+
):
|
|
211
|
+
raise MigrationRefused("staging index does not match its owned table and columns")
|
|
212
|
+
|
|
213
|
+
def _index(self, name: str) -> list[Any]:
|
|
214
|
+
return self.native.rows(
|
|
215
|
+
"SELECT i.indrelid::text,i.indisunique,i.indisvalid,i.indisready,"
|
|
216
|
+
"i.indpred IS NULL AND i.indexprs IS NULL AND i.indnkeyatts=i.indnatts,a.amname,"
|
|
217
|
+
"ARRAY(SELECT p.attname FROM unnest(i.indkey) WITH ORDINALITY k(num,pos) "
|
|
218
|
+
"JOIN pg_attribute p ON p.attrelid=i.indrelid AND p.attnum=k.num ORDER BY k.pos),"
|
|
219
|
+
"i.indoption::smallint[] FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid "
|
|
220
|
+
"JOIN pg_am a ON a.oid=c.relam WHERE c.oid=to_regclass(%s)",
|
|
221
|
+
[self.quote(name)],
|
|
222
|
+
)
|
sde/engines/_storage.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The ``system.parts`` columns a ClickHouse storage measurement reads - in a module of its own.
|
|
2
|
+
|
|
3
|
+
Shared by the adapter that reads them, the operator's runtime qualification that admits exactly
|
|
4
|
+
this grant, and the starter that gives it; kept apart from the adapter so that importing the
|
|
5
|
+
qualification does not import a driver.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
STORAGE_COLUMNS = (
|
|
11
|
+
"database",
|
|
12
|
+
"table",
|
|
13
|
+
"active",
|
|
14
|
+
"bytes_on_disk",
|
|
15
|
+
"secondary_indices_compressed_bytes",
|
|
16
|
+
"secondary_indices_marks_bytes",
|
|
17
|
+
)
|
|
18
|
+
"""The columns of ``system.parts`` a storage measurement reads, and all a runtime login is granted.
|
|
19
|
+
|
|
20
|
+
ClickHouse 24.8 refuses ``system.parts`` to a login with only table grants; a column grant on these
|
|
21
|
+
names lets it read the parts of the tables it may use and no others (measured). The operator's
|
|
22
|
+
runtime qualification admits exactly this grant, and the starter gives it."""
|