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,103 @@
|
|
|
1
|
+
"""Source-side advisory ownership for one registration (#3079 D8).
|
|
2
|
+
|
|
3
|
+
Session-scoped `pg_try_advisory_lock` on the SOURCE database, keyed on the slot: Postgres
|
|
4
|
+
releases it when the owning backend dies, so a killed drainer cannot split-brain. Held across
|
|
5
|
+
EVERY protected phase (publication reconcile, snapshot, backfill, each drain batch) — call
|
|
6
|
+
`assert_held()` before each; a dead connection or a lost lock raises `OwnershipLost`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
|
|
12
|
+
# NOT renamed with the module. This string is hashed into the advisory-lock key on the SOURCE
|
|
13
|
+
# database: two drainers of one slot exclude each other only while they agree on it. A deployment
|
|
14
|
+
# mid-cutover runs the old tree and this one against the same source, so changing it here would
|
|
15
|
+
# let both hold "the" lock at once. It changes when every drainer changes, not when the import
|
|
16
|
+
# name does.
|
|
17
|
+
KEY_PREFIX = "pg_md_cdc:"
|
|
18
|
+
|
|
19
|
+
# `pg_try_advisory_lock(bigint)` splits its key across pg_locks: classid = the high 32 bits,
|
|
20
|
+
# objid = the low 32 bits, objsubid = 1 (2 marks the two-int form). Probed on PG 17,
|
|
21
|
+
# 04-Sep-2026 — a negative hashtext sign-extends, so classid is 4294967295, never 0. Counting
|
|
22
|
+
# any granted advisory lock instead would call someone else's lock our own.
|
|
23
|
+
_HELD_SQL = """
|
|
24
|
+
select count(*) from pg_locks
|
|
25
|
+
where locktype = 'advisory' and pid = pg_backend_pid() and granted
|
|
26
|
+
and objsubid = 1
|
|
27
|
+
and classid = ((hashtext(%s)::bigint >> 32) & 4294967295)::bigint::oid
|
|
28
|
+
and objid = (hashtext(%s)::bigint & 4294967295)::bigint::oid
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OwnershipLost(RuntimeError):
|
|
33
|
+
"""The owner session is gone (connection dead or advisory lock no longer held)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OwnerSession:
|
|
37
|
+
def __init__(self, dsn: str, slot: str, *, connect_timeout: int = 10) -> None:
|
|
38
|
+
self.dsn = dsn
|
|
39
|
+
self.slot = slot
|
|
40
|
+
self.key = KEY_PREFIX + slot
|
|
41
|
+
self._conn: psycopg.Connection | None = None
|
|
42
|
+
self._held = False
|
|
43
|
+
|
|
44
|
+
# --- lifecycle ---
|
|
45
|
+
def acquire(self) -> bool:
|
|
46
|
+
if self._held:
|
|
47
|
+
return True
|
|
48
|
+
self._conn = psycopg.connect(self.dsn, connect_timeout=10, autocommit=True)
|
|
49
|
+
got = self._conn.execute(
|
|
50
|
+
"select pg_try_advisory_lock(hashtext(%s)::bigint)", (self.key,)
|
|
51
|
+
).fetchone()[0]
|
|
52
|
+
self._held = bool(got)
|
|
53
|
+
if not self._held:
|
|
54
|
+
self._close()
|
|
55
|
+
return self._held
|
|
56
|
+
|
|
57
|
+
def assert_held(self) -> None:
|
|
58
|
+
"""Cheap liveness + ownership re-check. Doubles as the keepalive that stops a managed
|
|
59
|
+
Postgres (Railway) reaping the owner connection as idle during a long destination load."""
|
|
60
|
+
if not self._held or self._conn is None or self._conn.closed:
|
|
61
|
+
raise OwnershipLost(f"owner session for slot {self.slot!r} is not held")
|
|
62
|
+
try:
|
|
63
|
+
self._conn.execute("select 1").fetchone()
|
|
64
|
+
granted = self._conn.execute(_HELD_SQL, (self.key, self.key)).fetchone()[0]
|
|
65
|
+
except Exception as exc: # noqa: BLE001 — a dead connection IS a lost lock
|
|
66
|
+
self._held = False
|
|
67
|
+
raise OwnershipLost(
|
|
68
|
+
f"owner session for slot {self.slot!r} died: {type(exc).__name__}: {exc}"
|
|
69
|
+
) from exc
|
|
70
|
+
if not granted:
|
|
71
|
+
self._held = False
|
|
72
|
+
raise OwnershipLost(
|
|
73
|
+
f"advisory lock for slot {self.slot!r} is no longer held by this session"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def release(self) -> None:
|
|
77
|
+
if self._conn is not None and not self._conn.closed and self._held:
|
|
78
|
+
try:
|
|
79
|
+
self._conn.execute("select pg_advisory_unlock(hashtext(%s)::bigint)", (self.key,))
|
|
80
|
+
except Exception: # noqa: BLE001 — closing the session releases it anyway
|
|
81
|
+
pass
|
|
82
|
+
self._held = False
|
|
83
|
+
self._close()
|
|
84
|
+
|
|
85
|
+
def _close(self) -> None:
|
|
86
|
+
if self._conn is not None:
|
|
87
|
+
try:
|
|
88
|
+
self._conn.close()
|
|
89
|
+
except Exception: # noqa: BLE001
|
|
90
|
+
pass
|
|
91
|
+
self._conn = None
|
|
92
|
+
|
|
93
|
+
# --- context manager ---
|
|
94
|
+
def __enter__(self) -> "OwnerSession":
|
|
95
|
+
self.acquired = self.acquire()
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, *exc) -> None:
|
|
99
|
+
self.release()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def held(self) -> bool:
|
|
103
|
+
return self._held
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Publication + replica-identity reconciliation, always schema-qualified (#3079 D4/D6).
|
|
2
|
+
|
|
3
|
+
The publication is an EXPLICIT table list reconciled to the desired set every run — never
|
|
4
|
+
`FOR TABLES IN SCHEMA`, because a table that auto-joins gets no baseline from us. Relation
|
|
5
|
+
identity is `(schema, table)` throughout: two schemas may legally hold the same table name.
|
|
6
|
+
Replica-identity ALTERs are diff-based (they take a brief exclusive lock) and plannable.
|
|
7
|
+
The publish list is `insert, update, delete, truncate`: a mirror that silently kept every row
|
|
8
|
+
of a truncated source table would be stale bronze with no event to say so (#3079 review). An
|
|
9
|
+
existing publication with a narrower list (the pilot's) is ALTERed up, never inherited.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
from psycopg import sql
|
|
17
|
+
|
|
18
|
+
Relation = tuple[str, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _connect(dsn: str) -> psycopg.Connection:
|
|
22
|
+
return psycopg.connect(dsn, connect_timeout=10, autocommit=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def publication_exists(dsn: str, publication: str) -> bool:
|
|
26
|
+
with _connect(dsn) as conn:
|
|
27
|
+
return conn.execute(
|
|
28
|
+
"select 1 from pg_publication where pubname = %s", (publication,)
|
|
29
|
+
).fetchone() is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def published_relations(dsn: str, publication: str) -> set[Relation]:
|
|
33
|
+
with _connect(dsn) as conn:
|
|
34
|
+
return {
|
|
35
|
+
(r[0], r[1])
|
|
36
|
+
for r in conn.execute(
|
|
37
|
+
"select schemaname, tablename from pg_publication_tables where pubname = %s",
|
|
38
|
+
(publication,),
|
|
39
|
+
).fetchall()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def drop_publication(dsn: str, publication: str) -> None:
|
|
44
|
+
with _connect(dsn) as conn:
|
|
45
|
+
conn.execute(sql.SQL("drop publication if exists {}").format(sql.Identifier(publication)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
PUBLISH = "insert, update, delete, truncate"
|
|
49
|
+
|
|
50
|
+
_PUB_FLAGS = {"insert": "pubinsert", "update": "pubupdate", "delete": "pubdelete",
|
|
51
|
+
"truncate": "pubtruncate"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reconcile(
|
|
55
|
+
dsn: str,
|
|
56
|
+
publication: str,
|
|
57
|
+
desired: Iterable[Relation],
|
|
58
|
+
*,
|
|
59
|
+
publish: str = PUBLISH,
|
|
60
|
+
plan_only: bool = False,
|
|
61
|
+
) -> tuple[list[Relation], list[Relation]]:
|
|
62
|
+
"""Bring `publication` to exactly `desired` (membership AND publish list). Returns
|
|
63
|
+
(added, dropped), both sorted."""
|
|
64
|
+
desired_set = {(s, t) for s, t in desired}
|
|
65
|
+
wanted_ops = {op.strip() for op in publish.split(",") if op.strip()}
|
|
66
|
+
with _connect(dsn) as conn:
|
|
67
|
+
flags = conn.execute(
|
|
68
|
+
"select pubinsert, pubupdate, pubdelete, pubtruncate from pg_publication "
|
|
69
|
+
"where pubname = %s", (publication,)
|
|
70
|
+
).fetchone()
|
|
71
|
+
exists = flags is not None
|
|
72
|
+
# PG 18 refuses UPDATEs on a published REPLICA IDENTITY FULL table whose stored
|
|
73
|
+
# generated columns are not published, so the publication always publishes them.
|
|
74
|
+
gencols = conn.info.server_version >= 180000
|
|
75
|
+
if exists and not plan_only:
|
|
76
|
+
current_ops = {op for op, col in _PUB_FLAGS.items()
|
|
77
|
+
if flags[list(_PUB_FLAGS).index(op)]}
|
|
78
|
+
if current_ops != wanted_ops:
|
|
79
|
+
conn.execute(sql.SQL("alter publication {} set (publish = {})").format(
|
|
80
|
+
sql.Identifier(publication), sql.Literal(publish)))
|
|
81
|
+
if gencols and conn.execute(
|
|
82
|
+
"select pubgencols from pg_publication where pubname = %s", (publication,)
|
|
83
|
+
).fetchone()[0] != "s":
|
|
84
|
+
conn.execute(sql.SQL("alter publication {} set (publish_generated_columns = stored)")
|
|
85
|
+
.format(sql.Identifier(publication)))
|
|
86
|
+
current: set[Relation] = set()
|
|
87
|
+
if exists:
|
|
88
|
+
current = {
|
|
89
|
+
(r[0], r[1])
|
|
90
|
+
for r in conn.execute(
|
|
91
|
+
"select schemaname, tablename from pg_publication_tables where pubname = %s",
|
|
92
|
+
(publication,),
|
|
93
|
+
).fetchall()
|
|
94
|
+
}
|
|
95
|
+
added = sorted(desired_set - current)
|
|
96
|
+
dropped = sorted(current - desired_set)
|
|
97
|
+
if plan_only:
|
|
98
|
+
return added, dropped
|
|
99
|
+
if not exists:
|
|
100
|
+
conn.execute(
|
|
101
|
+
sql.SQL("create publication {} with (publish = {}{})").format(
|
|
102
|
+
sql.Identifier(publication), sql.Literal(publish),
|
|
103
|
+
sql.SQL(", publish_generated_columns = stored" if gencols else ""),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
for schema, table in added:
|
|
107
|
+
conn.execute(
|
|
108
|
+
sql.SQL("alter publication {} add table {}.{}").format(
|
|
109
|
+
sql.Identifier(publication), sql.Identifier(schema), sql.Identifier(table)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
for schema, table in dropped:
|
|
113
|
+
conn.execute(
|
|
114
|
+
sql.SQL("alter publication {} drop table {}.{}").format(
|
|
115
|
+
sql.Identifier(publication), sql.Identifier(schema), sql.Identifier(table)
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return added, dropped
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ensure_replica_identity(
|
|
122
|
+
dsn: str,
|
|
123
|
+
relations: Sequence[Relation],
|
|
124
|
+
mode: str = "full",
|
|
125
|
+
*,
|
|
126
|
+
plan_only: bool = False,
|
|
127
|
+
) -> list[Relation]:
|
|
128
|
+
"""Diff-based: only tables whose `relreplident` differs from the declared mode are ALTERed.
|
|
129
|
+
`full` is what makes unchanged-TOAST updates decodable (see resolve_unchanged_toast)."""
|
|
130
|
+
if mode not in ("full", "default"):
|
|
131
|
+
raise ValueError(f"replica_identity must be 'full' or 'default', not {mode!r}")
|
|
132
|
+
want = "f" if mode == "full" else "d"
|
|
133
|
+
rels = list(relations)
|
|
134
|
+
if not rels:
|
|
135
|
+
return []
|
|
136
|
+
with _connect(dsn) as conn:
|
|
137
|
+
wanted = {(s_, t_) for s_, t_ in rels}
|
|
138
|
+
needs = [
|
|
139
|
+
(r[0], r[1])
|
|
140
|
+
for r in conn.execute(
|
|
141
|
+
"select n.nspname, c.relname from pg_class c "
|
|
142
|
+
"join pg_namespace n on n.oid = c.relnamespace "
|
|
143
|
+
"where c.relkind = 'r' and n.nspname = any(%s) and c.relreplident <> %s",
|
|
144
|
+
(sorted({s_ for s_, _ in rels}), want),
|
|
145
|
+
).fetchall()
|
|
146
|
+
if (r[0], r[1]) in wanted
|
|
147
|
+
]
|
|
148
|
+
if plan_only:
|
|
149
|
+
return sorted(needs)
|
|
150
|
+
for schema, table in needs:
|
|
151
|
+
conn.execute(
|
|
152
|
+
sql.SQL("alter table {}.{} replica identity {}").format(
|
|
153
|
+
sql.Identifier(schema), sql.Identifier(table),
|
|
154
|
+
sql.SQL("full" if mode == "full" else "default"),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return sorted(needs)
|