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/postgres.py
ADDED
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
"""PostgreSQL adapter.
|
|
2
|
+
|
|
3
|
+
Two rules run through everything here, and both come straight from the requirements rather than
|
|
4
|
+
from taste.
|
|
5
|
+
|
|
6
|
+
**A failed write is reported, never worked around.** No retry into another engine, no swallowing,
|
|
7
|
+
no "eventually consistent" story invented on the spot. If the source engine for a group will not
|
|
8
|
+
take the write, the client's code finds out (:class:`~sde.errors.EngineError`). The library
|
|
9
|
+
swallows its *own* internal problems - routing, telemetry - because a bug of ours must not take
|
|
10
|
+
down someone's application, but a write that did not happen is not our internal problem and
|
|
11
|
+
reporting success for it would be the single worst thing this library could do.
|
|
12
|
+
|
|
13
|
+
**Identifiers are quoted, always.** Not for injection - identifiers come from the placement map,
|
|
14
|
+
not from user input - but because entity names may contain non-ASCII characters, and an unquoted
|
|
15
|
+
identifier in PostgreSQL is folded to lower case in a way that is lossy for some of them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import replace
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .._usage import UsageGate, guarded
|
|
26
|
+
from ..bulk import batch_columns
|
|
27
|
+
from ..errors import EngineError
|
|
28
|
+
from ..explain import (
|
|
29
|
+
Cost,
|
|
30
|
+
QueryPlan,
|
|
31
|
+
QueryPlanRefused,
|
|
32
|
+
postgres_findings,
|
|
33
|
+
)
|
|
34
|
+
from ..logging import log
|
|
35
|
+
from ..migration import key_columns, same_width
|
|
36
|
+
from ..physical import PhysicalFinding, declared_tables
|
|
37
|
+
from ..placement import BACKFILL_TABLE, WATERMARK_TABLE, PhysicalLayout
|
|
38
|
+
from ..query import ReadColumn, ReadPlan, read_row, read_sql, summary_sql
|
|
39
|
+
from ..schema import QUOTE, schema_statements
|
|
40
|
+
from ..write_fence import WriteFence
|
|
41
|
+
from ._write_fences import PostgresFences
|
|
42
|
+
|
|
43
|
+
__all__ = ["PostgresEngine"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Bound from the one definition in sde.schema, so that DDL and DML cannot disagree about
|
|
47
|
+
# how an identifier is escaped.
|
|
48
|
+
_quote = QUOTE["postgres"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Seconds. Not a guess about networks: this bounds *opening* a connection, which either completes
|
|
52
|
+
# in milliseconds on a healthy link or is not going to complete. Ten seconds leaves room for a
|
|
53
|
+
# saturated cross-region hop and still fails long before a request timeout that a caller sets. It
|
|
54
|
+
# is a default rather than a rule - a `connect_timeout` in the DSN wins - and it exists because the
|
|
55
|
+
# alternative, measured, is a call that never returns.
|
|
56
|
+
CONNECT_TIMEOUT_SECONDS = 10
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PostgresEngine:
|
|
60
|
+
"""A thin adapter over psycopg. Deliberately thin: it executes decisions, it makes none."""
|
|
61
|
+
|
|
62
|
+
dialect = "postgres"
|
|
63
|
+
|
|
64
|
+
def __init__(self, dsn: str) -> None:
|
|
65
|
+
try:
|
|
66
|
+
import psycopg
|
|
67
|
+
except ImportError as exc: # pragma: no cover - depends on the install extra
|
|
68
|
+
raise EngineError(
|
|
69
|
+
"the PostgreSQL adapter needs the 'postgres' extra: "
|
|
70
|
+
"pip install 'smart-data-engine-sdk[postgres]'. "
|
|
71
|
+
"The core library has no dependencies, because it goes into your application and "
|
|
72
|
+
"every dependency here would be one you inherit."
|
|
73
|
+
) from exc
|
|
74
|
+
self._psycopg = psycopg
|
|
75
|
+
self._dsn = dsn
|
|
76
|
+
self._conn: Any = None
|
|
77
|
+
self._usage = UsageGate()
|
|
78
|
+
self._unusable = False
|
|
79
|
+
|
|
80
|
+
# --- connection ------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@guarded
|
|
83
|
+
def connect(self) -> None:
|
|
84
|
+
"""Open the connection, with a bound on how long that may take.
|
|
85
|
+
|
|
86
|
+
Measured before this bound existed: a host that accepts the TCP connection and never
|
|
87
|
+
answers hung the call **for as long as the test was willing to wait**, because libpq has no
|
|
88
|
+
default ``connect_timeout`` and neither did we. That is not an exotic case - it is a
|
|
89
|
+
firewall that accepts, a load balancer with no healthy backend, a server mid-restart - and
|
|
90
|
+
without a bound it happens inside the caller's request path with nothing to time out.
|
|
91
|
+
|
|
92
|
+
The default is only applied when the caller has not chosen one. A ``connect_timeout`` in
|
|
93
|
+
the DSN is their decision about their own network and this must not override it.
|
|
94
|
+
"""
|
|
95
|
+
if self._conn is not None and self._unusable:
|
|
96
|
+
raise EngineError(
|
|
97
|
+
"transaction completion was uncertain; close() then connect() before reuse"
|
|
98
|
+
)
|
|
99
|
+
if self._conn is None:
|
|
100
|
+
options: dict[str, Any] = {}
|
|
101
|
+
if "connect_timeout" not in self._dsn:
|
|
102
|
+
options["connect_timeout"] = CONNECT_TIMEOUT_SECONDS
|
|
103
|
+
try:
|
|
104
|
+
self._conn = self._psycopg.connect(self._dsn, autocommit=True, **options)
|
|
105
|
+
self._unusable = False
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise EngineError(f"could not connect to PostgreSQL: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
@guarded
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
if self._conn is not None:
|
|
112
|
+
self._conn.close()
|
|
113
|
+
self._conn = None
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> PostgresEngine:
|
|
116
|
+
self.connect()
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *_: object) -> None:
|
|
120
|
+
self.close()
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def _cx(self) -> Any:
|
|
124
|
+
if self._unusable:
|
|
125
|
+
raise EngineError(
|
|
126
|
+
"transaction completion was uncertain; close() then connect() before reuse"
|
|
127
|
+
)
|
|
128
|
+
if self._conn is None:
|
|
129
|
+
raise EngineError("not connected; call connect() first")
|
|
130
|
+
return self._conn
|
|
131
|
+
|
|
132
|
+
def _explain(self, exc: Exception) -> str:
|
|
133
|
+
"""The driver's message, plus the one sentence it cannot know to add.
|
|
134
|
+
|
|
135
|
+
Measured: cut the connection under a live session and the first failing call reports what
|
|
136
|
+
the server said ("terminating connection due to administrator command"), which is right.
|
|
137
|
+
**Every call after it reports "the connection is closed"** - true, unhelpful, and the point
|
|
138
|
+
at which a reader needs to be told that this library holds the connection it was handed and
|
|
139
|
+
does not reopen it. Reconnecting is one line and it is the caller's, because a library that
|
|
140
|
+
silently reconnected would also silently retry, and requirement 6.4 allows a retry only for
|
|
141
|
+
an operation known to be idempotent.
|
|
142
|
+
"""
|
|
143
|
+
message = str(exc)
|
|
144
|
+
if self._conn is not None and getattr(self._conn, "closed", False):
|
|
145
|
+
message += (
|
|
146
|
+
". The connection is gone and this library does not reopen one it was handed: "
|
|
147
|
+
"call close() then connect() on the engine, or hand the session a new one. "
|
|
148
|
+
"Nothing was retried, so no write reached the engine twice."
|
|
149
|
+
)
|
|
150
|
+
return message
|
|
151
|
+
|
|
152
|
+
def write_fence(self, table: str, *, project_id: str) -> WriteFence:
|
|
153
|
+
"""DDL capability; use a dedicated connection separate from application traffic."""
|
|
154
|
+
return WriteFence(PostgresFences(self._cx), table, project_id=project_id)
|
|
155
|
+
|
|
156
|
+
# --- schema ----------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
@guarded
|
|
159
|
+
def ensure_schema(
|
|
160
|
+
self, layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]]
|
|
161
|
+
) -> tuple[PhysicalFinding, ...]:
|
|
162
|
+
"""Create what is missing, change nothing that exists.
|
|
163
|
+
|
|
164
|
+
Idempotent on purpose: an application restarting must not reapply DDL, and two instances
|
|
165
|
+
starting at once must not race. Anything beyond creation - altering a column, dropping an
|
|
166
|
+
index - is a migration, which is the orchestrator's job and carries a safety
|
|
167
|
+
classification. A library that quietly altered a live column would be doing the one thing
|
|
168
|
+
this product promises never to do without a rollback path.
|
|
169
|
+
"""
|
|
170
|
+
tables = [
|
|
171
|
+
statement
|
|
172
|
+
for statement in schema_statements(layout, keys=keys, dialect=self.dialect)
|
|
173
|
+
if statement.startswith("CREATE TABLE ")
|
|
174
|
+
]
|
|
175
|
+
self._execute(tables)
|
|
176
|
+
self._verify_schema(layout)
|
|
177
|
+
# Indexes only on tables whose key is the declared one. A table with another primary key
|
|
178
|
+
# belongs to another design - an older map, or somebody else - and this map's refusal is
|
|
179
|
+
# coming. `CREATE INDEX` without CONCURRENTLY blocks that table's writes while it builds, so
|
|
180
|
+
# running it first would be a refused operation that still stopped the client's writes.
|
|
181
|
+
blocked = {
|
|
182
|
+
finding.table
|
|
183
|
+
for finding in self._physical_findings(layout, keys)
|
|
184
|
+
if finding.aspect == "primary key"
|
|
185
|
+
}
|
|
186
|
+
applicable = replace(
|
|
187
|
+
layout,
|
|
188
|
+
indexes=tuple(
|
|
189
|
+
index
|
|
190
|
+
for index in layout.indexes
|
|
191
|
+
if layout.tables.get(str(index["entity"])) not in blocked
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
indexes = [
|
|
195
|
+
statement
|
|
196
|
+
for statement in schema_statements(applicable, keys=keys, dialect=self.dialect)
|
|
197
|
+
if statement.startswith("CREATE INDEX ")
|
|
198
|
+
]
|
|
199
|
+
self._execute(indexes)
|
|
200
|
+
log("sde.schema.applied", engine=self.dialect, statements=len(tables) + len(indexes))
|
|
201
|
+
return self._physical_findings(layout, keys)
|
|
202
|
+
|
|
203
|
+
def _execute(self, statements: Sequence[str]) -> None:
|
|
204
|
+
with self._cx.cursor() as cur:
|
|
205
|
+
for statement in statements:
|
|
206
|
+
try:
|
|
207
|
+
cur.execute(statement)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
raise EngineError(f"schema statement failed: {statement}: {exc}") from exc
|
|
210
|
+
|
|
211
|
+
@guarded
|
|
212
|
+
def validate_schema(
|
|
213
|
+
self, layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]] | None = None
|
|
214
|
+
) -> tuple[PhysicalFinding, ...]:
|
|
215
|
+
"""Check the existing physical columns without issuing DDL; report the physical design."""
|
|
216
|
+
self._verify_schema(layout)
|
|
217
|
+
return () if keys is None else self._physical_findings(layout, keys)
|
|
218
|
+
|
|
219
|
+
def _physical_findings(
|
|
220
|
+
self, layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]
|
|
221
|
+
) -> tuple[PhysicalFinding, ...]:
|
|
222
|
+
"""Primary key order and each declared index's method and columns, from ``pg_index``.
|
|
223
|
+
|
|
224
|
+
``CREATE INDEX IF NOT EXISTS ... USING brin`` keeps an existing B-tree of that name -
|
|
225
|
+
measured - so the method is read back rather than assumed from the statement that ran.
|
|
226
|
+
An index with a predicate, an expression or INCLUDE columns is not the index a layout
|
|
227
|
+
declares, however its name reads.
|
|
228
|
+
|
|
229
|
+
Nor is a unique one, which refuses writes a layout never refuses, or one that is not valid
|
|
230
|
+
and ready. The last is what an interrupted ``CREATE INDEX CONCURRENTLY`` leaves behind -
|
|
231
|
+
measured, 15.19: the index stays in the catalogue with ``indisvalid`` and ``indisready``
|
|
232
|
+
false, the planner never uses it, and ``CREATE INDEX CONCURRENTLY IF NOT EXISTS`` over it
|
|
233
|
+
succeeds with only a notice. Reading the name, method and columns alone reported such a
|
|
234
|
+
leftover as the declared index.
|
|
235
|
+
"""
|
|
236
|
+
declared = declared_tables(layout, keys)
|
|
237
|
+
if not declared:
|
|
238
|
+
return ()
|
|
239
|
+
tables = [entry.table for entry in declared]
|
|
240
|
+
with self._cx.cursor() as cur:
|
|
241
|
+
cur.execute(
|
|
242
|
+
"SELECT t.relname, ic.relname, i.indisprimary, am.amname, "
|
|
243
|
+
"ARRAY(SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY k(num, pos) "
|
|
244
|
+
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num "
|
|
245
|
+
"ORDER BY k.pos), "
|
|
246
|
+
"i.indpred IS NULL AND i.indexprs IS NULL AND i.indnkeyatts = i.indnatts, "
|
|
247
|
+
"i.indisunique, i.indisvalid AND i.indisready "
|
|
248
|
+
"FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid "
|
|
249
|
+
"JOIN pg_class t ON t.oid = i.indrelid JOIN pg_am am ON am.oid = ic.relam "
|
|
250
|
+
"JOIN pg_namespace n ON n.oid = t.relnamespace "
|
|
251
|
+
"WHERE n.nspname = current_schema() AND t.relname = ANY(%s)",
|
|
252
|
+
[tables],
|
|
253
|
+
)
|
|
254
|
+
rows = cur.fetchall()
|
|
255
|
+
primary: dict[str, tuple[str, ...]] = {}
|
|
256
|
+
indexes: dict[str, dict[str, tuple[str, tuple[str, ...], bool, bool, bool]]] = {}
|
|
257
|
+
for table, index, is_primary, method, columns, simple, unique, usable in rows:
|
|
258
|
+
names = tuple(str(column) for column in columns)
|
|
259
|
+
if is_primary:
|
|
260
|
+
primary[str(table)] = names
|
|
261
|
+
else:
|
|
262
|
+
indexes.setdefault(str(table), {})[str(index)] = (
|
|
263
|
+
str(method),
|
|
264
|
+
names,
|
|
265
|
+
bool(simple),
|
|
266
|
+
bool(unique),
|
|
267
|
+
bool(usable),
|
|
268
|
+
)
|
|
269
|
+
findings: list[PhysicalFinding] = []
|
|
270
|
+
for entry in declared:
|
|
271
|
+
found_key = primary.get(entry.table)
|
|
272
|
+
if found_key != entry.key:
|
|
273
|
+
findings.append(
|
|
274
|
+
PhysicalFinding(
|
|
275
|
+
entry.table,
|
|
276
|
+
"primary key",
|
|
277
|
+
repr(list(entry.key)),
|
|
278
|
+
"absent" if found_key is None else repr(list(found_key)),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
for index in entry.indexes:
|
|
282
|
+
wanted = f"{index.method} on {list(index.columns)}"
|
|
283
|
+
got = indexes.get(entry.table, {}).get(index.name)
|
|
284
|
+
if got is None:
|
|
285
|
+
findings.append(
|
|
286
|
+
PhysicalFinding(entry.table, f"index {index.name}", wanted, "absent")
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
method, columns, simple, unique, usable = got
|
|
290
|
+
if (
|
|
291
|
+
method != index.method
|
|
292
|
+
or columns != index.columns
|
|
293
|
+
or not simple
|
|
294
|
+
or unique
|
|
295
|
+
or not usable
|
|
296
|
+
):
|
|
297
|
+
shape = "" if simple else " with a predicate, expression or INCLUDE"
|
|
298
|
+
if unique:
|
|
299
|
+
shape += ", unique"
|
|
300
|
+
if not usable:
|
|
301
|
+
shape += ", not valid (an unfinished concurrent build)"
|
|
302
|
+
findings.append(
|
|
303
|
+
PhysicalFinding(
|
|
304
|
+
entry.table,
|
|
305
|
+
f"index {index.name}",
|
|
306
|
+
wanted,
|
|
307
|
+
f"{method} on {list(columns)}{shape}",
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
return tuple(findings)
|
|
311
|
+
|
|
312
|
+
def _verify_schema(self, layout: PhysicalLayout) -> None:
|
|
313
|
+
"""Check that what exists is what the map describes, because IF NOT EXISTS does not.
|
|
314
|
+
|
|
315
|
+
`CREATE TABLE IF NOT EXISTS` accepts a table of that name whatever shape it is in, so a
|
|
316
|
+
table left over from something else - an older map, another application, a hand-run
|
|
317
|
+
migration - is silently kept and the first insert fails with `column "at" does not exist`.
|
|
318
|
+
That error names a column and not the cause, and it arrives in the client's request path
|
|
319
|
+
rather than at startup.
|
|
320
|
+
|
|
321
|
+
A **missing** column is refused: writes through this map cannot work. An **extra** column
|
|
322
|
+
is logged and allowed - a client may have added one outside SDE, the map does not name it,
|
|
323
|
+
writes are unaffected, and refusing would make this library an obstacle to work it has no
|
|
324
|
+
opinion about.
|
|
325
|
+
|
|
326
|
+
**Types as well as names, since 7 September 2026, and the reason the earlier version
|
|
327
|
+
checked names only was a true statement about the wrong catalogue.** It read
|
|
328
|
+
`information_schema.data_type`, which reports `numeric` for a `numeric(8,2)` column, and
|
|
329
|
+
concluded that comparing types would report differences that are not differences.
|
|
330
|
+
`pg_catalog.format_type(atttypid, atttypmod)` reports the canonical type *with* its
|
|
331
|
+
modifier - measured against every type this library renders, eleven of thirteen come back
|
|
332
|
+
as the exact string we wrote, and the two that do not are the timestamp aliases, which the
|
|
333
|
+
server itself will resolve for us.
|
|
334
|
+
|
|
335
|
+
What that cost while it was names-only, measured: a table whose `at` column is **`text`**
|
|
336
|
+
where the map says `timestamptz` passed this check and was reported as a good schema.
|
|
337
|
+
"""
|
|
338
|
+
expected = {
|
|
339
|
+
table: dict(layout.columns.get(entity, {}))
|
|
340
|
+
for entity, table in sorted(layout.tables.items())
|
|
341
|
+
}
|
|
342
|
+
if not expected:
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
with self._cx.cursor() as cur:
|
|
346
|
+
# `pg_attribute` rather than `information_schema`, for `format_type`: the canonical
|
|
347
|
+
# spelling *including* the modifier, which is the whole reason this can compare types.
|
|
348
|
+
cur.execute(
|
|
349
|
+
"SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod) "
|
|
350
|
+
"FROM pg_attribute a "
|
|
351
|
+
"JOIN pg_class c ON c.oid = a.attrelid "
|
|
352
|
+
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
353
|
+
"WHERE n.nspname = current_schema() AND c.relname = ANY(%s) "
|
|
354
|
+
"AND a.attnum > 0 AND NOT a.attisdropped",
|
|
355
|
+
[sorted(expected)],
|
|
356
|
+
)
|
|
357
|
+
found: dict[str, dict[str, str]] = {}
|
|
358
|
+
for table_name, column_name, column_type in cur.fetchall():
|
|
359
|
+
found.setdefault(str(table_name), {})[str(column_name)] = str(column_type)
|
|
360
|
+
|
|
361
|
+
for table, columns in sorted(expected.items()):
|
|
362
|
+
actual = found.get(table)
|
|
363
|
+
if actual is None:
|
|
364
|
+
raise EngineError(
|
|
365
|
+
f"{table!r} does not exist after applying the schema. The statement reported "
|
|
366
|
+
f"success, so this is a permissions or search_path problem rather than a bad "
|
|
367
|
+
f"map."
|
|
368
|
+
)
|
|
369
|
+
missing = sorted(set(columns) - set(actual))
|
|
370
|
+
if missing:
|
|
371
|
+
raise EngineError(
|
|
372
|
+
f"{table!r} already existed with a different shape: the map needs {missing} "
|
|
373
|
+
f"and the table has {sorted(actual)}. `CREATE TABLE IF NOT EXISTS` keeps "
|
|
374
|
+
f"whatever is there, so this table came from somewhere else - an older map, "
|
|
375
|
+
f"another application, a migration run by hand. Refusing here rather than at "
|
|
376
|
+
f"the first insert, which would fail in your request path with an error naming "
|
|
377
|
+
f"a column and not the cause."
|
|
378
|
+
)
|
|
379
|
+
for column, declared in sorted(columns.items()):
|
|
380
|
+
reported = actual[column]
|
|
381
|
+
if self._same_type(declared, reported):
|
|
382
|
+
continue
|
|
383
|
+
raise EngineError(
|
|
384
|
+
f"{table}.{column} is {reported!r} and this map declares it {declared!r}. "
|
|
385
|
+
f"`CREATE TABLE IF NOT EXISTS` keeps a table of that name whatever shape it "
|
|
386
|
+
f"is in, and this library never alters a column's type - so the table came "
|
|
387
|
+
f"from somewhere else, or from a map that rendered this column differently. "
|
|
388
|
+
f"Refusing rather than writing into it: a type that differs is either a write "
|
|
389
|
+
f"that fails in your request path or, worse, one that succeeds and hands the "
|
|
390
|
+
f"value back as something else."
|
|
391
|
+
)
|
|
392
|
+
extra = sorted(set(actual) - set(columns))
|
|
393
|
+
if extra:
|
|
394
|
+
log("sde.schema.extra_columns", table=table, columns=extra)
|
|
395
|
+
|
|
396
|
+
def _same_type(self, declared: str, reported: str) -> bool:
|
|
397
|
+
"""Whether two PostgreSQL type spellings denote the same type. Asked of the server.
|
|
398
|
+
|
|
399
|
+
Literal first, because that is the answer for every type this library renders except the
|
|
400
|
+
two timestamps. When it fails, the server is asked - `to_regtype` resolves an alias to the
|
|
401
|
+
type it names, so `timestamptz` and `timestamp with time zone` come back equal without this
|
|
402
|
+
module holding a table of aliases that could fall behind the renderer.
|
|
403
|
+
|
|
404
|
+
**A modifier makes a literal mismatch a real one.** `to_regtype` discards modifiers, so
|
|
405
|
+
`numeric(12,2)` and `numeric(8,2)` would both resolve to `numeric` and a precision change
|
|
406
|
+
would read as agreement. That is the one difference this check exists to catch, so a
|
|
407
|
+
parenthesis on either side ends the question here.
|
|
408
|
+
"""
|
|
409
|
+
if declared == reported:
|
|
410
|
+
return True
|
|
411
|
+
if "(" in declared or "(" in reported:
|
|
412
|
+
return False
|
|
413
|
+
with self._cx.cursor() as cur:
|
|
414
|
+
cur.execute("SELECT to_regtype(%s)::text, to_regtype(%s)::text", [declared, reported])
|
|
415
|
+
row = cur.fetchone()
|
|
416
|
+
return bool(row is not None and row[0] is not None and row[0] == row[1])
|
|
417
|
+
|
|
418
|
+
# --- data ------------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
@guarded
|
|
421
|
+
def explain_plan(self, sql: str) -> QueryPlan:
|
|
422
|
+
"""Plan an analyst's query without running it, inside a read-only transaction.
|
|
423
|
+
|
|
424
|
+
Requirement 19.4. Two ``EXPLAIN``s in one transaction: ``FORMAT JSON`` for the numbers and
|
|
425
|
+
the node types, and the plain one for the text a person reads - the engine's own rendering
|
|
426
|
+
rather than mine, because a plan reformatted by us is a plan whose wording somebody will
|
|
427
|
+
compare against the documentation and not find.
|
|
428
|
+
|
|
429
|
+
``SET TRANSACTION READ ONLY``, and a **corrected** account of what it is for. The first
|
|
430
|
+
version of this docstring said planning could reach a write, because constant folding
|
|
431
|
+
evaluates immutable functions - ``EXPLAIN SELECT 1/0`` raises at plan time - so an
|
|
432
|
+
immutable function that lied about being immutable would run. Measured: it cannot.
|
|
433
|
+
PostgreSQL refuses the write itself, with *"INSERT is not allowed in a non-volatile
|
|
434
|
+
function"*, in a read-only transaction and outside one alike. And a ``VOLATILE`` function
|
|
435
|
+
is not folded at all. So **planning cannot write, and the read-only transaction guards
|
|
436
|
+
against nothing reachable through ``EXPLAIN`` today.**
|
|
437
|
+
|
|
438
|
+
It is kept, and the reason is an edit inside this block rather than a query outside it. One
|
|
439
|
+
word - ``ANALYZE`` - turns either statement below into an execution, and with the
|
|
440
|
+
transaction read-only that mistake fails loudly instead of running an analyst's
|
|
441
|
+
data-modifying CTE. A guard whose present hazard is zero and whose future hazard is one
|
|
442
|
+
keyword is worth a statement.
|
|
443
|
+
|
|
444
|
+
``force_rollback=True`` has **no observable effect through EXPLAIN** and is kept for the
|
|
445
|
+
same reason one level along: it covers what read-only does not - a temporary table, a
|
|
446
|
+
``SET LOCAL``, an advisory lock - none of which anything here creates today. Said plainly
|
|
447
|
+
rather than counted as a tested guarantee, because a mutation of it changes no output and
|
|
448
|
+
a mutation that changes no output is not one.
|
|
449
|
+
|
|
450
|
+
What gets reported is the transaction, not a claim about the query: this library has no SQL
|
|
451
|
+
parser and cannot say it checked the SQL.
|
|
452
|
+
"""
|
|
453
|
+
with self._cx.transaction(force_rollback=True) as _tx, self._cx.cursor() as cursor:
|
|
454
|
+
cursor.execute("SET TRANSACTION READ ONLY")
|
|
455
|
+
# Read it back, because a statement that says nothing does not mean something
|
|
456
|
+
# happened - the lesson three GitHub API endpoints taught this project by returning
|
|
457
|
+
# 200 and changing nothing. It also turns this guard from unverifiable into checked:
|
|
458
|
+
# without the read-back, deleting the line above changes no observable behaviour, so
|
|
459
|
+
# nothing could fail when somebody did.
|
|
460
|
+
cursor.execute("SHOW transaction_read_only")
|
|
461
|
+
state = cursor.fetchone()
|
|
462
|
+
if not state or str(state[0]).strip().lower() != "on":
|
|
463
|
+
raise EngineError(
|
|
464
|
+
f"this connection would not go read-only for planning: "
|
|
465
|
+
f"transaction_read_only is {state[0] if state else 'unreadable'!r}. Refusing "
|
|
466
|
+
f"to plan anything: requirement 19.2 keeps this product out of the data path, "
|
|
467
|
+
f"and the one keyword between EXPLAIN and execution is ANALYZE - measured, an "
|
|
468
|
+
f"EXPLAIN (ANALYZE) of a write runs in a read-write transaction and is "
|
|
469
|
+
f"refused in a read-only one."
|
|
470
|
+
)
|
|
471
|
+
try:
|
|
472
|
+
cursor.execute(f"EXPLAIN (FORMAT JSON) {sql}")
|
|
473
|
+
raw = cursor.fetchone()
|
|
474
|
+
cursor.execute(f"EXPLAIN {sql}")
|
|
475
|
+
text = tuple(str(row[0]) for row in cursor.fetchall())
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
# The refusal *is* the validation. Re-raised with the engine's own wording,
|
|
478
|
+
# because the case requirement 19.4 exists for - a column our map says exists
|
|
479
|
+
# and this database says does not - is one where the engine names the column
|
|
480
|
+
# and any summary of ours would lose it.
|
|
481
|
+
raise QueryPlanRefused(
|
|
482
|
+
f"{self.dialect} would not plan this query: {exc} Nothing was executed. "
|
|
483
|
+
f"This is what validating against a live engine means: the control plane "
|
|
484
|
+
f"checked the query against the schema it authored, and this is the schema "
|
|
485
|
+
f"that exists."
|
|
486
|
+
) from exc
|
|
487
|
+
if not raw or not raw[0]:
|
|
488
|
+
raise QueryPlanRefused(
|
|
489
|
+
f"{self.dialect} returned no plan for this query and did not say why"
|
|
490
|
+
)
|
|
491
|
+
node = dict(raw[0][0]).get("Plan") or {}
|
|
492
|
+
return QueryPlan(
|
|
493
|
+
engine=self.dialect,
|
|
494
|
+
dialect=self.dialect,
|
|
495
|
+
plan=text or ("(the engine returned an empty plan)",),
|
|
496
|
+
cost=Cost(
|
|
497
|
+
units="PostgreSQL planner cost units",
|
|
498
|
+
basis=(
|
|
499
|
+
"arbitrary units, not seconds and not rows. The figure depends on this "
|
|
500
|
+
"machine and on this database's planner settings (seq_page_cost, "
|
|
501
|
+
"random_page_cost, the cost constants), so it is comparable between two plans "
|
|
502
|
+
"on this database and meaningless against a number from anywhere else. "
|
|
503
|
+
"plan_rows is the planner's estimate of rows returned, from statistics that "
|
|
504
|
+
"are as fresh as the last ANALYZE."
|
|
505
|
+
),
|
|
506
|
+
values={
|
|
507
|
+
"total_cost": str(node.get("Total Cost", "")),
|
|
508
|
+
"startup_cost": str(node.get("Startup Cost", "")),
|
|
509
|
+
"plan_rows": str(node.get("Plan Rows", "")),
|
|
510
|
+
"plan_width": str(node.get("Plan Width", "")),
|
|
511
|
+
"root_node": str(node.get("Node Type", "")),
|
|
512
|
+
},
|
|
513
|
+
),
|
|
514
|
+
findings=postgres_findings(node),
|
|
515
|
+
read_only_enforced=(
|
|
516
|
+
"SET TRANSACTION READ ONLY, and the transaction is rolled back. PostgreSQL "
|
|
517
|
+
"refuses a write in such a transaction whatever the statement looks like - "
|
|
518
|
+
"measured: a data-modifying CTE fails with 'cannot execute SELECT in a read-only "
|
|
519
|
+
"transaction'."
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
@guarded
|
|
524
|
+
def insert(self, table: str, values: Mapping[str, Any]) -> None:
|
|
525
|
+
if not values:
|
|
526
|
+
raise EngineError("nothing to insert")
|
|
527
|
+
cols = sorted(values)
|
|
528
|
+
placeholders = ", ".join(["%s"] * len(cols))
|
|
529
|
+
sql = (
|
|
530
|
+
f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
|
|
531
|
+
f"VALUES ({placeholders})"
|
|
532
|
+
)
|
|
533
|
+
try:
|
|
534
|
+
with self._cx.cursor() as cur:
|
|
535
|
+
cur.execute(sql, [values[c] for c in cols])
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
# Surfaced, not swallowed and not rerouted. See the module docstring.
|
|
538
|
+
log("sde.write.failed", table=table, error=type(exc).__name__)
|
|
539
|
+
raise EngineError(f"insert into {table} failed: {self._explain(exc)}") from exc
|
|
540
|
+
|
|
541
|
+
@guarded
|
|
542
|
+
def insert_many(self, table: str, rows: Sequence[Mapping[str, Any]]) -> None:
|
|
543
|
+
"""One ordinary INSERT; unlike copy_in, a conflicting key is an error."""
|
|
544
|
+
cols = batch_columns(rows)
|
|
545
|
+
if not cols:
|
|
546
|
+
return
|
|
547
|
+
from psycopg.types.json import Jsonb
|
|
548
|
+
|
|
549
|
+
placeholders = ", ".join(f"({', '.join(['%s'] * len(cols))})" for _ in rows)
|
|
550
|
+
sql = (
|
|
551
|
+
f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
|
|
552
|
+
f"VALUES {placeholders}"
|
|
553
|
+
)
|
|
554
|
+
params = [
|
|
555
|
+
Jsonb(row[c]) if isinstance(row[c], (dict, list)) else row[c]
|
|
556
|
+
for row in rows for c in cols
|
|
557
|
+
]
|
|
558
|
+
try:
|
|
559
|
+
with self._cx.cursor() as cur:
|
|
560
|
+
cur.execute(sql, params)
|
|
561
|
+
except Exception as exc:
|
|
562
|
+
log("sde.write.failed", table=table, error=type(exc).__name__)
|
|
563
|
+
raise EngineError(f"batch insert into {table} failed: {self._explain(exc)}") from exc
|
|
564
|
+
|
|
565
|
+
@guarded
|
|
566
|
+
def get(self, table: str, key: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
567
|
+
where = " AND ".join(f"{_quote(c)} = %s" for c in sorted(key))
|
|
568
|
+
sql = f"SELECT * FROM {_quote(table)} WHERE {where}"
|
|
569
|
+
try:
|
|
570
|
+
with self._cx.cursor() as cur:
|
|
571
|
+
cur.execute(sql, [key[c] for c in sorted(key)])
|
|
572
|
+
row = cur.fetchone()
|
|
573
|
+
if row is None:
|
|
574
|
+
return None
|
|
575
|
+
names = [d.name for d in cur.description or ()]
|
|
576
|
+
return dict(zip(names, row, strict=True))
|
|
577
|
+
except Exception as exc:
|
|
578
|
+
raise EngineError(f"select from {table} failed: {self._explain(exc)}") from exc
|
|
579
|
+
|
|
580
|
+
# --- rollback protection ------------------------------------------------------------------
|
|
581
|
+
#
|
|
582
|
+
# Two methods that satisfy `sde.watermark.WatermarkStore`, which is a separate optional
|
|
583
|
+
# protocol rather than part of `Engine`: adding these to `Engine` would break every adapter
|
|
584
|
+
# anybody has written, for a capability our own orderbook engine cannot provide.
|
|
585
|
+
|
|
586
|
+
@guarded
|
|
587
|
+
def map_watermark(self) -> int | None:
|
|
588
|
+
"""The highest map version applied against this engine, creating the table if missing.
|
|
589
|
+
|
|
590
|
+
Existing bookkeeping needs only read privileges. CREATE IF NOT EXISTS still checks schema
|
|
591
|
+
CREATE permission even when the table exists, so test existence through the catalog first.
|
|
592
|
+
Lazy creation remains available for legacy callers; prepare_schema creates signed-map
|
|
593
|
+
bookkeeping with the operator connection before restricted runtime credentials are used.
|
|
594
|
+
"""
|
|
595
|
+
try:
|
|
596
|
+
with self._cx.cursor() as cur:
|
|
597
|
+
cur.execute("SELECT to_regclass(%s)", [WATERMARK_TABLE])
|
|
598
|
+
existing = cur.fetchone()
|
|
599
|
+
if existing is None:
|
|
600
|
+
raise EngineError("watermark catalog lookup returned no result")
|
|
601
|
+
if existing[0] is None:
|
|
602
|
+
cur.execute(
|
|
603
|
+
f"CREATE TABLE IF NOT EXISTS {_quote(WATERMARK_TABLE)} ("
|
|
604
|
+
f"{_quote('map_version')} bigint NOT NULL, "
|
|
605
|
+
f"{_quote('model_version')} text NOT NULL, "
|
|
606
|
+
f"{_quote('seen_at')} timestamptz NOT NULL DEFAULT now())"
|
|
607
|
+
)
|
|
608
|
+
cur.execute(f"SELECT max({_quote('map_version')}) FROM {_quote(WATERMARK_TABLE)}")
|
|
609
|
+
row = cur.fetchone()
|
|
610
|
+
except Exception as exc:
|
|
611
|
+
raise EngineError(f"reading {WATERMARK_TABLE} failed: {self._explain(exc)}") from exc
|
|
612
|
+
if row is None or row[0] is None:
|
|
613
|
+
return None
|
|
614
|
+
return int(row[0])
|
|
615
|
+
|
|
616
|
+
@guarded
|
|
617
|
+
def record_map_version(self, version: int, *, model_version: str) -> None:
|
|
618
|
+
"""Append. Never update, so there is nothing to contend over and nothing to lose.
|
|
619
|
+
|
|
620
|
+
The timestamp comes from the engine's own `now()` rather than from this process: an audit
|
|
621
|
+
column wants the clock of the thing being audited, and this library reading a clock is a
|
|
622
|
+
thing its tests would then have to work around.
|
|
623
|
+
"""
|
|
624
|
+
try:
|
|
625
|
+
with self._cx.cursor() as cur:
|
|
626
|
+
cur.execute(
|
|
627
|
+
f"INSERT INTO {_quote(WATERMARK_TABLE)} "
|
|
628
|
+
f"({_quote('map_version')}, {_quote('model_version')}) VALUES (%s, %s)",
|
|
629
|
+
[version, model_version],
|
|
630
|
+
)
|
|
631
|
+
except Exception as exc:
|
|
632
|
+
raise EngineError(
|
|
633
|
+
f"recording a map version in {WATERMARK_TABLE} failed: {exc}"
|
|
634
|
+
) from exc
|
|
635
|
+
|
|
636
|
+
@guarded
|
|
637
|
+
def select_rows(self, table: str, plan: ReadPlan) -> list[dict[str, Any]]:
|
|
638
|
+
params: list[Any] = []
|
|
639
|
+
def parameter(value: Any) -> str:
|
|
640
|
+
params.append(value)
|
|
641
|
+
return "%s"
|
|
642
|
+
statement = read_sql(table, plan, dialect=self.dialect, parameter=parameter)
|
|
643
|
+
try:
|
|
644
|
+
with self._cx.cursor() as cursor:
|
|
645
|
+
cursor.execute(statement, params)
|
|
646
|
+
names = [column.name for column in cursor.description or ()]
|
|
647
|
+
return [read_row(plan.columns, dict(zip(names, row, strict=True)))
|
|
648
|
+
for row in cursor.fetchall()]
|
|
649
|
+
except Exception as exc:
|
|
650
|
+
raise EngineError(f"logical scan of {table} failed: {self._explain(exc)}") from exc
|
|
651
|
+
|
|
652
|
+
@guarded
|
|
653
|
+
def count_rows(self, table: str, plan: ReadPlan) -> int:
|
|
654
|
+
params: list[Any] = []
|
|
655
|
+
def parameter(value: Any) -> str:
|
|
656
|
+
params.append(value)
|
|
657
|
+
return "%s"
|
|
658
|
+
statement = read_sql(table, plan, dialect=self.dialect, parameter=parameter, count=True)
|
|
659
|
+
try:
|
|
660
|
+
with self._cx.cursor() as cursor:
|
|
661
|
+
cursor.execute(statement, params)
|
|
662
|
+
row = cursor.fetchone()
|
|
663
|
+
if row is None:
|
|
664
|
+
raise EngineError("count query returned no result")
|
|
665
|
+
return int(row[0])
|
|
666
|
+
except Exception as exc:
|
|
667
|
+
raise EngineError(f"logical count of {table} failed: {self._explain(exc)}") from exc
|
|
668
|
+
|
|
669
|
+
@guarded
|
|
670
|
+
def summarize_rows(
|
|
671
|
+
self, table: str, plan: ReadPlan, column: ReadColumn,
|
|
672
|
+
) -> Mapping[str, Any]:
|
|
673
|
+
params: list[Any] = []
|
|
674
|
+
def parameter(value: Any) -> str:
|
|
675
|
+
params.append(value)
|
|
676
|
+
return "%s"
|
|
677
|
+
statement = summary_sql(table, plan, column, dialect=self.dialect, parameter=parameter)
|
|
678
|
+
try:
|
|
679
|
+
with self._cx.cursor() as cursor:
|
|
680
|
+
cursor.execute(statement, params)
|
|
681
|
+
names = [item.name for item in cursor.description or ()]
|
|
682
|
+
row = cursor.fetchone()
|
|
683
|
+
if row is None:
|
|
684
|
+
raise EngineError("summary query returned no result")
|
|
685
|
+
return dict(zip(names, row, strict=True))
|
|
686
|
+
except Exception as exc:
|
|
687
|
+
raise EngineError(f"logical summary of {table} failed: {self._explain(exc)}") from exc
|
|
688
|
+
|
|
689
|
+
@guarded
|
|
690
|
+
def range(
|
|
691
|
+
self,
|
|
692
|
+
table: str,
|
|
693
|
+
column: str,
|
|
694
|
+
*,
|
|
695
|
+
low: Any = None,
|
|
696
|
+
high: Any = None,
|
|
697
|
+
limit: int | None = None,
|
|
698
|
+
) -> list[dict[str, Any]]:
|
|
699
|
+
clauses: list[str] = []
|
|
700
|
+
params: list[Any] = []
|
|
701
|
+
if low is not None:
|
|
702
|
+
clauses.append(f"{_quote(column)} >= %s")
|
|
703
|
+
params.append(low)
|
|
704
|
+
if high is not None:
|
|
705
|
+
clauses.append(f"{_quote(column)} < %s")
|
|
706
|
+
params.append(high)
|
|
707
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
708
|
+
order = f" ORDER BY {_quote(column)}"
|
|
709
|
+
cap = ""
|
|
710
|
+
if limit is not None:
|
|
711
|
+
cap = " LIMIT %s"
|
|
712
|
+
params.append(limit)
|
|
713
|
+
sql = f"SELECT * FROM {_quote(table)}{where}{order}{cap}"
|
|
714
|
+
try:
|
|
715
|
+
with self._cx.cursor() as cur:
|
|
716
|
+
cur.execute(sql, params)
|
|
717
|
+
names = [d.name for d in cur.description or ()]
|
|
718
|
+
return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
|
|
719
|
+
except Exception as exc:
|
|
720
|
+
raise EngineError(f"range select from {table} failed: {exc}") from exc
|
|
721
|
+
|
|
722
|
+
@guarded
|
|
723
|
+
def storage_sizes(self, tables: Sequence[str]) -> dict[str, tuple[int, int]]:
|
|
724
|
+
"""Each table's bytes and its secondary index bytes, from the catalogue. Numbers only.
|
|
725
|
+
|
|
726
|
+
``pg_total_relation_size`` - the table, its TOAST and every index - and the indexes other
|
|
727
|
+
than the primary key, one statement for every table named. A name the connection does not
|
|
728
|
+
resolve is absent from the answer rather than a zero: a missing table is not an empty one.
|
|
729
|
+
A login with nothing but SELECT and INSERT on the table reads the same numbers as the
|
|
730
|
+
administrator (measured on PostgreSQL 15), so this needs no grant of its own.
|
|
731
|
+
"""
|
|
732
|
+
if not tables:
|
|
733
|
+
return {}
|
|
734
|
+
sql = (
|
|
735
|
+
"SELECT t.name, pg_total_relation_size(r.oid), "
|
|
736
|
+
"COALESCE((SELECT sum(pg_relation_size(i.indexrelid)) FROM pg_index i "
|
|
737
|
+
"WHERE i.indrelid = r.oid AND NOT i.indisprimary), 0) "
|
|
738
|
+
"FROM unnest(%s::text[]) AS t(name) "
|
|
739
|
+
"JOIN pg_class r ON r.oid = to_regclass(quote_ident(t.name))"
|
|
740
|
+
)
|
|
741
|
+
try:
|
|
742
|
+
with self._cx.cursor() as cursor:
|
|
743
|
+
cursor.execute(sql, [list(tables)])
|
|
744
|
+
return {
|
|
745
|
+
str(name): (int(total), int(secondary))
|
|
746
|
+
for name, total, secondary in cursor.fetchall()
|
|
747
|
+
}
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
raise EngineError(f"storage sizes could not be read: {self._explain(exc)}") from exc
|
|
750
|
+
|
|
751
|
+
@guarded
|
|
752
|
+
def count(self, table: str) -> int:
|
|
753
|
+
try:
|
|
754
|
+
with self._cx.cursor() as cur:
|
|
755
|
+
cur.execute(f"SELECT count(*) FROM {_quote(table)}")
|
|
756
|
+
row = cur.fetchone()
|
|
757
|
+
return int(row[0]) if row else 0
|
|
758
|
+
except Exception as exc:
|
|
759
|
+
raise EngineError(f"count on {table} failed: {exc}") from exc
|
|
760
|
+
|
|
761
|
+
# --- migration ------------------------------------------------------------------------------
|
|
762
|
+
#
|
|
763
|
+
# Five methods that satisfy `sde.migration.Migratable`, which like `WatermarkStore` is a
|
|
764
|
+
# separate optional protocol. Same reason: our own orderbook engine cannot offer any of them -
|
|
765
|
+
# its schema is fixed in its own source and it has nowhere to keep a marker - and an engine that
|
|
766
|
+
# cannot take part in a migration should be a named refusal rather than a broken adapter.
|
|
767
|
+
|
|
768
|
+
@guarded
|
|
769
|
+
def key_range(
|
|
770
|
+
self,
|
|
771
|
+
table: str,
|
|
772
|
+
order: Sequence[str],
|
|
773
|
+
*,
|
|
774
|
+
after: Sequence[Any] | None = None,
|
|
775
|
+
upto: Sequence[Any] | None = None,
|
|
776
|
+
limit: int | None = None,
|
|
777
|
+
) -> list[dict[str, Any]]:
|
|
778
|
+
"""Rows in key order, strictly after one key and up to another inclusive.
|
|
779
|
+
|
|
780
|
+
Row-value comparison - ``(a, b) > (%s, %s)`` - rather than a hand-rolled disjunction over
|
|
781
|
+
the key's columns. The disjunction is where composite-key pagination goes wrong, and it goes
|
|
782
|
+
wrong by skipping rows.
|
|
783
|
+
|
|
784
|
+
The bounds are asymmetric on purpose. ``after`` is exclusive because it is a resume point:
|
|
785
|
+
the row it names has been dealt with. ``upto`` is inclusive because it names the last row of
|
|
786
|
+
a chunk read from somewhere else, and that row is one this range has to include.
|
|
787
|
+
"""
|
|
788
|
+
cols = key_columns(order, table)
|
|
789
|
+
clauses: list[str] = []
|
|
790
|
+
params: list[Any] = []
|
|
791
|
+
tuple_expr = f"({', '.join(_quote(c) for c in cols)})"
|
|
792
|
+
if after is not None:
|
|
793
|
+
same_width(after, cols, "after")
|
|
794
|
+
clauses.append(f"{tuple_expr} > ({', '.join(['%s'] * len(cols))})")
|
|
795
|
+
params.extend(after)
|
|
796
|
+
if upto is not None:
|
|
797
|
+
same_width(upto, cols, "upto")
|
|
798
|
+
clauses.append(f"{tuple_expr} <= ({', '.join(['%s'] * len(cols))})")
|
|
799
|
+
params.extend(upto)
|
|
800
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
801
|
+
cap = ""
|
|
802
|
+
if limit is not None:
|
|
803
|
+
cap = " LIMIT %s"
|
|
804
|
+
params.append(int(limit))
|
|
805
|
+
sql = f"SELECT * FROM {_quote(table)}{where} ORDER BY {tuple_expr}{cap}"
|
|
806
|
+
try:
|
|
807
|
+
with self._cx.cursor() as cur:
|
|
808
|
+
cur.execute(sql, params)
|
|
809
|
+
names = [d.name for d in cur.description or ()]
|
|
810
|
+
return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
|
|
811
|
+
except Exception as exc:
|
|
812
|
+
raise EngineError(f"key range select from {table} failed: {exc}") from exc
|
|
813
|
+
|
|
814
|
+
@guarded
|
|
815
|
+
def nth_key(self, table: str, order: Sequence[str], *, position: int) -> tuple[Any, ...] | None:
|
|
816
|
+
"""The key of the ``position``-th row in key order, one-based, or None if there is no such
|
|
817
|
+
row.
|
|
818
|
+
|
|
819
|
+
An ``OFFSET`` scan, which is the expensive kind of query, and it is here because it is paid
|
|
820
|
+
**once per resume** rather than once per chunk. See :mod:`sde.migration` for why the marker
|
|
821
|
+
is a row count and not a key.
|
|
822
|
+
"""
|
|
823
|
+
cols = key_columns(order, table)
|
|
824
|
+
if position < 1:
|
|
825
|
+
raise EngineError(f"position is one-based; {position} is not a row")
|
|
826
|
+
projection = ", ".join(_quote(c) for c in cols)
|
|
827
|
+
sql = f"SELECT {projection} FROM {_quote(table)} ORDER BY ({projection}) OFFSET %s LIMIT 1"
|
|
828
|
+
try:
|
|
829
|
+
with self._cx.cursor() as cur:
|
|
830
|
+
cur.execute(sql, [position - 1])
|
|
831
|
+
row = cur.fetchone()
|
|
832
|
+
except Exception as exc:
|
|
833
|
+
raise EngineError(f"reading row {position} of {table} failed: {exc}") from exc
|
|
834
|
+
return None if row is None else tuple(row)
|
|
835
|
+
|
|
836
|
+
@guarded
|
|
837
|
+
def copy_in(self, table: str, rows: Sequence[Mapping[str, Any]]) -> None:
|
|
838
|
+
"""Insert rows, skipping any whose key is already there.
|
|
839
|
+
|
|
840
|
+
``ON CONFLICT DO NOTHING`` is what makes a backfill chunk **idempotent**, and idempotence is
|
|
841
|
+
what makes it resumable: the marker is written after the chunk, so a crash in between costs
|
|
842
|
+
a recopy and never a lost row. Without it the recopy would be a primary-key violation and
|
|
843
|
+
the safe failure mode would become the loud one.
|
|
844
|
+
|
|
845
|
+
The bare form, with no conflict target, so it covers the primary key and any unique index
|
|
846
|
+
the layout asked for. Naming the key here would mean deriving it a second time, and two
|
|
847
|
+
derivations of one key is how they come to disagree.
|
|
848
|
+
|
|
849
|
+
Returns nothing on purpose. PostgreSQL can say how many rows it actually wrote and
|
|
850
|
+
ClickHouse cannot, so a count here would mean different things in different engines - and
|
|
851
|
+
the caller needs "how much of the source have I consumed", which it already knows.
|
|
852
|
+
"""
|
|
853
|
+
if not rows:
|
|
854
|
+
return
|
|
855
|
+
cols = sorted(rows[0])
|
|
856
|
+
for row in rows:
|
|
857
|
+
if sorted(row) != cols:
|
|
858
|
+
raise EngineError(
|
|
859
|
+
f"copy_in into {table} was given rows with different columns "
|
|
860
|
+
f"({cols} and {sorted(row)}). A chunk comes from one table, so this is a "
|
|
861
|
+
f"caller assembling it from two."
|
|
862
|
+
)
|
|
863
|
+
placeholders = ", ".join(f"({', '.join(['%s'] * len(cols))})" for _ in rows)
|
|
864
|
+
sql = (
|
|
865
|
+
f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
|
|
866
|
+
f"VALUES {placeholders} ON CONFLICT DO NOTHING"
|
|
867
|
+
)
|
|
868
|
+
params: list[Any] = []
|
|
869
|
+
for row in rows:
|
|
870
|
+
params.extend(row[c] for c in cols)
|
|
871
|
+
try:
|
|
872
|
+
with self._cx.cursor() as cur:
|
|
873
|
+
cur.execute(sql, params)
|
|
874
|
+
except Exception as exc:
|
|
875
|
+
log("sde.write.failed", table=table, error=type(exc).__name__)
|
|
876
|
+
raise EngineError(f"copying {len(rows)} rows into {table} failed: {exc}") from exc
|
|
877
|
+
|
|
878
|
+
@guarded
|
|
879
|
+
def backfill_marker(self, *, materialization: str, entity: str) -> int:
|
|
880
|
+
"""How many rows of this entity have been copied into this engine. Zero if none.
|
|
881
|
+
|
|
882
|
+
`max()` over an append-only table, exactly like the map watermark, and for the same reason:
|
|
883
|
+
no row to update, nothing to contend over, and identical semantics in an engine with no
|
|
884
|
+
unique constraint. A stale row can never lower the marker.
|
|
885
|
+
"""
|
|
886
|
+
try:
|
|
887
|
+
with self._cx.cursor() as cur:
|
|
888
|
+
cur.execute(
|
|
889
|
+
f"CREATE TABLE IF NOT EXISTS {_quote(BACKFILL_TABLE)} ("
|
|
890
|
+
f"{_quote('materialization')} text NOT NULL, "
|
|
891
|
+
f"{_quote('entity')} text NOT NULL, "
|
|
892
|
+
f"{_quote('rows_copied')} bigint NOT NULL, "
|
|
893
|
+
f"{_quote('at')} timestamptz NOT NULL DEFAULT now())"
|
|
894
|
+
)
|
|
895
|
+
cur.execute(
|
|
896
|
+
f"SELECT max({_quote('rows_copied')}) FROM {_quote(BACKFILL_TABLE)} "
|
|
897
|
+
f"WHERE {_quote('materialization')} = %s AND {_quote('entity')} = %s",
|
|
898
|
+
[materialization, entity],
|
|
899
|
+
)
|
|
900
|
+
row = cur.fetchone()
|
|
901
|
+
except Exception as exc:
|
|
902
|
+
raise EngineError(f"reading {BACKFILL_TABLE} failed: {exc}") from exc
|
|
903
|
+
if row is None or row[0] is None:
|
|
904
|
+
return 0
|
|
905
|
+
return int(row[0])
|
|
906
|
+
|
|
907
|
+
@guarded
|
|
908
|
+
def record_backfill_marker(self, *, materialization: str, entity: str, rows: int) -> None:
|
|
909
|
+
"""Append the new marker. Never update, so an interrupted run leaves a readable trail."""
|
|
910
|
+
try:
|
|
911
|
+
with self._cx.cursor() as cur:
|
|
912
|
+
cur.execute(
|
|
913
|
+
f"INSERT INTO {_quote(BACKFILL_TABLE)} ({_quote('materialization')}, "
|
|
914
|
+
f"{_quote('entity')}, {_quote('rows_copied')}) VALUES (%s, %s, %s)",
|
|
915
|
+
[materialization, entity, int(rows)],
|
|
916
|
+
)
|
|
917
|
+
except Exception as exc:
|
|
918
|
+
raise EngineError(
|
|
919
|
+
f"recording backfill progress in {BACKFILL_TABLE} failed: {exc}"
|
|
920
|
+
) from exc
|
|
921
|
+
|
|
922
|
+
# --- transactions ----------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
@contextmanager
|
|
925
|
+
def transaction(self) -> Iterator[PostgresEngine]:
|
|
926
|
+
"""One engine, one transaction, that engine's semantics.
|
|
927
|
+
|
|
928
|
+
There is no distributed transaction here and there will not be one. A client needing two
|
|
929
|
+
entities to commit together declares that, and the planner puts them in the same group and
|
|
930
|
+
therefore the same engine - so the requirement turns into a placement constraint instead
|
|
931
|
+
of a two-phase commit. That is the trade this product makes, and it is why this method is
|
|
932
|
+
four lines rather than a subsystem.
|
|
933
|
+
"""
|
|
934
|
+
with self._usage.transaction() as scope:
|
|
935
|
+
cx = self._cx
|
|
936
|
+
status = self._psycopg.pq.TransactionStatus
|
|
937
|
+
parent = scope.parent
|
|
938
|
+
nested = parent is not None and parent.gate is self._usage
|
|
939
|
+
expected = status.INTRANS if nested else status.IDLE
|
|
940
|
+
body_error: BaseException | None = None
|
|
941
|
+
try:
|
|
942
|
+
with cx.transaction():
|
|
943
|
+
try:
|
|
944
|
+
yield self
|
|
945
|
+
self._usage.idle()
|
|
946
|
+
if cx.info.transaction_status == status.INERROR:
|
|
947
|
+
raise EngineError(
|
|
948
|
+
"the transaction is aborted and cannot be reported as committed"
|
|
949
|
+
)
|
|
950
|
+
scope.active = False
|
|
951
|
+
except BaseException as exc:
|
|
952
|
+
scope.active = False
|
|
953
|
+
body_error = exc
|
|
954
|
+
raise
|
|
955
|
+
except BaseException as exc:
|
|
956
|
+
if body_error is None:
|
|
957
|
+
self._unusable = True
|
|
958
|
+
raise EngineError(
|
|
959
|
+
"transaction completion was uncertain; close() then connect() before reuse"
|
|
960
|
+
) from exc
|
|
961
|
+
raise
|
|
962
|
+
finally:
|
|
963
|
+
try:
|
|
964
|
+
if cx.info.transaction_status != expected:
|
|
965
|
+
self._unusable = True
|
|
966
|
+
except Exception:
|
|
967
|
+
self._unusable = True
|