feather-cdc-postgres 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.
- feather_cdc_postgres/__init__.py +6 -0
- feather_cdc_postgres/__main__.py +9 -0
- feather_cdc_postgres/adapters/__init__.py +44 -0
- feather_cdc_postgres/adapters/featherbase.py +126 -0
- feather_cdc_postgres/cli.py +247 -0
- feather_cdc_postgres/destination.py +640 -0
- feather_cdc_postgres/discovery.py +216 -0
- feather_cdc_postgres/drift.py +207 -0
- feather_cdc_postgres/generate_dbt.py +106 -0
- feather_cdc_postgres/lifecycle.py +79 -0
- feather_cdc_postgres/manifest.py +587 -0
- feather_cdc_postgres/ownership.py +103 -0
- feather_cdc_postgres/publication.py +157 -0
- feather_cdc_postgres/runner.py +1399 -0
- feather_cdc_postgres/schema_registry.py +283 -0
- feather_cdc_postgres/selectors.py +347 -0
- feather_cdc_postgres/snapshot.py +448 -0
- feather_cdc_postgres/stream.py +162 -0
- feather_cdc_postgres/telemetry.py +172 -0
- feather_cdc_postgres-0.1.0.dist-info/METADATA +239 -0
- feather_cdc_postgres-0.1.0.dist-info/RECORD +30 -0
- feather_cdc_postgres-0.1.0.dist-info/WHEEL +4 -0
- feather_cdc_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- pg_replication/LICENSE +201 -0
- pg_replication/README.md +100 -0
- pg_replication/__init__.py +106 -0
- pg_replication/decoders.py +428 -0
- pg_replication/exceptions.py +6 -0
- pg_replication/helpers.py +1405 -0
- pg_replication/schema_types.py +156 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""MotherDuck / duckdb load contract + runtime metadata (#3079 Deviation D-2).
|
|
2
|
+
|
|
3
|
+
One dlt pipeline PER TARGET SCHEMA (= one dlt dataset; a relation's target comes from
|
|
4
|
+
`destination.table_schemas`, then `schema_mapping`), so two source schemas holding the same
|
|
5
|
+
table name never collide and one source schema can fan out into several bronze schemas (D-13). Checkpoint, lifecycle and schema registry
|
|
6
|
+
live in `<database>._cdc_runtime` written through a PLAIN duckdb connection — no DDL on the
|
|
7
|
+
shared control plane (#3091 deadlock) and no dependence on opaque dlt state.
|
|
8
|
+
duckdb and MotherDuck differ only by the credentials string.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as _dt
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterable, Sequence
|
|
16
|
+
|
|
17
|
+
import dlt
|
|
18
|
+
import duckdb
|
|
19
|
+
from dlt.destinations.impl.motherduck.configuration import MotherDuckCredentials
|
|
20
|
+
|
|
21
|
+
RUNTIME_SCHEMA = "_cdc_runtime"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def q(identifier: str) -> str:
|
|
25
|
+
"""One duckdb identifier, quoted. An embedded double quote is doubled, never dropped."""
|
|
26
|
+
return '"' + str(identifier).replace('"', '""') + '"'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DestinationSpec:
|
|
31
|
+
kind: str # "duckdb" | "motherduck"
|
|
32
|
+
database: str # MotherDuck database name, or the logical name for duckdb
|
|
33
|
+
token_env: str | None = None # motherduck: env var NAME holding the token (never the value)
|
|
34
|
+
path: str | None = None # duckdb: file path
|
|
35
|
+
pipelines_dir: str | None = None
|
|
36
|
+
owner: str | None = None # motherduck: the account the token must sign in as (#4427)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WrongOwner(RuntimeError):
|
|
40
|
+
"""The token signs in as a different MotherDuck account than the registration declares."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Destination:
|
|
44
|
+
def __init__(self, spec: DestinationSpec) -> None:
|
|
45
|
+
self.spec = spec
|
|
46
|
+
if spec.kind not in ("duckdb", "motherduck"):
|
|
47
|
+
raise ValueError(f"unsupported destination kind {spec.kind!r}")
|
|
48
|
+
self._ensured = False
|
|
49
|
+
|
|
50
|
+
# --- credentials ---
|
|
51
|
+
def _token(self) -> str:
|
|
52
|
+
token = os.environ.get(self.spec.token_env or "")
|
|
53
|
+
if not token:
|
|
54
|
+
raise ValueError(f"env var {self.spec.token_env!r} is not set (no token value is logged)")
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
def credentials(self):
|
|
58
|
+
"""duckdb: the file path. motherduck: a typed `MotherDuckCredentials` whose `str()`
|
|
59
|
+
masks the password — NEVER `md:<db>?motherduck_token=<token>`, because duckdb echoes a
|
|
60
|
+
connection string back in its exception text and Railway logs it (#3079 review)."""
|
|
61
|
+
if self.spec.kind == "duckdb":
|
|
62
|
+
if not self.spec.path:
|
|
63
|
+
raise ValueError("duckdb destination needs `path`")
|
|
64
|
+
return self.spec.path
|
|
65
|
+
creds = MotherDuckCredentials()
|
|
66
|
+
creds.database = self.spec.database
|
|
67
|
+
creds.password = self._token()
|
|
68
|
+
return creds
|
|
69
|
+
|
|
70
|
+
def _dlt_destination(self):
|
|
71
|
+
if self.spec.kind == "duckdb":
|
|
72
|
+
return dlt.destinations.duckdb(credentials=self.credentials())
|
|
73
|
+
return dlt.destinations.motherduck(credentials=self.credentials())
|
|
74
|
+
|
|
75
|
+
def ensure_database(self) -> None:
|
|
76
|
+
"""MotherDuck: create the destination database if it is missing (#4426).
|
|
77
|
+
|
|
78
|
+
`md:<db>` will not open a database that does not exist, so a new registration's first
|
|
79
|
+
run would die on attach. The create runs on bare `md:` under the run's own token, so that
|
|
80
|
+
account owns what it will write (write = ownership). Call it only from `run`/`resnapshot`,
|
|
81
|
+
after every refusal; `validate`/`plan`/`status` must never create.
|
|
82
|
+
"""
|
|
83
|
+
if self.spec.kind != "motherduck":
|
|
84
|
+
return
|
|
85
|
+
from .manifest import database_name_problem
|
|
86
|
+
why = database_name_problem(self.spec.database)
|
|
87
|
+
if why:
|
|
88
|
+
raise ValueError(why)
|
|
89
|
+
self._on_bare_md(f"could not create MotherDuck database {self.spec.database!r}",
|
|
90
|
+
lambda con: con.execute(
|
|
91
|
+
f"create database if not exists {q(self.spec.database)}"))
|
|
92
|
+
|
|
93
|
+
def verify_owner(self) -> None:
|
|
94
|
+
"""MotherDuck: refuse unless the token signs in as the declared owner (#4427).
|
|
95
|
+
|
|
96
|
+
Read-only (`select current_user()` on bare `md:`). Without it, a valid token for the
|
|
97
|
+
wrong account would create the database in that account and load into it."""
|
|
98
|
+
if self.spec.kind != "motherduck":
|
|
99
|
+
return
|
|
100
|
+
if not self.spec.owner:
|
|
101
|
+
raise ValueError("destination.owner is not set: no account to check the token against")
|
|
102
|
+
row = self._on_bare_md("could not read the MotherDuck account the token signs in as",
|
|
103
|
+
lambda con: con.execute("select current_user()").fetchone())
|
|
104
|
+
actual = str(row[0]) if row else ""
|
|
105
|
+
if actual != self.spec.owner:
|
|
106
|
+
raise WrongOwner(f"the MotherDuck token signs in as {actual!r}, but the registration "
|
|
107
|
+
f"declares destination.owner {self.spec.owner!r} — refusing to "
|
|
108
|
+
"create or write anything")
|
|
109
|
+
|
|
110
|
+
def _on_bare_md(self, what: str, fn):
|
|
111
|
+
"""Run `fn(con)` on a bare `md:` connection, the token in `config=` only. Any failure is
|
|
112
|
+
re-raised as a RuntimeError with the token scrubbed and no chained cause."""
|
|
113
|
+
token = self._token()
|
|
114
|
+
try:
|
|
115
|
+
with duckdb.connect("md:", config={"motherduck_token": token}) as con:
|
|
116
|
+
return fn(con)
|
|
117
|
+
except Exception as exc: # noqa: BLE001 — re-raised below without the token
|
|
118
|
+
detail = f"{type(exc).__name__}: {exc}".replace(token, "***redacted***")
|
|
119
|
+
# `from None`: a chained cause would carry the unredacted text into the traceback.
|
|
120
|
+
raise RuntimeError(f"{what}: {detail}") from None
|
|
121
|
+
|
|
122
|
+
def connect(self) -> duckdb.DuckDBPyConnection:
|
|
123
|
+
if self.spec.kind == "duckdb":
|
|
124
|
+
return duckdb.connect(self.credentials())
|
|
125
|
+
# config= keeps the token out of the connection string duckdb quotes in errors.
|
|
126
|
+
return duckdb.connect(f"md:{self.spec.database}",
|
|
127
|
+
config={"motherduck_token": self._token()})
|
|
128
|
+
|
|
129
|
+
# --- pipelines ---
|
|
130
|
+
def pipeline_for_target(self, target_schema: str, pipeline_name: str | None = None, *,
|
|
131
|
+
source_id: str = "pg") -> dlt.Pipeline:
|
|
132
|
+
"""The pipeline whose dataset is `target_schema`; named after it unless pinned."""
|
|
133
|
+
name = pipeline_name or f"{source_id}_cdc__{target_schema}"
|
|
134
|
+
kwargs = {}
|
|
135
|
+
if self.spec.pipelines_dir:
|
|
136
|
+
kwargs["pipelines_dir"] = self.spec.pipelines_dir
|
|
137
|
+
return dlt.pipeline(pipeline_name=name, destination=self._dlt_destination(),
|
|
138
|
+
dataset_name=target_schema, **kwargs)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def table_of(item) -> str | None:
|
|
142
|
+
"""The table a marked item is bound for (TableNameMeta / HintsMeta), else None."""
|
|
143
|
+
from dlt.extract.hints import HintsMeta
|
|
144
|
+
from dlt.extract.items import DataItemWithMeta, TableNameMeta
|
|
145
|
+
if isinstance(item, DataItemWithMeta):
|
|
146
|
+
if isinstance(item.meta, TableNameMeta):
|
|
147
|
+
return item.meta.table_name
|
|
148
|
+
if isinstance(item.meta, HintsMeta):
|
|
149
|
+
return item.meta.hints.get("table_name")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def rounds(items) -> list[list]:
|
|
154
|
+
"""Split one batch into load ROUNDS so no dlt resource carries two descriptions of a
|
|
155
|
+
table (#4345). A table renamed or altered mid-batch arrives as `hints, rows, hints,
|
|
156
|
+
rows`; in one resource the LAST hints win for every row, so a column dropped and re-
|
|
157
|
+
added with another type pushed the earlier rows into a variant column. Round k holds
|
|
158
|
+
the k-th description group of every table, in WAL order — the per-description loads
|
|
159
|
+
the stream made before it learnt to decode through a Relation message. A batch with
|
|
160
|
+
one group per table is one round, as before."""
|
|
161
|
+
from dlt.extract.hints import HintsMeta
|
|
162
|
+
from dlt.extract.items import DataItemWithMeta
|
|
163
|
+
groups: dict[str, list[list]] = {}
|
|
164
|
+
for item in items:
|
|
165
|
+
table = Destination.table_of(item)
|
|
166
|
+
segments = groups.setdefault(table, [])
|
|
167
|
+
starts = (isinstance(item, DataItemWithMeta) and isinstance(item.meta, HintsMeta))
|
|
168
|
+
if not segments or (starts and segments[-1]):
|
|
169
|
+
segments.append([])
|
|
170
|
+
segments[-1].append(item)
|
|
171
|
+
depth = max((len(s) for s in groups.values()), default=0)
|
|
172
|
+
return [[item for segments in groups.values() if k < len(segments)
|
|
173
|
+
for item in segments[k]] for k in range(depth)]
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def finish_pending(pipeline: dlt.Pipeline) -> bool:
|
|
177
|
+
"""Load what a crashed run left extracted or normalized but unloaded. dlt's `run`
|
|
178
|
+
loads such a PENDING package and returns WITHOUT extracting the data it was handed
|
|
179
|
+
(#4345 Codex adversarial), so a replay must finish it first — before re-applying the
|
|
180
|
+
batch's truncates, and before loading any round."""
|
|
181
|
+
if not pipeline.has_pending_data:
|
|
182
|
+
return False
|
|
183
|
+
pipeline.run()
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def load(pipeline: dlt.Pipeline, items, *, resource_name: str = "cdc"):
|
|
188
|
+
"""Run one batch through a pipeline as ONE dlt resource PER TABLE, named after the
|
|
189
|
+
table. dlt records the resource that last wrote a table in the schema's `resource`
|
|
190
|
+
attribute, and `refresh="drop_resources"` finds tables by that attribute: a CDC batch
|
|
191
|
+
loaded under a resource named after the slot flipped it, and every later re-baseline
|
|
192
|
+
of that table (slot loss, re-widening) then dropped NOTHING and doubled an append_only
|
|
193
|
+
copy (#3079 final review). `resource_name` only names items carrying no table mark.
|
|
194
|
+
The caller checkpoints only after this returns."""
|
|
195
|
+
by_table: dict[str, list] = {}
|
|
196
|
+
for item in items:
|
|
197
|
+
by_table.setdefault(Destination.table_of(item) or resource_name, []).append(item)
|
|
198
|
+
Destination.finish_pending(pipeline) # never let `run` skip the items handed to it
|
|
199
|
+
return pipeline.run([dlt.resource(v, name=k) for k, v in by_table.items()])
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def row_counts(pipeline: dlt.Pipeline) -> dict[str, int]:
|
|
203
|
+
try:
|
|
204
|
+
trace = pipeline.last_trace
|
|
205
|
+
counts = trace.last_normalize_info.row_counts if trace and trace.last_normalize_info else {}
|
|
206
|
+
return {t: n for t, n in counts.items() if not t.startswith("_dlt")}
|
|
207
|
+
except Exception: # noqa: BLE001 — telemetry must never fail the drain
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
# --- runtime metadata ---
|
|
211
|
+
def ensure_runtime_tables(self) -> None:
|
|
212
|
+
if self._ensured:
|
|
213
|
+
return
|
|
214
|
+
with self.connect() as con:
|
|
215
|
+
con.execute(f'create schema if not exists "{RUNTIME_SCHEMA}"')
|
|
216
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".checkpoint (
|
|
217
|
+
registration varchar, slot varchar, last_commit_lsn bigint,
|
|
218
|
+
load_ids varchar, committed_at timestamptz)''')
|
|
219
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".lifecycle (
|
|
220
|
+
registration varchar, "schema" varchar, "table" varchar,
|
|
221
|
+
state varchar, since timestamptz, detail varchar)''')
|
|
222
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".schema_registry (
|
|
223
|
+
registration varchar, "schema" varchar, "table" varchar, "column" varchar,
|
|
224
|
+
type_oid bigint, typmod bigint, not_null boolean, key_role varchar,
|
|
225
|
+
dest_type varchar, mapping varchar, seen_at timestamptz)''')
|
|
226
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".signature (
|
|
227
|
+
registration varchar, "schema" varchar, "table" varchar,
|
|
228
|
+
signature_json varchar, seen_at timestamptz)''')
|
|
229
|
+
# The consistent point each relation's CURRENT baseline was taken at: the drain
|
|
230
|
+
# drops that relation's transactions with commit LSN below it (snapshot.py).
|
|
231
|
+
# Every OID a column-denied name resolved to (#4346): the deny follows the table
|
|
232
|
+
# through a rename, and survives a new table taking the old name.
|
|
233
|
+
# `attnum` binds the deny to the COLUMN's identity too, so it follows a column
|
|
234
|
+
# rename the way `oid` follows a table rename (Sonnet review F1).
|
|
235
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".denied_oid (
|
|
236
|
+
registration varchar, oid bigint, origin_schema varchar, origin_table varchar,
|
|
237
|
+
"column" varchar, attnum bigint, first_seen timestamptz)''')
|
|
238
|
+
# Relations whose changes the decoder skipped because a description did not match
|
|
239
|
+
# the reviewed layout (#4346): bronze is stale for them until a resnapshot clears
|
|
240
|
+
# the row. Keyed by `oid` (Codex round 17); schema/table are reporting metadata.
|
|
241
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".deny_quarantine (
|
|
242
|
+
registration varchar, "schema" varchar, "table" varchar, reason varchar,
|
|
243
|
+
since timestamptz, oid bigint)''')
|
|
244
|
+
# Each baselined relation's published column description (helpers
|
|
245
|
+
# `ReviewedLayout.to_json`), read under the baseline's locks (#4346): a decoded
|
|
246
|
+
# description of a relation that carries a deny must match it exactly.
|
|
247
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".reviewed_layout (
|
|
248
|
+
registration varchar, oid bigint, "schema" varchar, "table" varchar,
|
|
249
|
+
layout_json varchar, reviewed_at timestamptz)''')
|
|
250
|
+
con.execute(f'''create table if not exists "{RUNTIME_SCHEMA}".baseline (
|
|
251
|
+
registration varchar, "schema" varchar, "table" varchar,
|
|
252
|
+
boundary_lsn bigint, taken_at timestamptz)''')
|
|
253
|
+
self._ensured = True
|
|
254
|
+
|
|
255
|
+
def _read_metadata_with_retry(self, read_fn, *, max_wait_s: float = 40.0,
|
|
256
|
+
backoff_s: float = 2.0):
|
|
257
|
+
"""Retry a `_cdc_runtime` metadata READ against MotherDuck's catalog lag
|
|
258
|
+
(docs/agents/gotchas/motherduck-catalog-lags-a-write.md): a table created on one
|
|
259
|
+
connection can be invisible to a fresh connection for ~25s. Retries ONLY a
|
|
260
|
+
`duckdb.CatalogException` whose message names `_cdc_runtime` or "does not exist" —
|
|
261
|
+
every other exception (e.g. `duckdb.BinderException`) propagates immediately, and
|
|
262
|
+
WRITES never call this. Logs one line per retry."""
|
|
263
|
+
import time
|
|
264
|
+
deadline = time.monotonic() + max_wait_s
|
|
265
|
+
attempt = 0
|
|
266
|
+
while True:
|
|
267
|
+
try:
|
|
268
|
+
return read_fn()
|
|
269
|
+
except duckdb.CatalogException as exc:
|
|
270
|
+
msg = str(exc)
|
|
271
|
+
if RUNTIME_SCHEMA not in msg and "does not exist" not in msg.lower():
|
|
272
|
+
raise
|
|
273
|
+
if time.monotonic() >= deadline:
|
|
274
|
+
raise
|
|
275
|
+
attempt += 1
|
|
276
|
+
print(f"[feather_cdc_postgres] _cdc_runtime metadata read retry {attempt} "
|
|
277
|
+
f"(motherduck-catalog-lags-a-write): {msg}")
|
|
278
|
+
time.sleep(backoff_s)
|
|
279
|
+
|
|
280
|
+
def read_checkpoint(self, registration: str) -> tuple[int, str | None] | None:
|
|
281
|
+
self.ensure_runtime_tables()
|
|
282
|
+
|
|
283
|
+
def _read():
|
|
284
|
+
with self.connect() as con:
|
|
285
|
+
return con.execute(
|
|
286
|
+
f'select last_commit_lsn, load_ids from "{RUNTIME_SCHEMA}".checkpoint '
|
|
287
|
+
"where registration = ?", [registration]).fetchone()
|
|
288
|
+
row = self._read_metadata_with_retry(_read)
|
|
289
|
+
return (int(row[0]), row[1]) if row and row[0] is not None else None
|
|
290
|
+
|
|
291
|
+
def write_checkpoint(self, registration: str, slot: str, lsn: int,
|
|
292
|
+
load_ids: Iterable[str] | None = None) -> None:
|
|
293
|
+
self.ensure_runtime_tables()
|
|
294
|
+
ids = ",".join(load_ids or [])
|
|
295
|
+
with self.connect() as con:
|
|
296
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".checkpoint where registration = ?',
|
|
297
|
+
[registration])
|
|
298
|
+
con.execute(f'insert into "{RUNTIME_SCHEMA}".checkpoint values (?, ?, ?, ?, ?)',
|
|
299
|
+
[registration, slot, int(lsn), ids, _dt.datetime.now(_dt.timezone.utc)])
|
|
300
|
+
|
|
301
|
+
def write_baseline_lsns(self, registration: str, boundaries: dict) -> None:
|
|
302
|
+
"""{(schema, table): boundary_lsn} — one row per relation, replaced in place, one
|
|
303
|
+
transaction (a MotherDuck round trip per relation would make a 50-table init slow)."""
|
|
304
|
+
if not boundaries:
|
|
305
|
+
return
|
|
306
|
+
self.ensure_runtime_tables()
|
|
307
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
308
|
+
with self.connect() as con:
|
|
309
|
+
con.execute("begin transaction")
|
|
310
|
+
try:
|
|
311
|
+
for (schema, table), lsn in sorted(boundaries.items()):
|
|
312
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".baseline '
|
|
313
|
+
'where registration = ? and "schema" = ? and "table" = ?',
|
|
314
|
+
[registration, schema, table])
|
|
315
|
+
con.execute(f'insert into "{RUNTIME_SCHEMA}".baseline values (?, ?, ?, ?, ?)',
|
|
316
|
+
[registration, schema, table, int(lsn), now])
|
|
317
|
+
con.execute("commit")
|
|
318
|
+
except Exception:
|
|
319
|
+
con.execute("rollback")
|
|
320
|
+
raise
|
|
321
|
+
|
|
322
|
+
def record_denied_oids(self, registration: str, rows) -> None:
|
|
323
|
+
"""Insert the (oid, origin_schema, origin_table, column, attnum) rows not already
|
|
324
|
+
recorded (`attnum` may be None: the column was not there to bind). Append-only by
|
|
325
|
+
design: a deny must never be forgotten because a name moved."""
|
|
326
|
+
rows = sorted(set(rows), key=lambda r: (r[0], r[1], r[2], r[3], r[4] or -1))
|
|
327
|
+
if not rows:
|
|
328
|
+
return
|
|
329
|
+
self.ensure_runtime_tables()
|
|
330
|
+
known = set(self.read_denied_oids(registration))
|
|
331
|
+
new = [r for r in rows if r not in known]
|
|
332
|
+
if not new:
|
|
333
|
+
return
|
|
334
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
335
|
+
with self.connect() as con:
|
|
336
|
+
con.executemany(
|
|
337
|
+
f'insert into "{RUNTIME_SCHEMA}".denied_oid values (?, ?, ?, ?, ?, ?, ?)',
|
|
338
|
+
[[registration, int(o), s, t, c, None if a is None else int(a), now]
|
|
339
|
+
for o, s, t, c, a in new])
|
|
340
|
+
|
|
341
|
+
def read_denied_oids(self, registration: str) -> list[tuple]:
|
|
342
|
+
self.ensure_runtime_tables()
|
|
343
|
+
|
|
344
|
+
def _read():
|
|
345
|
+
with self.connect() as con:
|
|
346
|
+
return con.execute(
|
|
347
|
+
f'select oid, origin_schema, origin_table, "column", attnum '
|
|
348
|
+
f'from "{RUNTIME_SCHEMA}".denied_oid where registration = ?',
|
|
349
|
+
[registration]).fetchall()
|
|
350
|
+
return [(int(r[0]), r[1], r[2], r[3], None if r[4] is None else int(r[4]))
|
|
351
|
+
for r in self._read_metadata_with_retry(_read)]
|
|
352
|
+
|
|
353
|
+
def record_quarantine(self, registration: str, marks: dict) -> None:
|
|
354
|
+
"""{oid: (schema, table, reason)} — one row per OID, kept (first `since` wins) until
|
|
355
|
+
`clear_quarantine`. De-duplicated by OID, never by name (Codex round 17): a second
|
|
356
|
+
table that takes a quarantined name is its own quarantine."""
|
|
357
|
+
if not marks:
|
|
358
|
+
return
|
|
359
|
+
self.ensure_runtime_tables()
|
|
360
|
+
known = self.read_quarantine(registration)
|
|
361
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
362
|
+
new = [[registration, s, t, why, now, int(oid)]
|
|
363
|
+
for oid, (s, t, why) in sorted(marks.items()) if int(oid) not in known]
|
|
364
|
+
if new:
|
|
365
|
+
with self.connect() as con:
|
|
366
|
+
con.executemany(
|
|
367
|
+
f'insert into "{RUNTIME_SCHEMA}".deny_quarantine values (?, ?, ?, ?, ?, ?)',
|
|
368
|
+
new)
|
|
369
|
+
|
|
370
|
+
def read_quarantined_oids(self, registration: str) -> set[int]:
|
|
371
|
+
self.ensure_runtime_tables()
|
|
372
|
+
|
|
373
|
+
def _read():
|
|
374
|
+
with self.connect() as con:
|
|
375
|
+
return con.execute(
|
|
376
|
+
f'select oid from "{RUNTIME_SCHEMA}".deny_quarantine '
|
|
377
|
+
"where registration = ? and oid is not null", [registration]).fetchall()
|
|
378
|
+
return {int(r[0]) for r in self._read_metadata_with_retry(_read)}
|
|
379
|
+
|
|
380
|
+
def write_reviewed_layouts(self, registration: str, layouts: dict) -> None:
|
|
381
|
+
"""{(schema, table): (oid, ReviewedLayout | None)} — replaces each OID's row; None
|
|
382
|
+
removes it (a baseline that could not prove its layout leaves the relation with none,
|
|
383
|
+
so its next change quarantines it)."""
|
|
384
|
+
if not layouts:
|
|
385
|
+
return
|
|
386
|
+
import json
|
|
387
|
+
self.ensure_runtime_tables()
|
|
388
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
389
|
+
with self.connect() as con:
|
|
390
|
+
con.execute("begin transaction")
|
|
391
|
+
try:
|
|
392
|
+
for (schema, table), (oid, layout) in sorted(layouts.items()):
|
|
393
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".reviewed_layout '
|
|
394
|
+
"where registration = ? and oid = ?", [registration, int(oid)])
|
|
395
|
+
if layout is not None:
|
|
396
|
+
con.execute(
|
|
397
|
+
f'insert into "{RUNTIME_SCHEMA}".reviewed_layout '
|
|
398
|
+
"values (?, ?, ?, ?, ?, ?)",
|
|
399
|
+
[registration, int(oid), schema, table,
|
|
400
|
+
json.dumps(layout.to_json()), now])
|
|
401
|
+
con.execute("commit")
|
|
402
|
+
except Exception:
|
|
403
|
+
con.execute("rollback")
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
def read_reviewed_layouts(self, registration: str) -> dict:
|
|
407
|
+
"""{oid: ReviewedLayout}; a row in an older format is left out (no layout)."""
|
|
408
|
+
import json
|
|
409
|
+
from pg_replication.helpers import ReviewedLayout
|
|
410
|
+
self.ensure_runtime_tables()
|
|
411
|
+
|
|
412
|
+
def _read():
|
|
413
|
+
with self.connect() as con:
|
|
414
|
+
return con.execute(
|
|
415
|
+
f'select oid, layout_json from "{RUNTIME_SCHEMA}".reviewed_layout '
|
|
416
|
+
"where registration = ?", [registration]).fetchall()
|
|
417
|
+
out = {int(o): ReviewedLayout.from_json(json.loads(j))
|
|
418
|
+
for o, j in self._read_metadata_with_retry(_read)}
|
|
419
|
+
return {o: v for o, v in out.items() if v is not None}
|
|
420
|
+
|
|
421
|
+
def read_quarantine(self, registration: str) -> dict:
|
|
422
|
+
"""{oid: (schema, table, reason, since)} — schema/table as recorded at the mark."""
|
|
423
|
+
self.ensure_runtime_tables()
|
|
424
|
+
|
|
425
|
+
def _read():
|
|
426
|
+
with self.connect() as con:
|
|
427
|
+
return con.execute(
|
|
428
|
+
f'select oid, "schema", "table", reason, min(since) '
|
|
429
|
+
f'from "{RUNTIME_SCHEMA}".deny_quarantine '
|
|
430
|
+
"where registration = ? and oid is not null "
|
|
431
|
+
'group by oid, "schema", "table", reason', [registration]).fetchall()
|
|
432
|
+
out: dict = {}
|
|
433
|
+
for o, s, t, why, since in sorted(self._read_metadata_with_retry(_read),
|
|
434
|
+
key=lambda r: (r[0], r[4])):
|
|
435
|
+
out.setdefault(int(o), (s, t, why, since)) # earliest mark of the OID wins
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
def quarantined_names(self, registration: str) -> set:
|
|
439
|
+
"""(schema, table) of every mark, for reports and tests."""
|
|
440
|
+
return {(s, t) for s, t, _w, _since in self.read_quarantine(registration).values()}
|
|
441
|
+
|
|
442
|
+
def clear_quarantine(self, registration: str, oids) -> None:
|
|
443
|
+
"""Clear by the OIDs a baseline rebuilt (Codex round 17): a renamed quarantined table
|
|
444
|
+
resnapshotted under its new name is cleared, and another OID's mark under the same
|
|
445
|
+
name is not."""
|
|
446
|
+
oids = sorted({int(o) for o in oids})
|
|
447
|
+
if not oids:
|
|
448
|
+
return
|
|
449
|
+
self.ensure_runtime_tables()
|
|
450
|
+
with self.connect() as con:
|
|
451
|
+
for oid in oids:
|
|
452
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".deny_quarantine '
|
|
453
|
+
"where registration = ? and oid = ?", [registration, oid])
|
|
454
|
+
|
|
455
|
+
def read_baseline_lsns(self, registration: str) -> dict[tuple[str, str], int]:
|
|
456
|
+
self.ensure_runtime_tables()
|
|
457
|
+
|
|
458
|
+
def _read():
|
|
459
|
+
with self.connect() as con:
|
|
460
|
+
return con.execute(
|
|
461
|
+
f'select "schema", "table", boundary_lsn from "{RUNTIME_SCHEMA}".baseline '
|
|
462
|
+
"where registration = ?", [registration]).fetchall()
|
|
463
|
+
rows = self._read_metadata_with_retry(_read)
|
|
464
|
+
return {(r[0], r[1]): int(r[2]) for r in rows if r[2] is not None}
|
|
465
|
+
|
|
466
|
+
def write_lifecycle(self, registration: str, schema: str, table: str, state: str,
|
|
467
|
+
detail: str | None = None) -> None:
|
|
468
|
+
self.ensure_runtime_tables()
|
|
469
|
+
with self.connect() as con:
|
|
470
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".lifecycle '
|
|
471
|
+
'where registration = ? and "schema" = ? and "table" = ?',
|
|
472
|
+
[registration, schema, table])
|
|
473
|
+
con.execute(f'insert into "{RUNTIME_SCHEMA}".lifecycle values (?, ?, ?, ?, ?, ?)',
|
|
474
|
+
[registration, schema, table, state,
|
|
475
|
+
_dt.datetime.now(_dt.timezone.utc), detail])
|
|
476
|
+
|
|
477
|
+
def read_lifecycle(self, registration: str) -> dict[tuple[str, str], str]:
|
|
478
|
+
self.ensure_runtime_tables()
|
|
479
|
+
|
|
480
|
+
def _read():
|
|
481
|
+
with self.connect() as con:
|
|
482
|
+
return con.execute(
|
|
483
|
+
f'select "schema", "table", state from "{RUNTIME_SCHEMA}".lifecycle '
|
|
484
|
+
"where registration = ?", [registration]).fetchall()
|
|
485
|
+
rows = self._read_metadata_with_retry(_read)
|
|
486
|
+
return {(r[0], r[1]): r[2] for r in rows}
|
|
487
|
+
|
|
488
|
+
def write_registry_rows(self, rows: Sequence[dict]) -> int:
|
|
489
|
+
"""Called by schema_registry.py. Keys: registration, schema, table, column, type_oid,
|
|
490
|
+
typmod, not_null, key_role, dest_type, mapping (seen_at is stamped here).
|
|
491
|
+
|
|
492
|
+
REPLACES each named relation's rows — the registry is current state, like `lifecycle`
|
|
493
|
+
and `signature` beside it; the history of what CHANGED lives in `control.event`. Appending
|
|
494
|
+
instead would add one row per column per tick: ~500 rows every 5 minutes for Featherbase,
|
|
495
|
+
50M a year, in a table nothing prunes. One transaction, so a MotherDuck load pays one
|
|
496
|
+
round trip rather than one per column."""
|
|
497
|
+
if not rows:
|
|
498
|
+
return 0
|
|
499
|
+
self.ensure_runtime_tables()
|
|
500
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
501
|
+
pairs = sorted({(r["registration"], r["schema"], r["table"]) for r in rows})
|
|
502
|
+
with self.connect() as con:
|
|
503
|
+
con.execute("begin transaction")
|
|
504
|
+
try:
|
|
505
|
+
for registration, schema, table in pairs:
|
|
506
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".schema_registry '
|
|
507
|
+
'where registration = ? and "schema" = ? and "table" = ?',
|
|
508
|
+
[registration, schema, table])
|
|
509
|
+
con.executemany(
|
|
510
|
+
f'insert into "{RUNTIME_SCHEMA}".schema_registry values (?,?,?,?,?,?,?,?,?,?,?)',
|
|
511
|
+
[[r["registration"], r["schema"], r["table"], r["column"],
|
|
512
|
+
r.get("type_oid"), r.get("typmod"), r.get("not_null"),
|
|
513
|
+
r.get("key_role"), r.get("dest_type"), r.get("mapping"),
|
|
514
|
+
r.get("seen_at") or now] for r in rows])
|
|
515
|
+
con.execute("commit")
|
|
516
|
+
except Exception:
|
|
517
|
+
con.execute("rollback")
|
|
518
|
+
raise
|
|
519
|
+
return len(rows)
|
|
520
|
+
|
|
521
|
+
def write_signature(self, registration: str, schema: str, table: str, signature: dict) -> None:
|
|
522
|
+
"""One row per relation, replaced in place: the ONLY thing the next run diffs against.
|
|
523
|
+
A key_change deliberately leaves the old row standing (see drift.py)."""
|
|
524
|
+
import json
|
|
525
|
+
self.ensure_runtime_tables()
|
|
526
|
+
with self.connect() as con:
|
|
527
|
+
con.execute(f'delete from "{RUNTIME_SCHEMA}".signature '
|
|
528
|
+
'where registration = ? and "schema" = ? and "table" = ?',
|
|
529
|
+
[registration, schema, table])
|
|
530
|
+
con.execute(f'insert into "{RUNTIME_SCHEMA}".signature values (?, ?, ?, ?, ?)',
|
|
531
|
+
[registration, schema, table, json.dumps(signature, sort_keys=True),
|
|
532
|
+
_dt.datetime.now(_dt.timezone.utc)])
|
|
533
|
+
|
|
534
|
+
def read_signatures(self, registration: str) -> dict[tuple[str, str], dict]:
|
|
535
|
+
import json
|
|
536
|
+
self.ensure_runtime_tables()
|
|
537
|
+
|
|
538
|
+
def _read():
|
|
539
|
+
with self.connect() as con:
|
|
540
|
+
return con.execute(
|
|
541
|
+
f'select "schema", "table", signature_json from "{RUNTIME_SCHEMA}".signature '
|
|
542
|
+
"where registration = ?", [registration]).fetchall()
|
|
543
|
+
rows = self._read_metadata_with_retry(_read)
|
|
544
|
+
return {(r[0], r[1]): json.loads(r[2]) for r in rows if r[2]}
|
|
545
|
+
|
|
546
|
+
def destination_tables(self, schemas: Iterable[str]) -> set[tuple[str, str]]:
|
|
547
|
+
"""Tables in THIS database only. `md:<db>` attaches every MotherDuck database the token
|
|
548
|
+
can see and `information_schema` spans all of them (probed live 04-Sep-2026: connected to
|
|
549
|
+
bronze_featherbase it also lists bronze_sap.control, bronze_gofrugal.landing …), so an
|
|
550
|
+
unfiltered count inflates the `_slot` probe's `destination_tables` with someone else's
|
|
551
|
+
same-named schema. `current_database()` is the same predicate on a duckdb file."""
|
|
552
|
+
with self.connect() as con:
|
|
553
|
+
rows = con.execute(
|
|
554
|
+
"select table_schema, table_name from information_schema.tables "
|
|
555
|
+
"where table_catalog = current_database() and table_schema = any(?)",
|
|
556
|
+
[list(schemas)]).fetchall()
|
|
557
|
+
return {(r[0], r[1]) for r in rows if not r[1].startswith("_dlt")}
|
|
558
|
+
|
|
559
|
+
def denied_columns_present(self, wanted: dict) -> list[tuple[str, str, str]]:
|
|
560
|
+
"""{(target_schema, table): [column, ...]} -> every (schema, table, column) present in
|
|
561
|
+
THIS database, in the target schema or its `<schema>_staging` twin (#4346). Reads the
|
|
562
|
+
catalogue only — never a value of the column. Table and column are probed under BOTH
|
|
563
|
+
the source spelling and dlt's normalised one (`password$hash` lands `password_hash`)."""
|
|
564
|
+
from dlt.common.normalizers.naming.snake_case import NamingConvention
|
|
565
|
+
norm = NamingConvention().normalize_identifier
|
|
566
|
+
probes = sorted({(sch, t, c) for (schema, table), cols in wanted.items()
|
|
567
|
+
for sch in (schema, f"{schema}_staging")
|
|
568
|
+
for t in {table, norm(table)} for col in cols
|
|
569
|
+
for c in {col, norm(col)}})
|
|
570
|
+
if not probes:
|
|
571
|
+
return []
|
|
572
|
+
with self.connect() as con:
|
|
573
|
+
rows = con.execute(
|
|
574
|
+
"with probe as (select unnest(?::varchar[]) as s, unnest(?::varchar[]) as t, "
|
|
575
|
+
"unnest(?::varchar[]) as c) "
|
|
576
|
+
"select ic.table_schema, ic.table_name, ic.column_name "
|
|
577
|
+
"from information_schema.columns ic join probe "
|
|
578
|
+
"on ic.table_schema = probe.s and ic.table_name = probe.t "
|
|
579
|
+
"and ic.column_name = probe.c "
|
|
580
|
+
"where ic.table_catalog = current_database()",
|
|
581
|
+
[[p[0] for p in probes], [p[1] for p in probes], [p[2] for p in probes]],
|
|
582
|
+
).fetchall()
|
|
583
|
+
return sorted((r[0], r[1], r[2]) for r in rows)
|
|
584
|
+
|
|
585
|
+
def table_row_count(self, schema: str, table: str) -> int | None:
|
|
586
|
+
try:
|
|
587
|
+
with self.connect() as con:
|
|
588
|
+
return con.execute(f"select count(*) from {q(schema)}.{q(table)}").fetchone()[0]
|
|
589
|
+
except Exception: # noqa: BLE001 — table not there yet is not an error here
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
def table_row_counts(self, pairs) -> dict:
|
|
593
|
+
"""{(schema, table): rows | None} on ONE connection — `reconcile_counts` asked one
|
|
594
|
+
connection per relation, fifty MotherDuck round trips every tick for Featherbase."""
|
|
595
|
+
out = {}
|
|
596
|
+
with self.connect() as con:
|
|
597
|
+
for schema, table in pairs:
|
|
598
|
+
try:
|
|
599
|
+
out[(schema, table)] = con.execute(
|
|
600
|
+
f"select count(*) from {q(schema)}.{q(table)}").fetchone()[0]
|
|
601
|
+
except Exception: # noqa: BLE001 — not there yet is not an error here
|
|
602
|
+
out[(schema, table)] = None
|
|
603
|
+
return out
|
|
604
|
+
|
|
605
|
+
def _schema_exists(self, con, schema: str) -> bool:
|
|
606
|
+
return con.execute(
|
|
607
|
+
"select 1 from information_schema.schemata "
|
|
608
|
+
"where catalog_name = current_database() and schema_name = ?", [schema]
|
|
609
|
+
).fetchone() is not None
|
|
610
|
+
|
|
611
|
+
def delete_all_rows(self, schema: str, table: str) -> bool:
|
|
612
|
+
"""A source TRUNCATE applied to a keyed mirror. False when the destination table does
|
|
613
|
+
not exist yet (an empty-at-snapshot source table has no destination table until its
|
|
614
|
+
first row, so there is nothing to empty)."""
|
|
615
|
+
with self.connect() as con:
|
|
616
|
+
if not self._schema_exists(con, schema):
|
|
617
|
+
return False
|
|
618
|
+
exists = con.execute(
|
|
619
|
+
"select 1 from information_schema.tables where table_catalog = current_database() "
|
|
620
|
+
"and table_schema = ? and table_name = ?", [schema, table]).fetchone()
|
|
621
|
+
if exists is None:
|
|
622
|
+
return False
|
|
623
|
+
con.execute(f"delete from {q(schema)}.{q(table)}")
|
|
624
|
+
return True
|
|
625
|
+
|
|
626
|
+
def drop_table(self, schema: str, table: str) -> list[str]:
|
|
627
|
+
"""Resnapshot needs the destination table AND its dlt merge-staging twin gone —
|
|
628
|
+
a stale staging table replays into the fresh one on the next merge. The ONE
|
|
629
|
+
tolerated absence is a schema that was never created (a table dlt never merged has
|
|
630
|
+
no `_staging` twin); every other failure — permissions, a MotherDuck error, a view in
|
|
631
|
+
the way — propagates, because a resnapshot that then merges into the undropped table
|
|
632
|
+
keeps rows the source deleted (#3079 review)."""
|
|
633
|
+
dropped = []
|
|
634
|
+
with self.connect() as con:
|
|
635
|
+
for sch in (schema, f"{schema}_staging"):
|
|
636
|
+
if not self._schema_exists(con, sch):
|
|
637
|
+
continue
|
|
638
|
+
con.execute(f"drop table if exists {q(sch)}.{q(table)}")
|
|
639
|
+
dropped.append(f"{sch}.{table}")
|
|
640
|
+
return dropped
|