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,216 @@
|
|
|
1
|
+
"""Read-only PostgreSQL catalog discovery (#3079 D4/D6/D7).
|
|
2
|
+
|
|
3
|
+
Constraints:
|
|
4
|
+
- every relation carries (schema, table) together — two schemas may hold the same table name
|
|
5
|
+
and the runtime must never collapse them (D2).
|
|
6
|
+
- nothing here writes, locks, or sets a session GUC; `plan` is provably non-mutating because
|
|
7
|
+
this module has no DDL/DML in it.
|
|
8
|
+
- the WAL fuse is READ from the server (`max_slot_wal_keep_size`), never declared (Deviation D-7).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
|
|
17
|
+
SYSTEM_SCHEMAS = ("pg_catalog", "information_schema", "pg_toast")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SourceColumn:
|
|
22
|
+
name: str
|
|
23
|
+
attnum: int
|
|
24
|
+
type_oid: int
|
|
25
|
+
typmod: int
|
|
26
|
+
not_null: bool
|
|
27
|
+
type_name: str
|
|
28
|
+
is_array: bool = False
|
|
29
|
+
elem_oid: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class UniqueIndex:
|
|
34
|
+
name: str
|
|
35
|
+
columns: Tuple[str, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SourceRelation:
|
|
40
|
+
schema: str
|
|
41
|
+
table: str
|
|
42
|
+
oid: int
|
|
43
|
+
columns: List[SourceColumn] = field(default_factory=list)
|
|
44
|
+
primary_key: Tuple[str, ...] = ()
|
|
45
|
+
unique_indexes: Tuple[UniqueIndex, ...] = ()
|
|
46
|
+
replica_identity: str = "d" # d default | n nothing | f full | i index
|
|
47
|
+
row_estimate: float = 0.0
|
|
48
|
+
kind: str = "r" # relkind: r plain/leaf table | p partitioned parent
|
|
49
|
+
parent: Optional[str] = None # "schema.table" of the partitioned parent, for a leaf
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def qualified(self) -> str:
|
|
53
|
+
return f"{self.schema}.{self.table}"
|
|
54
|
+
|
|
55
|
+
def column(self, name: str) -> Optional[SourceColumn]:
|
|
56
|
+
for c in self.columns:
|
|
57
|
+
if c.name == name:
|
|
58
|
+
return c
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_REL_SQL = """
|
|
63
|
+
select c.oid, n.nspname, c.relname, c.relreplident, c.reltuples, c.relkind,
|
|
64
|
+
pn.nspname || '.' || pc.relname as parent
|
|
65
|
+
from pg_class c
|
|
66
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
67
|
+
left join pg_inherits i on i.inhrelid = c.oid
|
|
68
|
+
left join pg_class pc on pc.oid = i.inhparent and pc.relkind = 'p'
|
|
69
|
+
left join pg_namespace pn on pn.oid = pc.relnamespace
|
|
70
|
+
where c.relkind in ('r', 'p')
|
|
71
|
+
and n.nspname not in ('pg_catalog', 'information_schema', 'pg_toast')
|
|
72
|
+
and n.nspname not like 'pg\\_temp%%' and n.nspname not like 'pg\\_toast%%'
|
|
73
|
+
order by n.nspname, c.relname
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_COL_SQL = """
|
|
77
|
+
select a.attrelid, a.attname, a.attnum, a.atttypid, a.atttypmod, a.attnotnull,
|
|
78
|
+
t.typname, t.typcategory = 'A' as is_array, t.typelem
|
|
79
|
+
from pg_attribute a
|
|
80
|
+
join pg_class c on c.oid = a.attrelid
|
|
81
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
82
|
+
join pg_type t on t.oid = a.atttypid
|
|
83
|
+
where a.attnum > 0 and not a.attisdropped
|
|
84
|
+
and c.relkind in ('r', 'p')
|
|
85
|
+
and n.nspname not in ('pg_catalog', 'information_schema', 'pg_toast')
|
|
86
|
+
order by a.attrelid, a.attnum
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
# Non-partial (indpred is null) and non-expression (indexprs is null): a partial unique index
|
|
90
|
+
# does not guarantee global uniqueness, so it is not a merge key. `k.ord <= i.indnkeyatts` drops
|
|
91
|
+
# INCLUDE columns: they sit in indkey but carry no uniqueness, and merging on one turns every
|
|
92
|
+
# UPDATE of it into a duplicate row.
|
|
93
|
+
_IDX_SQL = """
|
|
94
|
+
select i.indrelid, ic.relname, i.indisprimary, k.ord, a.attname
|
|
95
|
+
from pg_index i
|
|
96
|
+
join pg_class ic on ic.oid = i.indexrelid
|
|
97
|
+
join pg_class c on c.oid = i.indrelid
|
|
98
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
99
|
+
cross join lateral unnest(i.indkey::smallint[]) with ordinality as k(attnum, ord)
|
|
100
|
+
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = k.attnum
|
|
101
|
+
where i.indisunique and i.indisvalid and i.indislive
|
|
102
|
+
and i.indpred is null and i.indexprs is null
|
|
103
|
+
and k.ord <= i.indnkeyatts
|
|
104
|
+
and c.relkind in ('r', 'p')
|
|
105
|
+
and n.nspname not in ('pg_catalog', 'information_schema', 'pg_toast')
|
|
106
|
+
order by i.indrelid, ic.relname, k.ord
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def discover(dsn: str, *, connect_timeout: int = 10) -> List[SourceRelation]:
|
|
111
|
+
"""Every user table in the source, schema-qualified, with columns, keys and identity."""
|
|
112
|
+
with psycopg.connect(dsn, connect_timeout=connect_timeout) as conn:
|
|
113
|
+
conn.read_only = True
|
|
114
|
+
rels = conn.execute(_REL_SQL).fetchall()
|
|
115
|
+
cols = conn.execute(_COL_SQL).fetchall()
|
|
116
|
+
idx = conn.execute(_IDX_SQL).fetchall()
|
|
117
|
+
|
|
118
|
+
by_oid: Dict[int, List[SourceColumn]] = {}
|
|
119
|
+
for attrelid, name, attnum, type_oid, typmod, not_null, type_name, is_array, elem in cols:
|
|
120
|
+
by_oid.setdefault(attrelid, []).append(
|
|
121
|
+
SourceColumn(
|
|
122
|
+
name=name,
|
|
123
|
+
attnum=attnum,
|
|
124
|
+
type_oid=type_oid,
|
|
125
|
+
typmod=typmod,
|
|
126
|
+
not_null=bool(not_null),
|
|
127
|
+
type_name=type_name,
|
|
128
|
+
is_array=bool(is_array),
|
|
129
|
+
elem_oid=elem or 0,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
pk: Dict[int, List[str]] = {}
|
|
134
|
+
uniq: Dict[int, Dict[str, List[str]]] = {}
|
|
135
|
+
for indrelid, indname, is_primary, _ord, attname in idx:
|
|
136
|
+
if is_primary:
|
|
137
|
+
pk.setdefault(indrelid, []).append(attname)
|
|
138
|
+
else:
|
|
139
|
+
uniq.setdefault(indrelid, {}).setdefault(indname, []).append(attname)
|
|
140
|
+
|
|
141
|
+
out: List[SourceRelation] = []
|
|
142
|
+
for oid, schema, table, relreplident, reltuples, relkind, parent in rels:
|
|
143
|
+
out.append(
|
|
144
|
+
SourceRelation(
|
|
145
|
+
schema=schema,
|
|
146
|
+
table=table,
|
|
147
|
+
oid=oid,
|
|
148
|
+
columns=by_oid.get(oid, []),
|
|
149
|
+
primary_key=tuple(pk.get(oid, ())),
|
|
150
|
+
unique_indexes=tuple(
|
|
151
|
+
UniqueIndex(name=n, columns=tuple(c)) for n, c in sorted(uniq.get(oid, {}).items())
|
|
152
|
+
),
|
|
153
|
+
replica_identity=relreplident,
|
|
154
|
+
row_estimate=float(reltuples or 0.0),
|
|
155
|
+
kind=relkind,
|
|
156
|
+
parent=parent,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return sorted(out, key=lambda r: (r.schema, r.table))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def publication_relations(dsn: str, publication: str, *, connect_timeout: int = 10) -> Optional[set]:
|
|
163
|
+
"""{(schema, table)} currently published, or None when the publication does not exist yet
|
|
164
|
+
(a first run — distinct from an existing but empty publication)."""
|
|
165
|
+
with psycopg.connect(dsn, connect_timeout=connect_timeout) as conn:
|
|
166
|
+
conn.read_only = True
|
|
167
|
+
exists = conn.execute("select 1 from pg_publication where pubname = %s", (publication,)).fetchone()
|
|
168
|
+
if exists is None:
|
|
169
|
+
return None
|
|
170
|
+
rows = conn.execute(
|
|
171
|
+
"select schemaname, tablename from pg_publication_tables where pubname = %s", (publication,)
|
|
172
|
+
).fetchall()
|
|
173
|
+
return {(s, t) for s, t in rows}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def slot_state(dsn: str, slot: str, *, connect_timeout: int = 10) -> Dict[str, object]:
|
|
177
|
+
"""Slot telemetry plus the server's own WAL retention fuse.
|
|
178
|
+
|
|
179
|
+
`max_slot_wal_keep_size` is read, not declared: a manifest number that disagrees with the
|
|
180
|
+
server is a false invariant (Deviation D-7). -1 on the server means unlimited → None here.
|
|
181
|
+
"""
|
|
182
|
+
with psycopg.connect(dsn, connect_timeout=connect_timeout) as conn:
|
|
183
|
+
conn.read_only = True
|
|
184
|
+
row = conn.execute(
|
|
185
|
+
"""
|
|
186
|
+
select active, wal_status, restart_lsn::text, confirmed_flush_lsn::text,
|
|
187
|
+
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::bigint
|
|
188
|
+
from pg_replication_slots where slot_name = %s
|
|
189
|
+
""",
|
|
190
|
+
(slot,),
|
|
191
|
+
).fetchone()
|
|
192
|
+
keep = conn.execute(
|
|
193
|
+
"select setting, unit from pg_settings where name = 'max_slot_wal_keep_size'"
|
|
194
|
+
).fetchone()
|
|
195
|
+
|
|
196
|
+
fuse: Optional[int] = None
|
|
197
|
+
if keep is not None:
|
|
198
|
+
setting, unit = int(keep[0]), (keep[1] or "")
|
|
199
|
+
if setting >= 0:
|
|
200
|
+
mult = {"B": 1, "kB": 1024, "MB": 1024**2, "GB": 1024**3, "8kB": 8192}.get(unit, 1)
|
|
201
|
+
fuse = setting * mult
|
|
202
|
+
|
|
203
|
+
state: Dict[str, object] = {"slot": slot, "exists": row is not None, "max_slot_wal_keep_size_bytes": fuse}
|
|
204
|
+
if row is not None:
|
|
205
|
+
state.update(
|
|
206
|
+
active=row[0],
|
|
207
|
+
wal_status=row[1],
|
|
208
|
+
restart_lsn=row[2],
|
|
209
|
+
confirmed_flush_lsn=row[3],
|
|
210
|
+
lag_bytes=row[4],
|
|
211
|
+
)
|
|
212
|
+
return state
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def relation_map(relations: Sequence[SourceRelation]) -> Dict[str, SourceRelation]:
|
|
216
|
+
return {r.qualified: r for r in relations}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Per-run schema registry + drift policy (#3079 D7, plan Deviation D-3).
|
|
2
|
+
|
|
3
|
+
Constraints:
|
|
4
|
+
- a key_change deliberately does NOT refresh the stored signature, so every later run
|
|
5
|
+
re-detects it and keeps the table out of the publication until `resnapshot` refreshes it;
|
|
6
|
+
- breaking non-key drift never stops the drain — one ordered slot cannot skip one relation,
|
|
7
|
+
and dlt's evolution is additive, so continuing is non-destructive;
|
|
8
|
+
- a `text_fallback` mapping is always an event, never a silent cast.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from control_plane import status as cp_status
|
|
16
|
+
|
|
17
|
+
from . import discovery, publication
|
|
18
|
+
from .lifecycle import (
|
|
19
|
+
DISCOVERED, DRIFT_REVIEW, RESNAPSHOT_REQUIRED, RETIRED, SNAPSHOTTING, Lifecycle,
|
|
20
|
+
)
|
|
21
|
+
from .schema_registry import (
|
|
22
|
+
TEXT_FALLBACK, classify_drift, compile_relation, project, project_signature, signature,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
KEY_CHANGE = "key_change"
|
|
26
|
+
# Reported and marked, but the table keeps loading (Appendix C: dlt never destroys a column).
|
|
27
|
+
BREAKING_KINDS = frozenset({"drop_column", "rename_column", "type_incompatible"})
|
|
28
|
+
# A column the reviewed signature does not name (#4346): on a table with denied columns it is
|
|
29
|
+
# withheld until an operator decides — it may be the secret renamed.
|
|
30
|
+
NEW_COLUMN_KINDS = frozenset({"add_nullable_column", "add_not_null_column", "rename_column"})
|
|
31
|
+
# States whose next step this tick is a full baseline (runner._adopt_streaming routes a
|
|
32
|
+
# re-selected RETIRED or DISCOVERED relation to SNAPSHOTTING; an unpublished one is added and
|
|
33
|
+
# baselined).
|
|
34
|
+
BASELINE_PENDING = frozenset({SNAPSHOTTING, RETIRED, DISCOVERED})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def remedy(manifest_path: str, qualified: str) -> str:
|
|
38
|
+
return f"python -m feather_cdc_postgres resnapshot {manifest_path} --tables {qualified} --yes"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class DriftOutcome:
|
|
43
|
+
"""`blocked` is out of the publication for this run and every run until a resnapshot."""
|
|
44
|
+
|
|
45
|
+
blocked: set = field(default_factory=set)
|
|
46
|
+
review: set = field(default_factory=set)
|
|
47
|
+
drifts: dict = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def active(self, relations: Sequence) -> list:
|
|
50
|
+
return [r for r in relations if (r.schema, r.table) not in self.blocked]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sources(cfg) -> dict:
|
|
54
|
+
return discovery.relation_map(discovery.discover(cfg.source_dsn))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _compile_all(cfg, relations, *, run, sources) -> tuple[list[dict], dict]:
|
|
58
|
+
"""Registry rows + fresh signature per relation; every text_fallback column is an event."""
|
|
59
|
+
rows: list[dict] = []
|
|
60
|
+
sigs: dict = {}
|
|
61
|
+
for rel in relations:
|
|
62
|
+
src = sources.get(rel.qualified)
|
|
63
|
+
if src is None:
|
|
64
|
+
run.event(entity=rel.qualified, status=cp_status.EV_ERROR, unit="schema_drift",
|
|
65
|
+
error_detail="selected relation does not exist at the source")
|
|
66
|
+
continue
|
|
67
|
+
key = tuple(rel.key)
|
|
68
|
+
denied = _denied(cfg, rel, src)
|
|
69
|
+
src = project(src, denied) # #4346: a denied column is not part of what lands
|
|
70
|
+
cols, _problems = compile_relation(src, key)
|
|
71
|
+
rows += [{"registration": cfg.registration, "schema": rel.schema, "table": rel.table,
|
|
72
|
+
"column": c.name, "type_oid": c.type_oid, "typmod": c.typmod,
|
|
73
|
+
"not_null": c.not_null, "key_role": c.key_role,
|
|
74
|
+
"dest_type": c.compiled.dest_type, "mapping": c.compiled.mapping}
|
|
75
|
+
for c in cols]
|
|
76
|
+
fallback = [c.name for c in cols if c.compiled.mapping == TEXT_FALLBACK]
|
|
77
|
+
if fallback:
|
|
78
|
+
run.event(entity=rel.qualified, status=cp_status.EV_OK, unit="schema_registry",
|
|
79
|
+
extra={"text_fallback_columns": fallback,
|
|
80
|
+
"detail": "no declared OID mapping — landing as text"})
|
|
81
|
+
sigs[(rel.schema, rel.table)] = signature(src, key)
|
|
82
|
+
return rows, sigs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _denied(cfg, rel, src=None) -> tuple:
|
|
86
|
+
"""The relation's column deny-list (#4346): the compiled Relation's plus the manifest map
|
|
87
|
+
the runtime carries (both come from `source.deny.columns`; a relation built outside the
|
|
88
|
+
compiler carries none of its own) — plus, given the catalogue relation `src`, every column
|
|
89
|
+
whose attnum is a denied one: a denied column renamed after the run resolved its names
|
|
90
|
+
must not be recorded as a reviewed, approved column (Codex round 12)."""
|
|
91
|
+
own = set(getattr(rel, "deny_columns", ()) or ())
|
|
92
|
+
own |= set((getattr(cfg, "deny_columns", None) or {}).get((rel.schema, rel.table), ()))
|
|
93
|
+
if src is not None:
|
|
94
|
+
attnums = set((getattr(cfg, "deny_attnums", None) or {}).get(src.oid, ()))
|
|
95
|
+
own |= {c.name for c in src.columns if c.attnum in attnums}
|
|
96
|
+
return tuple(sorted(own))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def record_all(cfg, relations, *, run) -> dict:
|
|
100
|
+
"""Registry + signatures with NO diff — what a full (re-)snapshot leaves behind."""
|
|
101
|
+
cfg.destination.ensure_runtime_tables()
|
|
102
|
+
rows, sigs = _compile_all(cfg, relations, run=run, sources=_sources(cfg))
|
|
103
|
+
cfg.destination.write_registry_rows(rows)
|
|
104
|
+
for (schema, table), sig in sigs.items():
|
|
105
|
+
cfg.destination.write_signature(cfg.registration, schema, table, sig)
|
|
106
|
+
return sigs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def record_signature(cfg, rel, *, run=None) -> dict | None:
|
|
110
|
+
"""Refresh ONE relation's signature — what `resnapshot` calls to clear a key_change."""
|
|
111
|
+
src = _sources(cfg).get(rel.qualified)
|
|
112
|
+
if src is None:
|
|
113
|
+
return None
|
|
114
|
+
sig = signature(src, tuple(rel.key), _denied(cfg, rel, src))
|
|
115
|
+
cfg.destination.write_signature(cfg.registration, rel.schema, rel.table, sig)
|
|
116
|
+
return sig
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def scan(cfg, relations, *, run, lc: Lifecycle, owner,
|
|
120
|
+
manifest_path: str = "<manifest>") -> DriftOutcome:
|
|
121
|
+
"""Compile every desired relation, persist the registry, and act on the diff vs last run."""
|
|
122
|
+
cfg.destination.ensure_runtime_tables()
|
|
123
|
+
stored = cfg.destination.read_signatures(cfg.registration)
|
|
124
|
+
sources = _sources(cfg)
|
|
125
|
+
rows, sigs = _compile_all(cfg, relations, run=run, sources=sources)
|
|
126
|
+
cfg.destination.write_registry_rows(rows)
|
|
127
|
+
out = DriftOutcome()
|
|
128
|
+
|
|
129
|
+
for rel in relations:
|
|
130
|
+
pair = (rel.schema, rel.table)
|
|
131
|
+
new_sig = sigs.get(pair)
|
|
132
|
+
if new_sig is None:
|
|
133
|
+
continue
|
|
134
|
+
# Projected like the new one: a signature stored before a deny was added still names the
|
|
135
|
+
# column, and must not read as a breaking `drop_column` (#4346).
|
|
136
|
+
old_sig = project_signature(stored.get(pair),
|
|
137
|
+
_denied(cfg, rel, sources.get(rel.qualified)))
|
|
138
|
+
drifts = classify_drift(old_sig, new_sig) if old_sig is not None else []
|
|
139
|
+
if not drifts:
|
|
140
|
+
# Identical → nothing to write: each write is a MotherDuck round trip, and fifty of
|
|
141
|
+
# them every tick for a schema that did not move is the cost of an idle registration.
|
|
142
|
+
if stored.get(pair) != new_sig:
|
|
143
|
+
cfg.destination.write_signature(cfg.registration, rel.schema, rel.table, new_sig)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
out.drifts[pair] = drifts
|
|
147
|
+
kinds = {d.kind for d in drifts}
|
|
148
|
+
payload = [{"kind": d.kind, "column": d.column, "severity": d.severity,
|
|
149
|
+
"detail": d.detail} for d in drifts]
|
|
150
|
+
|
|
151
|
+
if lc.state_of(rel.schema, rel.table) in BASELINE_PENDING:
|
|
152
|
+
# A full baseline is already due this tick (#4338: a re-selected retired table, a
|
|
153
|
+
# table renamed at the source, an interrupted snapshot): it rebuilds the copy in the
|
|
154
|
+
# new shape, which IS the remedy. Blocking or reviewing it would also need a
|
|
155
|
+
# lifecycle edge `snapshotting` does not have, and abort the whole tick.
|
|
156
|
+
cfg.destination.write_signature(cfg.registration, rel.schema, rel.table, new_sig)
|
|
157
|
+
run.event(entity=rel.qualified, status=cp_status.EV_OK, unit="schema_drift",
|
|
158
|
+
extra={"drifts": payload, "action": "superseded_by_baseline"})
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if KEY_CHANGE in kinds:
|
|
162
|
+
# The OLD signature stands: the block has to survive a restart, and the only thing
|
|
163
|
+
# that clears it is a resnapshot rebuilding the table under the new key.
|
|
164
|
+
out.blocked.add(pair)
|
|
165
|
+
run.event(entity=rel.qualified, status=cp_status.EV_ERROR, unit="schema_drift",
|
|
166
|
+
error_detail="merge key changed — removed from the publication",
|
|
167
|
+
extra={"drifts": payload, "action": "publication_removed",
|
|
168
|
+
"remedy": remedy(manifest_path, rel.qualified)})
|
|
169
|
+
lc.ensure(rel.schema, rel.table, RESNAPSHOT_REQUIRED, "key change")
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
added = kinds & NEW_COLUMN_KINDS
|
|
173
|
+
if added and _denied(cfg, rel, sources.get(rel.qualified)):
|
|
174
|
+
# Fail closed on a table that carries a deny (#4346, Sonnet F1): a new or renamed
|
|
175
|
+
# column may be the secret under another name. The OLD signature stands — it is the
|
|
176
|
+
# reviewed shape the decoder withholds everything else against — until an operator
|
|
177
|
+
# denies the column or resnapshots to approve it.
|
|
178
|
+
new_cols = sorted(d.column for d in drifts if d.kind in NEW_COLUMN_KINDS and d.column)
|
|
179
|
+
out.review.add(pair)
|
|
180
|
+
# unit `deny_columns`, every tick until resolved: the daily-loading privacy alarm
|
|
181
|
+
# reads that unit, and must stay red while the review is pending (Codex round 12).
|
|
182
|
+
run.event(entity=rel.qualified, status=cp_status.EV_ERROR, unit="deny_columns",
|
|
183
|
+
error_detail=(f"new or renamed column(s) {', '.join(new_cols)} on a table "
|
|
184
|
+
"with denied columns — withheld until reviewed"),
|
|
185
|
+
extra={"drifts": payload, "action": "withheld_pending_review",
|
|
186
|
+
"remedy": "add the column(s) to source.deny.columns, or "
|
|
187
|
+
+ remedy(manifest_path, rel.qualified) + " to approve"})
|
|
188
|
+
lc.ensure(rel.schema, rel.table, DRIFT_REVIEW, "new column on a denied table")
|
|
189
|
+
continue
|
|
190
|
+
cfg.destination.write_signature(cfg.registration, rel.schema, rel.table, new_sig)
|
|
191
|
+
breaking = kinds & BREAKING_KINDS
|
|
192
|
+
if breaking:
|
|
193
|
+
out.review.add(pair)
|
|
194
|
+
run.event(entity=rel.qualified, status=cp_status.EV_ERROR, unit="schema_drift",
|
|
195
|
+
error_detail=f"breaking drift: {', '.join(sorted(breaking))}",
|
|
196
|
+
extra={"drifts": payload, "action": "kept_loading"})
|
|
197
|
+
lc.ensure(rel.schema, rel.table, DRIFT_REVIEW, ", ".join(sorted(breaking)))
|
|
198
|
+
else:
|
|
199
|
+
run.event(entity=rel.qualified, status=cp_status.EV_OK, unit="schema_drift",
|
|
200
|
+
extra={"drifts": payload, "action": "auto_evolved"})
|
|
201
|
+
|
|
202
|
+
if out.blocked:
|
|
203
|
+
owner.assert_held()
|
|
204
|
+
publication.reconcile(cfg.source_dsn, cfg.publication,
|
|
205
|
+
[(r.schema, r.table) for r in relations
|
|
206
|
+
if (r.schema, r.table) not in out.blocked])
|
|
207
|
+
return out
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Generate dbt source declarations from a compiled plan (#3079 D3, supersedes
|
|
2
|
+
the retired scripts/generate_featherbase_sources.py, deleted in #3079).
|
|
3
|
+
|
|
4
|
+
Constraints:
|
|
5
|
+
- the dbt source NAME is the destination database when the plan lands in one target schema —
|
|
6
|
+
existing models say `source('bronze_featherbase', 'comment')` and must keep resolving.
|
|
7
|
+
- descriptions come from the source's own metadata when it has any, so regeneration never
|
|
8
|
+
clobbers hand-written prose (#2209 constraint 3).
|
|
9
|
+
- `_dlt_loads` carries the only freshness check: warn-only at 24h. Minutes-grade liveness is
|
|
10
|
+
the control plane's job — a dead sync must not be its own only alarm.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pathlib
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .manifest import Manifest
|
|
18
|
+
from .selectors import CompiledPlan, Relation, table_class
|
|
19
|
+
|
|
20
|
+
HEADER = "# GENERATED by `python -m feather_cdc_postgres generate-dbt` (#3079) — do not hand-edit."
|
|
21
|
+
DLT_LOADS_DESC = "dlt load ledger — one row per landed load package (liveness backstop)."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _prose_name(source_id: str) -> str:
|
|
25
|
+
return source_id.replace("_", " ").title()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_description(m: Manifest, r: Relation) -> str:
|
|
29
|
+
who = _prose_name(m.source_id)
|
|
30
|
+
if r.module:
|
|
31
|
+
return f"{who} native table (module: {r.module})."
|
|
32
|
+
return f"{who} source table ({r.schema}.{r.table})."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _q(text: str) -> str:
|
|
36
|
+
return '"' + text.replace('"', "'") + '"'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def source_name(m: Manifest, target_schemas: List[str]) -> Dict[str, str]:
|
|
40
|
+
"""Target schema → dbt source name. One schema keeps the bare database name (existing
|
|
41
|
+
`source()` calls); several disambiguate, because two dbt sources cannot share a name."""
|
|
42
|
+
if len(target_schemas) == 1:
|
|
43
|
+
return {target_schemas[0]: m.destination.database}
|
|
44
|
+
return {s: f"{m.destination.database}__{s}" for s in target_schemas}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render(m: Manifest, compiled: CompiledPlan, descriptions: Optional[Dict[str, str]] = None) -> str:
|
|
48
|
+
"""YAML text. `descriptions` (keyed 'schema.table') overrides whatever the adapter supplied."""
|
|
49
|
+
descriptions = descriptions or {}
|
|
50
|
+
by_schema: Dict[str, List[Relation]] = {}
|
|
51
|
+
for r in compiled.relations:
|
|
52
|
+
by_schema.setdefault(r.target_schema or r.schema, []).append(r)
|
|
53
|
+
schemas = sorted(by_schema)
|
|
54
|
+
names = source_name(m, schemas)
|
|
55
|
+
|
|
56
|
+
lines: List[str] = [
|
|
57
|
+
HEADER,
|
|
58
|
+
f"# Registration: {m.source_id}.yml · selection {compiled.selection_hash[:12]}",
|
|
59
|
+
"version: 2",
|
|
60
|
+
"",
|
|
61
|
+
"sources:",
|
|
62
|
+
]
|
|
63
|
+
for schema in schemas:
|
|
64
|
+
lines += [
|
|
65
|
+
f" - name: {names[schema]}",
|
|
66
|
+
f" database: {m.destination.database}",
|
|
67
|
+
f" schema: {schema}",
|
|
68
|
+
" description: "
|
|
69
|
+
+ _q(
|
|
70
|
+
f"CDC mirror of {m.source_id} ({schema}); slot-based logical replication, "
|
|
71
|
+
"minutes-fresh (#3079/#2839). Bronze doctrine: verbatim rows, silver fixes DQ."
|
|
72
|
+
),
|
|
73
|
+
" tables:",
|
|
74
|
+
]
|
|
75
|
+
for r in sorted(by_schema[schema], key=lambda x: x.table):
|
|
76
|
+
desc = descriptions.get(r.qualified) or r.description or _default_description(m, r)
|
|
77
|
+
lines += [
|
|
78
|
+
f" - name: {r.table}",
|
|
79
|
+
f" description: {_q(desc)}",
|
|
80
|
+
" meta:",
|
|
81
|
+
f" table_class: {r.table_class}",
|
|
82
|
+
f" source_module: {r.module if r.module else 'null'}",
|
|
83
|
+
f" source_schema: {r.schema}",
|
|
84
|
+
f" source_table: {r.table}",
|
|
85
|
+
]
|
|
86
|
+
# Liveness backstop. 24h warn is deliberate: the drain only loads when something
|
|
87
|
+
# changed, so a tight window false-warns on a quiet day.
|
|
88
|
+
lines += [
|
|
89
|
+
" - name: _dlt_loads",
|
|
90
|
+
f" description: {_q(DLT_LOADS_DESC)}",
|
|
91
|
+
" meta:",
|
|
92
|
+
f" table_class: {table_class(m, schema, '_dlt_loads')}",
|
|
93
|
+
" source_module: null",
|
|
94
|
+
f" source_schema: {schema}",
|
|
95
|
+
" source_table: _dlt_loads",
|
|
96
|
+
" loaded_at_field: inserted_at",
|
|
97
|
+
" freshness:",
|
|
98
|
+
" warn_after: {count: 24, period: hour}",
|
|
99
|
+
]
|
|
100
|
+
return "\n".join(lines) + "\n"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write(m: Manifest, compiled: CompiledPlan, out: pathlib.Path, descriptions=None) -> pathlib.Path:
|
|
104
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
out.write_text(render(m, compiled, descriptions), encoding="utf-8")
|
|
106
|
+
return out
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Per-relation lifecycle state machine (#3079 D5).
|
|
2
|
+
|
|
3
|
+
States are the ONLY record of why a table is not streaming, so every move is validated and
|
|
4
|
+
mirrored to `control.event` (unit='lifecycle'). Slot loss is never a silent empty stream: it
|
|
5
|
+
lands as `resnapshot_required` before any re-snapshot begins.
|
|
6
|
+
`drift_review` has NO edge back to `streaming`: a run that merely sees no further drift must
|
|
7
|
+
not clear a reported break, so the only way out is `resnapshot` (which passes through
|
|
8
|
+
resnapshot_required → snapshotting → streaming) or retirement. The state machine enforces
|
|
9
|
+
that rather than a convention (#3079 D7).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from control_plane import status as cp_status
|
|
14
|
+
|
|
15
|
+
DISCOVERED = "discovered"
|
|
16
|
+
SNAPSHOTTING = "snapshotting"
|
|
17
|
+
CATCHING_UP = "catching_up"
|
|
18
|
+
STREAMING = "streaming"
|
|
19
|
+
DRIFT_REVIEW = "drift_review"
|
|
20
|
+
RESNAPSHOT_REQUIRED = "resnapshot_required"
|
|
21
|
+
RETIRED = "retired"
|
|
22
|
+
|
|
23
|
+
STATES = frozenset({DISCOVERED, SNAPSHOTTING, CATCHING_UP, STREAMING,
|
|
24
|
+
DRIFT_REVIEW, RESNAPSHOT_REQUIRED, RETIRED})
|
|
25
|
+
|
|
26
|
+
ALLOWED: dict[str | None, frozenset[str]] = {
|
|
27
|
+
# STREAMING from nothing is ADOPTION only (runner._adopt_streaming): a slot that already
|
|
28
|
+
# exists is proof a previous drainer baselined the table; inventing a snapshot it never ran
|
|
29
|
+
# would be the bigger lie.
|
|
30
|
+
None: frozenset({DISCOVERED, SNAPSHOTTING, STREAMING}),
|
|
31
|
+
DISCOVERED: frozenset({SNAPSHOTTING, CATCHING_UP, RETIRED}),
|
|
32
|
+
SNAPSHOTTING: frozenset({CATCHING_UP, STREAMING, RESNAPSHOT_REQUIRED, RETIRED}),
|
|
33
|
+
CATCHING_UP: frozenset({STREAMING, DRIFT_REVIEW, RESNAPSHOT_REQUIRED, RETIRED}),
|
|
34
|
+
STREAMING: frozenset({CATCHING_UP, DRIFT_REVIEW, RESNAPSHOT_REQUIRED, RETIRED}),
|
|
35
|
+
DRIFT_REVIEW: frozenset({RESNAPSHOT_REQUIRED, RETIRED}),
|
|
36
|
+
RESNAPSHOT_REQUIRED: frozenset({SNAPSHOTTING, RETIRED}),
|
|
37
|
+
RETIRED: frozenset({DISCOVERED}), # re-widening the selection re-admits the table
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IllegalTransition(ValueError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Lifecycle:
|
|
46
|
+
def __init__(self, destination, registration: str, run=None) -> None:
|
|
47
|
+
self.destination = destination
|
|
48
|
+
self.registration = registration
|
|
49
|
+
self.run = run
|
|
50
|
+
self._states: dict[tuple[str, str], str] = destination.read_lifecycle(registration)
|
|
51
|
+
|
|
52
|
+
def state_of(self, schema: str, table: str) -> str | None:
|
|
53
|
+
return self._states.get((schema, table))
|
|
54
|
+
|
|
55
|
+
def known(self) -> dict[tuple[str, str], str]:
|
|
56
|
+
"""Every relation this registration has a row for, with its state (a copy)."""
|
|
57
|
+
return dict(self._states)
|
|
58
|
+
|
|
59
|
+
def transition(self, schema: str, table: str, new_state: str,
|
|
60
|
+
detail: str | None = None) -> str:
|
|
61
|
+
if new_state not in STATES:
|
|
62
|
+
raise IllegalTransition(f"unknown lifecycle state {new_state!r}")
|
|
63
|
+
current = self._states.get((schema, table))
|
|
64
|
+
if new_state not in ALLOWED[current]:
|
|
65
|
+
raise IllegalTransition(
|
|
66
|
+
f"{schema}.{table}: {current or 'new'} → {new_state} is not an allowed transition"
|
|
67
|
+
)
|
|
68
|
+
self.destination.write_lifecycle(self.registration, schema, table, new_state, detail)
|
|
69
|
+
self._states[(schema, table)] = new_state
|
|
70
|
+
if self.run is not None:
|
|
71
|
+
self.run.event(entity=f"{schema}.{table}", status=cp_status.EV_OK, unit="lifecycle",
|
|
72
|
+
extra={"from": current, "to": new_state, "detail": detail})
|
|
73
|
+
return new_state
|
|
74
|
+
|
|
75
|
+
def ensure(self, schema: str, table: str, new_state: str, detail: str | None = None) -> str:
|
|
76
|
+
"""Idempotent: already in `new_state` is a no-op, not an error."""
|
|
77
|
+
if self._states.get((schema, table)) == new_state:
|
|
78
|
+
return new_state
|
|
79
|
+
return self.transition(schema, table, new_state, detail)
|