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,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)