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.
@@ -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