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,587 @@
|
|
|
1
|
+
"""Registration manifest: parse, hold, validate statically (#3079 D1/D12).
|
|
2
|
+
|
|
3
|
+
Constraints this file enforces and callers rely on:
|
|
4
|
+
- a manifest NEVER carries a secret value — `dsn_env`/`token_env` are env-var NAMES, and
|
|
5
|
+
nothing here (or in `plan`) may print an env value; use `redact()` on anything unsure.
|
|
6
|
+
- `api_version` is exact; an unknown version is rejected rather than best-effort parsed.
|
|
7
|
+
- field names are the contract with the runtime half (plan Appendix B) — renaming one breaks
|
|
8
|
+
`runner.py`/`stream.py`, which are owned by another module.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
API_VERSION = "pg-md-cdc/v1"
|
|
22
|
+
|
|
23
|
+
_IDENT = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
24
|
+
_PG_IDENT = re.compile(r"^[a-z_][a-z0-9_$]*$")
|
|
25
|
+
_ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
26
|
+
_QUALIFIED = re.compile(r"^[^.]+\.[^.]+$")
|
|
27
|
+
|
|
28
|
+
# Which deployment environment a registration belongs to (#4340). It must equal the Railway
|
|
29
|
+
# environment the worker runs in, so a dev instance copied from production's settings refuses
|
|
30
|
+
# to start production's registration (and the reverse) instead of mixing the two.
|
|
31
|
+
PRODUCTION = "production"
|
|
32
|
+
ENVIRONMENTS = (PRODUCTION, "dev", "staging")
|
|
33
|
+
RAILWAY_ENVIRONMENT_VAR = "RAILWAY_ENVIRONMENT_NAME"
|
|
34
|
+
|
|
35
|
+
TABLE_CLASSES = (
|
|
36
|
+
"application",
|
|
37
|
+
"source_system",
|
|
38
|
+
"source_operational",
|
|
39
|
+
"loader_internal",
|
|
40
|
+
"warehouse_control",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Problem:
|
|
46
|
+
"""One validation finding. `code` is stable — tests and the CLI exit status key on it."""
|
|
47
|
+
|
|
48
|
+
code: str
|
|
49
|
+
severity: str # "error" | "warning"
|
|
50
|
+
message: str
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str: # pragma: no cover - formatting only
|
|
53
|
+
return f"[{self.severity}] {self.code}: {self.message}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Selection:
|
|
58
|
+
schemas: List[str] = field(default_factory=lambda: ["*"])
|
|
59
|
+
tables: List[str] = field(default_factory=list) # "schema.table" fnmatch globs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Discovery:
|
|
64
|
+
adapter: str
|
|
65
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Source:
|
|
70
|
+
dsn_env: str
|
|
71
|
+
include: Selection = field(default_factory=Selection)
|
|
72
|
+
exclude: Selection = field(default_factory=lambda: Selection(schemas=[], tables=[]))
|
|
73
|
+
deny: Selection = field(default_factory=lambda: Selection(schemas=[], tables=[]))
|
|
74
|
+
discovery: Optional[Discovery] = None
|
|
75
|
+
# `deny.columns` (#4346): exact "schema.table" -> column names that never leave the CDC
|
|
76
|
+
# process. A projection, not a publication column list: under REPLICA IDENTITY FULL a list
|
|
77
|
+
# must cover every column, so leaving one out makes UPDATE/DELETE fail at the SOURCE.
|
|
78
|
+
deny_columns: Dict[str, List[str]] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class TableSchema:
|
|
83
|
+
"""One per-table routing rule: relations matching `match` land in target `schema`."""
|
|
84
|
+
|
|
85
|
+
match: str # "schema.table" fnmatch glob
|
|
86
|
+
schema: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Destination:
|
|
91
|
+
token_env: str
|
|
92
|
+
database: str
|
|
93
|
+
schema_mapping: Union[str, Dict[str, str]] = "preserve_source"
|
|
94
|
+
# Per-table target schemas, first match wins; a relation no rule matches falls back to
|
|
95
|
+
# `schema_mapping`. Lets one source schema fan out into several bronze schemas (D-13).
|
|
96
|
+
table_schemas: List[TableSchema] = field(default_factory=list)
|
|
97
|
+
pipeline_name: Optional[str] = None
|
|
98
|
+
retirement_policy: str = "freeze" # freeze | archive
|
|
99
|
+
# The MotherDuck account `token_env`'s token must sign in as (#4427). Required: run and
|
|
100
|
+
# resnapshot check `current_user()` against it before creating or writing anything.
|
|
101
|
+
owner: str = ""
|
|
102
|
+
|
|
103
|
+
def target_schema(self, source_schema: str, table: Optional[str] = None) -> str:
|
|
104
|
+
if table is not None:
|
|
105
|
+
qualified = f"{source_schema}.{table}"
|
|
106
|
+
for rule in self.table_schemas:
|
|
107
|
+
if fnmatch.fnmatchcase(qualified, rule.match):
|
|
108
|
+
return rule.schema
|
|
109
|
+
if self.schema_mapping == "preserve_source":
|
|
110
|
+
return source_schema
|
|
111
|
+
if isinstance(self.schema_mapping, dict):
|
|
112
|
+
return self.schema_mapping.get(source_schema, source_schema)
|
|
113
|
+
return source_schema
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Replication:
|
|
118
|
+
slot: str
|
|
119
|
+
publication: str
|
|
120
|
+
cadence_minutes: int = 5
|
|
121
|
+
tables_without_key: str = "reject" # reject | append_only
|
|
122
|
+
replica_identity: str = "default" # full | default
|
|
123
|
+
slot_loss_policy: str = "resnapshot" # resnapshot | pause
|
|
124
|
+
target_batch_size: int = 1000
|
|
125
|
+
key_overrides: Dict[str, str] = field(default_factory=dict) # "schema.table" -> append_only
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Control:
|
|
130
|
+
extractor: str
|
|
131
|
+
availability_policy: str = "pause_if_control_unreachable"
|
|
132
|
+
event_durability: str = "direct_postgres" # direct_postgres | persistent_local_wal
|
|
133
|
+
wal_dir: Optional[str] = None
|
|
134
|
+
# "schema.table" globs whose row counts this drainer must NOT reconcile: relations it
|
|
135
|
+
# writes to DURING the run can never tie source-now against destination-now.
|
|
136
|
+
reconcile_exclude: List[str] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Rule:
|
|
141
|
+
match: str # "schema.table" fnmatch glob
|
|
142
|
+
table_class: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Classification:
|
|
147
|
+
rules: List[Rule] = field(default_factory=list) # first match wins
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Manifest:
|
|
152
|
+
api_version: str
|
|
153
|
+
source_id: str
|
|
154
|
+
source: Source
|
|
155
|
+
destination: Destination
|
|
156
|
+
replication: Replication
|
|
157
|
+
control: Control
|
|
158
|
+
classification: Classification = field(default_factory=Classification)
|
|
159
|
+
environment: str = "production"
|
|
160
|
+
path: Optional[str] = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------------------- parsing
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ManifestError(ValueError):
|
|
167
|
+
"""Malformed enough that no dataclass can be built — unknown key, wrong YAML shape."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sel(raw: Any, *, default_schemas: List[str]) -> Selection:
|
|
171
|
+
if raw is None:
|
|
172
|
+
return Selection(schemas=list(default_schemas), tables=[])
|
|
173
|
+
if not isinstance(raw, dict):
|
|
174
|
+
raise ManifestError(f"expected a mapping for a selection block, got {type(raw).__name__}")
|
|
175
|
+
_reject_unknown(raw, {"schemas", "tables"}, "selection")
|
|
176
|
+
return Selection(
|
|
177
|
+
schemas=list(raw.get("schemas") or default_schemas),
|
|
178
|
+
tables=list(raw.get("tables") or []),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _deny_columns(raw: Any) -> Dict[str, List[str]]:
|
|
183
|
+
"""`source.deny.columns`: a mapping of "schema.table" to a list of column names. Shape
|
|
184
|
+
only — names and keys are policed by validate_static so every finding is listed at once."""
|
|
185
|
+
if raw is None:
|
|
186
|
+
return {}
|
|
187
|
+
if not isinstance(raw, dict):
|
|
188
|
+
raise ManifestError("source.deny.columns must be a mapping of 'schema.table' to a list of columns")
|
|
189
|
+
out: Dict[str, List[str]] = {}
|
|
190
|
+
for rel, cols in raw.items():
|
|
191
|
+
if isinstance(cols, str) or not isinstance(cols, list):
|
|
192
|
+
raise ManifestError(f"source.deny.columns[{rel}] must be a list of column names")
|
|
193
|
+
out[str(rel)] = [str(c) for c in cols]
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _reject_unknown(raw: Dict[str, Any], allowed: set, where: str) -> None:
|
|
198
|
+
extra = sorted(set(raw) - allowed)
|
|
199
|
+
if extra:
|
|
200
|
+
raise ManifestError(f"unknown key(s) in {where}: {', '.join(extra)}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse(raw: Dict[str, Any], *, path: Optional[str] = None) -> Manifest:
|
|
204
|
+
"""Build a Manifest from already-loaded YAML. Shape errors raise; policy errors are
|
|
205
|
+
reported by validate_static so the CLI can list them all at once."""
|
|
206
|
+
if not isinstance(raw, dict):
|
|
207
|
+
raise ManifestError("manifest must be a YAML mapping")
|
|
208
|
+
_reject_unknown(
|
|
209
|
+
raw,
|
|
210
|
+
{"api_version", "source_id", "environment", "source", "destination", "replication", "control",
|
|
211
|
+
"classification"},
|
|
212
|
+
"manifest",
|
|
213
|
+
)
|
|
214
|
+
src = raw.get("source") or {}
|
|
215
|
+
_reject_unknown(src, {"dsn_env", "include", "exclude", "deny", "discovery"}, "source")
|
|
216
|
+
deny_raw = src.get("deny")
|
|
217
|
+
deny_columns_raw = None
|
|
218
|
+
if isinstance(deny_raw, dict) and "columns" in deny_raw:
|
|
219
|
+
deny_columns_raw = deny_raw["columns"]
|
|
220
|
+
deny_raw = {k: v for k, v in deny_raw.items() if k != "columns"}
|
|
221
|
+
disc_raw = src.get("discovery")
|
|
222
|
+
discovery = None
|
|
223
|
+
if disc_raw:
|
|
224
|
+
_reject_unknown(disc_raw, {"adapter", "options"}, "source.discovery")
|
|
225
|
+
discovery = Discovery(adapter=str(disc_raw.get("adapter", "")), options=dict(disc_raw.get("options") or {}))
|
|
226
|
+
|
|
227
|
+
dest = raw.get("destination") or {}
|
|
228
|
+
_reject_unknown(
|
|
229
|
+
dest,
|
|
230
|
+
{"token_env", "owner", "database", "schema_mapping", "table_schemas", "pipeline_name",
|
|
231
|
+
"retirement_policy"},
|
|
232
|
+
"destination",
|
|
233
|
+
)
|
|
234
|
+
table_schemas = []
|
|
235
|
+
for entry in dest.get("table_schemas") or []:
|
|
236
|
+
if not isinstance(entry, dict):
|
|
237
|
+
raise ManifestError("destination.table_schemas entries must be mappings {match, schema}")
|
|
238
|
+
_reject_unknown(entry, {"match", "schema"}, "destination.table_schemas[]")
|
|
239
|
+
table_schemas.append(TableSchema(match=str(entry.get("match", "")), schema=str(entry.get("schema", ""))))
|
|
240
|
+
rep = raw.get("replication") or {}
|
|
241
|
+
_reject_unknown(
|
|
242
|
+
rep,
|
|
243
|
+
{
|
|
244
|
+
"slot",
|
|
245
|
+
"publication",
|
|
246
|
+
"cadence_minutes",
|
|
247
|
+
"tables_without_key",
|
|
248
|
+
"replica_identity",
|
|
249
|
+
"slot_loss_policy",
|
|
250
|
+
"target_batch_size",
|
|
251
|
+
"key_overrides",
|
|
252
|
+
},
|
|
253
|
+
"replication",
|
|
254
|
+
)
|
|
255
|
+
ctl = raw.get("control") or {}
|
|
256
|
+
_reject_unknown(
|
|
257
|
+
ctl,
|
|
258
|
+
{"extractor", "availability_policy", "event_durability", "wal_dir", "reconcile_exclude"},
|
|
259
|
+
"control",
|
|
260
|
+
)
|
|
261
|
+
cls_raw = raw.get("classification") or {}
|
|
262
|
+
_reject_unknown(cls_raw, {"rules"}, "classification")
|
|
263
|
+
rules = [Rule(match=str(r["match"]), table_class=str(r["table_class"])) for r in (cls_raw.get("rules") or [])]
|
|
264
|
+
|
|
265
|
+
return Manifest(
|
|
266
|
+
api_version=str(raw.get("api_version", "")),
|
|
267
|
+
source_id=str(raw.get("source_id", "")),
|
|
268
|
+
source=Source(
|
|
269
|
+
dsn_env=str(src.get("dsn_env", "")),
|
|
270
|
+
include=_sel(src.get("include"), default_schemas=["*"]),
|
|
271
|
+
exclude=_sel(src.get("exclude"), default_schemas=[]),
|
|
272
|
+
deny=_sel(deny_raw, default_schemas=[]),
|
|
273
|
+
discovery=discovery,
|
|
274
|
+
deny_columns=_deny_columns(deny_columns_raw),
|
|
275
|
+
),
|
|
276
|
+
destination=Destination(
|
|
277
|
+
token_env=str(dest.get("token_env", "")),
|
|
278
|
+
database=str(dest.get("database", "")),
|
|
279
|
+
schema_mapping=dest.get("schema_mapping", "preserve_source"),
|
|
280
|
+
table_schemas=table_schemas,
|
|
281
|
+
pipeline_name=dest.get("pipeline_name"),
|
|
282
|
+
retirement_policy=str(dest.get("retirement_policy", "freeze")),
|
|
283
|
+
owner=str(dest.get("owner") or ""),
|
|
284
|
+
),
|
|
285
|
+
replication=Replication(
|
|
286
|
+
slot=str(rep.get("slot", "")),
|
|
287
|
+
publication=str(rep.get("publication", "")),
|
|
288
|
+
cadence_minutes=int(rep.get("cadence_minutes", 5)),
|
|
289
|
+
tables_without_key=str(rep.get("tables_without_key", "reject")),
|
|
290
|
+
replica_identity=str(rep.get("replica_identity", "default")),
|
|
291
|
+
slot_loss_policy=str(rep.get("slot_loss_policy", "resnapshot")),
|
|
292
|
+
target_batch_size=int(rep.get("target_batch_size", 1000)),
|
|
293
|
+
key_overrides={str(k): str(v) for k, v in (rep.get("key_overrides") or {}).items()},
|
|
294
|
+
),
|
|
295
|
+
control=Control(
|
|
296
|
+
extractor=str(ctl.get("extractor", "")),
|
|
297
|
+
availability_policy=str(ctl.get("availability_policy", "pause_if_control_unreachable")),
|
|
298
|
+
event_durability=str(ctl.get("event_durability", "direct_postgres")),
|
|
299
|
+
wal_dir=ctl.get("wal_dir"),
|
|
300
|
+
reconcile_exclude=[str(x) for x in (ctl.get("reconcile_exclude") or [])],
|
|
301
|
+
),
|
|
302
|
+
classification=Classification(rules=rules),
|
|
303
|
+
environment=str(raw.get("environment", "production")),
|
|
304
|
+
path=path,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load(path: str) -> Manifest:
|
|
309
|
+
"""Read one registration file. Never resolves the referenced env vars."""
|
|
310
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
311
|
+
raw = yaml.safe_load(fh)
|
|
312
|
+
return parse(raw, path=str(path))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def pipeline_name(m: Manifest, target_schema: str) -> str:
|
|
316
|
+
"""dlt pipeline name for one TARGET schema (= one dlt dataset). An explicit `pipeline_name`
|
|
317
|
+
pins the whole registration to one pipeline — only unambiguous with a single target schema."""
|
|
318
|
+
if m.destination.pipeline_name:
|
|
319
|
+
return m.destination.pipeline_name
|
|
320
|
+
return f"{m.source_id}_cdc__{target_schema}"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- redaction
|
|
324
|
+
|
|
325
|
+
_SECRETISH = re.compile(r"(?i)(password|token|secret|dsn|postgres(ql)?://\S+)")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def redact(text: str, *, env: Optional[Dict[str, str]] = None) -> str:
|
|
329
|
+
"""Blank out anything that looks like a credential before it reaches stdout or an event.
|
|
330
|
+
|
|
331
|
+
Two passes, because both failure modes have bitten: (1) a DSN pasted into a manifest by
|
|
332
|
+
mistake, (2) an env VALUE interpolated into a message. Values of the env vars a manifest
|
|
333
|
+
names are matched literally, so a token that looks like an ordinary word is still removed.
|
|
334
|
+
"""
|
|
335
|
+
env = os.environ if env is None else env
|
|
336
|
+
out = text
|
|
337
|
+
for value in sorted((v for v in env.values() if v and len(v) >= 8), key=len, reverse=True):
|
|
338
|
+
if value in out:
|
|
339
|
+
out = out.replace(value, "***redacted***")
|
|
340
|
+
out = re.sub(r"(?i)\b(postgres(?:ql)?|mysql)://[^\s'\"]+", r"\1://***redacted***", out)
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def redact_problems(problems: List[Problem]) -> List[Problem]:
|
|
345
|
+
return [replace(p, message=redact(p.message)) for p in problems]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --------------------------------------------------------------------------- validation
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _looks_like_a_value(s: str) -> bool:
|
|
352
|
+
"""An env-var REFERENCE that actually contains a value. Cheap and deliberately broad —
|
|
353
|
+
a false positive costs one manifest edit; a false negative leaks a DSN into a log."""
|
|
354
|
+
return "://" in s or "=" in s or " " in s or s != s.strip()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def validate_static(m: Manifest) -> List[Problem]:
|
|
358
|
+
"""Everything checkable without touching either database. Ordered, so output is stable."""
|
|
359
|
+
p: List[Problem] = []
|
|
360
|
+
err = lambda code, msg: p.append(Problem(code, "error", msg)) # noqa: E731
|
|
361
|
+
warn = lambda code, msg: p.append(Problem(code, "warning", msg)) # noqa: E731
|
|
362
|
+
|
|
363
|
+
if m.api_version != API_VERSION:
|
|
364
|
+
err("api_version", f"api_version must be {API_VERSION!r}, got {m.api_version!r}")
|
|
365
|
+
if not _IDENT.match(m.source_id or ""):
|
|
366
|
+
err("source_id", f"source_id must be a lowercase identifier, got {m.source_id!r}")
|
|
367
|
+
_check_enum(p, "environment", m.environment, ENVIRONMENTS)
|
|
368
|
+
# A non-production mirror may share its MotherDuck owner with production (the dev Featherbase
|
|
369
|
+
# CDC does, Siraj 23-Oct-2026), so the token alone does not keep it out of production bronze.
|
|
370
|
+
# Its database must carry its environment's name; the runtime also refuses any database a
|
|
371
|
+
# production registration owns (runner._refuse_wrong_environment).
|
|
372
|
+
if m.environment != PRODUCTION and not m.destination.database.endswith(f"_{m.environment}"):
|
|
373
|
+
err("environment_database",
|
|
374
|
+
f"a {m.environment!r} registration must write a database ending in "
|
|
375
|
+
f"'_{m.environment}', got {m.destination.database!r}")
|
|
376
|
+
|
|
377
|
+
for label, name in (("source.dsn_env", m.source.dsn_env), ("destination.token_env", m.destination.token_env)):
|
|
378
|
+
if not name:
|
|
379
|
+
err("env_ref_missing", f"{label} is required")
|
|
380
|
+
elif _looks_like_a_value(name):
|
|
381
|
+
err("env_ref_is_a_value", f"{label} must be an environment-variable NAME, not a value")
|
|
382
|
+
elif not _ENV_NAME.match(name):
|
|
383
|
+
err("env_ref_name", f"{label}={name!r} is not a valid environment-variable name")
|
|
384
|
+
|
|
385
|
+
for label, name in (("replication.slot", m.replication.slot), ("replication.publication", m.replication.publication)):
|
|
386
|
+
if not name:
|
|
387
|
+
err("pg_identifier", f"{label} is required")
|
|
388
|
+
elif not _PG_IDENT.match(name) or len(name) > 63:
|
|
389
|
+
err("pg_identifier", f"{label}={name!r} is not a valid unquoted PostgreSQL identifier")
|
|
390
|
+
|
|
391
|
+
if not m.destination.database:
|
|
392
|
+
err("destination_database", "destination.database is required")
|
|
393
|
+
else:
|
|
394
|
+
why = database_name_problem(m.destination.database)
|
|
395
|
+
if why:
|
|
396
|
+
err("destination_database", why)
|
|
397
|
+
why = owner_problem(m.destination.owner)
|
|
398
|
+
if why:
|
|
399
|
+
err("destination_owner", why)
|
|
400
|
+
_check_enum(p, "destination.retirement_policy", m.destination.retirement_policy, ("freeze", "archive"))
|
|
401
|
+
|
|
402
|
+
sm = m.destination.schema_mapping
|
|
403
|
+
if sm != "preserve_source":
|
|
404
|
+
if not isinstance(sm, dict):
|
|
405
|
+
err("schema_mapping", "destination.schema_mapping must be 'preserve_source' or a mapping")
|
|
406
|
+
else:
|
|
407
|
+
seen: Dict[str, str] = {}
|
|
408
|
+
for src_schema, tgt in sorted(sm.items()):
|
|
409
|
+
if tgt in seen:
|
|
410
|
+
err(
|
|
411
|
+
"schema_mapping_collision",
|
|
412
|
+
f"schemas {seen[tgt]!r} and {src_schema!r} both map to target schema {tgt!r}",
|
|
413
|
+
)
|
|
414
|
+
seen[tgt] = src_schema
|
|
415
|
+
_check_target_schema(p, f"destination.schema_mapping[{src_schema}]", tgt)
|
|
416
|
+
for i, rule in enumerate(m.destination.table_schemas):
|
|
417
|
+
if not _QUALIFIED.match(rule.match):
|
|
418
|
+
err("table_schema_match", f"destination.table_schemas[{i}].match {rule.match!r} must be a 'schema.table' glob")
|
|
419
|
+
_check_target_schema(p, f"destination.table_schemas[{i}].schema", rule.schema)
|
|
420
|
+
if m.destination.table_schemas and m.destination.pipeline_name:
|
|
421
|
+
n_targets = len({r.schema for r in m.destination.table_schemas})
|
|
422
|
+
if n_targets > 1:
|
|
423
|
+
err(
|
|
424
|
+
"pipeline_name_ambiguous",
|
|
425
|
+
"destination.pipeline_name pins ONE dlt pipeline, but table_schemas name "
|
|
426
|
+
f"{n_targets} target schemas — one pipeline cannot load several datasets",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
_check_enum(p, "replication.tables_without_key", m.replication.tables_without_key, ("reject", "append_only"))
|
|
430
|
+
_check_enum(p, "replication.replica_identity", m.replication.replica_identity, ("full", "default"))
|
|
431
|
+
_check_enum(p, "replication.slot_loss_policy", m.replication.slot_loss_policy, ("resnapshot", "pause"))
|
|
432
|
+
if m.replication.cadence_minutes <= 0:
|
|
433
|
+
err("cadence", "replication.cadence_minutes must be positive")
|
|
434
|
+
if m.replication.target_batch_size <= 0:
|
|
435
|
+
err("batch_size", "replication.target_batch_size must be positive")
|
|
436
|
+
for rel, policy in sorted(m.replication.key_overrides.items()):
|
|
437
|
+
if not _QUALIFIED.match(rel):
|
|
438
|
+
err("key_override_name", f"key_overrides key {rel!r} must be 'schema.table'")
|
|
439
|
+
if policy == "full_row":
|
|
440
|
+
err(
|
|
441
|
+
"full_row_unsupported",
|
|
442
|
+
f"replication.key_overrides[{rel}]=full_row cannot mirror an UPDATE: pgoutput emits "
|
|
443
|
+
"only the new tuple, so an all-column merge key never matches the old row and both "
|
|
444
|
+
"versions stay — declare append_only for a history, or give the table a key",
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
_check_enum(p, f"replication.key_overrides[{rel}]", policy, ("append_only",))
|
|
448
|
+
|
|
449
|
+
for label, sel in (("include", m.source.include), ("exclude", m.source.exclude), ("deny", m.source.deny)):
|
|
450
|
+
for t in sel.tables:
|
|
451
|
+
if not _QUALIFIED.match(t):
|
|
452
|
+
err("pattern_qualified", f"source.{label}.tables entry {t!r} must be 'schema.table'")
|
|
453
|
+
|
|
454
|
+
for rel, cols in sorted(m.source.deny_columns.items()):
|
|
455
|
+
if not _QUALIFIED.match(rel) or any(ch in rel for ch in "*?[]"):
|
|
456
|
+
err("deny_column_key", f"source.deny.columns key {rel!r} must be an exact 'schema.table' (no globs)")
|
|
457
|
+
if not cols:
|
|
458
|
+
err("deny_column_name", f"source.deny.columns[{rel}] is empty — name the column(s) to withhold")
|
|
459
|
+
for col in cols:
|
|
460
|
+
if not _PG_IDENT.match(col) or len(col) > 63:
|
|
461
|
+
err("deny_column_name",
|
|
462
|
+
f"source.deny.columns[{rel}] entry {col!r} is not a valid unquoted column name")
|
|
463
|
+
|
|
464
|
+
if m.source.discovery is not None and not m.source.discovery.adapter:
|
|
465
|
+
err("adapter", "source.discovery.adapter is required when a discovery block is present")
|
|
466
|
+
|
|
467
|
+
if not m.control.extractor:
|
|
468
|
+
err("extractor", "control.extractor is required")
|
|
469
|
+
if m.control.availability_policy != "pause_if_control_unreachable":
|
|
470
|
+
err(
|
|
471
|
+
"availability_policy",
|
|
472
|
+
f"availability_policy {m.control.availability_policy!r} is not implemented in this runtime",
|
|
473
|
+
)
|
|
474
|
+
_check_enum(
|
|
475
|
+
p, "control.event_durability", m.control.event_durability, ("direct_postgres", "persistent_local_wal")
|
|
476
|
+
)
|
|
477
|
+
for pat in m.control.reconcile_exclude:
|
|
478
|
+
if not _QUALIFIED.match(pat):
|
|
479
|
+
err("reconcile_exclude_pattern",
|
|
480
|
+
f"control.reconcile_exclude entry {pat!r} must be a 'schema.table' glob")
|
|
481
|
+
if m.control.event_durability == "persistent_local_wal":
|
|
482
|
+
if not m.control.wal_dir:
|
|
483
|
+
err("wal_dir_missing", "event_durability=persistent_local_wal requires control.wal_dir")
|
|
484
|
+
elif not _on_a_mount(m.control.wal_dir):
|
|
485
|
+
err(
|
|
486
|
+
"wal_dir_not_persistent",
|
|
487
|
+
f"control.wal_dir={m.control.wal_dir!r} is not on a mount point — a local WAL on "
|
|
488
|
+
"ephemeral container storage is not cross-run crash recovery (D9)",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
for rule in m.classification.rules:
|
|
492
|
+
if not _QUALIFIED.match(rule.match):
|
|
493
|
+
err("rule_pattern", f"classification rule match {rule.match!r} must be 'schema.table'")
|
|
494
|
+
if rule.table_class not in TABLE_CLASSES:
|
|
495
|
+
err("rule_class", f"unknown table_class {rule.table_class!r} (known: {', '.join(TABLE_CLASSES)})")
|
|
496
|
+
|
|
497
|
+
if m.replication.replica_identity == "full":
|
|
498
|
+
warn(
|
|
499
|
+
"replica_identity_full_cost",
|
|
500
|
+
"replica_identity: full writes the whole old row into WAL on every UPDATE/DELETE and "
|
|
501
|
+
"takes a brief ACCESS EXCLUSIVE lock per ALTER — `plan` lists the ALTERs before any run",
|
|
502
|
+
)
|
|
503
|
+
return redact_problems(p)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
RESERVED_TARGET_SCHEMAS = ("_cdc_runtime",)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _check_target_schema(p: List[Problem], label: str, name: str) -> None:
|
|
510
|
+
"""A target schema is a dlt dataset name AND a MotherDuck schema: an unquoted identifier,
|
|
511
|
+
and never the runtime's own metadata schema."""
|
|
512
|
+
if not _PG_IDENT.match(name or "") or len(name) > 63:
|
|
513
|
+
p.append(Problem("target_schema_identifier", "error",
|
|
514
|
+
f"{label}={name!r} is not a valid unquoted schema identifier"))
|
|
515
|
+
elif name in RESERVED_TARGET_SCHEMAS or name.endswith("_staging"):
|
|
516
|
+
p.append(Problem("target_schema_reserved", "error",
|
|
517
|
+
f"{label}={name!r} is reserved (runtime metadata / dlt merge staging)"))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _check_enum(p: List[Problem], label: str, value: str, allowed: tuple) -> None:
|
|
521
|
+
if value not in allowed:
|
|
522
|
+
p.append(Problem("enum", "error", f"{label}={value!r} must be one of {', '.join(allowed)}"))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _on_a_mount(path: str) -> bool:
|
|
526
|
+
"""True when `path` (or an existing ancestor other than '/') is a mount point. A declared
|
|
527
|
+
volume that resolves to the container root filesystem is exactly the case D9 rejects."""
|
|
528
|
+
cur = os.path.abspath(path)
|
|
529
|
+
while True:
|
|
530
|
+
if os.path.ismount(cur):
|
|
531
|
+
return cur != os.sep
|
|
532
|
+
parent = os.path.dirname(cur)
|
|
533
|
+
if parent == cur:
|
|
534
|
+
return False
|
|
535
|
+
cur = parent
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def environment_mismatch(m: Manifest, environ: Optional[Dict[str, str]] = None) -> Optional[str]:
|
|
539
|
+
"""Why this registration must not run here, or None (#4340).
|
|
540
|
+
|
|
541
|
+
Railway names every deployment's environment in RAILWAY_ENVIRONMENT_NAME. When it is set and
|
|
542
|
+
is not the registration's `environment`, the worker is in the wrong place — typically a dev
|
|
543
|
+
instance whose settings were copied from production. Unset (a laptop, a test) means no check.
|
|
544
|
+
"""
|
|
545
|
+
env = os.environ if environ is None else environ
|
|
546
|
+
here = (env.get(RAILWAY_ENVIRONMENT_VAR) or "").strip()
|
|
547
|
+
if here and here != m.environment:
|
|
548
|
+
return (f"registration {m.source_id!r} is for environment {m.environment!r} but this "
|
|
549
|
+
f"worker runs in Railway environment {here!r} — refusing to touch any database")
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def database_name_problem(database: str) -> Optional[str]:
|
|
554
|
+
"""Why `database` is not a bare identifier, or None (#4340).
|
|
555
|
+
|
|
556
|
+
The destination connects to `md:<database>`, which also accepts connection parameters and
|
|
557
|
+
other syntax, so `bronze_featherbase?session_hint=x_dev` would pass a suffix check and still
|
|
558
|
+
open bronze_featherbase. Only a full-string lowercase identifier is a database name here.
|
|
559
|
+
"""
|
|
560
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", database or "") or len(database) > 63:
|
|
561
|
+
return (f"destination.database must be a bare identifier ([a-z][a-z0-9_]*), "
|
|
562
|
+
f"got {database!r}")
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def owner_problem(owner: str) -> Optional[str]:
|
|
567
|
+
"""Why `owner` is not a usable MotherDuck account name, or None (#4427)."""
|
|
568
|
+
if not owner:
|
|
569
|
+
return ("destination.owner is required: the MotherDuck account the token must sign in "
|
|
570
|
+
"as, as recorded in your warehouse's layer access registry")
|
|
571
|
+
if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.@+-]*", owner) or len(owner) > 255:
|
|
572
|
+
return f"destination.owner must be a MotherDuck account name, got {owner!r}"
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def production_databases(registrations_dir: str) -> Dict[str, str]:
|
|
577
|
+
"""{destination database: source_id} for every production registration in the directory."""
|
|
578
|
+
out: Dict[str, str] = {}
|
|
579
|
+
for path in sorted(pathlib.Path(registrations_dir).glob("*.yml")):
|
|
580
|
+
other = load(str(path))
|
|
581
|
+
if other.environment == PRODUCTION:
|
|
582
|
+
out[other.destination.database] = other.source_id
|
|
583
|
+
return out
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def errors(problems: List[Problem]) -> List[Problem]:
|
|
587
|
+
return [x for x in problems if x.severity == "error"]
|