smart-data-engine-sdk 0.1.0.dev0__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 +226 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/engines/__init__.py +0 -0
- sde/engines/clickhouse.py +689 -0
- sde/engines/orderbook.py +454 -0
- sde/engines/postgres.py +672 -0
- sde/entity.py +170 -0
- sde/errors.py +88 -0
- sde/explain.py +300 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/infer.py +461 -0
- sde/internal.py +90 -0
- sde/layout.py +660 -0
- sde/logging.py +132 -0
- sde/migration.py +820 -0
- sde/model.py +482 -0
- sde/placement.py +818 -0
- sde/py.typed +0 -0
- sde/routing.py +85 -0
- sde/schema.py +370 -0
- sde/session.py +507 -0
- sde/shapes.py +153 -0
- sde/telemetry.py +736 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +318 -0
- sde/types.py +228 -0
- sde/watermark.py +222 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/METADATA +152 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/RECORD +35 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/NOTICE +13 -0
sde/engines/postgres.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
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 typing import Any
|
|
23
|
+
|
|
24
|
+
from ..errors import EngineError
|
|
25
|
+
from ..explain import (
|
|
26
|
+
Cost,
|
|
27
|
+
QueryPlan,
|
|
28
|
+
QueryPlanRefused,
|
|
29
|
+
postgres_findings,
|
|
30
|
+
)
|
|
31
|
+
from ..logging import log
|
|
32
|
+
from ..migration import key_columns, same_width
|
|
33
|
+
from ..placement import BACKFILL_TABLE, WATERMARK_TABLE, PhysicalLayout
|
|
34
|
+
from ..schema import QUOTE, schema_statements
|
|
35
|
+
|
|
36
|
+
__all__ = ["PostgresEngine"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Bound from the one definition in sde.schema, so that DDL and DML cannot disagree about
|
|
40
|
+
# how an identifier is escaped.
|
|
41
|
+
_quote = QUOTE["postgres"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Seconds. Not a guess about networks: this bounds *opening* a connection, which either completes
|
|
45
|
+
# in milliseconds on a healthy link or is not going to complete. Ten seconds leaves room for a
|
|
46
|
+
# saturated cross-region hop and still fails long before a request timeout that a caller sets. It
|
|
47
|
+
# is a default rather than a rule - a `connect_timeout` in the DSN wins - and it exists because the
|
|
48
|
+
# alternative, measured, is a call that never returns.
|
|
49
|
+
CONNECT_TIMEOUT_SECONDS = 10
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PostgresEngine:
|
|
53
|
+
"""A thin adapter over psycopg. Deliberately thin: it executes decisions, it makes none."""
|
|
54
|
+
|
|
55
|
+
dialect = "postgres"
|
|
56
|
+
|
|
57
|
+
def __init__(self, dsn: str) -> None:
|
|
58
|
+
try:
|
|
59
|
+
import psycopg
|
|
60
|
+
except ImportError as exc: # pragma: no cover - depends on the install extra
|
|
61
|
+
raise EngineError(
|
|
62
|
+
"the PostgreSQL adapter needs the 'postgres' extra: "
|
|
63
|
+
"pip install 'smart-data-engine-sdk[postgres]'. "
|
|
64
|
+
"The core library has no dependencies, because it goes into your application and "
|
|
65
|
+
"every dependency here would be one you inherit."
|
|
66
|
+
) from exc
|
|
67
|
+
self._psycopg = psycopg
|
|
68
|
+
self._dsn = dsn
|
|
69
|
+
self._conn: Any = None
|
|
70
|
+
|
|
71
|
+
# --- connection ------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def connect(self) -> None:
|
|
74
|
+
"""Open the connection, with a bound on how long that may take.
|
|
75
|
+
|
|
76
|
+
Measured before this bound existed: a host that accepts the TCP connection and never
|
|
77
|
+
answers hung the call **for as long as the test was willing to wait**, because libpq has no
|
|
78
|
+
default ``connect_timeout`` and neither did we. That is not an exotic case - it is a
|
|
79
|
+
firewall that accepts, a load balancer with no healthy backend, a server mid-restart - and
|
|
80
|
+
without a bound it happens inside the caller's request path with nothing to time out.
|
|
81
|
+
|
|
82
|
+
The default is only applied when the caller has not chosen one. A ``connect_timeout`` in
|
|
83
|
+
the DSN is their decision about their own network and this must not override it.
|
|
84
|
+
"""
|
|
85
|
+
if self._conn is None:
|
|
86
|
+
options: dict[str, Any] = {}
|
|
87
|
+
if "connect_timeout" not in self._dsn:
|
|
88
|
+
options["connect_timeout"] = CONNECT_TIMEOUT_SECONDS
|
|
89
|
+
try:
|
|
90
|
+
self._conn = self._psycopg.connect(self._dsn, autocommit=True, **options)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise EngineError(f"could not connect to PostgreSQL: {exc}") from exc
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
if self._conn is not None:
|
|
96
|
+
self._conn.close()
|
|
97
|
+
self._conn = None
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> PostgresEngine:
|
|
100
|
+
self.connect()
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *_: object) -> None:
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def _cx(self) -> Any:
|
|
108
|
+
if self._conn is None:
|
|
109
|
+
raise EngineError("not connected; call connect() first")
|
|
110
|
+
return self._conn
|
|
111
|
+
|
|
112
|
+
def _explain(self, exc: Exception) -> str:
|
|
113
|
+
"""The driver's message, plus the one sentence it cannot know to add.
|
|
114
|
+
|
|
115
|
+
Measured: cut the connection under a live session and the first failing call reports what
|
|
116
|
+
the server said ("terminating connection due to administrator command"), which is right.
|
|
117
|
+
**Every call after it reports "the connection is closed"** - true, unhelpful, and the point
|
|
118
|
+
at which a reader needs to be told that this library holds the connection it was handed and
|
|
119
|
+
does not reopen it. Reconnecting is one line and it is the caller's, because a library that
|
|
120
|
+
silently reconnected would also silently retry, and requirement 6.4 allows a retry only for
|
|
121
|
+
an operation known to be idempotent.
|
|
122
|
+
"""
|
|
123
|
+
message = str(exc)
|
|
124
|
+
if self._conn is not None and getattr(self._conn, "closed", False):
|
|
125
|
+
message += (
|
|
126
|
+
". The connection is gone and this library does not reopen one it was handed: "
|
|
127
|
+
"call close() then connect() on the engine, or hand the session a new one. "
|
|
128
|
+
"Nothing was retried, so no write reached the engine twice."
|
|
129
|
+
)
|
|
130
|
+
return message
|
|
131
|
+
|
|
132
|
+
# --- schema ----------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def ensure_schema(self, layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]]) -> None:
|
|
135
|
+
"""Create what is missing, change nothing that exists.
|
|
136
|
+
|
|
137
|
+
Idempotent on purpose: an application restarting must not reapply DDL, and two instances
|
|
138
|
+
starting at once must not race. Anything beyond creation - altering a column, dropping an
|
|
139
|
+
index - is a migration, which is the orchestrator's job and carries a safety
|
|
140
|
+
classification. A library that quietly altered a live column would be doing the one thing
|
|
141
|
+
this product promises never to do without a rollback path.
|
|
142
|
+
"""
|
|
143
|
+
statements = schema_statements(layout, keys=keys, dialect=self.dialect)
|
|
144
|
+
|
|
145
|
+
with self._cx.cursor() as cur:
|
|
146
|
+
for statement in statements:
|
|
147
|
+
try:
|
|
148
|
+
cur.execute(statement)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
raise EngineError(f"schema statement failed: {statement}: {exc}") from exc
|
|
151
|
+
log("sde.schema.applied", engine=self.dialect, statements=len(statements))
|
|
152
|
+
self._verify_schema(layout)
|
|
153
|
+
|
|
154
|
+
def _verify_schema(self, layout: PhysicalLayout) -> None:
|
|
155
|
+
"""Check that what exists is what the map describes, because IF NOT EXISTS does not.
|
|
156
|
+
|
|
157
|
+
`CREATE TABLE IF NOT EXISTS` accepts a table of that name whatever shape it is in, so a
|
|
158
|
+
table left over from something else - an older map, another application, a hand-run
|
|
159
|
+
migration - is silently kept and the first insert fails with `column "at" does not exist`.
|
|
160
|
+
That error names a column and not the cause, and it arrives in the client's request path
|
|
161
|
+
rather than at startup.
|
|
162
|
+
|
|
163
|
+
A **missing** column is refused: writes through this map cannot work. An **extra** column
|
|
164
|
+
is logged and allowed - a client may have added one outside SDE, the map does not name it,
|
|
165
|
+
writes are unaffected, and refusing would make this library an obstacle to work it has no
|
|
166
|
+
opinion about.
|
|
167
|
+
|
|
168
|
+
**Types as well as names, since 7 September 2026, and the reason the earlier version
|
|
169
|
+
checked names only was a true statement about the wrong catalogue.** It read
|
|
170
|
+
`information_schema.data_type`, which reports `numeric` for a `numeric(8,2)` column, and
|
|
171
|
+
concluded that comparing types would report differences that are not differences.
|
|
172
|
+
`pg_catalog.format_type(atttypid, atttypmod)` reports the canonical type *with* its
|
|
173
|
+
modifier - measured against every type this library renders, eleven of thirteen come back
|
|
174
|
+
as the exact string we wrote, and the two that do not are the timestamp aliases, which the
|
|
175
|
+
server itself will resolve for us.
|
|
176
|
+
|
|
177
|
+
What that cost while it was names-only, measured: a table whose `at` column is **`text`**
|
|
178
|
+
where the map says `timestamptz` passed this check and was reported as a good schema.
|
|
179
|
+
"""
|
|
180
|
+
expected = {
|
|
181
|
+
table: dict(layout.columns.get(entity, {}))
|
|
182
|
+
for entity, table in sorted(layout.tables.items())
|
|
183
|
+
}
|
|
184
|
+
if not expected:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
with self._cx.cursor() as cur:
|
|
188
|
+
# `pg_attribute` rather than `information_schema`, for `format_type`: the canonical
|
|
189
|
+
# spelling *including* the modifier, which is the whole reason this can compare types.
|
|
190
|
+
cur.execute(
|
|
191
|
+
"SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod) "
|
|
192
|
+
"FROM pg_attribute a "
|
|
193
|
+
"JOIN pg_class c ON c.oid = a.attrelid "
|
|
194
|
+
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
195
|
+
"WHERE n.nspname = current_schema() AND c.relname = ANY(%s) "
|
|
196
|
+
"AND a.attnum > 0 AND NOT a.attisdropped",
|
|
197
|
+
[sorted(expected)],
|
|
198
|
+
)
|
|
199
|
+
found: dict[str, dict[str, str]] = {}
|
|
200
|
+
for table_name, column_name, column_type in cur.fetchall():
|
|
201
|
+
found.setdefault(str(table_name), {})[str(column_name)] = str(column_type)
|
|
202
|
+
|
|
203
|
+
for table, columns in sorted(expected.items()):
|
|
204
|
+
actual = found.get(table)
|
|
205
|
+
if actual is None:
|
|
206
|
+
raise EngineError(
|
|
207
|
+
f"{table!r} does not exist after applying the schema. The statement reported "
|
|
208
|
+
f"success, so this is a permissions or search_path problem rather than a bad "
|
|
209
|
+
f"map."
|
|
210
|
+
)
|
|
211
|
+
missing = sorted(set(columns) - set(actual))
|
|
212
|
+
if missing:
|
|
213
|
+
raise EngineError(
|
|
214
|
+
f"{table!r} already existed with a different shape: the map needs {missing} "
|
|
215
|
+
f"and the table has {sorted(actual)}. `CREATE TABLE IF NOT EXISTS` keeps "
|
|
216
|
+
f"whatever is there, so this table came from somewhere else - an older map, "
|
|
217
|
+
f"another application, a migration run by hand. Refusing here rather than at "
|
|
218
|
+
f"the first insert, which would fail in your request path with an error naming "
|
|
219
|
+
f"a column and not the cause."
|
|
220
|
+
)
|
|
221
|
+
for column, declared in sorted(columns.items()):
|
|
222
|
+
reported = actual[column]
|
|
223
|
+
if self._same_type(declared, reported):
|
|
224
|
+
continue
|
|
225
|
+
raise EngineError(
|
|
226
|
+
f"{table}.{column} is {reported!r} and this map declares it {declared!r}. "
|
|
227
|
+
f"`CREATE TABLE IF NOT EXISTS` keeps a table of that name whatever shape it "
|
|
228
|
+
f"is in, and this library never alters a column's type - so the table came "
|
|
229
|
+
f"from somewhere else, or from a map that rendered this column differently. "
|
|
230
|
+
f"Refusing rather than writing into it: a type that differs is either a write "
|
|
231
|
+
f"that fails in your request path or, worse, one that succeeds and hands the "
|
|
232
|
+
f"value back as something else."
|
|
233
|
+
)
|
|
234
|
+
extra = sorted(set(actual) - set(columns))
|
|
235
|
+
if extra:
|
|
236
|
+
log("sde.schema.extra_columns", table=table, columns=extra)
|
|
237
|
+
|
|
238
|
+
def _same_type(self, declared: str, reported: str) -> bool:
|
|
239
|
+
"""Whether two PostgreSQL type spellings denote the same type. Asked of the server.
|
|
240
|
+
|
|
241
|
+
Literal first, because that is the answer for every type this library renders except the
|
|
242
|
+
two timestamps. When it fails, the server is asked - `to_regtype` resolves an alias to the
|
|
243
|
+
type it names, so `timestamptz` and `timestamp with time zone` come back equal without this
|
|
244
|
+
module holding a table of aliases that could fall behind the renderer.
|
|
245
|
+
|
|
246
|
+
**A modifier makes a literal mismatch a real one.** `to_regtype` discards modifiers, so
|
|
247
|
+
`numeric(12,2)` and `numeric(8,2)` would both resolve to `numeric` and a precision change
|
|
248
|
+
would read as agreement. That is the one difference this check exists to catch, so a
|
|
249
|
+
parenthesis on either side ends the question here.
|
|
250
|
+
"""
|
|
251
|
+
if declared == reported:
|
|
252
|
+
return True
|
|
253
|
+
if "(" in declared or "(" in reported:
|
|
254
|
+
return False
|
|
255
|
+
with self._cx.cursor() as cur:
|
|
256
|
+
cur.execute("SELECT to_regtype(%s)::text, to_regtype(%s)::text", [declared, reported])
|
|
257
|
+
row = cur.fetchone()
|
|
258
|
+
return bool(row is not None and row[0] is not None and row[0] == row[1])
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --- data ------------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def explain_plan(self, sql: str) -> QueryPlan:
|
|
264
|
+
"""Plan an analyst's query without running it, inside a read-only transaction.
|
|
265
|
+
|
|
266
|
+
Requirement 19.4. Two ``EXPLAIN``s in one transaction: ``FORMAT JSON`` for the numbers and
|
|
267
|
+
the node types, and the plain one for the text a person reads - the engine's own rendering
|
|
268
|
+
rather than mine, because a plan reformatted by us is a plan whose wording somebody will
|
|
269
|
+
compare against the documentation and not find.
|
|
270
|
+
|
|
271
|
+
``SET TRANSACTION READ ONLY``, and a **corrected** account of what it is for. The first
|
|
272
|
+
version of this docstring said planning could reach a write, because constant folding
|
|
273
|
+
evaluates immutable functions - ``EXPLAIN SELECT 1/0`` raises at plan time - so an
|
|
274
|
+
immutable function that lied about being immutable would run. Measured: it cannot.
|
|
275
|
+
PostgreSQL refuses the write itself, with *"INSERT is not allowed in a non-volatile
|
|
276
|
+
function"*, in a read-only transaction and outside one alike. And a ``VOLATILE`` function
|
|
277
|
+
is not folded at all. So **planning cannot write, and the read-only transaction guards
|
|
278
|
+
against nothing reachable through ``EXPLAIN`` today.**
|
|
279
|
+
|
|
280
|
+
It is kept, and the reason is an edit inside this block rather than a query outside it. One
|
|
281
|
+
word - ``ANALYZE`` - turns either statement below into an execution, and with the
|
|
282
|
+
transaction read-only that mistake fails loudly instead of running an analyst's
|
|
283
|
+
data-modifying CTE. A guard whose present hazard is zero and whose future hazard is one
|
|
284
|
+
keyword is worth a statement.
|
|
285
|
+
|
|
286
|
+
``force_rollback=True`` has **no observable effect through EXPLAIN** and is kept for the
|
|
287
|
+
same reason one level along: it covers what read-only does not - a temporary table, a
|
|
288
|
+
``SET LOCAL``, an advisory lock - none of which anything here creates today. Said plainly
|
|
289
|
+
rather than counted as a tested guarantee, because a mutation of it changes no output and
|
|
290
|
+
a mutation that changes no output is not one.
|
|
291
|
+
|
|
292
|
+
What gets reported is the transaction, not a claim about the query: this library has no SQL
|
|
293
|
+
parser and cannot say it checked the SQL.
|
|
294
|
+
"""
|
|
295
|
+
with self._cx.transaction(force_rollback=True) as _tx, self._cx.cursor() as cursor:
|
|
296
|
+
cursor.execute("SET TRANSACTION READ ONLY")
|
|
297
|
+
# Read it back, because a statement that says nothing does not mean something
|
|
298
|
+
# happened - the lesson three GitHub API endpoints taught this project by returning
|
|
299
|
+
# 200 and changing nothing. It also turns this guard from unverifiable into checked:
|
|
300
|
+
# without the read-back, deleting the line above changes no observable behaviour, so
|
|
301
|
+
# nothing could fail when somebody did.
|
|
302
|
+
cursor.execute("SHOW transaction_read_only")
|
|
303
|
+
state = cursor.fetchone()
|
|
304
|
+
if not state or str(state[0]).strip().lower() != "on":
|
|
305
|
+
raise EngineError(
|
|
306
|
+
f"this connection would not go read-only for planning: "
|
|
307
|
+
f"transaction_read_only is {state[0] if state else 'unreadable'!r}. Refusing "
|
|
308
|
+
f"to plan anything: requirement 19.2 keeps this product out of the data path, "
|
|
309
|
+
f"and the one keyword between EXPLAIN and execution is ANALYZE - measured, an "
|
|
310
|
+
f"EXPLAIN (ANALYZE) of a write runs in a read-write transaction and is "
|
|
311
|
+
f"refused in a read-only one."
|
|
312
|
+
)
|
|
313
|
+
try:
|
|
314
|
+
cursor.execute(f"EXPLAIN (FORMAT JSON) {sql}")
|
|
315
|
+
raw = cursor.fetchone()
|
|
316
|
+
cursor.execute(f"EXPLAIN {sql}")
|
|
317
|
+
text = tuple(str(row[0]) for row in cursor.fetchall())
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
# The refusal *is* the validation. Re-raised with the engine's own wording,
|
|
320
|
+
# because the case requirement 19.4 exists for - a column our map says exists
|
|
321
|
+
# and this database says does not - is one where the engine names the column
|
|
322
|
+
# and any summary of ours would lose it.
|
|
323
|
+
raise QueryPlanRefused(
|
|
324
|
+
f"{self.dialect} would not plan this query: {exc} Nothing was executed. "
|
|
325
|
+
f"This is what validating against a live engine means: the control plane "
|
|
326
|
+
f"checked the query against the schema it authored, and this is the schema "
|
|
327
|
+
f"that exists."
|
|
328
|
+
) from exc
|
|
329
|
+
if not raw or not raw[0]:
|
|
330
|
+
raise QueryPlanRefused(
|
|
331
|
+
f"{self.dialect} returned no plan for this query and did not say why"
|
|
332
|
+
)
|
|
333
|
+
node = dict(raw[0][0]).get("Plan") or {}
|
|
334
|
+
return QueryPlan(
|
|
335
|
+
engine=self.dialect,
|
|
336
|
+
dialect=self.dialect,
|
|
337
|
+
plan=text or ("(the engine returned an empty plan)",),
|
|
338
|
+
cost=Cost(
|
|
339
|
+
units="PostgreSQL planner cost units",
|
|
340
|
+
basis=(
|
|
341
|
+
"arbitrary units, not seconds and not rows. The figure depends on this "
|
|
342
|
+
"machine and on this database's planner settings (seq_page_cost, "
|
|
343
|
+
"random_page_cost, the cost constants), so it is comparable between two plans "
|
|
344
|
+
"on this database and meaningless against a number from anywhere else. "
|
|
345
|
+
"plan_rows is the planner's estimate of rows returned, from statistics that "
|
|
346
|
+
"are as fresh as the last ANALYZE."
|
|
347
|
+
),
|
|
348
|
+
values={
|
|
349
|
+
"total_cost": str(node.get("Total Cost", "")),
|
|
350
|
+
"startup_cost": str(node.get("Startup Cost", "")),
|
|
351
|
+
"plan_rows": str(node.get("Plan Rows", "")),
|
|
352
|
+
"plan_width": str(node.get("Plan Width", "")),
|
|
353
|
+
"root_node": str(node.get("Node Type", "")),
|
|
354
|
+
},
|
|
355
|
+
),
|
|
356
|
+
findings=postgres_findings(node),
|
|
357
|
+
read_only_enforced=(
|
|
358
|
+
"SET TRANSACTION READ ONLY, and the transaction is rolled back. PostgreSQL "
|
|
359
|
+
"refuses a write in such a transaction whatever the statement looks like - "
|
|
360
|
+
"measured: a data-modifying CTE fails with 'cannot execute SELECT in a read-only "
|
|
361
|
+
"transaction'."
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def insert(self, table: str, values: Mapping[str, Any]) -> None:
|
|
366
|
+
if not values:
|
|
367
|
+
raise EngineError("nothing to insert")
|
|
368
|
+
cols = sorted(values)
|
|
369
|
+
placeholders = ", ".join(["%s"] * len(cols))
|
|
370
|
+
sql = (
|
|
371
|
+
f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
|
|
372
|
+
f"VALUES ({placeholders})"
|
|
373
|
+
)
|
|
374
|
+
try:
|
|
375
|
+
with self._cx.cursor() as cur:
|
|
376
|
+
cur.execute(sql, [values[c] for c in cols])
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
# Surfaced, not swallowed and not rerouted. See the module docstring.
|
|
379
|
+
log("sde.write.failed", table=table, error=type(exc).__name__)
|
|
380
|
+
raise EngineError(f"insert into {table} failed: {self._explain(exc)}") from exc
|
|
381
|
+
|
|
382
|
+
def get(self, table: str, key: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
383
|
+
where = " AND ".join(f"{_quote(c)} = %s" for c in sorted(key))
|
|
384
|
+
sql = f"SELECT * FROM {_quote(table)} WHERE {where}"
|
|
385
|
+
try:
|
|
386
|
+
with self._cx.cursor() as cur:
|
|
387
|
+
cur.execute(sql, [key[c] for c in sorted(key)])
|
|
388
|
+
row = cur.fetchone()
|
|
389
|
+
if row is None:
|
|
390
|
+
return None
|
|
391
|
+
names = [d.name for d in cur.description or ()]
|
|
392
|
+
return dict(zip(names, row, strict=True))
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
raise EngineError(f"select from {table} failed: {self._explain(exc)}") from exc
|
|
395
|
+
|
|
396
|
+
# --- rollback protection ------------------------------------------------------------------
|
|
397
|
+
#
|
|
398
|
+
# Two methods that satisfy `sde.watermark.WatermarkStore`, which is a separate optional
|
|
399
|
+
# protocol rather than part of `Engine`: adding these to `Engine` would break every adapter
|
|
400
|
+
# anybody has written, for a capability our own orderbook engine cannot provide.
|
|
401
|
+
|
|
402
|
+
def map_watermark(self) -> int | None:
|
|
403
|
+
"""The highest map version applied against this engine, creating the table if missing.
|
|
404
|
+
|
|
405
|
+
Creating on read rather than on write, and that closes a real gap: with the table appearing
|
|
406
|
+
only on the first write, the first load of a signed map has nothing to compare against and a
|
|
407
|
+
file swapped immediately after a deployment goes unnoticed. Reading is also the cheaper of
|
|
408
|
+
the two paths to make idempotent - `CREATE TABLE IF NOT EXISTS` here costs one statement per
|
|
409
|
+
session, once per process.
|
|
410
|
+
"""
|
|
411
|
+
try:
|
|
412
|
+
with self._cx.cursor() as cur:
|
|
413
|
+
cur.execute(
|
|
414
|
+
f"CREATE TABLE IF NOT EXISTS {_quote(WATERMARK_TABLE)} ("
|
|
415
|
+
f"{_quote('map_version')} bigint NOT NULL, "
|
|
416
|
+
f"{_quote('model_version')} text NOT NULL, "
|
|
417
|
+
f"{_quote('seen_at')} timestamptz NOT NULL DEFAULT now())"
|
|
418
|
+
)
|
|
419
|
+
cur.execute(f"SELECT max({_quote('map_version')}) FROM {_quote(WATERMARK_TABLE)}")
|
|
420
|
+
row = cur.fetchone()
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
raise EngineError(f"reading {WATERMARK_TABLE} failed: {self._explain(exc)}") from exc
|
|
423
|
+
if row is None or row[0] is None:
|
|
424
|
+
return None
|
|
425
|
+
return int(row[0])
|
|
426
|
+
|
|
427
|
+
def record_map_version(self, version: int, *, model_version: str) -> None:
|
|
428
|
+
"""Append. Never update, so there is nothing to contend over and nothing to lose.
|
|
429
|
+
|
|
430
|
+
The timestamp comes from the engine's own `now()` rather than from this process: an audit
|
|
431
|
+
column wants the clock of the thing being audited, and this library reading a clock is a
|
|
432
|
+
thing its tests would then have to work around.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
with self._cx.cursor() as cur:
|
|
436
|
+
cur.execute(
|
|
437
|
+
f"INSERT INTO {_quote(WATERMARK_TABLE)} "
|
|
438
|
+
f"({_quote('map_version')}, {_quote('model_version')}) VALUES (%s, %s)",
|
|
439
|
+
[version, model_version],
|
|
440
|
+
)
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
raise EngineError(
|
|
443
|
+
f"recording a map version in {WATERMARK_TABLE} failed: {exc}"
|
|
444
|
+
) from exc
|
|
445
|
+
|
|
446
|
+
def range(
|
|
447
|
+
self,
|
|
448
|
+
table: str,
|
|
449
|
+
column: str,
|
|
450
|
+
*,
|
|
451
|
+
low: Any = None,
|
|
452
|
+
high: Any = None,
|
|
453
|
+
limit: int | None = None,
|
|
454
|
+
) -> list[dict[str, Any]]:
|
|
455
|
+
clauses: list[str] = []
|
|
456
|
+
params: list[Any] = []
|
|
457
|
+
if low is not None:
|
|
458
|
+
clauses.append(f"{_quote(column)} >= %s")
|
|
459
|
+
params.append(low)
|
|
460
|
+
if high is not None:
|
|
461
|
+
clauses.append(f"{_quote(column)} < %s")
|
|
462
|
+
params.append(high)
|
|
463
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
464
|
+
order = f" ORDER BY {_quote(column)}"
|
|
465
|
+
cap = ""
|
|
466
|
+
if limit is not None:
|
|
467
|
+
cap = " LIMIT %s"
|
|
468
|
+
params.append(limit)
|
|
469
|
+
sql = f"SELECT * FROM {_quote(table)}{where}{order}{cap}"
|
|
470
|
+
try:
|
|
471
|
+
with self._cx.cursor() as cur:
|
|
472
|
+
cur.execute(sql, params)
|
|
473
|
+
names = [d.name for d in cur.description or ()]
|
|
474
|
+
return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
raise EngineError(f"range select from {table} failed: {exc}") from exc
|
|
477
|
+
|
|
478
|
+
def count(self, table: str) -> int:
|
|
479
|
+
try:
|
|
480
|
+
with self._cx.cursor() as cur:
|
|
481
|
+
cur.execute(f"SELECT count(*) FROM {_quote(table)}")
|
|
482
|
+
row = cur.fetchone()
|
|
483
|
+
return int(row[0]) if row else 0
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
raise EngineError(f"count on {table} failed: {exc}") from exc
|
|
486
|
+
|
|
487
|
+
# --- migration ------------------------------------------------------------------------------
|
|
488
|
+
#
|
|
489
|
+
# Five methods that satisfy `sde.migration.Migratable`, which like `WatermarkStore` is a
|
|
490
|
+
# separate optional protocol. Same reason: our own orderbook engine cannot offer any of them -
|
|
491
|
+
# its schema is fixed in its own source and it has nowhere to keep a marker - and an engine that
|
|
492
|
+
# cannot take part in a migration should be a named refusal rather than a broken adapter.
|
|
493
|
+
|
|
494
|
+
def key_range(
|
|
495
|
+
self,
|
|
496
|
+
table: str,
|
|
497
|
+
order: Sequence[str],
|
|
498
|
+
*,
|
|
499
|
+
after: Sequence[Any] | None = None,
|
|
500
|
+
upto: Sequence[Any] | None = None,
|
|
501
|
+
limit: int | None = None,
|
|
502
|
+
) -> list[dict[str, Any]]:
|
|
503
|
+
"""Rows in key order, strictly after one key and up to another inclusive.
|
|
504
|
+
|
|
505
|
+
Row-value comparison - ``(a, b) > (%s, %s)`` - rather than a hand-rolled disjunction over
|
|
506
|
+
the key's columns. The disjunction is where composite-key pagination goes wrong, and it goes
|
|
507
|
+
wrong by skipping rows.
|
|
508
|
+
|
|
509
|
+
The bounds are asymmetric on purpose. ``after`` is exclusive because it is a resume point:
|
|
510
|
+
the row it names has been dealt with. ``upto`` is inclusive because it names the last row of
|
|
511
|
+
a chunk read from somewhere else, and that row is one this range has to include.
|
|
512
|
+
"""
|
|
513
|
+
cols = key_columns(order, table)
|
|
514
|
+
clauses: list[str] = []
|
|
515
|
+
params: list[Any] = []
|
|
516
|
+
tuple_expr = f"({', '.join(_quote(c) for c in cols)})"
|
|
517
|
+
if after is not None:
|
|
518
|
+
same_width(after, cols, "after")
|
|
519
|
+
clauses.append(f"{tuple_expr} > ({', '.join(['%s'] * len(cols))})")
|
|
520
|
+
params.extend(after)
|
|
521
|
+
if upto is not None:
|
|
522
|
+
same_width(upto, cols, "upto")
|
|
523
|
+
clauses.append(f"{tuple_expr} <= ({', '.join(['%s'] * len(cols))})")
|
|
524
|
+
params.extend(upto)
|
|
525
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
526
|
+
cap = ""
|
|
527
|
+
if limit is not None:
|
|
528
|
+
cap = " LIMIT %s"
|
|
529
|
+
params.append(int(limit))
|
|
530
|
+
sql = f"SELECT * FROM {_quote(table)}{where} ORDER BY {tuple_expr}{cap}"
|
|
531
|
+
try:
|
|
532
|
+
with self._cx.cursor() as cur:
|
|
533
|
+
cur.execute(sql, params)
|
|
534
|
+
names = [d.name for d in cur.description or ()]
|
|
535
|
+
return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
raise EngineError(f"key range select from {table} failed: {exc}") from exc
|
|
538
|
+
|
|
539
|
+
def nth_key(
|
|
540
|
+
self, table: str, order: Sequence[str], *, position: int
|
|
541
|
+
) -> tuple[Any, ...] | None:
|
|
542
|
+
"""The key of the ``position``-th row in key order, one-based, or None if there is no such
|
|
543
|
+
row.
|
|
544
|
+
|
|
545
|
+
An ``OFFSET`` scan, which is the expensive kind of query, and it is here because it is paid
|
|
546
|
+
**once per resume** rather than once per chunk. See :mod:`sde.migration` for why the marker
|
|
547
|
+
is a row count and not a key.
|
|
548
|
+
"""
|
|
549
|
+
cols = key_columns(order, table)
|
|
550
|
+
if position < 1:
|
|
551
|
+
raise EngineError(f"position is one-based; {position} is not a row")
|
|
552
|
+
projection = ", ".join(_quote(c) for c in cols)
|
|
553
|
+
sql = (
|
|
554
|
+
f"SELECT {projection} FROM {_quote(table)} ORDER BY ({projection}) "
|
|
555
|
+
f"OFFSET %s LIMIT 1"
|
|
556
|
+
)
|
|
557
|
+
try:
|
|
558
|
+
with self._cx.cursor() as cur:
|
|
559
|
+
cur.execute(sql, [position - 1])
|
|
560
|
+
row = cur.fetchone()
|
|
561
|
+
except Exception as exc:
|
|
562
|
+
raise EngineError(f"reading row {position} of {table} failed: {exc}") from exc
|
|
563
|
+
return None if row is None else tuple(row)
|
|
564
|
+
|
|
565
|
+
def copy_in(self, table: str, rows: Sequence[Mapping[str, Any]]) -> None:
|
|
566
|
+
"""Insert rows, skipping any whose key is already there.
|
|
567
|
+
|
|
568
|
+
``ON CONFLICT DO NOTHING`` is what makes a backfill chunk **idempotent**, and idempotence is
|
|
569
|
+
what makes it resumable: the marker is written after the chunk, so a crash in between costs
|
|
570
|
+
a recopy and never a lost row. Without it the recopy would be a primary-key violation and
|
|
571
|
+
the safe failure mode would become the loud one.
|
|
572
|
+
|
|
573
|
+
The bare form, with no conflict target, so it covers the primary key and any unique index
|
|
574
|
+
the layout asked for. Naming the key here would mean deriving it a second time, and two
|
|
575
|
+
derivations of one key is how they come to disagree.
|
|
576
|
+
|
|
577
|
+
Returns nothing on purpose. PostgreSQL can say how many rows it actually wrote and
|
|
578
|
+
ClickHouse cannot, so a count here would mean different things in different engines - and
|
|
579
|
+
the caller needs "how much of the source have I consumed", which it already knows.
|
|
580
|
+
"""
|
|
581
|
+
if not rows:
|
|
582
|
+
return
|
|
583
|
+
cols = sorted(rows[0])
|
|
584
|
+
for row in rows:
|
|
585
|
+
if sorted(row) != cols:
|
|
586
|
+
raise EngineError(
|
|
587
|
+
f"copy_in into {table} was given rows with different columns "
|
|
588
|
+
f"({cols} and {sorted(row)}). A chunk comes from one table, so this is a "
|
|
589
|
+
f"caller assembling it from two."
|
|
590
|
+
)
|
|
591
|
+
placeholders = ", ".join(f"({', '.join(['%s'] * len(cols))})" for _ in rows)
|
|
592
|
+
sql = (
|
|
593
|
+
f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
|
|
594
|
+
f"VALUES {placeholders} ON CONFLICT DO NOTHING"
|
|
595
|
+
)
|
|
596
|
+
params: list[Any] = []
|
|
597
|
+
for row in rows:
|
|
598
|
+
params.extend(row[c] for c in cols)
|
|
599
|
+
try:
|
|
600
|
+
with self._cx.cursor() as cur:
|
|
601
|
+
cur.execute(sql, params)
|
|
602
|
+
except Exception as exc:
|
|
603
|
+
log("sde.write.failed", table=table, error=type(exc).__name__)
|
|
604
|
+
raise EngineError(f"copying {len(rows)} rows into {table} failed: {exc}") from exc
|
|
605
|
+
|
|
606
|
+
def backfill_marker(self, *, materialization: str, entity: str) -> int:
|
|
607
|
+
"""How many rows of this entity have been copied into this engine. Zero if none.
|
|
608
|
+
|
|
609
|
+
`max()` over an append-only table, exactly like the map watermark, and for the same reason:
|
|
610
|
+
no row to update, nothing to contend over, and identical semantics in an engine with no
|
|
611
|
+
unique constraint. A stale row can never lower the marker.
|
|
612
|
+
"""
|
|
613
|
+
try:
|
|
614
|
+
with self._cx.cursor() as cur:
|
|
615
|
+
cur.execute(
|
|
616
|
+
f"CREATE TABLE IF NOT EXISTS {_quote(BACKFILL_TABLE)} ("
|
|
617
|
+
f"{_quote('materialization')} text NOT NULL, "
|
|
618
|
+
f"{_quote('entity')} text NOT NULL, "
|
|
619
|
+
f"{_quote('rows_copied')} bigint NOT NULL, "
|
|
620
|
+
f"{_quote('at')} timestamptz NOT NULL DEFAULT now())"
|
|
621
|
+
)
|
|
622
|
+
cur.execute(
|
|
623
|
+
f"SELECT max({_quote('rows_copied')}) FROM {_quote(BACKFILL_TABLE)} "
|
|
624
|
+
f"WHERE {_quote('materialization')} = %s AND {_quote('entity')} = %s",
|
|
625
|
+
[materialization, entity],
|
|
626
|
+
)
|
|
627
|
+
row = cur.fetchone()
|
|
628
|
+
except Exception as exc:
|
|
629
|
+
raise EngineError(f"reading {BACKFILL_TABLE} failed: {exc}") from exc
|
|
630
|
+
if row is None or row[0] is None:
|
|
631
|
+
return 0
|
|
632
|
+
return int(row[0])
|
|
633
|
+
|
|
634
|
+
def record_backfill_marker(
|
|
635
|
+
self, *, materialization: str, entity: str, rows: int
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Append the new marker. Never update, so an interrupted run leaves a readable trail."""
|
|
638
|
+
try:
|
|
639
|
+
with self._cx.cursor() as cur:
|
|
640
|
+
cur.execute(
|
|
641
|
+
f"INSERT INTO {_quote(BACKFILL_TABLE)} ({_quote('materialization')}, "
|
|
642
|
+
f"{_quote('entity')}, {_quote('rows_copied')}) VALUES (%s, %s, %s)",
|
|
643
|
+
[materialization, entity, int(rows)],
|
|
644
|
+
)
|
|
645
|
+
except Exception as exc:
|
|
646
|
+
raise EngineError(
|
|
647
|
+
f"recording backfill progress in {BACKFILL_TABLE} failed: {exc}"
|
|
648
|
+
) from exc
|
|
649
|
+
|
|
650
|
+
# --- transactions ----------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
@contextmanager
|
|
653
|
+
def transaction(self) -> Iterator[PostgresEngine]:
|
|
654
|
+
"""One engine, one transaction, that engine's semantics.
|
|
655
|
+
|
|
656
|
+
There is no distributed transaction here and there will not be one. A client needing two
|
|
657
|
+
entities to commit together declares that, and the planner puts them in the same group and
|
|
658
|
+
therefore the same engine - so the requirement turns into a placement constraint instead
|
|
659
|
+
of a two-phase commit. That is the trade this product makes, and it is why this method is
|
|
660
|
+
four lines rather than a subsystem.
|
|
661
|
+
"""
|
|
662
|
+
cx = self._cx
|
|
663
|
+
previous = cx.autocommit
|
|
664
|
+
cx.autocommit = False
|
|
665
|
+
try:
|
|
666
|
+
yield self
|
|
667
|
+
cx.commit()
|
|
668
|
+
except Exception:
|
|
669
|
+
cx.rollback()
|
|
670
|
+
raise
|
|
671
|
+
finally:
|
|
672
|
+
cx.autocommit = previous
|