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,283 @@
|
|
|
1
|
+
"""Source type → destination type, and drift classification (#3079 D7).
|
|
2
|
+
|
|
3
|
+
Constraints:
|
|
4
|
+
- an unknown OID becomes text but NEVER silently: every fallback emits a Problem(warning)
|
|
5
|
+
naming schema.table.column and the OID.
|
|
6
|
+
- the integer family compiles to plain BIGINT on both the snapshot and the CDC path — dlt
|
|
7
|
+
renders precision 32 as INTEGER, and one live table already carries both spellings.
|
|
8
|
+
- drift kinds are a closed set; `severity == "breaking"` is what the runtime pauses on.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
from pg_replication.schema_types import _PG_TYPES, _to_dlt_column_type
|
|
16
|
+
|
|
17
|
+
from .discovery import SourceRelation
|
|
18
|
+
from .manifest import Problem
|
|
19
|
+
|
|
20
|
+
# How faithfully the destination type carries the source value.
|
|
21
|
+
EXACT = "exact"
|
|
22
|
+
WIDENED = "widened" # a strictly larger destination type; no value is lost
|
|
23
|
+
EXACT_AS_TEXT = "exact_as_text" # round-trips through its text rendering (uuid)
|
|
24
|
+
LOSSY_TEXT = "lossy_text" # text rendering only; arithmetic/ordering is not preserved
|
|
25
|
+
TEXT_FALLBACK = "text_fallback" # no declared mapping — reported, never silent
|
|
26
|
+
|
|
27
|
+
_MAPPING: Dict[int, str] = {
|
|
28
|
+
16: EXACT, 17: EXACT, 20: EXACT, 21: WIDENED, 23: WIDENED, 25: EXACT, 26: WIDENED,
|
|
29
|
+
114: EXACT, 700: WIDENED, 701: EXACT, 1042: EXACT, 1043: EXACT, 1082: EXACT,
|
|
30
|
+
1083: EXACT, 1114: EXACT, 1184: EXACT, 1700: EXACT, 3802: EXACT,
|
|
31
|
+
19: EXACT_AS_TEXT, 2950: EXACT_AS_TEXT,
|
|
32
|
+
1186: LOSSY_TEXT, 1266: LOSSY_TEXT,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CompiledType:
|
|
38
|
+
dest_type: str # dlt data_type: bigint | text | double | decimal | date | time | timestamp | json | bool | binary
|
|
39
|
+
precision: Optional[int] = None
|
|
40
|
+
scale: Optional[int] = None
|
|
41
|
+
mapping: str = EXACT
|
|
42
|
+
timezone: Optional[bool] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class CompiledColumn:
|
|
47
|
+
name: str
|
|
48
|
+
type_oid: int
|
|
49
|
+
typmod: int
|
|
50
|
+
not_null: bool
|
|
51
|
+
key_role: str # key | none
|
|
52
|
+
compiled: CompiledType
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Drift:
|
|
57
|
+
kind: str
|
|
58
|
+
column: Optional[str]
|
|
59
|
+
detail: str
|
|
60
|
+
severity: str # additive | warning | breaking | info
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compile_column(type_oid: int, typmod: int, not_null: bool = False, is_array: bool = False) -> CompiledType:
|
|
64
|
+
"""One source column's destination type. `not_null` is carried for the caller's schema hint;
|
|
65
|
+
it never changes the type (a NOT NULL column still lands nullable after a key-only delete)."""
|
|
66
|
+
if is_array:
|
|
67
|
+
# pgoutput ships arrays as their text rendering ('{a,b}'); nothing downstream parses it.
|
|
68
|
+
return CompiledType(dest_type="text", mapping=TEXT_FALLBACK)
|
|
69
|
+
dlt_type = _to_dlt_column_type(type_oid, typmod)
|
|
70
|
+
mapping = _MAPPING.get(type_oid, TEXT_FALLBACK)
|
|
71
|
+
return CompiledType(
|
|
72
|
+
dest_type=str(dlt_type["data_type"]),
|
|
73
|
+
precision=dlt_type.get("precision"),
|
|
74
|
+
scale=dlt_type.get("scale"),
|
|
75
|
+
mapping=mapping,
|
|
76
|
+
timezone=dlt_type.get("timezone"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compile_relation(
|
|
81
|
+
relation: SourceRelation, key: Tuple[str, ...] = ()
|
|
82
|
+
) -> Tuple[List[CompiledColumn], List[Problem]]:
|
|
83
|
+
"""Compile every column of one relation; surface each fallback as a warning."""
|
|
84
|
+
cols: List[CompiledColumn] = []
|
|
85
|
+
problems: List[Problem] = []
|
|
86
|
+
for c in relation.columns:
|
|
87
|
+
ct = compile_column(c.type_oid, c.typmod, c.not_null, c.is_array)
|
|
88
|
+
if ct.mapping == TEXT_FALLBACK:
|
|
89
|
+
reason = "array type" if c.is_array else f"OID {c.type_oid} has no declared mapping"
|
|
90
|
+
problems.append(
|
|
91
|
+
Problem(
|
|
92
|
+
"type_fallback",
|
|
93
|
+
"warning",
|
|
94
|
+
f"{relation.schema}.{relation.table}.{c.name} ({c.type_name}): {reason} — "
|
|
95
|
+
"landing as text; add it to schema_registry._MAPPING to make the choice explicit",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
cols.append(
|
|
99
|
+
CompiledColumn(
|
|
100
|
+
name=c.name,
|
|
101
|
+
type_oid=c.type_oid,
|
|
102
|
+
typmod=c.typmod,
|
|
103
|
+
not_null=c.not_null,
|
|
104
|
+
key_role="key" if c.name in key else "none",
|
|
105
|
+
compiled=ct,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return cols, problems
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def known_oids() -> Tuple[int, ...]:
|
|
112
|
+
return tuple(sorted(_PG_TYPES))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------- signatures
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def project(relation: SourceRelation, exclude: Sequence[str] = ()) -> SourceRelation:
|
|
119
|
+
"""The relation as the destination sees it: denied columns (#4346) removed. Every
|
|
120
|
+
registry row, signature and drift diff runs on this, so a deny is never a `drop_column`."""
|
|
121
|
+
if not exclude:
|
|
122
|
+
return relation
|
|
123
|
+
denied = set(exclude)
|
|
124
|
+
return replace(relation, columns=[c for c in relation.columns if c.name not in denied])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def project_signature(sig: dict, exclude: Sequence[str] = ()) -> dict:
|
|
128
|
+
"""A STORED signature projected the same way — one written before the deny existed still
|
|
129
|
+
names the column, and diffing it raw would report the deny as a breaking drop."""
|
|
130
|
+
if not exclude or not sig:
|
|
131
|
+
return sig
|
|
132
|
+
denied = set(exclude)
|
|
133
|
+
return {**sig, "columns": {k: v for k, v in sig.get("columns", {}).items() if k not in denied}}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def signature(relation: SourceRelation, key: Tuple[str, ...] = (),
|
|
137
|
+
exclude: Sequence[str] = ()) -> dict:
|
|
138
|
+
"""The inspectable shape a drift comparison runs on. Plain dicts so it can be stored in
|
|
139
|
+
`_cdc_runtime.schema_registry` and diffed across runs without unpickling anything."""
|
|
140
|
+
relation = project(relation, exclude)
|
|
141
|
+
cols, _ = compile_relation(relation, key)
|
|
142
|
+
return {
|
|
143
|
+
"schema": relation.schema,
|
|
144
|
+
"table": relation.table,
|
|
145
|
+
"key": list(key),
|
|
146
|
+
"replica_identity": relation.replica_identity,
|
|
147
|
+
# Not compared by classify_drift: it keys the reviewed shape to the relation's OID, so
|
|
148
|
+
# the decoder's column guard (#4346) follows a table rename.
|
|
149
|
+
"oid": relation.oid,
|
|
150
|
+
"columns": {
|
|
151
|
+
c.name: {
|
|
152
|
+
"type_oid": c.type_oid,
|
|
153
|
+
"typmod": c.typmod,
|
|
154
|
+
"not_null": c.not_null,
|
|
155
|
+
"dest_type": c.compiled.dest_type,
|
|
156
|
+
"precision": c.compiled.precision,
|
|
157
|
+
"scale": c.compiled.scale,
|
|
158
|
+
"mapping": c.compiled.mapping,
|
|
159
|
+
}
|
|
160
|
+
for c in cols
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_INT_RANK = {21: 1, 23: 2, 20: 3}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _varchar_len(oid: int, typmod: int) -> Optional[int]:
|
|
169
|
+
if oid in (1043, 1042) and typmod != -1:
|
|
170
|
+
return typmod - 4
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _numeric_ps(typmod: int) -> Tuple[Optional[int], Optional[int]]:
|
|
175
|
+
if typmod == -1:
|
|
176
|
+
return None, None
|
|
177
|
+
return ((typmod - 4) >> 16) & 65535, (typmod - 4) & 65535
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _is_widen(old: dict, new: dict) -> bool:
|
|
181
|
+
o, n = old["type_oid"], new["type_oid"]
|
|
182
|
+
if o == n:
|
|
183
|
+
if o in (1043, 1042):
|
|
184
|
+
ol, nl = _varchar_len(o, old["typmod"]), _varchar_len(n, new["typmod"])
|
|
185
|
+
return ol is not None and (nl is None or nl > ol)
|
|
186
|
+
if o == 1700:
|
|
187
|
+
op, os_ = _numeric_ps(old["typmod"])
|
|
188
|
+
np, ns = _numeric_ps(new["typmod"])
|
|
189
|
+
if op is None:
|
|
190
|
+
return False
|
|
191
|
+
return np is None or (np >= op and ns == os_ and np > op)
|
|
192
|
+
return False
|
|
193
|
+
if o in _INT_RANK and n in _INT_RANK:
|
|
194
|
+
return _INT_RANK[n] > _INT_RANK[o]
|
|
195
|
+
if o in (1043, 1042) and n == 25: # varchar/bpchar -> text
|
|
196
|
+
return True
|
|
197
|
+
if o == 700 and n == 701: # float4 -> float8
|
|
198
|
+
return True
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def classify_drift(old_sig: dict, new_sig: dict) -> List[Drift]:
|
|
203
|
+
"""Compare two signatures of the SAME relation. Ordered and deterministic."""
|
|
204
|
+
out: List[Drift] = []
|
|
205
|
+
old_cols: Dict[str, dict] = old_sig.get("columns", {})
|
|
206
|
+
new_cols: Dict[str, dict] = new_sig.get("columns", {})
|
|
207
|
+
dropped = sorted(set(old_cols) - set(new_cols))
|
|
208
|
+
added = sorted(set(new_cols) - set(old_cols))
|
|
209
|
+
|
|
210
|
+
# One dropped and one added of the same destination type is far more often a rename than a
|
|
211
|
+
# coincidental pair; report it as a rename so a human sees the real event, at warning level
|
|
212
|
+
# because the runtime cannot prove it (logical replication carries rows, not the DDL).
|
|
213
|
+
if len(dropped) == 1 and len(added) == 1 and old_cols[dropped[0]]["dest_type"] == new_cols[added[0]]["dest_type"]:
|
|
214
|
+
out.append(
|
|
215
|
+
Drift(
|
|
216
|
+
"rename_column",
|
|
217
|
+
added[0],
|
|
218
|
+
f"{dropped[0]!r} disappeared and {added[0]!r} appeared with the same type "
|
|
219
|
+
f"({new_cols[added[0]]['dest_type']}) — probably a rename",
|
|
220
|
+
"warning",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
dropped, added = [], []
|
|
224
|
+
|
|
225
|
+
for name in added:
|
|
226
|
+
if new_cols[name]["not_null"]:
|
|
227
|
+
out.append(
|
|
228
|
+
Drift(
|
|
229
|
+
"add_not_null_column",
|
|
230
|
+
name,
|
|
231
|
+
"added NOT NULL — rows already in bronze keep NULL there; backfill or resnapshot to align",
|
|
232
|
+
"warning",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
out.append(Drift("add_nullable_column", name, "added nullable column", "additive"))
|
|
237
|
+
for name in dropped:
|
|
238
|
+
out.append(
|
|
239
|
+
Drift(
|
|
240
|
+
"drop_column",
|
|
241
|
+
name,
|
|
242
|
+
"dropped at the source — bronze keeps the column and its history (dlt never destroys one)",
|
|
243
|
+
"breaking",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
for name in sorted(set(old_cols) & set(new_cols)):
|
|
247
|
+
o, n = old_cols[name], new_cols[name]
|
|
248
|
+
if o["type_oid"] == n["type_oid"] and o["typmod"] == n["typmod"]:
|
|
249
|
+
continue
|
|
250
|
+
if _is_widen(o, n):
|
|
251
|
+
out.append(
|
|
252
|
+
Drift("type_widen", name, f"OID {o['type_oid']}→{n['type_oid']} widened, applied automatically", "additive")
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
out.append(
|
|
256
|
+
Drift(
|
|
257
|
+
"type_incompatible",
|
|
258
|
+
name,
|
|
259
|
+
f"OID {o['type_oid']}→{n['type_oid']} is not a proven-safe widening",
|
|
260
|
+
"breaking",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if list(old_sig.get("key", [])) != list(new_sig.get("key", [])):
|
|
265
|
+
out.append(
|
|
266
|
+
Drift(
|
|
267
|
+
"key_change",
|
|
268
|
+
None,
|
|
269
|
+
f"merge key {old_sig.get('key')} → {new_sig.get('key')}; a live key change double-loads "
|
|
270
|
+
"under dlt merge — the table is removed from the publication and needs `resnapshot`",
|
|
271
|
+
"breaking",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
if old_sig.get("replica_identity") != new_sig.get("replica_identity"):
|
|
275
|
+
out.append(
|
|
276
|
+
Drift(
|
|
277
|
+
"replica_identity_change",
|
|
278
|
+
None,
|
|
279
|
+
f"REPLICA IDENTITY {old_sig.get('replica_identity')} → {new_sig.get('replica_identity')}",
|
|
280
|
+
"info",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return out
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Desired-state compiler: manifest + catalog + adapter → one deterministic relation set.
|
|
2
|
+
|
|
3
|
+
Constraints:
|
|
4
|
+
- deny is evaluated on the COMPILED set, not on the include list — an adapter or a wildcard
|
|
5
|
+
must not be able to smuggle a denied relation in (#3079 D12).
|
|
6
|
+
- ordering is sorted everywhere, so `selection_hash` is a function of the selection alone.
|
|
7
|
+
- a relation is always (schema, table); the target schema is derived per TABLE
|
|
8
|
+
(`destination.table_schemas`, then `schema_mapping`), never assumed equal (D-13).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import hashlib
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
17
|
+
|
|
18
|
+
from .adapters import USER_CREATED, AdapterRelation
|
|
19
|
+
from .discovery import SourceRelation
|
|
20
|
+
from .manifest import Manifest, Problem, Selection
|
|
21
|
+
|
|
22
|
+
# Key policies, in the order the compiler prefers them.
|
|
23
|
+
PRIMARY_KEY = "primary_key"
|
|
24
|
+
UNIQUE_INDEX = "unique_index"
|
|
25
|
+
APPEND_ONLY = "append_only"
|
|
26
|
+
# Rejected, never compiled: an all-column merge key cannot mirror an UPDATE — pgoutput emits
|
|
27
|
+
# only the new tuple, so the old row is never matched and both versions stay (#3079 review).
|
|
28
|
+
FULL_ROW = "full_row"
|
|
29
|
+
|
|
30
|
+
# Featherbase's own platform tables. Physical-schema-faithful bronze keeps them where they are;
|
|
31
|
+
# the class is what lets a consumer filter them out (#3079 D3).
|
|
32
|
+
_SOURCE_SYSTEM = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"table_def", "column_def", "person", "identifier_system", "role", "has_role",
|
|
35
|
+
"permission", "data_scope",
|
|
36
|
+
"share", "user_event", "access_log", "activity_log", "version", "migration", "site",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
_SOURCE_OPERATIONAL = frozenset({"background_job", "job_execution"})
|
|
40
|
+
# A selected column whose NAME looks secret-bearing and is not in `deny.columns` (#4346). A
|
|
41
|
+
# warning, never an error: the name is a hint, and `validate` must stay green on a false hit.
|
|
42
|
+
_SECRET_LIKE = re.compile(r"password|_hash$|token|secret", re.IGNORECASE)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Relation:
|
|
47
|
+
schema: str
|
|
48
|
+
table: str
|
|
49
|
+
key: Tuple[str, ...]
|
|
50
|
+
key_policy: str
|
|
51
|
+
table_class: str
|
|
52
|
+
module: Optional[str] = None
|
|
53
|
+
target_schema: str = ""
|
|
54
|
+
key_index: Optional[str] = None # the unique index a UNIQUE_INDEX key came from
|
|
55
|
+
description: Optional[str] = None
|
|
56
|
+
origin: Optional[str] = None # adapters.USER_CREATED | NAMED_SYSTEM | None
|
|
57
|
+
# Columns that never leave the CDC process (`source.deny.columns`, #4346), sorted.
|
|
58
|
+
deny_columns: Tuple[str, ...] = ()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def qualified(self) -> str:
|
|
62
|
+
return f"{self.schema}.{self.table}"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def target(self) -> str:
|
|
66
|
+
return f"{self.target_schema or self.schema}.{self.table}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class CompiledPlan:
|
|
71
|
+
relations: List[Relation] = field(default_factory=list)
|
|
72
|
+
selection_hash: str = ""
|
|
73
|
+
publication_add: List[Tuple[str, str]] = field(default_factory=list)
|
|
74
|
+
publication_drop: List[Tuple[str, str]] = field(default_factory=list)
|
|
75
|
+
replica_identity_alters: List[Tuple[str, str]] = field(default_factory=list)
|
|
76
|
+
denied_hits: List[str] = field(default_factory=list)
|
|
77
|
+
problems: List[Problem] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _matches(qualified: str, schema: str, sel: Selection) -> bool:
|
|
81
|
+
if any(fnmatch.fnmatchcase(schema, pat) for pat in sel.schemas):
|
|
82
|
+
return True
|
|
83
|
+
return any(fnmatch.fnmatchcase(qualified, pat) for pat in sel.tables)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _included(qualified: str, schema: str, sel: Selection) -> bool:
|
|
87
|
+
"""Include is a conjunction: the schema must be allowed AND, when table patterns are given,
|
|
88
|
+
one must match. Table patterns alone (empty `schemas`) select by table pattern only."""
|
|
89
|
+
schema_ok = any(fnmatch.fnmatchcase(schema, pat) for pat in sel.schemas)
|
|
90
|
+
if not sel.tables:
|
|
91
|
+
return schema_ok
|
|
92
|
+
table_ok = any(fnmatch.fnmatchcase(qualified, pat) for pat in sel.tables)
|
|
93
|
+
return table_ok if not sel.schemas else (schema_ok and table_ok)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def table_class(m: Manifest, schema: str, table: str, origin: Optional[str] = None) -> str:
|
|
97
|
+
"""First matching manifest rule wins; otherwise the defaults below. Loader-internal is
|
|
98
|
+
checked first so a broad application rule cannot claim `_dlt_loads`. A relation the
|
|
99
|
+
adapter offered as USER_CREATED is application data whatever it is called — a user's
|
|
100
|
+
table named `share` is not the platform's `share`."""
|
|
101
|
+
qualified = f"{schema}.{table}"
|
|
102
|
+
for rule in m.classification.rules:
|
|
103
|
+
if fnmatch.fnmatchcase(qualified, rule.match):
|
|
104
|
+
return rule.table_class
|
|
105
|
+
if table.startswith("_dlt_") or schema == "_cdc_runtime":
|
|
106
|
+
return "loader_internal"
|
|
107
|
+
if origin == USER_CREATED:
|
|
108
|
+
return "application"
|
|
109
|
+
if m.source_id == "control_plane" and schema == "control":
|
|
110
|
+
return "warehouse_control"
|
|
111
|
+
if table in _SOURCE_OPERATIONAL:
|
|
112
|
+
return "source_operational"
|
|
113
|
+
if table in _SOURCE_SYSTEM:
|
|
114
|
+
return "source_system"
|
|
115
|
+
return "application"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _key_for(
|
|
119
|
+
m: Manifest, rel: SourceRelation, problems: List[Problem]
|
|
120
|
+
) -> Tuple[Tuple[str, ...], str, Optional[str], bool]:
|
|
121
|
+
"""(key, policy, index_name, keep) per D6."""
|
|
122
|
+
override = m.replication.key_overrides.get(rel.qualified)
|
|
123
|
+
if override == APPEND_ONLY:
|
|
124
|
+
return (), APPEND_ONLY, None, True
|
|
125
|
+
if override == FULL_ROW:
|
|
126
|
+
problems.append(
|
|
127
|
+
Problem(
|
|
128
|
+
"full_row_unsupported",
|
|
129
|
+
"error",
|
|
130
|
+
f"{rel.qualified}: full_row cannot mirror an UPDATE (the old row is never matched, "
|
|
131
|
+
"so both versions would stay) — declare append_only for a history, or add a key",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return (), FULL_ROW, None, False
|
|
135
|
+
|
|
136
|
+
if rel.primary_key:
|
|
137
|
+
return tuple(rel.primary_key), PRIMARY_KEY, None, True
|
|
138
|
+
|
|
139
|
+
# A unique index is a merge key only when it is also NOT NULL: dlt joins the destination to
|
|
140
|
+
# the staging table on key equality, and NULL = NULL is never true, so a nullable key row
|
|
141
|
+
# would insert a duplicate on every load instead of updating.
|
|
142
|
+
notnull = {c.name for c in rel.columns if c.not_null}
|
|
143
|
+
for idx in rel.unique_indexes:
|
|
144
|
+
if all(c in notnull for c in idx.columns):
|
|
145
|
+
if m.replication.replica_identity != "full" and rel.replica_identity not in ("f", "i"):
|
|
146
|
+
problems.append(
|
|
147
|
+
Problem(
|
|
148
|
+
"unique_key_needs_identity",
|
|
149
|
+
"warning",
|
|
150
|
+
f"{rel.qualified}: merging on unique index {idx.name!r}, but REPLICA IDENTITY is "
|
|
151
|
+
f"{rel.replica_identity!r} — DELETE will not carry those columns; set "
|
|
152
|
+
"replica_identity: full or ALTER ... REPLICA IDENTITY USING INDEX",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return tuple(idx.columns), UNIQUE_INDEX, idx.name, True
|
|
156
|
+
|
|
157
|
+
if m.replication.tables_without_key == APPEND_ONLY:
|
|
158
|
+
problems.append(
|
|
159
|
+
Problem(
|
|
160
|
+
"no_key_append_only",
|
|
161
|
+
"warning",
|
|
162
|
+
f"{rel.qualified}: no primary key and no NOT NULL unique index — loading append-only; "
|
|
163
|
+
"updates and deletes are not promised at the destination",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return (), APPEND_ONLY, None, True
|
|
167
|
+
|
|
168
|
+
problems.append(
|
|
169
|
+
Problem(
|
|
170
|
+
"no_key_rejected",
|
|
171
|
+
"error",
|
|
172
|
+
f"{rel.qualified}: no primary key and no NOT NULL unique index; set "
|
|
173
|
+
"replication.tables_without_key: append_only or add key_overrides for this table",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return (), APPEND_ONLY, None, False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def compile(
|
|
180
|
+
m: Manifest,
|
|
181
|
+
discovered: Sequence[SourceRelation],
|
|
182
|
+
adapter_relations: Optional[Sequence[AdapterRelation]] = None,
|
|
183
|
+
published: Optional[Set[Tuple[str, str]]] = None,
|
|
184
|
+
) -> CompiledPlan:
|
|
185
|
+
"""Compile the manifest against a discovered catalog. Pure — no connection, no mutation."""
|
|
186
|
+
problems: List[Problem] = []
|
|
187
|
+
by_qualified = {r.qualified: r for r in discovered}
|
|
188
|
+
|
|
189
|
+
adapter_allowed: Optional[Set[str]] = None
|
|
190
|
+
adapter_meta: Dict[str, AdapterRelation] = {}
|
|
191
|
+
if m.source.discovery is not None:
|
|
192
|
+
adapter_meta = {f"{a.schema}.{a.table}": a for a in (adapter_relations or [])}
|
|
193
|
+
adapter_allowed = set(adapter_meta)
|
|
194
|
+
for q in sorted(adapter_allowed - set(by_qualified)):
|
|
195
|
+
problems.append(
|
|
196
|
+
Problem("adapter_phantom", "warning", f"{q}: selected by the adapter but not physically present — skipped")
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
denied: List[str] = []
|
|
200
|
+
candidates: List[SourceRelation] = []
|
|
201
|
+
leaves_of: Dict[str, List[str]] = {}
|
|
202
|
+
for rel in discovered:
|
|
203
|
+
if rel.parent:
|
|
204
|
+
leaves_of.setdefault(rel.parent, []).append(rel.qualified)
|
|
205
|
+
for rel in sorted(discovered, key=lambda r: (r.schema, r.table)):
|
|
206
|
+
q = rel.qualified
|
|
207
|
+
if not _included(q, rel.schema, m.source.include):
|
|
208
|
+
continue
|
|
209
|
+
if _matches(q, rel.schema, m.source.exclude):
|
|
210
|
+
continue
|
|
211
|
+
if adapter_allowed is not None and q not in adapter_allowed:
|
|
212
|
+
continue
|
|
213
|
+
if rel.kind == "p":
|
|
214
|
+
# Leaves only: `pg_publication_tables` expands a parent to its leaves (so a parent
|
|
215
|
+
# in the desired set is "added" again every tick), and pgoutput emits leaf
|
|
216
|
+
# identity by default (so a parent's destination copy would stop receiving rows).
|
|
217
|
+
# The leaves are selected by the same globs; the parent is named so the operator
|
|
218
|
+
# can see what was skipped (#3079 review).
|
|
219
|
+
problems.append(
|
|
220
|
+
Problem(
|
|
221
|
+
"partitioned_parent_skipped",
|
|
222
|
+
"warning",
|
|
223
|
+
f"{q}: partitioned parent — not replicated as one relation; its partitions "
|
|
224
|
+
f"{', '.join(sorted(leaves_of.get(q, []))) or '(none yet)'} are selected "
|
|
225
|
+
"individually when the include globs match them",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
# Deny last, on what selection actually produced: a deny rule is a tripwire, so it must
|
|
230
|
+
# fire only when something really would have been replicated (D12).
|
|
231
|
+
if _matches(q, rel.schema, m.source.deny):
|
|
232
|
+
denied.append(q)
|
|
233
|
+
continue
|
|
234
|
+
candidates.append(rel)
|
|
235
|
+
|
|
236
|
+
for q in denied:
|
|
237
|
+
problems.append(
|
|
238
|
+
Problem("deny_hit", "error", f"{q} matches a deny rule but was selected — refusing to replicate it")
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
relations: List[Relation] = []
|
|
242
|
+
for rel in candidates:
|
|
243
|
+
key, policy, idx_name, keep = _key_for(m, rel, problems)
|
|
244
|
+
if not keep:
|
|
245
|
+
continue
|
|
246
|
+
meta = adapter_meta.get(rel.qualified)
|
|
247
|
+
deny_cols = _deny_columns_for(m, rel, key, problems)
|
|
248
|
+
if deny_cols is None:
|
|
249
|
+
continue
|
|
250
|
+
relations.append(
|
|
251
|
+
Relation(
|
|
252
|
+
schema=rel.schema,
|
|
253
|
+
table=rel.table,
|
|
254
|
+
key=key,
|
|
255
|
+
key_policy=policy,
|
|
256
|
+
table_class=table_class(m, rel.schema, rel.table, meta.origin if meta else None),
|
|
257
|
+
module=meta.module if meta else None,
|
|
258
|
+
target_schema=m.destination.target_schema(rel.schema, rel.table),
|
|
259
|
+
key_index=idx_name,
|
|
260
|
+
description=meta.description if meta else None,
|
|
261
|
+
origin=meta.origin if meta else None,
|
|
262
|
+
deny_columns=deny_cols,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
seen_target: Dict[str, str] = {}
|
|
267
|
+
for r in relations:
|
|
268
|
+
prior = seen_target.get(r.target)
|
|
269
|
+
if prior:
|
|
270
|
+
problems.append(
|
|
271
|
+
Problem(
|
|
272
|
+
"target_collision",
|
|
273
|
+
"error",
|
|
274
|
+
f"{prior} and {r.qualified} both map to {m.destination.database}.{r.target}",
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
seen_target[r.target] = r.qualified
|
|
278
|
+
|
|
279
|
+
desired = {(r.schema, r.table) for r in relations}
|
|
280
|
+
add, drop = diff_publication(desired, published)
|
|
281
|
+
alters = (
|
|
282
|
+
sorted((r.schema, r.table) for r in relations if by_qualified[r.qualified].replica_identity != "f")
|
|
283
|
+
if m.replication.replica_identity == "full"
|
|
284
|
+
else []
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return CompiledPlan(
|
|
288
|
+
relations=relations,
|
|
289
|
+
selection_hash=selection_hash(relations),
|
|
290
|
+
publication_add=add,
|
|
291
|
+
publication_drop=drop,
|
|
292
|
+
replica_identity_alters=alters,
|
|
293
|
+
denied_hits=denied,
|
|
294
|
+
problems=problems,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _deny_columns_for(
|
|
299
|
+
m: Manifest, rel: SourceRelation, key: Tuple[str, ...], problems: List[Problem]
|
|
300
|
+
) -> Optional[Tuple[str, ...]]:
|
|
301
|
+
"""The relation's column projection (#4346), or None when it cannot be honoured: a denied
|
|
302
|
+
merge-key column would leave the mirror unable to merge, so the relation is refused. A name
|
|
303
|
+
the catalogue does not carry is a warning (it may be dropped, or not yet added) and is still
|
|
304
|
+
withheld, so a column added later can never flow before the manifest is re-read."""
|
|
305
|
+
denied = sorted(set(m.source.deny_columns.get(rel.qualified, [])))
|
|
306
|
+
present = {c.name for c in rel.columns}
|
|
307
|
+
for col in denied:
|
|
308
|
+
if col in key:
|
|
309
|
+
problems.append(Problem(
|
|
310
|
+
"deny_column_is_key", "error",
|
|
311
|
+
f"{rel.qualified}.{col} is part of the merge key and cannot be withheld — "
|
|
312
|
+
"the mirror could not merge without it"))
|
|
313
|
+
return None
|
|
314
|
+
if col not in present:
|
|
315
|
+
problems.append(Problem(
|
|
316
|
+
"deny_column_absent", "warning",
|
|
317
|
+
f"{rel.qualified}.{col} is denied but not in the source catalogue — still withheld "
|
|
318
|
+
"if it appears"))
|
|
319
|
+
for col in sorted(present - set(denied)):
|
|
320
|
+
if _SECRET_LIKE.search(col):
|
|
321
|
+
problems.append(Problem(
|
|
322
|
+
"secret_like_column", "warning",
|
|
323
|
+
f"{rel.qualified}.{col} looks secret-bearing and is replicated — add it to "
|
|
324
|
+
"source.deny.columns if it should never leave the source"))
|
|
325
|
+
return tuple(denied)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def selection_hash(relations: Iterable[Relation]) -> str:
|
|
329
|
+
"""Stable over the selection AND its key policy — a table that silently changed from
|
|
330
|
+
merge to append-only must produce a different hash in the control plane. A column
|
|
331
|
+
projection (#4346) is part of the selection; it is appended only when present, so a
|
|
332
|
+
registration with no column deny keeps the hash it always had."""
|
|
333
|
+
lines = sorted(
|
|
334
|
+
f"{r.schema}.{r.table}|{','.join(r.key)}|{r.key_policy}"
|
|
335
|
+
+ (f"|deny={','.join(r.deny_columns)}" if r.deny_columns else "")
|
|
336
|
+
for r in relations)
|
|
337
|
+
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def diff_publication(
|
|
341
|
+
desired: Set[Tuple[str, str]], published: Optional[Set[Tuple[str, str]]]
|
|
342
|
+
) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
|
|
343
|
+
"""(add, drop). `published is None` means the publication does not exist yet — everything
|
|
344
|
+
is an add and nothing can be dropped."""
|
|
345
|
+
if published is None:
|
|
346
|
+
return sorted(desired), []
|
|
347
|
+
return sorted(desired - published), sorted(published - desired)
|