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