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,448 @@
1
+ """Consistent baselines with NO staging tables on the source (#3079 Deviation D-1).
2
+
3
+ The slot's exported snapshot is read directly inside one REPEATABLE READ transaction, so the
4
+ source gains no `_dlt_*` tables (50 stray ones found live, #2874). Ordering invariant, proved
5
+ against PG 17 on 04-Sep-2026: `SET TRANSACTION SNAPSHOT` must run while the exporting
6
+ replication connection is still open (it fails "snapshot does not exist" otherwise); once the
7
+ import succeeds the exporter may close and the read continues.
8
+ Convergence: the publication is reconciled BEFORE the slot exists, so any concurrent change is
9
+ also in the stream and the merge write-disposition dedups on the declared key.
10
+ A baseline taken LATER (a table added mid-life, a resumed snapshot, an operator resnapshot) is
11
+ `consistent_baseline`: a throwaway slot hands us PG's own consistent point + exported snapshot,
12
+ so "visible in the baseline" ⟺ "commit LSN < boundary" by construction, and the drain drops
13
+ what the baseline already saw. That is what makes an append_only overlap exact (#3079 review).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import threading
19
+ from dataclasses import dataclass, field
20
+ from typing import Iterator, Sequence
21
+
22
+ import dlt
23
+ import psycopg
24
+ import psycopg2
25
+ from psycopg import sql
26
+ from psycopg2.extras import LogicalReplicationConnection
27
+
28
+ from pg_replication.decoders import ColumnType
29
+ from pg_replication.helpers import published_shape
30
+ from pg_replication.schema_types import _PG_TYPES, _to_dlt_column_schema
31
+
32
+ Relation = tuple[str, str]
33
+
34
+ BASELINE_WAIT_S_DEFAULT = 300
35
+
36
+
37
+ class BaselineBlocked(RuntimeError):
38
+ """CREATE_REPLICATION_SLOT ... EXPORT_SNAPSHOT did not complete within `baseline_wait_s`
39
+ because it is waiting for every in-flight source transaction to finish. See
40
+ `create_slot_with_snapshot` for why `statement_timeout` cannot be used here, and
41
+ docs/agents/pg-md-cdc-runbook.md §c for the remedy."""
42
+
43
+
44
+ def _regclass(schema: str, table: str) -> str:
45
+ """A quoted `schema.table` for `to_regclass`. Every catalogue read of a baseline resolves the
46
+ name the way `LOCK`/`SELECT` do — through CURRENT name resolution — never by joining
47
+ `pg_class` on the name, which an imported REPEATABLE READ snapshot answers from older
48
+ catalogue rows: after a swap the two disagree about which table a name means (#4346)."""
49
+ return '"' + schema.replace('"', '""') + '"."' + table.replace('"', '""') + '"'
50
+
51
+
52
+ def lsn_hex_to_int(lsn: str) -> int:
53
+ hi, lo = lsn.split("/")
54
+ return (int(hi, 16) << 32) + int(lo, 16)
55
+
56
+
57
+ @dataclass
58
+ class ExportedSnapshot:
59
+ """Handle on a freshly created slot + its exported snapshot. Keep it open until the
60
+ importing transaction has run SET TRANSACTION SNAPSHOT (see module docstring)."""
61
+
62
+ slot: str
63
+ consistent_point_lsn: int
64
+ snapshot_name: str
65
+ _conn: object | None = field(default=None, repr=False)
66
+
67
+ def close(self) -> None:
68
+ if self._conn is not None:
69
+ with contextlib.suppress(Exception):
70
+ self._conn.close()
71
+ self._conn = None
72
+
73
+ def __iter__(self): # so `lsn, name = handle` reads naturally at call sites
74
+ return iter((self.consistent_point_lsn, self.snapshot_name))
75
+
76
+
77
+ def drop_slot_if_exists(dsn: str, slot: str) -> bool:
78
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
79
+ row = conn.execute("select 1 from pg_replication_slots where slot_name = %s",
80
+ (slot,)).fetchone()
81
+ if row:
82
+ conn.execute("select pg_drop_replication_slot(%s)", (slot,))
83
+ return bool(row)
84
+
85
+
86
+ def temp_slot_name(slot: str) -> str:
87
+ """The throwaway slot a consistent baseline is taken from. Ours by name; the caller holds
88
+ the registration's owner lock, so a leftover (an older build's persistent one) is safe to
89
+ drop. Created TEMPORARY, so a crash mid-baseline leaks nothing."""
90
+ return (slot + "_bl")[:63]
91
+
92
+
93
+ def _describe_blocker(dsn: str, waiting_pid: int) -> str:
94
+ """Best-effort context for `BaselineBlocked`: the oldest session `pg_blocking_pids` names
95
+ as blocking `waiting_pid`, read on a plain connection (the stuck replication connection is
96
+ still busy). Never raises — a failure to identify the blocker must not hide the timeout."""
97
+ try:
98
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as c:
99
+ rows = c.execute(
100
+ # NOT `query`: the blocker's last statement is arbitrary source SQL — an
101
+ # `UPDATE person SET password_hash = '…'` would carry the hash into the
102
+ # exception, control.event and the log (#4346, Codex round 9).
103
+ "select pid, state, xact_start, usename, application_name from pg_stat_activity "
104
+ "where pid = any(pg_blocking_pids(%s)) order by xact_start asc nulls last",
105
+ (waiting_pid,)).fetchall()
106
+ except Exception as exc: # noqa: BLE001 — context only, the timeout itself is the finding
107
+ return f"could not identify the blocking session: {type(exc).__name__}: {exc}"
108
+ if not rows:
109
+ return "no blocking session found in pg_stat_activity (it may have just ended)"
110
+ pid, state, xact_start, user, app = rows[0]
111
+ return (f"oldest blocker pid={pid} state={state!r} xact_start={xact_start} "
112
+ f"user={user!r} application={app!r}")
113
+
114
+
115
+ def create_slot_with_snapshot(dsn: str, slot: str, *, temporary: bool = False,
116
+ baseline_wait_s: float = BASELINE_WAIT_S_DEFAULT) -> ExportedSnapshot:
117
+ """Create `slot` with pgoutput and KEEP the replication connection open so its exported
118
+ snapshot stays importable. Raises if the slot already exists (the caller drops it first).
119
+ `temporary`: PostgreSQL drops the slot itself when this session ends — a killed container
120
+ cannot leave it behind retaining WAL. Only the main slot must outlive the process.
121
+
122
+ `CREATE_REPLICATION_SLOT ... EXPORT_SNAPSHOT` blocks until every transaction in flight on
123
+ the source ends (pg_stat_activity shows it waiting `Lock`/`transactionid`) — a long-running
124
+ or idle-in-transaction session can block it indefinitely. `statement_timeout` does NOT
125
+ bound this: verified empirically on PG 17 + psycopg2's `LogicalReplicationConnection`,
126
+ 04-Sep-2026 — neither `SET statement_timeout` on the replication cursor nor
127
+ `options=-c statement_timeout=...` in the DSN cancels a blocked CREATE_REPLICATION_SLOT,
128
+ even though the session's own `SHOW statement_timeout` confirms the GUC took. Replication
129
+ protocol commands run through PostgreSQL's `exec_replication_command()` path, not the
130
+ ordinary simple-query path that arms the statement timer, so the GUC is simply never
131
+ consulted there. The bound is therefore enforced from the CLIENT instead: the call runs on
132
+ a background thread and, if it has not returned within `baseline_wait_s`, this function
133
+ looks up the blocking session with `pg_blocking_pids()`, cancels the stuck backend with
134
+ `pg_cancel_backend()` (proven to unblock it), and raises `BaselineBlocked` naming the
135
+ oldest blocker's pid/state/xact_start — see docs/agents/pg-md-cdc-runbook.md §c for the
136
+ remedy (end that session; the next tick retries from `snapshotting`)."""
137
+ conn = psycopg2.connect(dsn, connection_factory=LogicalReplicationConnection)
138
+ pid = conn.get_backend_pid()
139
+ outcome: dict = {}
140
+
141
+ def _create() -> None:
142
+ try:
143
+ cur = conn.cursor()
144
+ if temporary:
145
+ quoted = '"' + slot.replace('"', '""') + '"'
146
+ cur.execute(f"CREATE_REPLICATION_SLOT {quoted} TEMPORARY LOGICAL pgoutput "
147
+ "EXPORT_SNAPSHOT")
148
+ else:
149
+ cur.create_replication_slot(slot, output_plugin="pgoutput")
150
+ outcome["row"] = cur.fetchone()
151
+ except BaseException as exc: # noqa: BLE001 — re-raised on the calling thread below
152
+ outcome["exc"] = exc
153
+
154
+ worker = threading.Thread(target=_create, daemon=True)
155
+ worker.start()
156
+ worker.join(timeout=baseline_wait_s)
157
+ if worker.is_alive():
158
+ blocker = _describe_blocker(dsn, pid)
159
+ with contextlib.suppress(Exception):
160
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as c:
161
+ c.execute("select pg_cancel_backend(%s)", (pid,))
162
+ worker.join(timeout=10) # let the cancelled thread unwind before we close its connection
163
+ with contextlib.suppress(Exception):
164
+ conn.close()
165
+ raise BaselineBlocked(
166
+ f"CREATE_REPLICATION_SLOT for slot {slot!r} did not complete within "
167
+ f"{baseline_wait_s:g}s: {blocker}. Remedy: end the blocking session "
168
+ "(docs/agents/pg-md-cdc-runbook.md §c) — the table stays `snapshotting` and "
169
+ "the next tick retries.")
170
+ if "exc" in outcome:
171
+ conn.close()
172
+ raise outcome["exc"]
173
+ _, consistent_point, snapshot_name, _ = outcome["row"]
174
+ return ExportedSnapshot(slot, lsn_hex_to_int(consistent_point), snapshot_name, conn)
175
+
176
+
177
+ def column_hints(conn: psycopg.Connection, schema: str, table: str, keys: Sequence[str],
178
+ exclude: Sequence[str] = ()) -> dict:
179
+ """Destination column types compiled from the source catalog through the SAME
180
+ OID→dlt mapper the CDC path uses — otherwise int4 lands BIGINT on one path and INTEGER
181
+ on the other (observed live in bronze_featherbase, plan Appendix A). A denied column
182
+ (`exclude`, #4346) is not even declared to dlt."""
183
+ rows = conn.execute(
184
+ "select a.attname, a.atttypid, a.atttypmod from pg_attribute a "
185
+ "where a.attrelid = to_regclass(%s) and a.attnum > 0 and not a.attisdropped "
186
+ "order by a.attnum",
187
+ (_regclass(schema, table),),
188
+ ).fetchall()
189
+ key_set = set(keys or ())
190
+ denied = set(exclude or ())
191
+ return {
192
+ name: _to_dlt_column_schema(ColumnType(int(name in key_set), name, type_id, typmod))
193
+ for name, type_id, typmod in rows
194
+ if name not in denied
195
+ }
196
+
197
+
198
+ def _select_list(conn: psycopg.Connection, schema: str, table: str, exclude: Sequence[str] = ()):
199
+ """`select *` except that every column with no declared OID mapping is cast to text.
200
+
201
+ Those columns compile to `text_fallback` (schema_registry), and the CDC path already
202
+ receives pgoutput's text rendering — but psycopg hands the SNAPSHOT path a native object
203
+ (`inet` → IPv4Address), which dlt cannot serialise. Casting here makes both paths agree.
204
+ A denied column (`exclude`, #4346) is never read; a list the deny would leave empty raises
205
+ rather than fall back to `select *`, which would read exactly what was denied.
206
+ """
207
+ rows = conn.execute(
208
+ "select a.attname, a.atttypid, t.typcategory = 'A' as is_array from pg_attribute a "
209
+ "join pg_type t on t.oid = a.atttypid "
210
+ "where a.attrelid = to_regclass(%s) and a.attnum > 0 and not a.attisdropped "
211
+ "order by a.attnum",
212
+ (_regclass(schema, table),),
213
+ ).fetchall()
214
+ denied = set(exclude or ())
215
+ parts = []
216
+ for name, type_oid, is_array in rows:
217
+ if name in denied:
218
+ continue
219
+ if is_array or int(type_oid) not in _PG_TYPES:
220
+ parts.append(sql.SQL("{}::text as {}").format(sql.Identifier(name),
221
+ sql.Identifier(name)))
222
+ else:
223
+ parts.append(sql.Identifier(name))
224
+ if not parts and rows:
225
+ raise ValueError(f"{schema}.{table}: every column is denied — nothing to baseline")
226
+ return sql.SQL(", ").join(parts) if parts else sql.SQL("*")
227
+
228
+
229
+ def _rows(conn: psycopg.Connection, schema: str, table: str, cursor_name: str, itersize: int,
230
+ select_list):
231
+ """Server-side cursor so a large table never materialises in memory."""
232
+ def gen():
233
+ with conn.cursor(name=cursor_name) as cur:
234
+ cur.itersize = itersize
235
+ cur.execute(
236
+ sql.SQL("select {} from {}.{}").format(
237
+ select_list, sql.Identifier(schema), sql.Identifier(table))
238
+ )
239
+ cols = [d.name for d in cur.description]
240
+ for row in cur:
241
+ yield dict(zip(cols, row))
242
+ return gen
243
+
244
+
245
+ def _resource(conn, schema, table, keys, key_policy, cursor_name, itersize, exclude=(),
246
+ catalog=None):
247
+ """`catalog`: the connection column names are read from — a fresh one taken under the
248
+ relation locks for a snapshot read (see snapshot_resources); else `conn` itself."""
249
+ hints = column_hints(catalog or conn, schema, table, keys, exclude)
250
+ select_list = _select_list(catalog or conn, schema, table, exclude)
251
+ merge = bool(keys) and key_policy != "append_only"
252
+ return dlt.resource(
253
+ _rows(conn, schema, table, cursor_name, itersize, select_list),
254
+ name=table,
255
+ write_disposition="merge" if merge else "append",
256
+ primary_key=list(keys) if merge else None,
257
+ columns=hints,
258
+ )
259
+
260
+
261
+ @contextlib.contextmanager
262
+ def snapshot_resources(
263
+ dsn: str,
264
+ snapshot_name: str,
265
+ relations: Sequence[Relation],
266
+ keys: dict[Relation, Sequence[str]],
267
+ *,
268
+ key_policies: dict[Relation, str] | None = None,
269
+ itersize: int = 5000,
270
+ deny_columns: dict[Relation, Sequence[str]] | None = None,
271
+ oids_out: dict | None = None,
272
+ deny_by_oid: dict[int, Sequence[str]] | None = None,
273
+ deny_attnums_by_oid: dict[int, Sequence[int]] | None = None,
274
+ layouts_out: dict | None = None,
275
+ bound_out: dict | None = None,
276
+ ) -> Iterator[dict[str, list]]:
277
+ """Yields {source_schema: [dlt resources]} reading the exported snapshot. The importing
278
+ transaction is opened EAGERLY (before this returns) so the caller may then close the
279
+ exporting replication connection; the reads themselves stay lazy.
280
+
281
+ #4346: `layouts_out[(schema, table)] = (oid, ReviewedLayout | None)` — the description
282
+ every later Relation message of the table must match exactly, read under the locks; None
283
+ when a column was dropped between the export and the lock.
284
+ `bound_out[(schema, table)] = [(column, attnum)]` binds each denied name under the locks.
285
+ A denied name that is not a column there, on a table with no bound attnum, refuses."""
286
+ conn = psycopg.connect(dsn, connect_timeout=10)
287
+ cat = None
288
+ try:
289
+ conn.execute("begin isolation level repeatable read")
290
+ conn.execute(sql.SQL("set transaction snapshot {}").format(sql.Literal(snapshot_name)))
291
+ by_schema: dict[str, list] = {}
292
+ policies = key_policies or {}
293
+ # Pin each name to its relation for the whole read (#4346, Codex round 8): PostgreSQL
294
+ # resolves a name in every later statement against the CURRENT catalogue, not this
295
+ # snapshot, so a rename/swap between the OID lookup below and the lazy SELECTs would
296
+ # hand a protected table's rows to an unprotected projection. ACCESS SHARE conflicts
297
+ # with the ACCESS EXCLUSIVE a rename needs, so the rename waits for this transaction.
298
+ for schema, table in relations:
299
+ conn.execute(sql.SQL("lock table {}.{} in access share mode").format(
300
+ sql.Identifier(schema), sql.Identifier(table)))
301
+ # Column NAMES come from a fresh catalogue read, taken while the locks are held (#4346,
302
+ # Codex round 14): the SELECT resolves names against the CURRENT catalogue, but a
303
+ # pg_attribute query inside this snapshot answers with the snapshot's names — a column
304
+ # swap between export and lock would split them. The locks forbid any later rename, so
305
+ # these names are exactly the ones the SELECT will use.
306
+ cat = psycopg.connect(dsn, autocommit=True, connect_timeout=10)
307
+ denied = {k: set(v) for k, v in (deny_columns or {}).items()}
308
+ # The OID each name has INSIDE the snapshot the baseline reads (#4346): exactly the
309
+ # relation whose rows are read. Its OID deny is unioned in HERE, so a rename or swap
310
+ # after the run resolved its map cannot hand a protected relation's rows to a name
311
+ # with no deny; the OID is also reported for the caller to persist.
312
+ for schema, table in relations:
313
+ row = conn.execute("select to_regclass(%s)::oid",
314
+ (_regclass(schema, table),)).fetchone()
315
+ if not row or row[0] is None:
316
+ continue
317
+ oid = int(row[0])
318
+ if oids_out is not None:
319
+ oids_out[(schema, table)] = oid
320
+ if deny_by_oid and deny_by_oid.get(oid):
321
+ denied.setdefault((schema, table), set()).update(deny_by_oid[oid])
322
+ if deny_attnums_by_oid and deny_attnums_by_oid.get(oid):
323
+ # A denied COLUMN's identity (Sonnet review F1): whatever it is called in the
324
+ # catalogue this read uses, the column at that attnum is withheld.
325
+ names = cat.execute(
326
+ "select attname from pg_attribute where attrelid = %s "
327
+ "and attnum = any(%s) and not attisdropped",
328
+ (oid, list(deny_attnums_by_oid[oid]))).fetchall()
329
+ denied.setdefault((schema, table), set()).update(n for (n,) in names)
330
+ # The reviewed layout (#4346, Codex rounds 16-17): the full published description,
331
+ # fresh, under the locks — every later description of the table must match it
332
+ # exactly. A column live in the snapshot (the boundary) but dropped by now went in
333
+ # the window, and WAL written before that drop could carry it: no layout.
334
+ fresh, _ = published_shape(cat.cursor(), oid)
335
+ at_boundary, _ = published_shape(conn.cursor(), oid)
336
+ vanished = {c[0] for c in at_boundary.columns} - {c[0] for c in fresh.columns}
337
+ if layouts_out is not None:
338
+ layouts_out[(schema, table)] = (oid, None if vanished else fresh)
339
+ want = denied.get((schema, table), set())
340
+ if want:
341
+ got = dict(cat.execute(
342
+ "select attname, attnum from pg_attribute where attrelid = %s "
343
+ "and attnum > 0 and not attisdropped and attname = any(%s)",
344
+ (oid, sorted(want))).fetchall())
345
+ missing = sorted(want - set(got))
346
+ if missing and not (deny_attnums_by_oid or {}).get(oid):
347
+ # Never bound, and not there under its name: it may have been renamed
348
+ # before anyone bound it, so no column can be read safely (Codex round 16).
349
+ raise ValueError(
350
+ f"{schema}.{table}: denied column(s) {missing} are not columns of the "
351
+ "table and were never bound to one — refusing to baseline (#4346)")
352
+ if bound_out is not None:
353
+ bound_out[(schema, table)] = sorted((c, int(a)) for c, a in got.items())
354
+ for (schema, table) in relations:
355
+ clash = set(keys.get((schema, table), [])) & denied.get((schema, table), set())
356
+ if clash:
357
+ raise ValueError(f"{schema}.{table}: merge key column(s) {sorted(clash)} are "
358
+ "denied — refusing to baseline (#4346)")
359
+ for i, (schema, table) in enumerate(relations):
360
+ res = _resource(conn, schema, table, keys.get((schema, table), []),
361
+ policies.get((schema, table), "merge"), f"cdc_snap_{i}", itersize,
362
+ denied.get((schema, table), ()), catalog=cat)
363
+ by_schema.setdefault(schema, []).append(res)
364
+ cat.close()
365
+ yield by_schema
366
+ finally:
367
+ if cat is not None:
368
+ with contextlib.suppress(Exception):
369
+ cat.close()
370
+ with contextlib.suppress(Exception):
371
+ conn.rollback()
372
+ with contextlib.suppress(Exception):
373
+ conn.close()
374
+
375
+
376
+ @contextlib.contextmanager
377
+ def consistent_baseline(
378
+ dsn: str,
379
+ slot: str,
380
+ relation: Relation,
381
+ keys: Sequence[str],
382
+ *,
383
+ key_policy: str = "merge",
384
+ itersize: int = 5000,
385
+ baseline_wait_s: float = BASELINE_WAIT_S_DEFAULT,
386
+ deny_columns: Sequence[str] = (),
387
+ oids_out: dict | None = None,
388
+ deny_by_oid: dict[int, Sequence[str]] | None = None,
389
+ deny_attnums_by_oid: dict[int, Sequence[int]] | None = None,
390
+ layouts_out: dict | None = None,
391
+ bound_out: dict | None = None,
392
+ ) -> Iterator[tuple[object, int]]:
393
+ """Yields (resource, boundary_lsn) for ONE relation that is already published.
394
+
395
+ The publication add happened before this, so the table's changes are in the main slot's
396
+ WAL from that moment — and a plain snapshot read overlaps that WAL by however long the
397
+ read takes. A keyed merge hides the overlap; an append_only row has no key and lands twice.
398
+ A throwaway slot `<slot>_bl` gives the two things a plain read cannot: PG waits for every
399
+ transaction in flight before exporting the snapshot, and the consistent point it returns is
400
+ the exact LSN below which every transaction is in that snapshot (the decoder's own skip
401
+ predicate, strict `<`). The runner persists the boundary per relation and the drain drops
402
+ that relation's transactions with commit LSN < boundary. The slot is TEMPORARY — it dies
403
+ with its session, so a kill mid-baseline leaks nothing — and is dropped on exit anyway.
404
+ """
405
+ schema, table = relation
406
+ temp = temp_slot_name(slot)
407
+ drop_slot_if_exists(dsn, temp) # a persistent leftover from an older build
408
+ handle = create_slot_with_snapshot(dsn, temp, temporary=True,
409
+ baseline_wait_s=baseline_wait_s)
410
+ try:
411
+ with snapshot_resources(dsn, handle.snapshot_name, [relation], {relation: list(keys)},
412
+ key_policies={relation: key_policy}, itersize=itersize,
413
+ deny_columns={relation: list(deny_columns)},
414
+ oids_out=oids_out, deny_by_oid=deny_by_oid,
415
+ deny_attnums_by_oid=deny_attnums_by_oid,
416
+ layouts_out=layouts_out, bound_out=bound_out) as by_schema:
417
+ handle.close() # the import already happened; the exporter is no longer needed
418
+ (resource,) = by_schema[schema]
419
+ yield resource, handle.consistent_point_lsn
420
+ finally:
421
+ handle.close()
422
+ drop_slot_if_exists(dsn, temp)
423
+
424
+
425
+ @contextlib.contextmanager
426
+ def baseline_resource(
427
+ dsn: str,
428
+ relation: Relation,
429
+ keys: Sequence[str],
430
+ *,
431
+ key_policy: str = "merge",
432
+ itersize: int = 5000,
433
+ deny_columns: Sequence[str] = (),
434
+ ) -> Iterator[object]:
435
+ """Plain REPEATABLE READ read of one table — a reader for tests and telemetry. The runtime
436
+ never baselines with it: a row committed between the publication add and this read would
437
+ land from both paths (see `consistent_baseline`)."""
438
+ schema, table = relation
439
+ conn = psycopg.connect(dsn, connect_timeout=10)
440
+ try:
441
+ conn.execute("begin isolation level repeatable read")
442
+ yield _resource(conn, schema, table, keys, key_policy, "cdc_base_0", itersize,
443
+ deny_columns)
444
+ finally:
445
+ with contextlib.suppress(Exception):
446
+ conn.rollback()
447
+ with contextlib.suppress(Exception):
448
+ conn.close()
@@ -0,0 +1,162 @@
1
+ """Slot consumption in committed batches, grouped by source schema (#3079).
2
+
3
+ Yields `Batch`es the caller loads and only THEN checkpoints — the checkpoint is runtime-owned
4
+ (`_cdc_runtime.checkpoint`), never dlt resource state, so an LSN is never discarded before its
5
+ destination load commits. A transaction larger than `target_batch_size` still lands whole
6
+ (pgoutput never splits a commit). Kept from the #2851 pilot via the vendored consumer: true-PK
7
+ override, unchanged-TOAST splice, `lsn` dedup_sort desc, `deleted_ts` hard_delete. An
8
+ `append_only` relation merges on `lsn` instead (no key of its own), so a replay is exact.
9
+ Two review additions (#3079): a relation's transactions below its baseline boundary are dropped
10
+ (the baseline already holds them), and a TRUNCATE arrives on `Batch.truncates` for the runner
11
+ to apply BEFORE the batch's rows.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Iterable, Iterator, Mapping, Sequence
17
+
18
+ from dlt.sources.credentials import ConnectionStringCredentials
19
+
20
+ from pg_replication.helpers import ItemGenerator, ReviewedLayout, advance_slot, get_max_lsn
21
+
22
+ Relation = tuple[str, str]
23
+
24
+
25
+ @dataclass
26
+ class Batch:
27
+ """One drained batch: source schema -> [meta_item, marked_items, ...]."""
28
+
29
+ items_by_namespace: dict[str, list]
30
+ last_commit_lsn: int
31
+ consumed_all: bool
32
+ # TRUNCATEs in WAL order: {namespace, table, lsn, commit_lsn, commit_ts, applied}. Any rows
33
+ # of a keyed relation that preceded its truncate in this batch are already gone from
34
+ # `items_by_namespace`, so "delete, then load the batch" reproduces the source order.
35
+ truncates: list = field(default_factory=list)
36
+ # (schema, table) -> why a record's merge key could not be honoured (#4338); the runner
37
+ # reports each as an error event.
38
+ key_problems: dict = field(default_factory=dict)
39
+ # (oid, schema, table, column) the decoder matched to a denied NAME (#4346); the runner
40
+ # persists them before checkpointing so the deny follows that OID through a later rename.
41
+ denied_seen: set = field(default_factory=set)
42
+ # (schema, table) -> columns withheld because no reviewed signature names them (#4346).
43
+ unreviewed: dict = field(default_factory=dict)
44
+ # relation OID -> (schema, table, why) for each relation quarantined in this batch (#4346):
45
+ # a change arrived under a description that did not match its reviewed layout; nothing
46
+ # more of it loads. Keyed by OID (Codex round 17); the names are reporting metadata.
47
+ quarantined: dict = field(default_factory=dict)
48
+
49
+ @property
50
+ def quarantined_names(self) -> set:
51
+ """(schema, table) of every relation quarantined in this batch, for reports."""
52
+ return {(s, t) for s, t, _why in self.quarantined.values()}
53
+
54
+ @property
55
+ def is_empty(self) -> bool:
56
+ return not any(self.items_by_namespace.values()) and not self.truncates
57
+
58
+
59
+ def credentials(dsn: str) -> ConnectionStringCredentials:
60
+ return ConnectionStringCredentials(dsn)
61
+
62
+
63
+ def max_lsn(dsn: str, slot: str, publication: str) -> int | None:
64
+ """Highest LSN sitting in the slot, or None when the slot is empty. Does not consume."""
65
+ return get_max_lsn(slot, _options(publication), credentials(dsn))
66
+
67
+
68
+ def _options(publication: str) -> dict[str, str]:
69
+ return {"publication_names": publication, "proto_version": "1"}
70
+
71
+
72
+ def drain(
73
+ dsn: str,
74
+ slot: str,
75
+ publication: str,
76
+ start_lsn: int = 0,
77
+ upto_lsn: int | None = None,
78
+ *,
79
+ target_batch_size: int = 1000,
80
+ keys_by_relation: Mapping[Relation, Sequence[str]] | None = None,
81
+ policies_by_relation: Mapping[Relation, str] | None = None,
82
+ baseline_lsn_by_relation: Mapping[Relation, int] | None = None,
83
+ deny_columns_by_relation: Mapping[Relation, Sequence[str]] | None = None,
84
+ deny_columns_by_oid: Mapping[int, Sequence[str]] | None = None,
85
+ approved_columns_by_relation: Mapping[Relation, Sequence[str]] | None = None,
86
+ approved_columns_by_oid: Mapping[int, Sequence[str]] | None = None,
87
+ deny_attnums_by_oid: Mapping[int, Sequence[int]] | None = None,
88
+ reviewed_layout_by_oid: Mapping[int, ReviewedLayout] | None = None,
89
+ bound_oids: Iterable[int] | None = None,
90
+ quarantined_oids: Iterable[int] = (),
91
+ ) -> Iterator[Batch]:
92
+ """Iterate committed batches from `start_lsn` up to the slot's current max LSN.
93
+
94
+ Does NOT advance the slot — `runner` does that after the load commits. Restarting at a
95
+ checkpoint re-delivers the boundary commit (measured on PG 17); merge makes that harmless.
96
+ `deny_columns_by_relation` (#4346) is dropped by name from every record and from the
97
+ hints, so a denied column is never declared to dlt nor loaded. `deny_columns_by_oid` seeds
98
+ the per-OID deny that follows a renamed table; it is shared by every batch of this drain.
99
+ On a relation that carries a deny, each description must match its
100
+ `reviewed_layout_by_oid` entry exactly (helpers `_prove_layout`); any difference, or no
101
+ entry, quarantines it at its next change. `bound_oids` (the runner's: OIDs bound at run start or by a baseline)
102
+ quarantines any other OID met under a denied name; None leaves that check off.
103
+ `quarantined_oids` are skipped from the first batch on.
104
+ """
105
+ creds = credentials(dsn)
106
+ options = _options(publication)
107
+ if upto_lsn is None:
108
+ upto_lsn = get_max_lsn(slot, options, creds)
109
+ if upto_lsn is None:
110
+ return
111
+ cursor_lsn = start_lsn
112
+ by_oid = {int(k): frozenset(v) for k, v in (deny_columns_by_oid or {}).items() if v}
113
+ approved_by_oid = {int(k): frozenset(v) for k, v in (approved_columns_by_oid or {}).items()}
114
+ attnums_by_oid = {int(k): frozenset(v) for k, v in (deny_attnums_by_oid or {}).items() if v}
115
+ layouts = {int(k): v for k, v in (reviewed_layout_by_oid or {}).items()}
116
+ bound = None if bound_oids is None else frozenset(int(o) for o in bound_oids)
117
+ quarantined = {int(o) for o in quarantined_oids} # shared: a quarantine is sticky
118
+ while True:
119
+ gen = ItemGenerator(
120
+ credentials=creds,
121
+ slot_name=slot,
122
+ options=options,
123
+ upto_lsn=upto_lsn,
124
+ start_lsn=cursor_lsn,
125
+ target_batch_size=target_batch_size,
126
+ include_columns=None,
127
+ columns=None,
128
+ keys_by_relation=dict(keys_by_relation or {}),
129
+ policies_by_relation=dict(policies_by_relation or {}),
130
+ baseline_lsn_by_relation=dict(baseline_lsn_by_relation or {}),
131
+ exclude_columns={k: tuple(v) for k, v in (deny_columns_by_relation or {}).items()},
132
+ exclude_columns_by_relid=by_oid,
133
+ approved_columns={k: frozenset(v)
134
+ for k, v in (approved_columns_by_relation or {}).items()},
135
+ approved_by_relid=approved_by_oid,
136
+ exclude_attnums_by_relid=attnums_by_oid,
137
+ reviewed_layout_by_relid=layouts,
138
+ bound_relids=bound,
139
+ quarantined_relids=quarantined,
140
+ )
141
+ grouped = gen.collect()
142
+ if gen.last_commit_lsn is None:
143
+ # Backstop (#4345): the consumer stops only at a commit, so this cannot happen
144
+ # today. If it ever does, the batch is a partial transaction and there is no LSN to
145
+ # restart at — fail before the runner loads it, never loop on a `None` restart.
146
+ raise RuntimeError(
147
+ f"slot {slot}: replication stopped without reaching a commit (restarted at LSN "
148
+ f"{cursor_lsn}); refusing to load a partial transaction — see issue #4345")
149
+ yield Batch(grouped, gen.last_commit_lsn, gen.generated_all,
150
+ truncates=list(gen.truncates),
151
+ key_problems=dict(getattr(gen, "key_problems", {}) or {}),
152
+ denied_seen=set(getattr(gen, "denied_seen", set()) or ()),
153
+ unreviewed=dict(getattr(gen, "unreviewed", {}) or {}),
154
+ quarantined=dict(getattr(gen, "quarantined", {}) or {}))
155
+ if gen.generated_all:
156
+ return
157
+ cursor_lsn = gen.last_commit_lsn
158
+
159
+
160
+ def advance(dsn: str, slot: str, upto_lsn: int) -> None:
161
+ """Flush the slot up to an LSN whose destination load has ALREADY committed."""
162
+ advance_slot(upto_lsn, slot, credentials(dsn))