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,1399 @@
|
|
|
1
|
+
"""The run/status/resnapshot engine (#3079 Phase 2).
|
|
2
|
+
|
|
3
|
+
Correctness invariants this file exists to hold:
|
|
4
|
+
- the advisory owner session is asserted before EVERY mutating phase AND again after every
|
|
5
|
+
blocking destination load, before the checkpoint/slot move; losing it aborts with the load
|
|
6
|
+
left replayable;
|
|
7
|
+
- the checkpoint is written only after every schema's load has committed, and the slot is
|
|
8
|
+
advanced only to a checkpointed LSN — so an ambiguous destination outcome replays, never skips;
|
|
9
|
+
- every baseline records the consistent point it was taken at; the drain drops a relation's
|
|
10
|
+
transactions below that boundary (they are in the baseline), so no row lands twice;
|
|
11
|
+
- relation identity is (schema, table) end to end; the destination side is the relation's
|
|
12
|
+
compiled `target_schema` (D-13), one dlt pipeline per TARGET schema, never the source's;
|
|
13
|
+
- a key-changed relation is out of the desired set for the WHOLE run (drift.scan), so the
|
|
14
|
+
drain, the reconcile counts and the probe all see the same active set;
|
|
15
|
+
- a table RENAMED at the source stays in the publication under its new name (membership is
|
|
16
|
+
by OID): a published relation with no lifecycle row and no destination copy is baselined,
|
|
17
|
+
not adopted, and
|
|
18
|
+
a known relation that left the selection is retired whether or not the publication still
|
|
19
|
+
named it (#4338, Featherbase `user` → `person`).
|
|
20
|
+
Contract with the compiler half: manifest.Manifest / selectors.Relation (plan Appendix B).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Sequence
|
|
30
|
+
|
|
31
|
+
from control_plane import ControlPlane, status as cp_status
|
|
32
|
+
|
|
33
|
+
from . import drift, publication, snapshot, stream, telemetry
|
|
34
|
+
from .destination import Destination, DestinationSpec
|
|
35
|
+
from .lifecycle import (
|
|
36
|
+
CATCHING_UP, DISCOVERED, DRIFT_REVIEW, RESNAPSHOT_REQUIRED, RETIRED, SNAPSHOTTING, STREAMING,
|
|
37
|
+
Lifecycle,
|
|
38
|
+
)
|
|
39
|
+
from .ownership import OwnerSession, OwnershipLost
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RuntimeConfig:
|
|
44
|
+
registration: str
|
|
45
|
+
source_id: str
|
|
46
|
+
source_dsn: str
|
|
47
|
+
slot: str
|
|
48
|
+
publication: str
|
|
49
|
+
extractor: str
|
|
50
|
+
destination: Destination
|
|
51
|
+
schema_map: dict = field(default_factory=dict) # source schema -> target schema
|
|
52
|
+
table_schemas: list = field(default_factory=list) # [("schema.table" glob, target schema)]
|
|
53
|
+
pipeline_name: str | None = None
|
|
54
|
+
target_batch_size: int = 1000
|
|
55
|
+
replica_identity: str = "full"
|
|
56
|
+
slot_loss_policy: str = "resnapshot"
|
|
57
|
+
retirement_policy: str = "freeze"
|
|
58
|
+
reconcile_threshold: int = telemetry.RECONCILE_MAX_ROWS
|
|
59
|
+
reconcile_exclude: list = field(default_factory=list)
|
|
60
|
+
manifest_path: str = "<manifest>" # only ever interpolated into operator remedy text
|
|
61
|
+
# Bounds CREATE_REPLICATION_SLOT ... EXPORT_SNAPSHOT (snapshot.create_slot_with_snapshot):
|
|
62
|
+
# a long-running source transaction blocks it, and statement_timeout cannot cancel that
|
|
63
|
+
# command (see the docstring there) — so the client enforces this deadline itself.
|
|
64
|
+
baseline_wait_s: float = snapshot.BASELINE_WAIT_S_DEFAULT
|
|
65
|
+
# `source.deny.columns` (#4346): (schema, table) -> columns that never leave the process.
|
|
66
|
+
# The WHOLE manifest map, not just the compiled set: a retired relation's queued WAL is
|
|
67
|
+
# projected too, and the destination tripwire watches its frozen copy.
|
|
68
|
+
deny_columns: dict = field(default_factory=dict)
|
|
69
|
+
# Relation OID -> denied columns, resolved per run by `_with_inherited_denies` (#4346).
|
|
70
|
+
deny_oids: dict = field(default_factory=dict)
|
|
71
|
+
# Relation OID -> denied column attnums: a column's identity, so the deny follows a column
|
|
72
|
+
# RENAME (the baseline resolves them inside its own snapshot).
|
|
73
|
+
deny_attnums: dict = field(default_factory=dict)
|
|
74
|
+
# "schema.table.column" denies that bound to no attnum this run (#4346): the run refuses.
|
|
75
|
+
deny_unbound: tuple = ()
|
|
76
|
+
|
|
77
|
+
def target_schema(self, source_schema: str, table: str | None = None) -> str:
|
|
78
|
+
"""Manifest rules only — the runtime prefers a compiled Relation's own `target_schema`
|
|
79
|
+
(`_Targets`); this is the fallback for a relation outside the current set."""
|
|
80
|
+
if table is not None:
|
|
81
|
+
import fnmatch
|
|
82
|
+
qualified = f"{source_schema}.{table}"
|
|
83
|
+
for pattern, target in self.table_schemas:
|
|
84
|
+
if fnmatch.fnmatchcase(qualified, pattern):
|
|
85
|
+
return target
|
|
86
|
+
return self.schema_map.get(source_schema, source_schema)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _log(msg: str) -> None:
|
|
90
|
+
print(f"[feather_cdc_postgres] {msg}", flush=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _denied(cfg: RuntimeConfig, relations=()) -> dict:
|
|
94
|
+
"""(schema, table) -> sorted denied columns: the manifest map plus each compiled Relation's
|
|
95
|
+
own (#4346). One answer for the baseline, the drain and the tripwire."""
|
|
96
|
+
out = {k: set(v) for k, v in (cfg.deny_columns or {}).items()}
|
|
97
|
+
for r in relations:
|
|
98
|
+
if getattr(r, "deny_columns", ()):
|
|
99
|
+
out.setdefault((r.schema, r.table), set()).update(r.deny_columns)
|
|
100
|
+
return {k: tuple(sorted(v)) for k, v in out.items() if v}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _denied_oids(cfg: RuntimeConfig, denied: dict, unbound: list | None = None) -> dict:
|
|
104
|
+
"""Relation OID -> denied columns (#4346), so the deny follows a table through a RENAME:
|
|
105
|
+
pgoutput names a relation by its CURRENT description, and a renamed `person` would
|
|
106
|
+
otherwise arrive under a name with no deny entry. Every OID a manifest-denied name has ever
|
|
107
|
+
resolved to is PERSISTED (`_cdc_runtime.denied_oid`, with the origin name and column), never
|
|
108
|
+
re-derived from a name: a new table re-using the old name must not erase the old OID's deny.
|
|
109
|
+
A persisted row applies only while its origin name still denies that column, so removing a
|
|
110
|
+
deny from the manifest does remove it.
|
|
111
|
+
|
|
112
|
+
Each denied (table, column) must BIND to an attnum (Codex round 16): by name, in the same
|
|
113
|
+
catalogue statement that finds the table's OID (so a rename cannot slip between two
|
|
114
|
+
lookups), or through an earlier binding to an attnum. One that binds neither
|
|
115
|
+
way — renamed before it was ever bound, or not there at all — is appended to `unbound`,
|
|
116
|
+
and the run refuses: its identity is unknown, so no column of that table is safe to read."""
|
|
117
|
+
if not denied:
|
|
118
|
+
return {}
|
|
119
|
+
import psycopg
|
|
120
|
+
found, missing = [], []
|
|
121
|
+
with psycopg.connect(cfg.source_dsn, autocommit=True, connect_timeout=10) as c:
|
|
122
|
+
for (schema, table), cols in sorted(denied.items()):
|
|
123
|
+
rows = c.execute(
|
|
124
|
+
"select c.oid, a.attname, a.attnum from pg_class c "
|
|
125
|
+
"join pg_namespace n on n.oid = c.relnamespace "
|
|
126
|
+
"left join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 "
|
|
127
|
+
"and not a.attisdropped and a.attname = any(%s) "
|
|
128
|
+
"where n.nspname = %s and c.relname = %s",
|
|
129
|
+
(list(cols), schema, table)).fetchall()
|
|
130
|
+
if not rows:
|
|
131
|
+
continue # no such table at the source (a frozen copy, say): nothing to bind
|
|
132
|
+
oid = int(rows[0][0])
|
|
133
|
+
got = {n: int(a) for _o, n, a in rows if n is not None}
|
|
134
|
+
found += [(oid, schema, table, col, got[col]) for col in cols if col in got]
|
|
135
|
+
missing += [(oid, schema, table, col) for col in cols if col not in got]
|
|
136
|
+
_record_denied(cfg, found)
|
|
137
|
+
live = _live_denied_columns(cfg, denied)
|
|
138
|
+
if unbound is not None:
|
|
139
|
+
# Any earlier binding covers it, a dropped one too: its identity is known, the value is
|
|
140
|
+
# gone from the table, and the decoder's layout proof quarantines WAL that may carry it.
|
|
141
|
+
covered = {(int(o), col) for o, _s, _t, col, a in
|
|
142
|
+
cfg.destination.read_denied_oids(cfg.registration) if a is not None}
|
|
143
|
+
unbound += sorted(f"{s}.{t}.{col}" for oid, s, t, col in missing
|
|
144
|
+
if (oid, col) not in covered)
|
|
145
|
+
out: dict = {}
|
|
146
|
+
for oid, attnum, name, _origin in live:
|
|
147
|
+
out.setdefault(oid, set()).add(name)
|
|
148
|
+
for oid, schema, table, col, _attnum in cfg.destination.read_denied_oids(cfg.registration):
|
|
149
|
+
if col in denied.get((schema, table), ()):
|
|
150
|
+
out.setdefault(int(oid), set()).add(col)
|
|
151
|
+
return {k: tuple(sorted(v)) for k, v in out.items()}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _record_denied(cfg: RuntimeConfig, rows) -> None:
|
|
155
|
+
"""Persist (oid, schema, table, column, attnum) deny bindings. Every caller resolved the
|
|
156
|
+
attnum at a trusted moment — one catalogue statement at run start, under a baseline's
|
|
157
|
+
locks, or from a proven decoder position — never by a later name lookup (Codex round 16)."""
|
|
158
|
+
rows = sorted({(int(o), s, t, c, int(a)) for o, s, t, c, a in rows if a is not None})
|
|
159
|
+
if not rows:
|
|
160
|
+
return
|
|
161
|
+
cfg.destination.record_denied_oids(cfg.registration, rows)
|
|
162
|
+
# ...and into this run's live attnum map (the decoder and later baselines read it).
|
|
163
|
+
for o, _s, _t, _c, a in rows:
|
|
164
|
+
cfg.deny_attnums[o] = tuple(sorted(set(cfg.deny_attnums.get(o, ())) | {a}))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _live_denied_columns(cfg: RuntimeConfig, denied: dict) -> list:
|
|
168
|
+
"""(oid, attnum, CURRENT column name, origin column) for every persisted, still-applicable
|
|
169
|
+
deny binding: a denied column renamed at the source (`password_hash` -> `pw`) keeps its
|
|
170
|
+
attnum, so its new name is denied too — the column's identity, not its spelling (Sonnet
|
|
171
|
+
review F1). A dropped column (`attisdropped`) is gone (the decoder's layout proof then
|
|
172
|
+
quarantines any WAL that may carry it); a re-added one of the same name binds anew."""
|
|
173
|
+
bindings: dict = {} # (oid, attnum) -> origin columns (two origin names may share one)
|
|
174
|
+
for o, s, t, col, a in cfg.destination.read_denied_oids(cfg.registration):
|
|
175
|
+
if a is not None and col in denied.get((s, t), ()):
|
|
176
|
+
bindings.setdefault((int(o), int(a)), set()).add(col)
|
|
177
|
+
if not bindings:
|
|
178
|
+
return []
|
|
179
|
+
pairs = sorted(bindings)
|
|
180
|
+
import psycopg
|
|
181
|
+
with psycopg.connect(cfg.source_dsn, autocommit=True, connect_timeout=10) as c:
|
|
182
|
+
rows = c.execute(
|
|
183
|
+
"select attrelid::bigint, attnum, attname from pg_attribute "
|
|
184
|
+
"where not attisdropped and (attrelid, attnum) in "
|
|
185
|
+
"(select unnest(%s::oid[]), unnest(%s::int2[]))",
|
|
186
|
+
([p[0] for p in pairs], [p[1] for p in pairs])).fetchall()
|
|
187
|
+
return [(int(o), int(a), n, origin) for o, a, n in rows
|
|
188
|
+
for origin in sorted(bindings[(int(o), int(a))])]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _denied_attnums(cfg: RuntimeConfig, denied: dict) -> dict:
|
|
192
|
+
"""Relation OID -> denied attnums, for the baseline to resolve inside its snapshot."""
|
|
193
|
+
out: dict = {}
|
|
194
|
+
for o, s, t, col, a in cfg.destination.read_denied_oids(cfg.registration):
|
|
195
|
+
if a is not None and col in denied.get((s, t), ()):
|
|
196
|
+
out.setdefault(int(o), set()).add(int(a))
|
|
197
|
+
return {k: tuple(sorted(v)) for k, v in out.items()}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _baseline_capture() -> dict:
|
|
201
|
+
"""What a baseline reports from under its locks (snapshot_resources' *_out arguments):
|
|
202
|
+
each relation's OID, its reviewed layout, and each denied name's attnum."""
|
|
203
|
+
return {"oids_out": {}, "layouts_out": {}, "bound_out": {}}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _persist_bound(cfg: RuntimeConfig, bound: dict) -> None:
|
|
207
|
+
"""Persist what the baseline read under its locks (#4346), before its load: the OID each
|
|
208
|
+
denied relation had INSIDE the snapshot, bound to its deny and to the attnum each denied
|
|
209
|
+
name had there (so a replacement-then-rename between lookups cannot leave an OID with rows
|
|
210
|
+
in bronze but no recorded deny), and each relation's reviewed layout, which later
|
|
211
|
+
descriptions are proven against."""
|
|
212
|
+
oids, attnums = bound.get("oids_out", {}), bound.get("bound_out", {})
|
|
213
|
+
rows = [(oid, s, t, c) for (s, t), oid in oids.items()
|
|
214
|
+
for c in cfg.deny_columns.get((s, t), ())]
|
|
215
|
+
_record_denied(cfg, [(oid, s, t, c, dict(attnums.get((s, t), ())).get(c))
|
|
216
|
+
for oid, s, t, c in rows])
|
|
217
|
+
# ...and into THIS run's live map, which the drain hands the decoder: a rename between the
|
|
218
|
+
# baseline and the drain must not find the bound OID missing (Codex round 6).
|
|
219
|
+
for oid, _s, _t, col in rows:
|
|
220
|
+
cfg.deny_oids[oid] = tuple(sorted(set(cfg.deny_oids.get(oid, ())) | {col}))
|
|
221
|
+
if bound.get("layouts_out"): # every baseline: a deny added later has its reference
|
|
222
|
+
cfg.destination.write_reviewed_layouts(cfg.registration, bound["layouts_out"])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _clear_rebuilt_quarantine(cfg: RuntimeConfig, bound: dict, relations) -> None:
|
|
226
|
+
"""Clear the quarantine of each OID this baseline read (Codex round 17): by OID, never by
|
|
227
|
+
name, so a quarantined table renamed and then resnapshotted under its new name is cleared,
|
|
228
|
+
and another OID's mark under the rebuilt name is not. A mark whose OID no longer exists at
|
|
229
|
+
the source and whose recorded name was rebuilt is cleared too: that table can emit nothing
|
|
230
|
+
more, and the copy it fed was just replaced."""
|
|
231
|
+
oids = set((bound.get("oids_out") or {}).values())
|
|
232
|
+
names = {(r.schema, r.table) for r in relations}
|
|
233
|
+
marks = cfg.destination.read_quarantine(cfg.registration)
|
|
234
|
+
orphans = [o for o, (s, t, _w, _since) in marks.items() if (s, t) in names and o not in oids]
|
|
235
|
+
if orphans:
|
|
236
|
+
import psycopg
|
|
237
|
+
with psycopg.connect(cfg.source_dsn, autocommit=True, connect_timeout=10) as c:
|
|
238
|
+
live = {int(r[0]) for r in c.execute(
|
|
239
|
+
"select oid::bigint from pg_class where oid = any(%s::oid[])",
|
|
240
|
+
(orphans,)).fetchall()}
|
|
241
|
+
oids |= set(orphans) - live
|
|
242
|
+
cfg.destination.clear_quarantine(cfg.registration, oids)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _refuse_denied_keys(cfg: RuntimeConfig, relations) -> list:
|
|
246
|
+
"""Relations whose merge key now intersects the EFFECTIVE deny map — possible only through
|
|
247
|
+
a deny inherited by OID onto a renamed table (the compiler refuses an explicit one,
|
|
248
|
+
`deny_column_is_key`). Withholding a key column breaks every merge, so the run refuses,
|
|
249
|
+
exactly as a compile error would."""
|
|
250
|
+
return sorted(f"{r.qualified}: {', '.join(sorted(set(r.key) & set(cfg.deny_columns.get((r.schema, r.table), ()))))}"
|
|
251
|
+
for r in relations
|
|
252
|
+
if set(r.key) & set(cfg.deny_columns.get((r.schema, r.table), ())))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _forms(name: str) -> set:
|
|
256
|
+
"""A source identifier and its dlt-normalised spelling (`password$hash` lands
|
|
257
|
+
`password_hash`): every comparison against a dlt schema or the destination uses both."""
|
|
258
|
+
from dlt.common.normalizers.naming.snake_case import NamingConvention
|
|
259
|
+
return {name, NamingConvention().normalize_identifier(name)}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _pending_safe(cfg: RuntimeConfig, pipe, target: str) -> bool:
|
|
263
|
+
"""Settle a package a crashed run left pending in `pipe` (#4346, Codex). If the pipeline's
|
|
264
|
+
schema already names a denied column of a table in this dataset, the package may carry the
|
|
265
|
+
value (extracted before the deny existed): it is DISCARDED, never loaded. That is replay-safe —
|
|
266
|
+
the checkpoint never moved past a load that did not commit, so the WAL (or the baseline)
|
|
267
|
+
comes again, through the projection. Otherwise it is finished as #4345 requires."""
|
|
268
|
+
if not pipe.has_pending_data:
|
|
269
|
+
return False
|
|
270
|
+
tables: dict = {} # unioned: two denied relations may route to one destination table
|
|
271
|
+
for (s, t), cols in cfg.deny_columns.items():
|
|
272
|
+
if cfg.target_schema(s, t) == target:
|
|
273
|
+
tables.setdefault(t, set()).update(cols)
|
|
274
|
+
try:
|
|
275
|
+
schema_tables = pipe.default_schema.tables if pipe.default_schema_name else {}
|
|
276
|
+
except Exception: # noqa: BLE001 — no schema yet: nothing loaded can name a denied column
|
|
277
|
+
schema_tables = {}
|
|
278
|
+
unsafe = sorted(f"{st}.{sc}" for t, cols in tables.items() for st in _forms(t)
|
|
279
|
+
if st in schema_tables
|
|
280
|
+
for c in cols for sc in _forms(c)
|
|
281
|
+
if sc in schema_tables[st].get("columns", {}))
|
|
282
|
+
if unsafe:
|
|
283
|
+
_log(f"discarding a pending dlt package in {target}: its schema names denied "
|
|
284
|
+
f"column(s) {unsafe} — the load replays through the projection")
|
|
285
|
+
pipe.drop_pending_packages() # deletes only; abort_packages would also run a load
|
|
286
|
+
return False
|
|
287
|
+
return Destination.finish_pending(pipe)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _dlt_schema_hits(pipelines, wanted: dict, selected_targets: set) -> list:
|
|
291
|
+
"""Denied columns in the dlt schema of each SELECTED table this run's pipelines hold — the
|
|
292
|
+
second, lag-free signal (a fresh MotherDuck connection can miss a column for ~25 s after a
|
|
293
|
+
write, gotcha `motherduck-catalog-lags-a-write`). A selected table's dlt schema is what its
|
|
294
|
+
next merge re-creates from, so a denied column there is a finding in its own right. Frozen
|
|
295
|
+
copies are not checked here: after their purge (a plain DROP COLUMN) dlt's schema still
|
|
296
|
+
names the column, and nothing writes them again."""
|
|
297
|
+
hits = []
|
|
298
|
+
for (target, table), cols in wanted.items():
|
|
299
|
+
if (target, table) not in selected_targets:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
pipe = pipelines.get(target)
|
|
303
|
+
if not pipe.default_schema_name:
|
|
304
|
+
continue
|
|
305
|
+
present = {t: set(pipe.default_schema.get_table_columns(t))
|
|
306
|
+
for t in _forms(table) if t in pipe.default_schema.tables}
|
|
307
|
+
except Exception: # noqa: BLE001 — no schema in this process: the catalogue decides
|
|
308
|
+
continue
|
|
309
|
+
hits += [(target, t, c) for t, cols_there in present.items()
|
|
310
|
+
for col in cols for c in _forms(col) if c in cols_there]
|
|
311
|
+
return hits
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _with_inherited_denies(cfg: RuntimeConfig, relations) -> RuntimeConfig:
|
|
315
|
+
"""The run's EFFECTIVE deny map (#4346): the manifest's, plus every table that now carries
|
|
316
|
+
a denied OID under another name — a denied table renamed between runs, whose new name is
|
|
317
|
+
selected, would otherwise be baselined (and watched) with no deny at all. Resolved once per
|
|
318
|
+
run, so the baseline, the drain, drift and the tripwire all see the same projection."""
|
|
319
|
+
import dataclasses
|
|
320
|
+
base = _denied(cfg, relations)
|
|
321
|
+
unbound: list = []
|
|
322
|
+
by_oid = _denied_oids(cfg, base, unbound=unbound)
|
|
323
|
+
attnums = _denied_attnums(cfg, base) if base else {}
|
|
324
|
+
if not by_oid:
|
|
325
|
+
return dataclasses.replace(cfg, deny_columns=base, deny_oids={}, deny_attnums=attnums,
|
|
326
|
+
deny_unbound=tuple(unbound))
|
|
327
|
+
import psycopg
|
|
328
|
+
with psycopg.connect(cfg.source_dsn, autocommit=True, connect_timeout=10) as c:
|
|
329
|
+
rows = c.execute("select c.oid, n.nspname, c.relname from pg_class c "
|
|
330
|
+
"join pg_namespace n on n.oid = c.relnamespace where c.oid = any(%s)",
|
|
331
|
+
(list(by_oid),)).fetchall()
|
|
332
|
+
merged = {k: set(v) for k, v in base.items()}
|
|
333
|
+
for oid, schema, table in rows:
|
|
334
|
+
merged.setdefault((schema, table), set()).update(by_oid[int(oid)])
|
|
335
|
+
inherited = sorted(k for k in merged if k not in base)
|
|
336
|
+
if inherited:
|
|
337
|
+
_log(f"deny columns inherited through a rename: {inherited}")
|
|
338
|
+
return dataclasses.replace(
|
|
339
|
+
cfg, deny_columns={k: tuple(sorted(v)) for k, v in merged.items() if v},
|
|
340
|
+
deny_oids=by_oid, deny_attnums=attnums, deny_unbound=tuple(unbound))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _check_denied_columns(cfg: RuntimeConfig, relations, *, run, targets,
|
|
344
|
+
pipelines=None) -> int:
|
|
345
|
+
"""Destination tripwire (#4346): a denied column present in a mirror or its merge-staging
|
|
346
|
+
twin is a leak — it was loaded before the deny existed, or something bypassed the
|
|
347
|
+
projection. Each hit is an EV_ERROR naming table and column, never a value; the run is then
|
|
348
|
+
`partial`. Remedy: `resnapshot` a selected table; drop the column from a frozen copy."""
|
|
349
|
+
denied = _denied(cfg, relations)
|
|
350
|
+
if not denied:
|
|
351
|
+
return 0
|
|
352
|
+
# Unioned per DESTINATION table: a retired and a selected relation may route to the same
|
|
353
|
+
# one, and a plain dict comprehension would let the later deny-list replace the earlier.
|
|
354
|
+
wanted: dict = {}
|
|
355
|
+
for (schema, table), cols in denied.items():
|
|
356
|
+
wanted.setdefault((targets(schema, table), table), set()).update(cols)
|
|
357
|
+
selected = {(r.schema, r.table) for r in relations}
|
|
358
|
+
found = set(cfg.destination.denied_columns_present(wanted))
|
|
359
|
+
if pipelines is not None:
|
|
360
|
+
found |= set(_dlt_schema_hits(
|
|
361
|
+
pipelines, wanted, {(targets(s, t), t) for s, t in selected}))
|
|
362
|
+
hits = 0
|
|
363
|
+
for (schema, table), cols in sorted(denied.items()):
|
|
364
|
+
target = targets(schema, table)
|
|
365
|
+
names = _forms(table)
|
|
366
|
+
where = sorted(f"{s}.{t}.{c}" for s, t, c in found
|
|
367
|
+
if t in names and s in (target, f"{target}_staging"))
|
|
368
|
+
if not where:
|
|
369
|
+
continue
|
|
370
|
+
hits += len(where)
|
|
371
|
+
remedy = (drift.remedy(cfg.manifest_path, f"{schema}.{table}") if (schema, table) in selected
|
|
372
|
+
else "frozen copy: drop the column at the destination (runbook, #4346)")
|
|
373
|
+
run.event(entity=f"{schema}.{table}", status=cp_status.EV_ERROR, unit="deny_columns",
|
|
374
|
+
error_detail=f"denied column(s) present at the destination: {', '.join(where)}",
|
|
375
|
+
extra={"columns": where, "remedy": remedy})
|
|
376
|
+
return hits
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --------------------------------------------------------------------------- pipelines
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class _Targets:
|
|
383
|
+
"""(source schema, table) -> target schema for one run's relation set. The compiled
|
|
384
|
+
Relation carries the answer; anything outside the set (a truncate for a table that was
|
|
385
|
+
narrowed out mid-batch, say) falls back to the manifest rules."""
|
|
386
|
+
|
|
387
|
+
def __init__(self, cfg: RuntimeConfig, relations) -> None:
|
|
388
|
+
self.cfg = cfg
|
|
389
|
+
self.of: dict[tuple[str, str], str] = {
|
|
390
|
+
(r.schema, r.table): (r.target_schema or cfg.target_schema(r.schema, r.table))
|
|
391
|
+
for r in relations}
|
|
392
|
+
|
|
393
|
+
def __call__(self, schema: str, table: str) -> str:
|
|
394
|
+
return self.of.get((schema, table)) or self.cfg.target_schema(schema, table)
|
|
395
|
+
|
|
396
|
+
def all(self) -> set[str]:
|
|
397
|
+
return set(self.of.values())
|
|
398
|
+
|
|
399
|
+
def source_of(self, target: str) -> dict[str, str]:
|
|
400
|
+
"""table -> source schema within one target dataset (table names are unique there:
|
|
401
|
+
selectors.compile refuses a (target_schema, table) collision)."""
|
|
402
|
+
return {t: s for (s, t), tgt in self.of.items() if tgt == target}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class _Pipelines:
|
|
406
|
+
"""One dlt pipeline per TARGET schema (= dlt dataset), created lazily, reused across batches."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, cfg: RuntimeConfig, target_schemas) -> None:
|
|
409
|
+
self.cfg = cfg
|
|
410
|
+
self._cache: dict[str, object] = {}
|
|
411
|
+
# An explicit pipeline_name pins ONE pipeline; that is only unambiguous for a
|
|
412
|
+
# single-dataset registration.
|
|
413
|
+
self._pin = cfg.pipeline_name if len(set(target_schemas)) <= 1 else None
|
|
414
|
+
|
|
415
|
+
def get(self, target_schema: str):
|
|
416
|
+
if target_schema not in self._cache:
|
|
417
|
+
self._cache[target_schema] = self.cfg.destination.pipeline_for_target(
|
|
418
|
+
target_schema, self._pin, source_id=self.cfg.source_id)
|
|
419
|
+
return self._cache[target_schema]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _load_ids(info) -> list[str]:
|
|
423
|
+
try:
|
|
424
|
+
return [p.load_id for p in info.load_packages]
|
|
425
|
+
except Exception: # noqa: BLE001 — telemetry, never correctness
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# --------------------------------------------------------------------------- phases
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# Every baseline REPLACES the destination copy — keyed relations too. A merge on top of an
|
|
433
|
+
# older copy keeps every row the source deleted in between (a resumed snapshot, a slot-loss
|
|
434
|
+
# re-init, a table re-widened after `retired`), and the boundary skip then drops the very
|
|
435
|
+
# DELETE that used to heal it; an append_only copy has no key at all and simply doubles.
|
|
436
|
+
# dlt's `drop_resources` drops the table AND forgets it, which a bare `drop table` does not
|
|
437
|
+
# (dlt then skips the CREATE and the load dies "table does not exist"); it finds the table by
|
|
438
|
+
# the schema's `resource` attribute, which Destination.load keeps equal to the table name.
|
|
439
|
+
BASELINE_KWARGS = {"refresh": "drop_resources"}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _mark_resnapshot_required(lc: Lifecycle, schema: str, table: str, detail: str) -> None:
|
|
443
|
+
"""The state a resnapshot leaves a table in when it fails partway — and the only state it
|
|
444
|
+
may be in while the destination copy is being dropped."""
|
|
445
|
+
if lc.state_of(schema, table) in (STREAMING, CATCHING_UP, DRIFT_REVIEW, SNAPSHOTTING):
|
|
446
|
+
lc.transition(schema, table, RESNAPSHOT_REQUIRED, detail)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _to_snapshotting(lc: Lifecycle, schema: str, table: str, detail: str) -> None:
|
|
450
|
+
"""Route ANY current state to `snapshotting` on a legal path — a vanished slot re-baselines
|
|
451
|
+
every selected relation whatever state it was in, and an IllegalTransition here would abort
|
|
452
|
+
the whole tick. A table that was streaming passes through resnapshot_required (the state
|
|
453
|
+
history is the only place an operator sees WHY a baseline was retaken); one that never got a
|
|
454
|
+
baseline (`discovered`, or no row at all) goes straight there, which is what happened."""
|
|
455
|
+
current = lc.state_of(schema, table)
|
|
456
|
+
if current == RETIRED:
|
|
457
|
+
lc.transition(schema, table, DISCOVERED, detail)
|
|
458
|
+
current = DISCOVERED
|
|
459
|
+
if current in (STREAMING, CATCHING_UP, DRIFT_REVIEW):
|
|
460
|
+
lc.transition(schema, table, RESNAPSHOT_REQUIRED, detail)
|
|
461
|
+
lc.ensure(schema, table, SNAPSHOTTING, detail)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _emit_table_events(run, pipeline, unit: str, source_of: dict[str, str], tables: set[str],
|
|
465
|
+
extra: dict | None = None) -> int:
|
|
466
|
+
"""One event per loaded table, entity `<source schema>.table` — the SOURCE identity, which
|
|
467
|
+
lifecycle/reconcile key on; `source_of` maps the dataset's table names back to it, since
|
|
468
|
+
one target dataset may carry tables from several source schemas (#3079 review, D-13)."""
|
|
469
|
+
counts = Destination.row_counts(pipeline)
|
|
470
|
+
n = 0
|
|
471
|
+
for table, rows in counts.items():
|
|
472
|
+
if tables and table not in tables:
|
|
473
|
+
continue
|
|
474
|
+
entity = f"{source_of[table]}.{table}" if table in source_of else table
|
|
475
|
+
run.event(entity=entity, status=cp_status.EV_OK, unit=unit, rows=rows, extra=extra)
|
|
476
|
+
n += 1
|
|
477
|
+
return n
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _initialize(cfg: RuntimeConfig, relations, *, run, owner, lc: Lifecycle,
|
|
481
|
+
pipelines: _Pipelines, targets: _Targets, reason: str) -> int:
|
|
482
|
+
"""Slot missing or lost: reconcile the publication FIRST, then create the slot with an
|
|
483
|
+
exported snapshot, then stream the baseline out of that snapshot (no source-side staging)."""
|
|
484
|
+
owner.assert_held()
|
|
485
|
+
desired = [(r.schema, r.table) for r in relations]
|
|
486
|
+
added, dropped = publication.reconcile(cfg.source_dsn, cfg.publication, desired)
|
|
487
|
+
_log(f"init ({reason}): publication +{len(added)} -{len(dropped)}")
|
|
488
|
+
publication.ensure_replica_identity(cfg.source_dsn, desired, cfg.replica_identity)
|
|
489
|
+
|
|
490
|
+
for r in relations:
|
|
491
|
+
_to_snapshotting(lc, r.schema, r.table, reason)
|
|
492
|
+
|
|
493
|
+
owner.assert_held()
|
|
494
|
+
handle = snapshot.create_slot_with_snapshot(cfg.source_dsn, cfg.slot,
|
|
495
|
+
baseline_wait_s=cfg.baseline_wait_s)
|
|
496
|
+
keys = {(r.schema, r.table): list(r.key) for r in relations}
|
|
497
|
+
policies = {(r.schema, r.table): r.key_policy for r in relations}
|
|
498
|
+
bound = _baseline_capture()
|
|
499
|
+
try:
|
|
500
|
+
with snapshot.snapshot_resources(cfg.source_dsn, handle.snapshot_name, desired,
|
|
501
|
+
keys, key_policies=policies,
|
|
502
|
+
deny_columns=_denied(cfg, relations),
|
|
503
|
+
**bound,
|
|
504
|
+
deny_by_oid=cfg.deny_oids, deny_attnums_by_oid=cfg.deny_attnums) as by_schema:
|
|
505
|
+
handle.close() # the import already happened; the exporter is no longer needed
|
|
506
|
+
# Before any load: an interrupted load must not lose an OID first met here.
|
|
507
|
+
_persist_bound(cfg, bound)
|
|
508
|
+
by_target: dict[str, list] = {} # a snapshot resource is named after its table
|
|
509
|
+
for source_schema, resources in by_schema.items():
|
|
510
|
+
for res in resources:
|
|
511
|
+
by_target.setdefault(targets(source_schema, res.name), []).append(res)
|
|
512
|
+
for target, resources in sorted(by_target.items()):
|
|
513
|
+
owner.assert_held()
|
|
514
|
+
pipe = pipelines.get(target)
|
|
515
|
+
_pending_safe(cfg, pipe, target) # never let `run` load a stale package instead
|
|
516
|
+
pipe.run(resources, **BASELINE_KWARGS)
|
|
517
|
+
owner.assert_held() # the load blocked for a while; the lock may be gone
|
|
518
|
+
_emit_table_events(run, pipe, "snapshot", targets.source_of(target),
|
|
519
|
+
{t for s, t in desired if targets(s, t) == target},
|
|
520
|
+
{"reinit_reason": reason})
|
|
521
|
+
finally:
|
|
522
|
+
handle.close()
|
|
523
|
+
|
|
524
|
+
owner.assert_held()
|
|
525
|
+
# The exported snapshot IS the consistent point: nothing below it is ever streamed, so the
|
|
526
|
+
# boundary is recorded for uniformity (every baseline has one), not because it filters.
|
|
527
|
+
cfg.destination.write_baseline_lsns(
|
|
528
|
+
cfg.registration, {(r.schema, r.table): handle.consistent_point_lsn for r in relations})
|
|
529
|
+
cfg.destination.write_checkpoint(cfg.registration, cfg.slot, handle.consistent_point_lsn)
|
|
530
|
+
# A full re-baseline IS a resnapshot: the registry is rewritten with no diff, so a key
|
|
531
|
+
# change the previous signature would have blocked on is already resolved. Written BEFORE
|
|
532
|
+
# any relation is marked streaming, so a streaming relation always has a recorded key — a
|
|
533
|
+
# later rename's queued WAL is merged on it (`_retired_keys`, #4338).
|
|
534
|
+
drift.record_all(cfg, relations, run=run)
|
|
535
|
+
# A full re-baseline rebuilt every copy: nothing it read stays quarantined (#4346).
|
|
536
|
+
_clear_rebuilt_quarantine(cfg, bound, relations)
|
|
537
|
+
for r in relations:
|
|
538
|
+
lc.ensure(r.schema, r.table, CATCHING_UP, reason)
|
|
539
|
+
lc.ensure(r.schema, r.table, STREAMING, reason)
|
|
540
|
+
return handle.consistent_point_lsn
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _take_baseline(cfg: RuntimeConfig, rel, *, run, owner, pipelines: _Pipelines,
|
|
544
|
+
targets: _Targets, unit: str, extra: dict) -> int:
|
|
545
|
+
"""One consistent baseline of a table the publication ALREADY carries. Returns the boundary
|
|
546
|
+
LSN, persisted before the lifecycle moves on: a baseline whose boundary was not recorded
|
|
547
|
+
would let the drain re-land what it just read."""
|
|
548
|
+
pipe = pipelines.get(targets(rel.schema, rel.table))
|
|
549
|
+
bound = _baseline_capture()
|
|
550
|
+
with snapshot.consistent_baseline(cfg.source_dsn, cfg.slot, (rel.schema, rel.table),
|
|
551
|
+
list(rel.key), key_policy=rel.key_policy,
|
|
552
|
+
baseline_wait_s=cfg.baseline_wait_s,
|
|
553
|
+
deny_columns=_denied(cfg, [rel]).get(
|
|
554
|
+
(rel.schema, rel.table), ()),
|
|
555
|
+
**bound,
|
|
556
|
+
deny_by_oid=cfg.deny_oids, deny_attnums_by_oid=cfg.deny_attnums) as (resource, boundary):
|
|
557
|
+
_persist_bound(cfg, bound) # before the load: an interruption must not lose it
|
|
558
|
+
_pending_safe(cfg, pipe, targets(rel.schema, rel.table))
|
|
559
|
+
pipe.run(resource, **BASELINE_KWARGS)
|
|
560
|
+
owner.assert_held() # the load blocked for a while; the lock may be gone
|
|
561
|
+
cfg.destination.write_baseline_lsns(cfg.registration, {(rel.schema, rel.table): boundary})
|
|
562
|
+
_clear_rebuilt_quarantine(cfg, bound, [rel]) # rebuilt
|
|
563
|
+
_emit_table_events(run, pipe, unit, {rel.table: rel.schema}, {rel.table},
|
|
564
|
+
{**extra, "boundary_lsn": boundary})
|
|
565
|
+
return boundary
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _baseline(cfg: RuntimeConfig, rel, *, run, owner, lc: Lifecycle, pipelines: _Pipelines,
|
|
569
|
+
targets: _Targets, detail: str) -> None:
|
|
570
|
+
"""Full read of a table just ADDED to the publication, at a consistent point — the drain
|
|
571
|
+
drops this relation's transactions below it, so the overlap with the WAL lands once."""
|
|
572
|
+
owner.assert_held()
|
|
573
|
+
# `snapshotting`, not `catching_up`: a crash during this load must leave the ONE state
|
|
574
|
+
# _resume_interrupted_snapshots retakes a baseline from (idempotent — the caller already
|
|
575
|
+
# marked it before the publication add).
|
|
576
|
+
_to_snapshotting(lc, rel.schema, rel.table, detail)
|
|
577
|
+
_take_baseline(cfg, rel, run=run, owner=owner, pipelines=pipelines, targets=targets,
|
|
578
|
+
unit="backfill", extra={"detail": detail})
|
|
579
|
+
lc.ensure(rel.schema, rel.table, CATCHING_UP, detail)
|
|
580
|
+
lc.ensure(rel.schema, rel.table, STREAMING, detail)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _resume_interrupted_snapshots(cfg: RuntimeConfig, relations, *, run, owner,
|
|
584
|
+
lc: Lifecycle, pipelines: _Pipelines,
|
|
585
|
+
targets: _Targets) -> list:
|
|
586
|
+
"""A run killed between `create_slot_with_snapshot` and the snapshot load leaves relations
|
|
587
|
+
in `snapshotting` — and because the SLOT now exists, the next run goes straight to steady
|
|
588
|
+
state and would never take that baseline: the pre-slot rows are lost silently (the CDC
|
|
589
|
+
stream only carries what happened after the consistent point). Retake the baseline here,
|
|
590
|
+
before the drain, so a concurrent change also arrives via CDC and merge dedups it."""
|
|
591
|
+
resumed = []
|
|
592
|
+
for r in relations:
|
|
593
|
+
if lc.state_of(r.schema, r.table) != SNAPSHOTTING:
|
|
594
|
+
continue
|
|
595
|
+
owner.assert_held()
|
|
596
|
+
_take_baseline(cfg, r, run=run, owner=owner, pipelines=pipelines, targets=targets,
|
|
597
|
+
unit="snapshot", extra={"detail": "resumed an interrupted snapshot"})
|
|
598
|
+
lc.ensure(r.schema, r.table, CATCHING_UP, "resumed an interrupted snapshot")
|
|
599
|
+
lc.ensure(r.schema, r.table, STREAMING, "resumed an interrupted snapshot")
|
|
600
|
+
resumed.append((r.schema, r.table))
|
|
601
|
+
if resumed:
|
|
602
|
+
_log(f"resumed interrupted snapshots: {resumed}")
|
|
603
|
+
return resumed
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _reconcile_selection(cfg: RuntimeConfig, relations, *, run, owner, lc: Lifecycle,
|
|
607
|
+
pipelines: _Pipelines, targets: _Targets,
|
|
608
|
+
blocked: set | None = None) -> tuple[list, list]:
|
|
609
|
+
owner.assert_held()
|
|
610
|
+
desired = [(r.schema, r.table) for r in relations]
|
|
611
|
+
by_key = {(r.schema, r.table): r for r in relations}
|
|
612
|
+
# Mark the newcomers BEFORE the ALTER PUBLICATION. A crash in between leaves a published
|
|
613
|
+
# table with no lifecycle row, which _adopt_streaming then reads as "already streaming" —
|
|
614
|
+
# and nothing would ever baseline it, so its pre-add rows (in no WAL record) are lost
|
|
615
|
+
# silently and forever. `snapshotting` written first is what _resume_interrupted_snapshots
|
|
616
|
+
# picks up on the next tick.
|
|
617
|
+
planned_add, _ = publication.reconcile(cfg.source_dsn, cfg.publication, desired,
|
|
618
|
+
plan_only=True)
|
|
619
|
+
for schema, table in planned_add:
|
|
620
|
+
_to_snapshotting(lc, schema, table, "added to selection")
|
|
621
|
+
added, dropped = publication.reconcile(cfg.source_dsn, cfg.publication, desired)
|
|
622
|
+
if added:
|
|
623
|
+
publication.ensure_replica_identity(cfg.source_dsn, added, cfg.replica_identity)
|
|
624
|
+
for key in added:
|
|
625
|
+
_baseline(cfg, by_key[key], run=run, owner=owner, lc=lc, pipelines=pipelines,
|
|
626
|
+
targets=targets, detail="added to selection")
|
|
627
|
+
for schema, table in dropped:
|
|
628
|
+
if (schema, table) in (blocked or set()):
|
|
629
|
+
continue # out of the publication for drift, not retired from the selection
|
|
630
|
+
if lc.state_of(schema, table) is None:
|
|
631
|
+
# Published by an earlier drainer (the pilot's 50) and never selected here: its
|
|
632
|
+
# membership of the publication is the same evidence _adopt_streaming reads, and a
|
|
633
|
+
# `retired` row with no history would say the table was never live at all.
|
|
634
|
+
lc.transition(schema, table, STREAMING, "adopted: already in the publication")
|
|
635
|
+
# `freeze`: the destination copy stays as a frozen mirror — bronze never deletes history.
|
|
636
|
+
lc.ensure(schema, table, RETIRED, f"dropped from selection ({cfg.retirement_policy})")
|
|
637
|
+
if dropped:
|
|
638
|
+
_log(f"dropped from publication: {dropped} (retirement={cfg.retirement_policy})")
|
|
639
|
+
_retire_unselected(cfg, lc, desired, skip=blocked or set())
|
|
640
|
+
publication.ensure_replica_identity(cfg.source_dsn, desired, cfg.replica_identity)
|
|
641
|
+
return added, dropped
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _retire_unselected(cfg: RuntimeConfig, lc: Lifecycle, desired, *, skip=frozenset()) -> list:
|
|
645
|
+
"""A relation the lifecycle knows that is no longer selected is retired even when the
|
|
646
|
+
publication no longer names it — a source-side DROP or RENAME takes it out of the
|
|
647
|
+
publication by itself, so `publication.reconcile` never reports it as dropped, and its row
|
|
648
|
+
would otherwise say `streaming` for ever. Drift-blocked relations (`skip`) are out of the
|
|
649
|
+
selection for drift, not retired."""
|
|
650
|
+
wanted = set(desired) | set(skip)
|
|
651
|
+
retired = []
|
|
652
|
+
for (schema, table), state in sorted(lc.known().items()):
|
|
653
|
+
if state == RETIRED or (schema, table) in wanted:
|
|
654
|
+
continue
|
|
655
|
+
lc.ensure(schema, table, RETIRED,
|
|
656
|
+
f"no longer selected and gone from the publication ({cfg.retirement_policy})")
|
|
657
|
+
retired.append((schema, table))
|
|
658
|
+
if retired:
|
|
659
|
+
_log(f"retired (no longer selected): {retired} (retirement={cfg.retirement_policy})")
|
|
660
|
+
return retired
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _retired_keys(cfg: RuntimeConfig, lc: Lifecycle, active) -> dict:
|
|
664
|
+
"""Merge keys for RETIRED relations, from their last recorded signature. WAL written before
|
|
665
|
+
a rename still names the old relation; the stream would otherwise look its key up in the
|
|
666
|
+
source catalog, find nothing (the name is gone) and fall back to REPLICA IDENTITY FULL's
|
|
667
|
+
every-column key — an UPDATE would then land as a second row in the frozen copy."""
|
|
668
|
+
active_keys = {(r.schema, r.table) for r in active}
|
|
669
|
+
retired = [k for k, s in lc.known().items() if s == RETIRED and k not in active_keys]
|
|
670
|
+
if not retired:
|
|
671
|
+
return {}
|
|
672
|
+
# One with no signature (the pilot's adopted-then-retired tables) falls back to the source
|
|
673
|
+
# catalog in the stream; only a record whose key resolves NOWHERE is reported, by the
|
|
674
|
+
# drain (`Batch.key_problems`), so an idle retired table never raises anything.
|
|
675
|
+
signatures = cfg.destination.read_signatures(cfg.registration)
|
|
676
|
+
return {k: tuple(signatures[k].get("key") or ()) for k in retired if k in signatures}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _drain(cfg: RuntimeConfig, relations, *, run, owner, pipelines: _Pipelines,
|
|
680
|
+
targets: _Targets, retired_keys: dict | None = None) -> dict:
|
|
681
|
+
"""Batch loop. The slot hands over items grouped by SOURCE schema; they are regrouped by
|
|
682
|
+
TARGET schema here (one dlt pipeline per dataset). The checkpoint moves only after every
|
|
683
|
+
dataset in the batch committed; the slot is advanced only to a checkpointed LSN."""
|
|
684
|
+
from .selectors import APPEND_ONLY, PRIMARY_KEY
|
|
685
|
+
keys = {(r.schema, r.table): list(r.key) for r in relations}
|
|
686
|
+
policies = {(r.schema, r.table): r.key_policy for r in relations}
|
|
687
|
+
for rel, key in (retired_keys or {}).items():
|
|
688
|
+
if rel not in keys:
|
|
689
|
+
keys[rel] = list(key)
|
|
690
|
+
policies[rel] = PRIMARY_KEY if key else APPEND_ONLY
|
|
691
|
+
baselines = cfg.destination.read_baseline_lsns(cfg.registration)
|
|
692
|
+
checkpoint = cfg.destination.read_checkpoint(cfg.registration)
|
|
693
|
+
start_lsn = checkpoint[0] if checkpoint else 0
|
|
694
|
+
|
|
695
|
+
probe = telemetry.slot_probe(cfg.source_dsn, cfg.slot) or {}
|
|
696
|
+
confirmed = probe.get("confirmed_flush_int")
|
|
697
|
+
if start_lsn and confirmed is not None and confirmed < start_lsn:
|
|
698
|
+
# A crash between "load committed" and "slot advanced" leaves the slot behind the
|
|
699
|
+
# checkpoint; re-assert it so the drain does not replay work already durable.
|
|
700
|
+
stream.advance(cfg.source_dsn, cfg.slot, start_lsn)
|
|
701
|
+
|
|
702
|
+
batches = rows = 0
|
|
703
|
+
reported_key_problems: set = set()
|
|
704
|
+
reported_unreviewed: set = set()
|
|
705
|
+
quarantined_now: set = set()
|
|
706
|
+
# The reviewed shape of each relation that carries a deny (#4346, Sonnet F1): its stored
|
|
707
|
+
# signature's columns. Anything else the decoder meets there is withheld until reviewed.
|
|
708
|
+
signatures = cfg.destination.read_signatures(cfg.registration) if cfg.deny_columns else {}
|
|
709
|
+
# Keyed by the relation's OID where the signature records one (it follows a table rename);
|
|
710
|
+
# by name only for a signature written before the OID was recorded.
|
|
711
|
+
approved_by_oid = {int(sig["oid"]): list(sig.get("columns", {}))
|
|
712
|
+
for sig in signatures.values() if sig and sig.get("oid")}
|
|
713
|
+
approved = {k: list((sig or {}).get("columns", {})) for k, sig in signatures.items()
|
|
714
|
+
if sig and not sig.get("oid")}
|
|
715
|
+
# The proof inputs (#4346, Codex round 16): each relation's reviewed layout (its baseline),
|
|
716
|
+
# the OIDs whose deny was bound at a trusted moment (this run's start, or a baseline), and
|
|
717
|
+
# the relations already quarantined, whose changes stay skipped until a resnapshot.
|
|
718
|
+
deny_on = bool(cfg.deny_columns)
|
|
719
|
+
layouts = cfg.destination.read_reviewed_layouts(cfg.registration) if deny_on else {}
|
|
720
|
+
held = cfg.destination.read_quarantined_oids(cfg.registration) if deny_on else set()
|
|
721
|
+
for batch in stream.drain(cfg.source_dsn, cfg.slot, cfg.publication, start_lsn,
|
|
722
|
+
target_batch_size=cfg.target_batch_size,
|
|
723
|
+
keys_by_relation=keys,
|
|
724
|
+
policies_by_relation=policies,
|
|
725
|
+
baseline_lsn_by_relation=baselines,
|
|
726
|
+
deny_columns_by_relation=_denied(cfg, relations),
|
|
727
|
+
deny_columns_by_oid=cfg.deny_oids,
|
|
728
|
+
approved_columns_by_relation=approved,
|
|
729
|
+
approved_columns_by_oid=approved_by_oid,
|
|
730
|
+
deny_attnums_by_oid=cfg.deny_attnums,
|
|
731
|
+
reviewed_layout_by_oid=layouts,
|
|
732
|
+
bound_oids=(set(cfg.deny_oids) | set(cfg.deny_attnums)
|
|
733
|
+
if deny_on else None),
|
|
734
|
+
quarantined_oids=held):
|
|
735
|
+
owner.assert_held()
|
|
736
|
+
for oid, (schema, table, why) in sorted(batch.quarantined.items()):
|
|
737
|
+
run.event(entity=f"{schema}.{table}", status=cp_status.EV_ERROR, unit="deny_columns",
|
|
738
|
+
error_detail=(f"changes NOT loaded: {why}. The table carries denied "
|
|
739
|
+
"columns, so nothing of it loads until a resnapshot "
|
|
740
|
+
"re-reviews it; bronze is stale for it until then"),
|
|
741
|
+
extra={"action": "quarantined", "oid": oid,
|
|
742
|
+
"remedy": drift.remedy(cfg.manifest_path, f"{schema}.{table}")})
|
|
743
|
+
for (schema, table), cols in sorted(batch.unreviewed.items()):
|
|
744
|
+
if (schema, table) not in reported_unreviewed:
|
|
745
|
+
reported_unreviewed.add((schema, table))
|
|
746
|
+
run.event(entity=f"{schema}.{table}", status=cp_status.EV_ERROR,
|
|
747
|
+
unit="deny_columns",
|
|
748
|
+
error_detail=(f"column(s) {', '.join(cols)} on a table with denied "
|
|
749
|
+
"columns are not in its reviewed signature and were "
|
|
750
|
+
"WITHHELD: add them to source.deny.columns, or "
|
|
751
|
+
"resnapshot the table to approve"),
|
|
752
|
+
extra={"withheld_columns": cols,
|
|
753
|
+
"remedy": drift.remedy(cfg.manifest_path, f"{schema}.{table}")})
|
|
754
|
+
if batch.denied_seen:
|
|
755
|
+
# FIRST, before any destination change: an OID the decoder matched to a denied name
|
|
756
|
+
# keeps its deny even if this batch's load fails and the name moves before the
|
|
757
|
+
# retry (#4346, Codex round 10).
|
|
758
|
+
_record_denied(cfg, batch.denied_seen)
|
|
759
|
+
if batch.quarantined:
|
|
760
|
+
# Before the checkpoint passes the skipped changes: the relation stays marked
|
|
761
|
+
# (and reported every tick) until a resnapshot rebuilds it (Codex round 14).
|
|
762
|
+
cfg.destination.record_quarantine(cfg.registration, batch.quarantined)
|
|
763
|
+
quarantined_now |= set(batch.quarantined) # OIDs
|
|
764
|
+
# TRUNCATEs first: the consumer already discarded a keyed relation's earlier rows of
|
|
765
|
+
# this batch, so "empty, then load what followed" is the source's own order. A replay
|
|
766
|
+
# after a crash deletes again and re-merges — idempotent.
|
|
767
|
+
for (schema, table), why in sorted(batch.key_problems.items()):
|
|
768
|
+
if (schema, table) not in reported_key_problems:
|
|
769
|
+
reported_key_problems.add((schema, table))
|
|
770
|
+
run.event(entity=f"{schema}.{table}", status=cp_status.EV_ERROR,
|
|
771
|
+
unit="merge_key", error_detail=why)
|
|
772
|
+
load_ids: list[str] = []
|
|
773
|
+
by_target: dict[str, list] = {}
|
|
774
|
+
for source_schema, items in batch.items_by_namespace.items():
|
|
775
|
+
for item in items:
|
|
776
|
+
table = Destination.table_of(item)
|
|
777
|
+
target = (targets(source_schema, table) if table
|
|
778
|
+
else cfg.target_schema(source_schema))
|
|
779
|
+
by_target.setdefault(target, []).append(item)
|
|
780
|
+
# A replay after a crash may find a pipeline with a package left pending (#4345 Codex
|
|
781
|
+
# adversarial: a later load round normalized, never loaded). Finish it BEFORE the
|
|
782
|
+
# truncates below, which then remove it again — after them it would resurrect rows.
|
|
783
|
+
for target in sorted(set(by_target) | {targets(t["namespace"], t["table"])
|
|
784
|
+
for t in batch.truncates}):
|
|
785
|
+
if _pending_safe(cfg, pipelines.get(target), target):
|
|
786
|
+
owner.assert_held()
|
|
787
|
+
for t in batch.truncates:
|
|
788
|
+
keyed = policies.get((t["namespace"], t["table"])) != APPEND_ONLY
|
|
789
|
+
applied = False
|
|
790
|
+
if keyed:
|
|
791
|
+
applied = cfg.destination.delete_all_rows(targets(t["namespace"], t["table"]),
|
|
792
|
+
t["table"])
|
|
793
|
+
run.event(entity=f"{t['namespace']}.{t['table']}", status=cp_status.EV_OK,
|
|
794
|
+
unit="truncate",
|
|
795
|
+
extra={"lsn": t["lsn"], "commit_lsn": t["commit_lsn"],
|
|
796
|
+
"applied": "delete_all" if keyed else "recorded_only",
|
|
797
|
+
"destination_table_existed": applied})
|
|
798
|
+
for target, items in sorted(by_target.items()):
|
|
799
|
+
pipe = pipelines.get(target)
|
|
800
|
+
# Usually one round; more only when a table changed description mid-batch (#4345).
|
|
801
|
+
for round_items in Destination.rounds(items):
|
|
802
|
+
info = Destination.load(pipe, round_items, resource_name=cfg.slot)
|
|
803
|
+
# The load is the one long blocking call per batch: Postgres may have reaped
|
|
804
|
+
# the owner backend meanwhile, freeing the lock to a peer. Checkpointing or
|
|
805
|
+
# advancing the slot without the lock would hand that peer a hole; re-assert
|
|
806
|
+
# first, and leave the committed load replayable (merge / lsn-merge make the
|
|
807
|
+
# replay exact).
|
|
808
|
+
owner.assert_held()
|
|
809
|
+
load_ids += _load_ids(info)
|
|
810
|
+
rows += sum(Destination.row_counts(pipe).values())
|
|
811
|
+
_emit_table_events(run, pipe, "cdc", targets.source_of(target), set())
|
|
812
|
+
if batch.last_commit_lsn:
|
|
813
|
+
owner.assert_held()
|
|
814
|
+
cfg.destination.write_checkpoint(cfg.registration, cfg.slot,
|
|
815
|
+
batch.last_commit_lsn, load_ids)
|
|
816
|
+
stream.advance(cfg.source_dsn, cfg.slot, batch.last_commit_lsn)
|
|
817
|
+
batches += 1
|
|
818
|
+
return {"batches": batches, "rows": rows, "start_lsn": start_lsn,
|
|
819
|
+
"quarantined": quarantined_now}
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
# --------------------------------------------------------------------------- entry points
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
class _Tally:
|
|
826
|
+
"""The run handle every phase writes through, counting EV_ERROR entities on the way so the
|
|
827
|
+
control.run row can be finished honestly: `ok` means every unit succeeded (control_plane
|
|
828
|
+
status.py), and a drift-blocked table, a reconcile mismatch or a paused slot is not that."""
|
|
829
|
+
|
|
830
|
+
def __init__(self, run) -> None:
|
|
831
|
+
self._run = run
|
|
832
|
+
self.errors: set[str] = set()
|
|
833
|
+
|
|
834
|
+
def event(self, *, entity: str, status: str, **kw) -> None:
|
|
835
|
+
if status == cp_status.EV_ERROR:
|
|
836
|
+
self.errors.add(entity)
|
|
837
|
+
return self._run.event(entity=entity, status=status, **kw)
|
|
838
|
+
|
|
839
|
+
def __getattr__(self, name):
|
|
840
|
+
return getattr(self._run, name)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _outcome(relations, active, *, errors: set, paused: bool) -> dict:
|
|
844
|
+
selected = [r.qualified for r in relations]
|
|
845
|
+
active_names = [r.qualified for r in active]
|
|
846
|
+
skipped = len(selected) if paused else len(selected) - len(active_names)
|
|
847
|
+
failed_active = [q for q in active_names if q in errors]
|
|
848
|
+
n_ok = 0 if paused else len(active_names) - len(failed_active)
|
|
849
|
+
if paused:
|
|
850
|
+
status, stop = cp_status.RUN_FAILED, cp_status.STOP_ERROR
|
|
851
|
+
elif errors or skipped:
|
|
852
|
+
status, stop = cp_status.RUN_PARTIAL, cp_status.STOP_CLEAN
|
|
853
|
+
else:
|
|
854
|
+
status, stop = cp_status.RUN_OK, cp_status.STOP_CLEAN
|
|
855
|
+
return {"status": status, "stop_reason": stop, "n_ok": n_ok, "n_err": len(errors),
|
|
856
|
+
"n_skipped": skipped, "errors": sorted(errors)}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def run_relations(cfg: RuntimeConfig, relations, *, run, owner) -> dict:
|
|
860
|
+
"""One full drain cycle for an already-compiled relation set. Callers hold the owner lock.
|
|
861
|
+
The result carries `outcome` — status + n_ok/n_err/n_skipped — for `execute` to finish
|
|
862
|
+
the control.run row with."""
|
|
863
|
+
t0 = time.monotonic()
|
|
864
|
+
relations = list(relations)
|
|
865
|
+
run = _Tally(run)
|
|
866
|
+
cfg.destination.ensure_runtime_tables()
|
|
867
|
+
cfg = _with_inherited_denies(cfg, relations)
|
|
868
|
+
refused = _refuse_denied_keys(cfg, relations)
|
|
869
|
+
if refused:
|
|
870
|
+
run.event(entity="_deny_columns", status=cp_status.EV_ERROR, unit="deny_columns",
|
|
871
|
+
error_detail="a merge key includes a denied column (inherited through a "
|
|
872
|
+
f"rename): {'; '.join(refused)} — nothing was replicated",
|
|
873
|
+
extra={"refused": refused})
|
|
874
|
+
return {"paused": True,
|
|
875
|
+
"outcome": _outcome(relations, [], errors=run.errors, paused=True)}
|
|
876
|
+
if cfg.deny_unbound:
|
|
877
|
+
# Fail closed (Codex round 16): a denied column that binds to no attnum may have been
|
|
878
|
+
# renamed before it was ever bound — any column of its table could be the secret.
|
|
879
|
+
run.event(entity="_deny_columns", status=cp_status.EV_ERROR, unit="deny_columns",
|
|
880
|
+
error_detail=("denied column(s) bound to no column of their table: "
|
|
881
|
+
f"{', '.join(cfg.deny_unbound)} — nothing was replicated. "
|
|
882
|
+
"If the column was renamed, add the new name to "
|
|
883
|
+
"source.deny.columns; if it does not exist, remove the entry"),
|
|
884
|
+
extra={"unbound": list(cfg.deny_unbound)})
|
|
885
|
+
return {"paused": True,
|
|
886
|
+
"outcome": _outcome(relations, [], errors=run.errors, paused=True)}
|
|
887
|
+
lc = Lifecycle(cfg.destination, cfg.registration, run)
|
|
888
|
+
targets = _Targets(cfg, relations)
|
|
889
|
+
pipelines = _Pipelines(cfg, targets.all())
|
|
890
|
+
|
|
891
|
+
slot = telemetry.slot_probe(cfg.source_dsn, cfg.slot)
|
|
892
|
+
lost = slot is not None and slot.get("wal_status") == "lost"
|
|
893
|
+
if (slot is None or lost) and cfg.slot_loss_policy == "pause" and slot is not None:
|
|
894
|
+
for r in relations:
|
|
895
|
+
# State-aware: a relation selected since the last tick has no row (and a re-selected
|
|
896
|
+
# one is `retired`); neither has a legal edge to resnapshot_required (#4338).
|
|
897
|
+
_mark_resnapshot_required(lc, r.schema, r.table, "slot lost; slot_loss_policy=pause")
|
|
898
|
+
run.event(entity="_slot", status=cp_status.EV_ERROR, unit="probe",
|
|
899
|
+
error_detail="slot lost and slot_loss_policy=pause", extra=slot)
|
|
900
|
+
return {"paused": True,
|
|
901
|
+
"outcome": _outcome(relations, [], errors=run.errors, paused=True)}
|
|
902
|
+
|
|
903
|
+
blocked: set = set()
|
|
904
|
+
if slot is None or lost:
|
|
905
|
+
reason = "first run" if slot is None else f"slot invalidated ({slot.get('wal_status')})"
|
|
906
|
+
if lost:
|
|
907
|
+
# Dropping the publication and the slot is the most destructive thing this runtime
|
|
908
|
+
# does — a peer that acquired the freed lock could already be re-initialising. Prove
|
|
909
|
+
# ownership BEFORE the drop, not at the top of _initialize which runs after it.
|
|
910
|
+
owner.assert_held()
|
|
911
|
+
for r in relations:
|
|
912
|
+
# State-aware (#4338): a newly selected or re-selected relation is routed
|
|
913
|
+
# straight to `snapshotting` by _initialize instead of aborting the tick here.
|
|
914
|
+
_mark_resnapshot_required(lc, r.schema, r.table, reason)
|
|
915
|
+
publication.drop_publication(cfg.source_dsn, cfg.publication)
|
|
916
|
+
snapshot.drop_slot_if_exists(cfg.source_dsn, cfg.slot)
|
|
917
|
+
_initialize(cfg, relations, run=run, owner=owner, lc=lc, pipelines=pipelines,
|
|
918
|
+
targets=targets, reason=reason)
|
|
919
|
+
# A rebuilt source (slot and publication gone) may no longer carry a table the
|
|
920
|
+
# lifecycle still calls live; nothing else would ever retire it.
|
|
921
|
+
_retire_unselected(cfg, lc, [(r.schema, r.table) for r in relations])
|
|
922
|
+
active = relations
|
|
923
|
+
else:
|
|
924
|
+
_adopt_streaming(lc, relations,
|
|
925
|
+
publication.published_relations(cfg.source_dsn, cfg.publication),
|
|
926
|
+
existing=cfg.destination.destination_tables(targets.all()),
|
|
927
|
+
targets=targets)
|
|
928
|
+
outcome = drift.scan(cfg, relations, run=run, lc=lc, owner=owner,
|
|
929
|
+
manifest_path=cfg.manifest_path)
|
|
930
|
+
blocked = outcome.blocked
|
|
931
|
+
active = outcome.active(relations)
|
|
932
|
+
_reconcile_selection(cfg, active, run=run, owner=owner, lc=lc, pipelines=pipelines,
|
|
933
|
+
targets=targets, blocked=blocked)
|
|
934
|
+
_resume_interrupted_snapshots(cfg, active, run=run, owner=owner, lc=lc,
|
|
935
|
+
pipelines=pipelines, targets=targets)
|
|
936
|
+
|
|
937
|
+
result = _drain(cfg, active, run=run, owner=owner, pipelines=pipelines, targets=targets,
|
|
938
|
+
retired_keys=_retired_keys(cfg, lc, active))
|
|
939
|
+
result["drift_blocked"] = sorted(blocked)
|
|
940
|
+
# Re-resolved: OIDs bound during this run may now sit under a name the startup map lacks.
|
|
941
|
+
cfg = _with_inherited_denies(cfg, relations)
|
|
942
|
+
for oid, (schema, table, why, since) in sorted(
|
|
943
|
+
cfg.destination.read_quarantine(cfg.registration).items()):
|
|
944
|
+
if oid in result["quarantined"]:
|
|
945
|
+
continue # already reported by this run's drain
|
|
946
|
+
run.event(entity=f"{schema}.{table}", status=cp_status.EV_ERROR, unit="deny_columns",
|
|
947
|
+
error_detail=(f"changes were skipped since {since} ({why}); bronze is stale "
|
|
948
|
+
"for this table until it is resnapshotted"),
|
|
949
|
+
extra={"action": "quarantine_pending", "oid": oid,
|
|
950
|
+
"remedy": drift.remedy(cfg.manifest_path, f"{schema}.{table}")})
|
|
951
|
+
result["denied_columns_at_destination"] = _check_denied_columns(
|
|
952
|
+
cfg, relations, run=run, targets=targets, pipelines=pipelines)
|
|
953
|
+
|
|
954
|
+
probe = telemetry.slot_probe(cfg.source_dsn, cfg.slot)
|
|
955
|
+
checkpoint = cfg.destination.read_checkpoint(cfg.registration)
|
|
956
|
+
published = publication.published_relations(cfg.source_dsn, cfg.publication)
|
|
957
|
+
dest_tables = cfg.destination.destination_tables(
|
|
958
|
+
{targets(r.schema, r.table) for r in active})
|
|
959
|
+
telemetry.emit_probe(run, probe, {
|
|
960
|
+
"selected_tables": len(active),
|
|
961
|
+
"published_tables": len(published),
|
|
962
|
+
"destination_tables": len(dest_tables),
|
|
963
|
+
"drift_blocked_tables": len(blocked),
|
|
964
|
+
"denied_columns_at_destination": result["denied_columns_at_destination"],
|
|
965
|
+
"checkpoint_lsn": checkpoint[0] if checkpoint else None,
|
|
966
|
+
"checkpoint_age_s": telemetry.checkpoint_age_s(_checkpoint_time(cfg)),
|
|
967
|
+
}, pull_ms=int((time.monotonic() - t0) * 1000))
|
|
968
|
+
|
|
969
|
+
from .selectors import APPEND_ONLY
|
|
970
|
+
telemetry.emit_reconcile(run, telemetry.reconcile_counts(
|
|
971
|
+
cfg.source_dsn, cfg.destination, [(r.schema, r.table) for r in active],
|
|
972
|
+
targets.of, threshold=cfg.reconcile_threshold,
|
|
973
|
+
exclude=list(cfg.reconcile_exclude),
|
|
974
|
+
append_only={(r.schema, r.table) for r in active
|
|
975
|
+
if r.key_policy == APPEND_ONLY}))
|
|
976
|
+
result["outcome"] = _outcome(relations, active, errors=run.errors, paused=False)
|
|
977
|
+
return result
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _adopt_streaming(lc: Lifecycle, relations, published: set, *, existing: set,
|
|
981
|
+
targets) -> None:
|
|
982
|
+
"""A steady-state run over tables with no lifecycle row (a takeover from the pilot, or a
|
|
983
|
+
destination rebuilt underneath us): record what is observably true. Membership of the
|
|
984
|
+
publication is the evidence — a newly selected table is NOT adopted, it gets a baseline.
|
|
985
|
+
|
|
986
|
+
Adoption also needs the DESTINATION table to exist: a table RENAMED at the source stays in
|
|
987
|
+
the publication under its new name (membership is by OID), and its earlier rows are in no
|
|
988
|
+
WAL record under that name, so with no destination copy it is routed to `snapshotting` and
|
|
989
|
+
baselined this tick (#4338). Both signals are re-read on every tick, so a takeover that
|
|
990
|
+
crashed half-way decides the same way on retry — an adopted table is never re-baselined
|
|
991
|
+
(which would replace an append_only history with the source's current rows)."""
|
|
992
|
+
for r in relations:
|
|
993
|
+
key = (r.schema, r.table)
|
|
994
|
+
if key not in published:
|
|
995
|
+
continue
|
|
996
|
+
state = lc.state_of(r.schema, r.table)
|
|
997
|
+
if state in (RETIRED, DISCOVERED):
|
|
998
|
+
# Re-selected while already published (a rename undone, a restored source whose
|
|
999
|
+
# publication still names it): `publication.reconcile` will not report it as
|
|
1000
|
+
# added, so nothing else would re-baseline its frozen copy. DISCOVERED is the same
|
|
1001
|
+
# case interrupted between `_to_snapshotting`'s two writes.
|
|
1002
|
+
_to_snapshotting(lc, r.schema, r.table, "re-selected after retirement — baselining")
|
|
1003
|
+
elif state is None and (targets(r.schema, r.table), r.table) in existing:
|
|
1004
|
+
lc.ensure(r.schema, r.table, STREAMING, "adopted: already in the publication")
|
|
1005
|
+
elif state is None:
|
|
1006
|
+
_to_snapshotting(lc, r.schema, r.table,
|
|
1007
|
+
"published, but no lifecycle row and no destination copy "
|
|
1008
|
+
"(renamed at the source?) — baselining")
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _checkpoint_time(cfg: RuntimeConfig):
|
|
1012
|
+
try:
|
|
1013
|
+
with cfg.destination.connect() as con:
|
|
1014
|
+
row = con.execute(
|
|
1015
|
+
'select committed_at from "_cdc_runtime".checkpoint where registration = ?',
|
|
1016
|
+
[cfg.registration]).fetchone()
|
|
1017
|
+
return row[0] if row else None
|
|
1018
|
+
except Exception: # noqa: BLE001
|
|
1019
|
+
return None
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def execute(cfg: RuntimeConfig, relations, *, cp: ControlPlane) -> int:
|
|
1023
|
+
"""Ownership + control-plane run wrapper. A second concurrent run touches NOTHING."""
|
|
1024
|
+
with cp.run("cdc") as run:
|
|
1025
|
+
owner = OwnerSession(cfg.source_dsn, cfg.slot)
|
|
1026
|
+
if not owner.acquire():
|
|
1027
|
+
_log(f"another run owns slot {cfg.slot!r} — skipping this tick")
|
|
1028
|
+
run.event(entity="_owner", status=cp_status.EV_ZERO_ROWS, unit="ownership",
|
|
1029
|
+
extra={"slot": cfg.slot, "acquired": False})
|
|
1030
|
+
run.finish(cp_status.RUN_OK, stop_reason=cp_status.STOP_CLEAN, n_ok=0, n_skipped=1)
|
|
1031
|
+
return 0
|
|
1032
|
+
try:
|
|
1033
|
+
result = run_relations(cfg, relations, run=run, owner=owner)
|
|
1034
|
+
except OwnershipLost as exc:
|
|
1035
|
+
# The ONE event whose text is an arbitrary driver message: psycopg wraps whatever
|
|
1036
|
+
# killed the owner connection. control.event is read by everyone with the plane, so
|
|
1037
|
+
# it goes through the same redaction as stdout.
|
|
1038
|
+
from .manifest import redact
|
|
1039
|
+
run.event(entity="_owner", status=cp_status.EV_ERROR, unit="ownership",
|
|
1040
|
+
error_detail=redact(str(exc)))
|
|
1041
|
+
run.finish(cp_status.RUN_FAILED, stop_reason=cp_status.STOP_ERROR, n_err=1)
|
|
1042
|
+
raise
|
|
1043
|
+
except snapshot.BaselineBlocked as exc:
|
|
1044
|
+
# The relation(s) mid-baseline stay `snapshotting` (never advanced past it) — the
|
|
1045
|
+
# next tick's _resume_interrupted_snapshots retakes them once the blocker clears.
|
|
1046
|
+
from .manifest import redact
|
|
1047
|
+
run.event(entity="_snapshot", status=cp_status.EV_ERROR, unit="snapshot",
|
|
1048
|
+
error_detail=redact(str(exc)))
|
|
1049
|
+
run.finish(cp_status.RUN_FAILED, stop_reason=cp_status.STOP_ERROR, n_err=1)
|
|
1050
|
+
raise
|
|
1051
|
+
finally:
|
|
1052
|
+
owner.release()
|
|
1053
|
+
outcome = result["outcome"]
|
|
1054
|
+
run.finish(outcome["status"], stop_reason=outcome["stop_reason"],
|
|
1055
|
+
n_ok=outcome["n_ok"], n_err=outcome["n_err"], n_skipped=outcome["n_skipped"])
|
|
1056
|
+
_log(f"done: {result}")
|
|
1057
|
+
# A paused registration drained nothing: exit non-zero so the cron surface is red too.
|
|
1058
|
+
return 0 if outcome["status"] != cp_status.RUN_FAILED else 1
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# --------------------------------------------------------------------------- manifest binding
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def config_from_manifest(m, *, dsn: str, destination: Destination,
|
|
1065
|
+
schemas: Sequence[str] = ()) -> RuntimeConfig:
|
|
1066
|
+
mapping = m.destination.schema_mapping
|
|
1067
|
+
schema_map = dict(mapping) if isinstance(mapping, dict) else {}
|
|
1068
|
+
return RuntimeConfig(
|
|
1069
|
+
registration=m.source_id,
|
|
1070
|
+
source_id=m.source_id,
|
|
1071
|
+
source_dsn=dsn,
|
|
1072
|
+
slot=m.replication.slot,
|
|
1073
|
+
publication=m.replication.publication,
|
|
1074
|
+
extractor=m.control.extractor,
|
|
1075
|
+
destination=destination,
|
|
1076
|
+
schema_map=schema_map,
|
|
1077
|
+
table_schemas=[(r.match, r.schema) for r in m.destination.table_schemas],
|
|
1078
|
+
pipeline_name=m.destination.pipeline_name,
|
|
1079
|
+
target_batch_size=m.replication.target_batch_size,
|
|
1080
|
+
replica_identity=m.replication.replica_identity,
|
|
1081
|
+
slot_loss_policy=m.replication.slot_loss_policy,
|
|
1082
|
+
retirement_policy=m.destination.retirement_policy,
|
|
1083
|
+
reconcile_exclude=list(m.control.reconcile_exclude),
|
|
1084
|
+
deny_columns={tuple(q.split(".", 1)): tuple(sorted(set(cols)))
|
|
1085
|
+
for q, cols in m.source.deny_columns.items() if cols},
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _compile(m):
|
|
1090
|
+
"""Lazy import so the runtime's tests can drive `run_relations` with a literal relation
|
|
1091
|
+
list, and so `validate`/`plan` never pull the runtime in."""
|
|
1092
|
+
from . import discovery, manifest as mf
|
|
1093
|
+
from .adapters import get as get_adapter
|
|
1094
|
+
from .selectors import compile as compile_selection
|
|
1095
|
+
|
|
1096
|
+
_refuse_static_errors(m)
|
|
1097
|
+
dsn = os.environ.get(m.source.dsn_env)
|
|
1098
|
+
if not dsn:
|
|
1099
|
+
sys.exit(f"FATAL: {m.source.dsn_env} is not set")
|
|
1100
|
+
relations = discovery.discover(dsn)
|
|
1101
|
+
adapter_rels = []
|
|
1102
|
+
if m.source.discovery is not None:
|
|
1103
|
+
adapter_rels = list(get_adapter(m.source.discovery.adapter)(dsn, m.source.discovery.options))
|
|
1104
|
+
plan = compile_selection(m, relations, adapter_rels)
|
|
1105
|
+
if mf.errors(list(plan.problems)):
|
|
1106
|
+
for p in mf.redact_problems(list(plan.problems)):
|
|
1107
|
+
print(p, file=sys.stderr)
|
|
1108
|
+
sys.exit("FATAL: the compiled plan has errors — run `validate` and fix the manifest")
|
|
1109
|
+
return dsn, plan
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _refuse_static_errors(m) -> None:
|
|
1113
|
+
"""The manifest contract (unknown api_version rejected, enums exact) is only a contract if
|
|
1114
|
+
the MUTATING entry points hold it too — `validate` alone would let `run` execute a v999
|
|
1115
|
+
manifest best-effort (#3079 review). Runs before any connection is opened."""
|
|
1116
|
+
from . import manifest as mf
|
|
1117
|
+
problems = mf.errors(list(mf.validate_static(m)))
|
|
1118
|
+
if problems:
|
|
1119
|
+
for prob in mf.redact_problems(problems):
|
|
1120
|
+
print(prob, file=sys.stderr)
|
|
1121
|
+
sys.exit("FATAL: the manifest fails static validation — run `validate` and fix it")
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _destination_override_path() -> str | None:
|
|
1125
|
+
"""The proof-only override's LOCAL FILE path, or None when unset; exits on anything else.
|
|
1126
|
+
|
|
1127
|
+
DuckDB opens `md:`/`motherduck:` (any case) as MotherDuck and reads `?key=value` as connection
|
|
1128
|
+
options, so an unchecked path could reach a production database with the run's token — the
|
|
1129
|
+
dev Featherbase CDC shares production's owner (#4340). A path with a scheme or a `?` is refused.
|
|
1130
|
+
"""
|
|
1131
|
+
override = os.environ.get("PG_MD_CDC_DESTINATION", "")
|
|
1132
|
+
if not override:
|
|
1133
|
+
return None
|
|
1134
|
+
kind, _, path = override.partition(":")
|
|
1135
|
+
if kind != "duckdb" or not path:
|
|
1136
|
+
sys.exit("FATAL: PG_MD_CDC_DESTINATION must look like 'duckdb:<file path>'")
|
|
1137
|
+
if re.match(r"[A-Za-z][A-Za-z0-9+.-]*:", path) or "?" in path:
|
|
1138
|
+
sys.exit("FATAL: PG_MD_CDC_DESTINATION's duckdb target must be a local file path, "
|
|
1139
|
+
"not a URI or connection string")
|
|
1140
|
+
return path
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _destination(m) -> Destination:
|
|
1144
|
+
"""MotherDuck unless `PG_MD_CDC_DESTINATION=duckdb:<path>` overrides it.
|
|
1145
|
+
|
|
1146
|
+
DEV/PROOF ONLY: it exists so `run`/`status`/`resnapshot` can be driven end to end against a
|
|
1147
|
+
local duckdb file (the same dlt load path minus credentials). `PG_MD_CDC_PIPELINES_DIR`
|
|
1148
|
+
likewise redirects dlt's pipeline state so a proof never writes into ~/.dlt. Unset, both are
|
|
1149
|
+
inert and the destination is MotherDuck.
|
|
1150
|
+
"""
|
|
1151
|
+
path = _destination_override_path()
|
|
1152
|
+
pipelines_dir = os.environ.get("PG_MD_CDC_PIPELINES_DIR") or None
|
|
1153
|
+
if path:
|
|
1154
|
+
return Destination(DestinationSpec(kind="duckdb", database=m.destination.database,
|
|
1155
|
+
path=path, pipelines_dir=pipelines_dir))
|
|
1156
|
+
return Destination(DestinationSpec(kind="motherduck", database=m.destination.database,
|
|
1157
|
+
token_env=m.destination.token_env,
|
|
1158
|
+
owner=m.destination.owner,
|
|
1159
|
+
pipelines_dir=pipelines_dir))
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _run_log_dsn() -> str | None:
|
|
1163
|
+
# #4078: the CDC logs to its own CDC log DB. CONTROL_PG_DSN is accepted for one release, and
|
|
1164
|
+
# must lose: for the control-plane CDC it is ALSO the source being mirrored.
|
|
1165
|
+
return os.environ.get("CDC_LOG_PG_DSN") or os.environ.get("CONTROL_PG_DSN")
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _control_plane(m, dev: bool) -> ControlPlane:
|
|
1169
|
+
control_dsn = _run_log_dsn()
|
|
1170
|
+
if not control_dsn and not dev:
|
|
1171
|
+
sys.exit("FATAL: CDC_LOG_PG_DSN not set (CONTROL_PG_DSN is accepted for one release). "
|
|
1172
|
+
"The CDC log DB is a HARD dependency (#4078) — set it, or pass --dev for a "
|
|
1173
|
+
"local WAL-only run.")
|
|
1174
|
+
wal_dir = m.control.wal_dir or os.environ.get("CONTROL_WAL_DIR", "control_wal")
|
|
1175
|
+
return ControlPlane(m.control.extractor, dsn=None if dev else control_dsn, wal_dir=wal_dir)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _refuse_wrong_environment(manifest) -> None:
|
|
1179
|
+
"""Pre-connection refusals for the mutating entry points: wrong Railway environment, a
|
|
1180
|
+
destination that is not a bare database name, a non-production run aimed at production."""
|
|
1181
|
+
from . import manifest as mf
|
|
1182
|
+
|
|
1183
|
+
# Before ANY connection, the run log included: a misplaced worker writing a run row under
|
|
1184
|
+
# production's extractor would make production's stalled-drain alarm read as healthy.
|
|
1185
|
+
why = mf.environment_mismatch(manifest) or mf.database_name_problem(manifest.destination.database)
|
|
1186
|
+
if why:
|
|
1187
|
+
sys.exit(f"FATAL: {why}")
|
|
1188
|
+
_destination_override_path() # exits on a non-local override, before any connection
|
|
1189
|
+
if manifest.environment != mf.PRODUCTION:
|
|
1190
|
+
# The dev mirror's token may also own production bronze (Siraj, 23-Oct-2026), so refuse
|
|
1191
|
+
# any database a production registration shipped beside this one writes. Fail closed: a
|
|
1192
|
+
# sibling that cannot be read stops the run rather than skipping the check.
|
|
1193
|
+
here = os.path.dirname(os.path.abspath(manifest.path or "registrations/_"))
|
|
1194
|
+
try:
|
|
1195
|
+
prod = mf.production_databases(here)
|
|
1196
|
+
except Exception as exc: # noqa: BLE001
|
|
1197
|
+
sys.exit(f"FATAL: cannot read the production registrations beside "
|
|
1198
|
+
f"{manifest.source_id!r} to rule out a production database: {exc}")
|
|
1199
|
+
db = manifest.destination.database
|
|
1200
|
+
if db in prod:
|
|
1201
|
+
sys.exit(f"FATAL: {manifest.environment} registration {manifest.source_id!r} targets "
|
|
1202
|
+
f"{db!r}, the database of production registration {prod[db]!r} — refusing")
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _check_owner(manifest) -> BaseException | None:
|
|
1206
|
+
"""#4427: the token must sign in as `destination.owner` before anything is created or
|
|
1207
|
+
written. A mismatch exits here, before the run log is opened. A check that could not run
|
|
1208
|
+
(MotherDuck unreachable) is returned for the caller to record as a failed tick."""
|
|
1209
|
+
from .destination import WrongOwner
|
|
1210
|
+
_refuse_static_errors(manifest) # a missing or malformed owner never reaches a connection
|
|
1211
|
+
try:
|
|
1212
|
+
_destination(manifest).verify_owner()
|
|
1213
|
+
except WrongOwner as exc:
|
|
1214
|
+
sys.exit(f"FATAL: {exc}")
|
|
1215
|
+
except Exception as exc: # noqa: BLE001 — already token-free (Destination._on_bare_md)
|
|
1216
|
+
return exc
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def run(manifest, *, dev: bool = False, manifest_path: str = "<manifest>") -> int:
|
|
1221
|
+
_refuse_wrong_environment(manifest)
|
|
1222
|
+
owner_error = _check_owner(manifest)
|
|
1223
|
+
cp = _control_plane(manifest, dev)
|
|
1224
|
+
if owner_error is not None:
|
|
1225
|
+
_record_failed_run(cp, "_destination", "owner_check", owner_error)
|
|
1226
|
+
raise owner_error
|
|
1227
|
+
try:
|
|
1228
|
+
dsn, plan = _compile(manifest)
|
|
1229
|
+
except SystemExit:
|
|
1230
|
+
raise # a manifest refusal already explained itself; nothing ran
|
|
1231
|
+
except Exception as exc:
|
|
1232
|
+
# Discovery runs BEFORE the control-plane run opens, so a source outage here used to
|
|
1233
|
+
# exit 1 with no control.run row at all — which the runbook's first trust step reads as
|
|
1234
|
+
# "the cron never fired". Record it as the failed tick it is (#3079 final review).
|
|
1235
|
+
_record_failed_run(cp, "_source", "discovery", exc)
|
|
1236
|
+
raise
|
|
1237
|
+
cfg = config_from_manifest(manifest, dsn=dsn, destination=_destination(manifest))
|
|
1238
|
+
cfg.manifest_path = manifest_path
|
|
1239
|
+
if not plan.relations:
|
|
1240
|
+
_refuse_empty_selection(cfg, cp=cp)
|
|
1241
|
+
try:
|
|
1242
|
+
# A new registration's database may not exist yet (#4426): every refusal is behind us.
|
|
1243
|
+
cfg.destination.ensure_database()
|
|
1244
|
+
except Exception as exc:
|
|
1245
|
+
_record_failed_run(cp, "_destination", "create_database", exc)
|
|
1246
|
+
raise
|
|
1247
|
+
return execute(cfg, plan.relations, cp=cp)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _record_failed_run(cp: ControlPlane, entity: str, unit: str, exc: BaseException) -> None:
|
|
1251
|
+
"""One failed control.run row + one redacted error event for a tick that died before
|
|
1252
|
+
`execute` could own it. Best effort: an unreachable control plane must not mask the cause."""
|
|
1253
|
+
from .manifest import redact
|
|
1254
|
+
try:
|
|
1255
|
+
with cp.run("cdc") as run:
|
|
1256
|
+
run.event(entity=entity, status=cp_status.EV_ERROR, unit=unit,
|
|
1257
|
+
error_detail=redact(f"{type(exc).__name__}: {exc}"))
|
|
1258
|
+
run.finish(cp_status.RUN_FAILED, stop_reason=cp_status.STOP_ERROR, n_err=1)
|
|
1259
|
+
except Exception as cp_exc: # noqa: BLE001
|
|
1260
|
+
_log(f"could not record the failed run: {redact(f'{type(cp_exc).__name__}: {cp_exc}')}")
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _refuse_empty_selection(cfg: RuntimeConfig, *, cp: ControlPlane) -> None:
|
|
1264
|
+
"""A declaration that compiles to nothing is a mistake or a decommission — never "keep the
|
|
1265
|
+
publication and the slot but stop consuming": a slot nobody drains retains WAL until the
|
|
1266
|
+
fuse, and exiting 0 with no control.run row hides it (#3079 review). Fail closed, say so
|
|
1267
|
+
in control.run, touch nothing; §i of the runbook is the explicit way out."""
|
|
1268
|
+
published = (publication.published_relations(cfg.source_dsn, cfg.publication)
|
|
1269
|
+
if publication.publication_exists(cfg.source_dsn, cfg.publication) else None)
|
|
1270
|
+
slot = telemetry.slot_probe(cfg.source_dsn, cfg.slot)
|
|
1271
|
+
detail = (f"the compiled selection is empty; publication {cfg.publication!r} "
|
|
1272
|
+
+ (f"still carries {len(published)} table(s)" if published is not None
|
|
1273
|
+
else "does not exist")
|
|
1274
|
+
+ f", slot {cfg.slot!r} " + ("exists" if slot else "does not exist"))
|
|
1275
|
+
with cp.run("cdc") as run:
|
|
1276
|
+
run.event(entity="_selection", status=cp_status.EV_ERROR, unit="selection",
|
|
1277
|
+
error_detail=detail,
|
|
1278
|
+
extra={"published_tables": len(published or ()), "slot_exists": bool(slot)})
|
|
1279
|
+
run.finish(cp_status.RUN_FAILED, stop_reason=cp_status.STOP_ERROR, n_ok=0, n_err=1,
|
|
1280
|
+
n_skipped=len(published or ()))
|
|
1281
|
+
sys.exit(f"FATAL: {detail}. Fix the manifest's selection, or decommission the registration "
|
|
1282
|
+
"explicitly (docs/agents/pg-md-cdc-runbook.md §i) — a run never retires a whole "
|
|
1283
|
+
"registration on its own.")
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def status(manifest) -> int:
|
|
1287
|
+
"""Read-only: slot probe, checkpoint, lifecycle table."""
|
|
1288
|
+
_refuse_static_errors(manifest)
|
|
1289
|
+
dsn = os.environ.get(manifest.source.dsn_env)
|
|
1290
|
+
if not dsn:
|
|
1291
|
+
sys.exit(f"FATAL: {manifest.source.dsn_env} is not set")
|
|
1292
|
+
dest = _destination(manifest)
|
|
1293
|
+
probe = telemetry.slot_probe(dsn, manifest.replication.slot)
|
|
1294
|
+
print(f"slot {manifest.replication.slot}: " + (str(probe) if probe else "ABSENT"))
|
|
1295
|
+
checkpoint = dest.read_checkpoint(manifest.source_id)
|
|
1296
|
+
print(f"checkpoint: {checkpoint[0] if checkpoint else 'none'}")
|
|
1297
|
+
states = dest.read_lifecycle(manifest.source_id)
|
|
1298
|
+
for (schema, table), state in sorted(states.items()):
|
|
1299
|
+
target = manifest.destination.target_schema(schema, table)
|
|
1300
|
+
print(f" {schema}.{table:<40} -> {target}.{table:<40} {state}")
|
|
1301
|
+
return 0
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def resnapshot(manifest, *, tables: Sequence[str] | None = None, yes: bool = False,
|
|
1305
|
+
dev: bool = False) -> int:
|
|
1306
|
+
"""Drop the destination table(s) and retake a baseline. DESTRUCTIVE — requires `yes`
|
|
1307
|
+
(D6's "separately approved drop"); the retirement policy never drops on its own."""
|
|
1308
|
+
if not yes:
|
|
1309
|
+
sys.exit("resnapshot DROPS destination tables — re-run with --yes to confirm")
|
|
1310
|
+
_refuse_wrong_environment(manifest)
|
|
1311
|
+
owner_error = _check_owner(manifest)
|
|
1312
|
+
if owner_error is not None:
|
|
1313
|
+
_record_failed_run(_control_plane(manifest, dev=dev), "_resnapshot", "owner_check",
|
|
1314
|
+
owner_error)
|
|
1315
|
+
raise owner_error
|
|
1316
|
+
dsn, plan = _compile(manifest)
|
|
1317
|
+
wanted = set(tables or [])
|
|
1318
|
+
wanted_relations = [r for r in plan.relations if not wanted or r.qualified in wanted]
|
|
1319
|
+
if not wanted_relations:
|
|
1320
|
+
sys.exit(f"no selected relation matches {sorted(wanted)}")
|
|
1321
|
+
cfg = config_from_manifest(manifest, dsn=dsn, destination=_destination(manifest))
|
|
1322
|
+
cp = _control_plane(manifest, dev=dev)
|
|
1323
|
+
with cp.run("resnapshot") as run_ctx:
|
|
1324
|
+
owner = OwnerSession(cfg.source_dsn, cfg.slot)
|
|
1325
|
+
if not owner.acquire():
|
|
1326
|
+
run_ctx.finish(cp_status.RUN_OK, stop_reason=cp_status.STOP_CLEAN, n_skipped=1)
|
|
1327
|
+
sys.exit("another run owns this registration — try again after its tick")
|
|
1328
|
+
try:
|
|
1329
|
+
cfg.destination.ensure_database() # #4426: after every refusal, before any table
|
|
1330
|
+
cfg.destination.ensure_runtime_tables()
|
|
1331
|
+
cfg = _with_inherited_denies(cfg, plan.relations)
|
|
1332
|
+
refused = _refuse_denied_keys(cfg, plan.relations)
|
|
1333
|
+
if refused:
|
|
1334
|
+
raise RuntimeError("a merge key includes a denied column (inherited through a "
|
|
1335
|
+
f"rename): {'; '.join(refused)}")
|
|
1336
|
+
if cfg.deny_unbound:
|
|
1337
|
+
raise RuntimeError("denied column(s) bound to no column of their table: "
|
|
1338
|
+
f"{', '.join(cfg.deny_unbound)} (#4346)")
|
|
1339
|
+
lc = Lifecycle(cfg.destination, cfg.registration, run_ctx)
|
|
1340
|
+
targets = _Targets(cfg, plan.relations)
|
|
1341
|
+
pipelines = _Pipelines(cfg, targets.all())
|
|
1342
|
+
for rel in wanted_relations:
|
|
1343
|
+
owner.assert_held()
|
|
1344
|
+
# Drop FIRST, in `resnapshot_required`. A drop that fails must leave the
|
|
1345
|
+
# table exactly where a failed resnapshot belongs — not `snapshotting`, which
|
|
1346
|
+
# the next run would resume by merging a fresh baseline INTO the undropped
|
|
1347
|
+
# copy, resurrecting rows the source deleted (#3079 review). The publication
|
|
1348
|
+
# is only touched once the destination copy is really gone.
|
|
1349
|
+
_mark_resnapshot_required(lc, rel.schema, rel.table, "operator resnapshot")
|
|
1350
|
+
dropped = cfg.destination.drop_table(targets(rel.schema, rel.table), rel.table)
|
|
1351
|
+
_log(f"resnapshot {rel.qualified}: dropped {dropped}")
|
|
1352
|
+
_to_snapshotting(lc, rel.schema, rel.table, "operator resnapshot")
|
|
1353
|
+
# Re-admits THIS key-changed table drift.scan removed; a no-op otherwise. Any
|
|
1354
|
+
# other relation still `resnapshot_required` stays out: re-admitted for even
|
|
1355
|
+
# one tick, its changes would stream under the new key into the old-keyed
|
|
1356
|
+
# copy — the double-load the block exists to prevent (#3079 final review).
|
|
1357
|
+
publication.reconcile(cfg.source_dsn, cfg.publication, [
|
|
1358
|
+
(r.schema, r.table) for r in plan.relations
|
|
1359
|
+
if r in wanted_relations
|
|
1360
|
+
or lc.state_of(r.schema, r.table) != RESNAPSHOT_REQUIRED])
|
|
1361
|
+
pipe = pipelines.get(targets(rel.schema, rel.table))
|
|
1362
|
+
bound = _baseline_capture()
|
|
1363
|
+
with snapshot.consistent_baseline(
|
|
1364
|
+
cfg.source_dsn, cfg.slot, (rel.schema, rel.table), list(rel.key),
|
|
1365
|
+
key_policy=rel.key_policy,
|
|
1366
|
+
baseline_wait_s=cfg.baseline_wait_s,
|
|
1367
|
+
deny_columns=_denied(cfg, [rel]).get(
|
|
1368
|
+
(rel.schema, rel.table), ()),
|
|
1369
|
+
**bound,
|
|
1370
|
+
deny_by_oid=cfg.deny_oids, deny_attnums_by_oid=cfg.deny_attnums) as (res, boundary):
|
|
1371
|
+
# `drop_resources` again here: the drop above went through OUR duckdb
|
|
1372
|
+
# connection, and MotherDuck's catalog can still serve the dropped table
|
|
1373
|
+
# to dlt's own connection for tens of seconds (gotcha
|
|
1374
|
+
# `motherduck-catalog-lags-a-write`) — dlt would then skip the CREATE and
|
|
1375
|
+
# the load would die. Doing the drop inside the same dlt client removes the
|
|
1376
|
+
# cross-session read entirely.
|
|
1377
|
+
_persist_bound(cfg, bound) # before the load (see _take_baseline)
|
|
1378
|
+
_pending_safe(cfg, pipe, targets(rel.schema, rel.table))
|
|
1379
|
+
pipe.run(res, **BASELINE_KWARGS)
|
|
1380
|
+
owner.assert_held()
|
|
1381
|
+
cfg.destination.write_baseline_lsns(cfg.registration,
|
|
1382
|
+
{(rel.schema, rel.table): boundary})
|
|
1383
|
+
_clear_rebuilt_quarantine(cfg, bound, [rel])
|
|
1384
|
+
_emit_table_events(run_ctx, pipe, "snapshot", {rel.table: rel.schema}, {rel.table},
|
|
1385
|
+
{"reason": "operator resnapshot", "boundary_lsn": boundary})
|
|
1386
|
+
# The refreshed signature is what clears a key_change block: without it the
|
|
1387
|
+
# next run would diff against the pre-change key and pull the table again.
|
|
1388
|
+
drift.record_signature(cfg, rel)
|
|
1389
|
+
lc.ensure(rel.schema, rel.table, STREAMING, "operator resnapshot")
|
|
1390
|
+
except Exception as exc: # noqa: BLE001 — finish the run row honestly, then re-raise
|
|
1391
|
+
from .manifest import redact
|
|
1392
|
+
run_ctx.event(entity="_resnapshot", status=cp_status.EV_ERROR, unit="resnapshot",
|
|
1393
|
+
error_detail=redact(f"{type(exc).__name__}: {exc}"))
|
|
1394
|
+
run_ctx.finish(cp_status.RUN_FAILED, stop_reason=cp_status.STOP_ERROR, n_err=1)
|
|
1395
|
+
raise
|
|
1396
|
+
finally:
|
|
1397
|
+
owner.release()
|
|
1398
|
+
run_ctx.finish(cp_status.RUN_OK, stop_reason=cp_status.STOP_CLEAN, n_ok=len(wanted_relations))
|
|
1399
|
+
return 0
|