feather-cdc-postgres 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- feather_cdc_postgres/__init__.py +6 -0
- feather_cdc_postgres/__main__.py +9 -0
- feather_cdc_postgres/adapters/__init__.py +44 -0
- feather_cdc_postgres/adapters/featherbase.py +126 -0
- feather_cdc_postgres/cli.py +247 -0
- feather_cdc_postgres/destination.py +640 -0
- feather_cdc_postgres/discovery.py +216 -0
- feather_cdc_postgres/drift.py +207 -0
- feather_cdc_postgres/generate_dbt.py +106 -0
- feather_cdc_postgres/lifecycle.py +79 -0
- feather_cdc_postgres/manifest.py +587 -0
- feather_cdc_postgres/ownership.py +103 -0
- feather_cdc_postgres/publication.py +157 -0
- feather_cdc_postgres/runner.py +1399 -0
- feather_cdc_postgres/schema_registry.py +283 -0
- feather_cdc_postgres/selectors.py +347 -0
- feather_cdc_postgres/snapshot.py +448 -0
- feather_cdc_postgres/stream.py +162 -0
- feather_cdc_postgres/telemetry.py +172 -0
- feather_cdc_postgres-0.1.0.dist-info/METADATA +239 -0
- feather_cdc_postgres-0.1.0.dist-info/RECORD +30 -0
- feather_cdc_postgres-0.1.0.dist-info/WHEEL +4 -0
- feather_cdc_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- pg_replication/LICENSE +201 -0
- pg_replication/README.md +100 -0
- pg_replication/__init__.py +106 -0
- pg_replication/decoders.py +428 -0
- pg_replication/exceptions.py +6 -0
- pg_replication/helpers.py +1405 -0
- pg_replication/schema_types.py +156 -0
|
@@ -0,0 +1,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))
|