feather-control-plane 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,69 @@
1
+ """control_plane — durable failure observability for an extractor fleet.
2
+
3
+ A WAL for crash durability, a Postgres control plane for run lifecycle / events / budget
4
+ telemetry, and a lease for deterministic death detection. The data plane lives elsewhere.
5
+
6
+ Usage:
7
+ from control_plane import ControlPlane, status
8
+ cp = ControlPlane("gofrugal", dsn=PG_DSN, wal_dir="control_wal")
9
+ with cp.run("nightly", run_id=run_id) as run:
10
+ run.event(entity="sales_item_wise", unit="1001", business_date=d,
11
+ status=status.EV_OK, http_status=200, rows=42,
12
+ source_attempts=1, sink_attempts=1, pull_ms=1200, load_ms=800)
13
+ run.budget(status.BUDGET_WALLTIME, limit=1800, used=1800, remaining=0, hit=True)
14
+ run.finish(status.RUN_PARTIAL, stop_reason=status.STOP_BUDGET_TIME,
15
+ n_ok=47, n_err=1, n_skipped=0)
16
+
17
+ The facade above is resolved LAZILY (PEP 562), and that is load-bearing rather than tidy.
18
+ `control_plane.business_day` and `control_plane.status` are pure stdlib, and consumers want them
19
+ on their own — a cron tick asking what the business day is has no database in the picture. A
20
+ plain `from .run import ControlPlane` at the top of this file runs on `import
21
+ control_plane.business_day` too, because a submodule import executes its package first, so every
22
+ such consumer was forced to have psycopg installed. That tax is why the same `business_day.py`
23
+ ended up hand-copied into scripts that only needed `ist_today()`.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ __all__ = ["ControlPlane", "ControlPlaneError", "RunContext", "new_run_id", "status"]
30
+
31
+ # Name -> the submodule that defines it. `status` is absent deliberately: `from control_plane
32
+ # import status` is a submodule import, which Python's import machinery already resolves on its
33
+ # own without help from here.
34
+ _LAZY = {
35
+ "ControlPlane": ".run",
36
+ "ControlPlaneError": ".run",
37
+ "RunContext": ".run",
38
+ "new_run_id": ".run",
39
+ }
40
+
41
+ if TYPE_CHECKING: # the lazy names, visible to type checkers and IDEs
42
+ from . import status as status
43
+ from .run import ControlPlane as ControlPlane
44
+ from .run import ControlPlaneError as ControlPlaneError
45
+ from .run import RunContext as RunContext
46
+ from .run import new_run_id as new_run_id
47
+
48
+
49
+ def __getattr__(name: str):
50
+ module = _LAZY.get(name)
51
+ if module is None:
52
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
53
+ import importlib
54
+
55
+ try:
56
+ return getattr(importlib.import_module(module, __name__), name)
57
+ except ModuleNotFoundError as exc:
58
+ if exc.name != "psycopg":
59
+ raise
60
+ raise ModuleNotFoundError(
61
+ f"control_plane.{name} talks to Postgres and needs the psycopg driver, which is an "
62
+ "optional dependency: install `feather-control-plane[postgres]`. The stdlib-only half "
63
+ "(control_plane.business_day, control_plane.status) needs no extra.",
64
+ name="psycopg",
65
+ ) from exc
66
+
67
+
68
+ def __dir__() -> list[str]:
69
+ return sorted(__all__)
@@ -0,0 +1,54 @@
1
+ """The warehouse's business day, in IST (#3854).
2
+
3
+ Two clocks, and confusing them is the bug this module exists to prevent:
4
+
5
+ * a **business day** — a load window, a grid key, an audit window, a report "as of" —
6
+ is a partition of the *business*, and the business runs on IST;
7
+ * an **instant** — `_loaded_at`, a run id, a log stamp, an elapsed/deadline comparison,
8
+ a cron hour compared against `REPAIR_UTC_HOURS` — is UTC (ADR 0027).
9
+
10
+ This module is the first half only. Never reach for it to stamp an instant.
11
+
12
+ Import it rather than writing `ZoneInfo("Asia/Kolkata")` inline: this docstring is the one
13
+ place the reason is recorded, and a literal carries no reason with it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime as dt
18
+ from zoneinfo import ZoneInfo
19
+
20
+ IST = ZoneInfo("Asia/Kolkata")
21
+
22
+
23
+ def ist_now() -> dt.datetime:
24
+ """The current instant as an IST-aware datetime — for the callers that need the HOUR.
25
+
26
+ A tick that asks "is it past 22:00 IST?" needs a datetime, not a date, and building
27
+ `now_utc().astimezone(ZoneInfo(...))` inline is how a module ends up with two spellings of IST
28
+ and no rule about which to use. Use this, then `.hour` / `.date()` off it.
29
+
30
+ This is still a *business* clock, not an instant to store: stamp `_loaded_at` with
31
+ `datetime.now(timezone.utc)` (ADR 0027), never with this.
32
+ """
33
+ return dt.datetime.now(IST)
34
+
35
+
36
+ def ist_today() -> dt.date:
37
+ """The calendar date it is *now in India* — the warehouse's business day.
38
+
39
+ Never `date.today()`. No service sets `TZ`, so every container runs UTC, and between
40
+ 00:00 and 05:30 IST `date.today()` returns the PREVIOUS business day. Every window
41
+ derived from it then shifts a day, and the run still reports success: a GoFrugal
42
+ nightly started at 04:48 IST on 20-Sep-2026 (23:18 UTC on the 19th) pulled 18/17/16-Sep
43
+ and lost 19-Sep entirely, with a clean `control.run` row and an all-`ok` grid (#3854).
44
+ """
45
+ return ist_now().date()
46
+
47
+
48
+ def ist_yesterday() -> dt.date:
49
+ """The business day before `ist_today()` — the usual edge / nightly anchor.
50
+
51
+ Plain calendar arithmetic, deliberately: the group's stores trade seven days a week, so
52
+ there is no trading-day calendar to skip weekends or holidays against.
53
+ """
54
+ return ist_today() - dt.timedelta(days=1)
@@ -0,0 +1,33 @@
1
+ """Startup recovery: replay un-shipped WAL tails of prior runs (issue #204).
2
+
3
+ ARIES-simplified. Before a run does any work it replays the WAL tails left by *prior* runs
4
+ of this extractor on this host — pushing their last-logged events (incl. a dead run's cause)
5
+ into Postgres. Reconciling those runs to `died` is done by the caller, *after* it acquires
6
+ the lease (holding the lease ⇒ no prior run is alive ⇒ unfinished runs are dead), so death
7
+ detection stays deterministic — never a runtime guess.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import glob
12
+ import os
13
+
14
+ import psycopg
15
+
16
+ from . import shipper
17
+
18
+
19
+ def replay_prior(conn: psycopg.Connection, extractor: str, wal_dir: str, this_run_id: str) -> int:
20
+ """Ship the un-shipped tail of every prior run's WAL for this extractor. Returns total
21
+ records shipped. Best-effort per file: one unreadable WAL never blocks the others."""
22
+ shipped = 0
23
+ pattern = os.path.join(wal_dir, extractor, "*.jsonl")
24
+ for path in sorted(glob.glob(pattern)):
25
+ run_id = os.path.basename(path)[: -len(".jsonl")]
26
+ if run_id == this_run_id: # @spec prior_wal_replayed_before_lease
27
+ continue
28
+ try:
29
+ shipped += shipper.ship(conn, path)
30
+ except Exception as exc: # noqa: BLE001 — one bad WAL must not abort recovery
31
+ print(f"[control_plane] WAL replay failed for {path}: "
32
+ f"{type(exc).__name__}: {str(exc)[:160]}", flush=True)
33
+ return shipped
control_plane/run.py ADDED
@@ -0,0 +1,237 @@
1
+ """The consumer-facing facade (issue #204).
2
+
3
+ An extractor wraps its work in `ControlPlane(...).run(mode) as run:` and calls `run.event(...)`
4
+ per unit, `run.budget(...)` for quota telemetry, and `run.finish(...)` with final counts. The
5
+ facade handles WAL durability, ship-as-you-go to Postgres, startup recovery, the lease, and a
6
+ background renewer thread.
7
+
8
+ Postgres is a **hard dependency** (ADR 0028): if a DSN is configured and unreachable at startup,
9
+ the run **aborts** — no blind runs (a silent load with no provenance is worse than no load). The
10
+ WAL is a local **crash redo-log** *under* the Postgres writes, not an outage buffer: every event
11
+ hits the WAL first (fsync), then ships to PG; if the process dies, the next run replays the tail so
12
+ the death's breadcrumbs survive ("died after table X / chunk Y"), and the lease supplies the fact
13
+ of death. `dsn=None` is the explicit no-control-plane mode for dev/test (WAL-only).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime as _dt
18
+ import os
19
+ import secrets
20
+ import socket
21
+ import threading
22
+ from contextlib import contextmanager
23
+
24
+ import psycopg
25
+
26
+ from . import recovery, shipper, status, store
27
+ from .status import ERROR_DETAIL_CAP
28
+ from .wal import Wal
29
+
30
+
31
+ def _now() -> _dt.datetime:
32
+ return _dt.datetime.now(_dt.timezone.utc)
33
+
34
+
35
+ def new_run_id(extractor: str) -> str: # @spec run_id_unique_across_extractors
36
+ """`<UTC second>-<extractor>-<6 hex>`. `control.run` is keyed on run_id alone, and every Railway
37
+ container is pid 1, so the old `<second>-<pid>` gave two services starting in the same second
38
+ one id (#4614). The timestamp stays first: SAP names backups `run_id[:8]`."""
39
+ return f"{_now().strftime('%Y%m%dT%H%M%SZ')}-{extractor}-{secrets.token_hex(3)}"
40
+
41
+
42
+ class ControlPlaneError(RuntimeError):
43
+ pass
44
+
45
+
46
+ class _Renewer:
47
+ """Renews the lease on its OWN connection, decoupled from the work loop — so a 14h job or
48
+ a 90-min SAP chunk keeps proving liveness and is never falsely declared dead. Swallows
49
+ transient PG errors and retries on the next tick (reconnecting if needed)."""
50
+
51
+ def __init__(self, dsn: str, extractor: str, run_id: str, ttl_s: int, interval_s: int):
52
+ self._dsn = dsn
53
+ self._extractor = extractor
54
+ self._run_id = run_id
55
+ self._ttl = ttl_s
56
+ self._interval = interval_s
57
+ self._stop = threading.Event()
58
+ self._thread = threading.Thread(target=self._loop, name="cp-lease-renewer", daemon=True)
59
+ self._conn: psycopg.Connection | None = None
60
+
61
+ def start(self) -> None:
62
+ self._thread.start()
63
+
64
+ def stop(self) -> None:
65
+ self._stop.set()
66
+ self._thread.join(timeout=self._interval + 5)
67
+ if self._conn is not None:
68
+ try:
69
+ self._conn.close()
70
+ except Exception: # noqa: BLE001
71
+ pass
72
+
73
+ def _loop(self) -> None:
74
+ while not self._stop.wait(self._interval): # @spec lease_renewed_in_background
75
+ try:
76
+ if self._conn is None or self._conn.closed:
77
+ self._conn = psycopg.connect(self._dsn, connect_timeout=10)
78
+ if not store.lease_renew(self._conn, self._extractor, self._run_id, self._ttl):
79
+ # @spec lease_loss_does_not_stop_run
80
+ print(f"[control_plane] lease lost for {self._extractor}/{self._run_id} "
81
+ "(another run reclaimed it?)", flush=True)
82
+ except Exception as exc: # noqa: BLE001 — renew must never crash the run # @spec false_death_of_live_run
83
+ print(f"[control_plane] lease renew failed (will retry): "
84
+ f"{type(exc).__name__}: {str(exc)[:160]}", flush=True)
85
+ if self._conn is not None:
86
+ try:
87
+ self._conn.close()
88
+ except Exception: # noqa: BLE001
89
+ pass
90
+ self._conn = None
91
+
92
+
93
+ class RunContext:
94
+ def __init__(self, cp: "ControlPlane", run_id: str, wal: Wal, conn: psycopg.Connection | None):
95
+ self.cp = cp
96
+ self.run_id = run_id
97
+ self.wal = wal
98
+ self.conn = conn # None ⇒ PG was down at start; WAL-only degraded mode
99
+ self._seq = 0
100
+ self._finished = False
101
+
102
+ # --- internal ---
103
+ def _ship_now(self) -> None:
104
+ if self.conn is None:
105
+ return
106
+ try:
107
+ shipper.ship(self.conn, self.wal.path)
108
+ except Exception as exc: # noqa: BLE001 — WAL already holds it; recovery ships later # @spec ship_as_you_go
109
+ print(f"[control_plane] ship failed (buffered in WAL): "
110
+ f"{type(exc).__name__}: {str(exc)[:160]}", flush=True)
111
+
112
+ def _emit(self, record: dict) -> None:
113
+ self.wal.append(record) # durable first # @spec wal_before_network
114
+ self._ship_now() # then best-effort to PG
115
+
116
+ # --- public API ---
117
+ def event(self, *, entity: str, status: str, unit: str | None = None,
118
+ business_date=None, http_status: int | None = None, rows: int | None = None,
119
+ source_attempts: int | None = None, sink_attempts: int | None = None,
120
+ pull_ms: int | None = None, load_ms: int | None = None,
121
+ error_detail: str | None = None, extra: dict | None = None) -> None:
122
+ """Record one unit/cell outcome (one row in control.event). `status` here is the
123
+ event-status string (status.EV_*), not the module. `extra` is a small JSONB bag for
124
+ source-specific metrics that don't warrant a shared column (e.g. Zakya's n_requests /
125
+ gen_ms / rate_remaining); leave it None when there's nothing source-specific to keep."""
126
+ self._emit({
127
+ "kind": "event", "extractor": self.cp.extractor, "run_id": self.run_id,
128
+ "seq": self._seq, "ran_at": _now(), "entity": entity, "unit": unit,
129
+ "business_date": business_date, "status": status, "http_status": http_status,
130
+ "rows": rows, "source_attempts": source_attempts, "sink_attempts": sink_attempts,
131
+ "pull_ms": pull_ms, "load_ms": load_ms,
132
+ "error_detail": (error_detail[:ERROR_DETAIL_CAP] if error_detail else None), # @spec error_detail_capped
133
+ "extra": extra,
134
+ })
135
+ self._seq += 1 # @spec event_one_row_per_unit
136
+
137
+ def budget(self, name: str, *, limit: int | None, used: int | None,
138
+ remaining: int | None, hit: bool) -> None:
139
+ """Record quota utilisation (every run, not only on a hit) → headroom is queryable."""
140
+ self._emit({
141
+ "kind": "budget", "extractor": self.cp.extractor, "run_id": self.run_id,
142
+ "budget_name": name, "limit_val": limit, "used": used, "remaining": remaining,
143
+ "hit": hit, "ran_at": _now(),
144
+ })
145
+
146
+ def finish(self, run_status: str, *, stop_reason: str, n_ok: int | None = None,
147
+ n_err: int | None = None, n_skipped: int | None = None) -> None:
148
+ """Close the run with its final disposition. Idempotent (first call wins)."""
149
+ if self._finished: # @spec run_finish_first_call_wins
150
+ return
151
+ self._emit({
152
+ "kind": "run_finish", "extractor": self.cp.extractor, "run_id": self.run_id,
153
+ "started_at": self.cp._started_at, "finished_at": _now(),
154
+ "status": run_status, "stop_reason": stop_reason,
155
+ "n_ok": n_ok, "n_err": n_err, "n_skipped": n_skipped,
156
+ })
157
+ self._finished = True
158
+
159
+
160
+ class ControlPlane:
161
+ def __init__(self, extractor: str, dsn: str | None, wal_dir: str, *,
162
+ lease_ttl_s: int = 300, renew_interval_s: int = 60, host: str | None = None):
163
+ self.extractor = extractor
164
+ self.dsn = dsn
165
+ self.wal_dir = wal_dir
166
+ self.lease_ttl_s = lease_ttl_s
167
+ self.renew_interval_s = renew_interval_s
168
+ self.host = host or socket.gethostname()
169
+ self._started_at: str | None = None
170
+
171
+ @contextmanager
172
+ def run(self, mode: str, *, run_id: str | None = None):
173
+ run_id = run_id or new_run_id(self.extractor)
174
+ self._started_at = _now().isoformat()
175
+ wal = Wal(os.path.join(self.wal_dir, self.extractor, run_id + ".jsonl"))
176
+ conn: psycopg.Connection | None = None
177
+ renewer: _Renewer | None = None
178
+
179
+ # Postgres is a HARD dependency (ADR 0028): if a DSN is configured, the control plane
180
+ # MUST be reachable or the run aborts — never run blind. A failure here propagates out of
181
+ # the `with` before any work begins. (dsn=None is the explicit no-control-plane mode for
182
+ # dev/test → WAL-only.)
183
+ if self.dsn: # @spec hard_startup_dependency
184
+ conn = psycopg.connect(self.dsn, connect_timeout=10)
185
+ try:
186
+ store.ensure_schema(conn)
187
+ recovery.replay_prior(conn, self.extractor, self.wal_dir, run_id)
188
+ # @spec lease_refuses_second_writer
189
+ if not store.lease_acquire(conn, self.extractor, run_id, self.lease_ttl_s):
190
+ raise ControlPlaneError(
191
+ f"another live run holds the lease for {self.extractor!r} "
192
+ "(fresh lease present) — refusing to start a second writer")
193
+ store.reconcile_dead(conn, self.extractor, run_id) # @spec death_visible_only_at_next_start
194
+ renewer = _Renewer(self.dsn, self.extractor, run_id,
195
+ self.lease_ttl_s, self.renew_interval_s)
196
+ renewer.start()
197
+ except BaseException:
198
+ # setup failed — clean up and abort; never degrade to a blind run
199
+ if renewer is not None:
200
+ renewer.stop()
201
+ try:
202
+ conn.close()
203
+ except Exception: # noqa: BLE001
204
+ pass
205
+ raise
206
+
207
+ rc = RunContext(self, run_id, wal, conn)
208
+ rc._emit({ # @spec run_start_recorded_before_work
209
+ "kind": "run_start", "run_id": run_id, "extractor": self.extractor,
210
+ "mode": mode, "host": self.host, "pid": os.getpid(),
211
+ "started_at": self._started_at, "status": status.RUN_RUNNING,
212
+ "wal_path": wal.path,
213
+ })
214
+
215
+ try:
216
+ yield rc
217
+ except BaseException:
218
+ if not rc._finished:
219
+ rc.finish(status.RUN_FAILED, stop_reason=status.STOP_ERROR) # @spec implicit_finish_on_exit.sys_exit_inside_block
220
+ raise
221
+ finally:
222
+ if not rc._finished:
223
+ rc.finish(status.RUN_OK, stop_reason=status.STOP_CLEAN) # @spec implicit_finish_on_exit
224
+ if renewer is not None:
225
+ renewer.stop()
226
+ if conn is not None:
227
+ try:
228
+ store.lease_release(conn, self.extractor, run_id) # @spec lease_released_on_exit
229
+ shipper.ship(conn, wal.path) # final flush # @spec final_flush_on_exit
230
+ except Exception as exc: # noqa: BLE001
231
+ print(f"[control_plane] final flush/release failed (WAL retained): "
232
+ f"{type(exc).__name__}: {str(exc)[:160]}", flush=True)
233
+ finally:
234
+ try:
235
+ conn.close()
236
+ except Exception: # noqa: BLE001
237
+ pass
@@ -0,0 +1,56 @@
1
+ """WAL → Postgres projection (issue #204).
2
+
3
+ Tails a WAL file from a byte offset (sidecar `<wal>.off`) and applies each complete record
4
+ to Postgres in one transaction, then advances the offset. At-least-once + idempotent upserts
5
+ (store.apply) = effectively-once, with no distributed transaction. Re-shipping after a lost
6
+ offset is harmless (ON CONFLICT), so the offset is an optimisation, not a correctness anchor.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import psycopg
13
+
14
+ from . import store, wal
15
+
16
+
17
+ def _off_path(wal_path: str) -> str:
18
+ return wal_path + ".off"
19
+
20
+
21
+ def _read_off(wal_path: str) -> int:
22
+ p = _off_path(wal_path)
23
+ try:
24
+ with open(p, encoding="utf-8") as f:
25
+ return int(f.read().strip() or 0)
26
+ except (FileNotFoundError, ValueError):
27
+ return 0
28
+
29
+
30
+ def _write_off(wal_path: str, offset: int) -> None:
31
+ p = _off_path(wal_path)
32
+ tmp = p + ".tmp"
33
+ with open(tmp, "w", encoding="utf-8") as f:
34
+ f.write(str(offset))
35
+ os.replace(tmp, p) # atomic
36
+
37
+
38
+ def ship(conn: psycopg.Connection, wal_path: str) -> int:
39
+ """Ship the un-shipped tail of `wal_path` to Postgres. Returns records shipped.
40
+
41
+ One transaction for the whole tail: if any apply fails we roll back and leave the offset
42
+ untouched, so the next attempt re-ships the same tail cleanly (idempotent)."""
43
+ start = _read_off(wal_path)
44
+ records, new_off = wal.read_new(wal_path, start)
45
+ if not records:
46
+ return 0
47
+ try:
48
+ with conn.cursor() as cur:
49
+ for rec in records:
50
+ store.apply(cur, rec)
51
+ conn.commit()
52
+ except Exception:
53
+ conn.rollback()
54
+ raise
55
+ _write_off(wal_path, new_off) # @spec whole_tail_one_transaction
56
+ return len(records)
@@ -0,0 +1,60 @@
1
+ """Control-plane status vocabulary (issue #204).
2
+
3
+ One place for the words written to `control.run.status`, `control.run.stop_reason`,
4
+ `control.event.status`, and `control.budget_usage.budget_name`, so every extractor speaks
5
+ the same language and the #206 cross-source health query never special-cases per source.
6
+
7
+ Vocabulary is a **blend** (locked 2026-06-24): API extractors (gofrugal, zakya) split the
8
+ source leg from the sink leg (`source_error` vs `sink_error`); DB extractors (sap, ptp,
9
+ stylehr) use the single fused `error` (read+load happen in one retry). The `run` lifecycle
10
+ states and budget `stop_reason` are standard across all five.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ # --- control.run.status (run lifecycle) ---
15
+ RUN_RUNNING = "running" # inserted at start; live
16
+ RUN_OK = "ok" # finished, every unit succeeded
17
+ RUN_PARTIAL = "partial" # finished, some units errored (or budget cut it short)
18
+ RUN_FAILED = "failed" # finished by an unhandled exception
19
+ RUN_DIED = "died" # never finished itself; reconciled by a later run (lease lapsed)
20
+
21
+ RUN_STATES = frozenset({RUN_RUNNING, RUN_OK, RUN_PARTIAL, RUN_FAILED, RUN_DIED}) # @spec run_vocabulary_unvalidated
22
+
23
+ # --- control.run.stop_reason (why the run ended) ---
24
+ STOP_CLEAN = "clean" # ran to natural completion
25
+ STOP_BUDGET_API = "budget_api" # hit a per-day API quota
26
+ STOP_BUDGET_EXPORT = "budget_export" # hit a per-hour export quota
27
+ STOP_BUDGET_TIME = "budget_time" # walltime budget cut it short (still healthy)
28
+ STOP_TOKEN_REFRESH_FAILED = "token_refresh_failed" # no usable OAuth token: the token endpoint
29
+ # refused or is cooling down, or a forced refresh was withheld.
30
+ # NOT a quota stop — budget_usage hit stays false (#4245)
31
+ STOP_TOKEN_REJECTED = "token_rejected" # Zoho kept answering 401 to freshly minted tokens: a
32
+ # scope / org / grant problem, not the token endpoint (#4245)
33
+ STOP_ERROR = "error" # unhandled exception
34
+ STOP_DIED = "died" # set by the reconciler on a dead run
35
+
36
+ STOP_REASONS = frozenset({
37
+ STOP_CLEAN, STOP_BUDGET_API, STOP_BUDGET_EXPORT, STOP_BUDGET_TIME, STOP_TOKEN_REFRESH_FAILED,
38
+ STOP_TOKEN_REJECTED, STOP_ERROR, STOP_DIED,
39
+ })
40
+
41
+ # --- control.event.status (per unit/cell outcome) ---
42
+ EV_OK = "ok" # landed rows # @spec event_vocabulary_unvalidated
43
+ EV_ZERO_ROWS = "zero_rows" # source reachable, genuinely empty (http 200)
44
+ EV_SOURCE_ERROR = "source_error" # source returned non-200 / bad payload (API extractors)
45
+ EV_TIMEOUT = "timeout" # source did not respond at all (http=0, all attempts threw)
46
+ EV_SINK_ERROR = "sink_error" # the load/sink (MotherDuck) failed after retries (API extractors)
47
+ EV_RATE_LIMITED = "rate_limited" # source 429 (Zakya)
48
+ EV_OK_PARTIAL = "ok_partial" # landed, but some records quarantined (Zakya, ADR 0021)
49
+ EV_SOURCE_NOT_SETTLED = "source_not_settled" # source answered, but as-of a moment before the
50
+ # business date closed, so the answer cannot be complete —
51
+ # NOT terminal, retry once the source has caught up (#2957)
52
+ EV_ERROR = "error" # DB extractors: fused read-or-load failure (single leg)
53
+
54
+ # --- control.budget_usage.budget_name ---
55
+ BUDGET_WALLTIME = "walltime" # per-run wall-clock budget (seconds)
56
+ BUDGET_API_DAILY = "api_daily" # Zakya 25k calls/day
57
+ BUDGET_EXPORT_HOURLY = "export_hourly" # Zakya 10 exports/hour
58
+
59
+ # error_detail is truncated to this (matches #108).
60
+ ERROR_DETAIL_CAP = 2000
control_plane/store.py ADDED
@@ -0,0 +1,466 @@
1
+ """Postgres control plane: schema, idempotent upserts, the liveness lease (issue #204).
2
+
3
+ This is the *control* plane — run lifecycle, the event log, budget telemetry, and the lease.
4
+ The *data* plane stays in MotherDuck. Control is a transactional/concurrent/locking workload
5
+ (the opposite of columnar analytics), and you cannot durably log a MotherDuck failure into
6
+ MotherDuck — hence a separate Postgres (supersedes ADR 0005 + #65).
7
+
8
+ All writes are idempotent so the WAL shipper can replay at-least-once and converge:
9
+ - `event` keyed `(extractor, run_id, seq)` → ON CONFLICT DO NOTHING
10
+ - `run` keyed `run_id` → start DO NOTHING, finish is an UPDATE
11
+ - `budget_usage` keyed `(run_id, extractor, budget_name)` → ON CONFLICT DO UPDATE
12
+
13
+ `ran_at` is the canonical run-health timestamp (ADR 0027). `limit_val` avoids the reserved
14
+ word `limit`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import psycopg
19
+ from psycopg.types.json import Json
20
+
21
+ from . import status
22
+
23
+ SCHEMA_DDL = """
24
+ CREATE SCHEMA IF NOT EXISTS control;
25
+
26
+ CREATE TABLE IF NOT EXISTS control.run (
27
+ run_id TEXT PRIMARY KEY,
28
+ extractor TEXT NOT NULL,
29
+ mode TEXT,
30
+ host TEXT,
31
+ pid INTEGER,
32
+ started_at TIMESTAMPTZ NOT NULL,
33
+ finished_at TIMESTAMPTZ,
34
+ status TEXT NOT NULL,
35
+ stop_reason TEXT,
36
+ n_ok INTEGER,
37
+ n_err INTEGER,
38
+ n_skipped INTEGER,
39
+ reconciled_by TEXT,
40
+ wal_path TEXT,
41
+ extra JSONB
42
+ );
43
+ CREATE INDEX IF NOT EXISTS run_extractor_unfinished
44
+ ON control.run (extractor) WHERE finished_at IS NULL;
45
+
46
+ CREATE TABLE IF NOT EXISTS control.lease (
47
+ extractor TEXT PRIMARY KEY,
48
+ run_id TEXT NOT NULL,
49
+ lease_expires_at TIMESTAMPTZ NOT NULL
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS control.event (
53
+ extractor TEXT NOT NULL,
54
+ run_id TEXT NOT NULL,
55
+ seq BIGINT NOT NULL,
56
+ ran_at TIMESTAMPTZ NOT NULL,
57
+ entity TEXT,
58
+ unit TEXT,
59
+ business_date DATE,
60
+ status TEXT NOT NULL,
61
+ http_status INTEGER,
62
+ rows BIGINT,
63
+ source_attempts INTEGER,
64
+ sink_attempts INTEGER,
65
+ pull_ms INTEGER,
66
+ load_ms INTEGER,
67
+ error_detail TEXT,
68
+ extra JSONB,
69
+ PRIMARY KEY (extractor, run_id, seq)
70
+ );
71
+ CREATE INDEX IF NOT EXISTS event_run ON control.event (run_id);
72
+
73
+ CREATE TABLE IF NOT EXISTS control.budget_usage (
74
+ run_id TEXT NOT NULL,
75
+ extractor TEXT NOT NULL,
76
+ budget_name TEXT NOT NULL,
77
+ limit_val BIGINT,
78
+ used BIGINT,
79
+ remaining BIGINT,
80
+ hit BOOLEAN,
81
+ ran_at TIMESTAMPTZ,
82
+ PRIMARY KEY (run_id, extractor, budget_name)
83
+ );
84
+ """
85
+
86
+
87
+ # Advisory-lock key serializing the DDL path across processes (#3091). An arbitrary but FIXED
88
+ # 64-bit constant: every writer must pick the same number or the lock serializes nothing. Recorded
89
+ # here so a future advisory lock in this repo picks a different one.
90
+ _SCHEMA_ADVISORY_LOCK = 3_204_198_401
91
+
92
+ # The shape ensure_schema guarantees. Checked as a READ before any DDL runs, because DDL on this
93
+ # path is what deadlocked the fleet: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` takes an
94
+ # AccessExclusiveLock *even when the column already exists*, so running it on every startup gave
95
+ # every pair of concurrently-starting writers a chance to deadlock (#3091).
96
+ _REQUIRED_TABLES = ("run", "lease", "event", "budget_usage")
97
+ _REQUIRED_COLUMNS = (("event", "extra"), ("run", "extra"))
98
+
99
+ # The dbt-ready notification (#3884). dbt-runner's worker LISTENs on this channel and runs a tick
100
+ # the moment Zakya's coherent-batch marker is recorded, instead of waiting for its next slot.
101
+ # AFTER INSERT ... FOR EACH ROW fires only for a row actually inserted, so a WAL replay that hits
102
+ # `ON CONFLICT DO NOTHING` notifies nobody; a duplicate would be harmless anyway (the tick is
103
+ # idempotent). NOTIFY is delivered at COMMIT, and the shipper commits each shipped tail at once
104
+ # (ship-as-you-go), so the listener hears it seconds after the marker lands. Not durable: a
105
+ # listener that is down misses it, which is why the worker also ticks on its registry slots.
106
+ # To CHANGE the function body or filter, rename the trigger (the presence check below is by name).
107
+ DBT_READY_CHANNEL = "dbt_ready" # @spec ready_event_notify
108
+ DBT_READY_TRIGGER = "event_dbt_ready_notify"
109
+ DBT_READY_SOURCE = ("zakya", "transform_ready", status.EV_OK) # extractor, entity, status
110
+ DBT_READY_DDL = f"""
111
+ CREATE OR REPLACE FUNCTION control.notify_dbt_ready() RETURNS trigger
112
+ LANGUAGE plpgsql AS $fn$
113
+ BEGIN
114
+ PERFORM pg_notify('{DBT_READY_CHANNEL}', json_build_object(
115
+ 'extractor', NEW.extractor, 'run_id', NEW.run_id,
116
+ 'business_date', NEW.business_date)::text);
117
+ RETURN NULL;
118
+ END
119
+ $fn$;
120
+ DO $do$
121
+ BEGIN
122
+ IF NOT EXISTS (
123
+ SELECT 1 FROM pg_trigger t
124
+ JOIN pg_class c ON c.oid = t.tgrelid
125
+ JOIN pg_namespace n ON n.oid = c.relnamespace
126
+ WHERE n.nspname = 'control' AND c.relname = 'event' AND t.tgname = '{DBT_READY_TRIGGER}'
127
+ ) THEN
128
+ CREATE TRIGGER {DBT_READY_TRIGGER}
129
+ AFTER INSERT ON control.event FOR EACH ROW
130
+ WHEN (NEW.extractor = '{DBT_READY_SOURCE[0]}' AND NEW.entity = '{DBT_READY_SOURCE[1]}'
131
+ AND NEW.status = '{DBT_READY_SOURCE[2]}')
132
+ EXECUTE FUNCTION control.notify_dbt_ready();
133
+ END IF;
134
+ END
135
+ $do$;
136
+ """
137
+
138
+ # The run-finish notification (#4024), for every OTHER source. GoFrugal and SAP have no marker
139
+ # event; their proof of a complete load is their own `control.run` row finishing `ok`, which the
140
+ # worker used to find only at its 15-minute check. This trigger fires on the TRANSITION into
141
+ # (status ok, finished_at set) -- on the INSERT a `run_finish` makes when no `run_start` landed, or
142
+ # on the UPDATE from running/died -- and never again for that row: a WAL replay of `run_finish` is
143
+ # `ON CONFLICT DO UPDATE` on a row already `ok`, so the body drops it. Every extractor except `dbt`
144
+ # (the listener's own rows) notifies; the WORKER owns which extractors it acts on
145
+ # (listen_worker.RUN_NOTIFY_EXTRACTORS, derived from the admitted sources), so admitting a source
146
+ # never needs this trigger renamed. Its OWN channel, not `dbt_ready`: a worker from before #4024
147
+ # treats every `dbt_ready` payload as a Zakya marker and exits 76 for a run_id it cannot find, and
148
+ # with the CDC instances finishing a run every 5 minutes that retry chain never runs out -- so on
149
+ # the shared channel a rolled-back or not-yet-redeployed dbt-runner would stop building while any
150
+ # other writer had installed this trigger (review of #4025). On its own channel an old worker
151
+ # never hears it. The payload carries `kind: run` as well, so a listener on both channels can route
152
+ # by payload alone.
153
+ DBT_RUN_FINISHED_CHANNEL = "dbt_run_finished" # @spec run_finish_notify
154
+ RUN_FINISH_TRIGGER = "run_dbt_ready_notify"
155
+ RUN_FINISH_DDL = f"""
156
+ CREATE OR REPLACE FUNCTION control.notify_dbt_run_finished() RETURNS trigger
157
+ LANGUAGE plpgsql AS $fn$
158
+ BEGIN
159
+ IF TG_OP = 'UPDATE' AND OLD.status = '{status.RUN_OK}' AND OLD.finished_at IS NOT NULL THEN
160
+ RETURN NULL;
161
+ END IF;
162
+ PERFORM pg_notify('{DBT_RUN_FINISHED_CHANNEL}', json_build_object(
163
+ 'kind', 'run', 'extractor', NEW.extractor, 'run_id', NEW.run_id,
164
+ 'finished_at', NEW.finished_at)::text);
165
+ RETURN NULL;
166
+ END
167
+ $fn$;
168
+ DO $do$
169
+ BEGIN
170
+ IF NOT EXISTS (
171
+ SELECT 1 FROM pg_trigger t
172
+ JOIN pg_class c ON c.oid = t.tgrelid
173
+ JOIN pg_namespace n ON n.oid = c.relnamespace
174
+ WHERE n.nspname = 'control' AND c.relname = 'run' AND t.tgname = '{RUN_FINISH_TRIGGER}'
175
+ ) THEN
176
+ CREATE TRIGGER {RUN_FINISH_TRIGGER}
177
+ AFTER INSERT OR UPDATE ON control.run FOR EACH ROW
178
+ WHEN (NEW.status = '{status.RUN_OK}' AND NEW.finished_at IS NOT NULL
179
+ AND NEW.extractor <> 'dbt')
180
+ EXECUTE FUNCTION control.notify_dbt_run_finished();
181
+ END IF;
182
+ END
183
+ $do$;
184
+ """
185
+ # (trigger name, table) for every notification trigger a current plane carries; the presence check
186
+ # in `_schema_state` and the create-if-absent DDL are both by name, so changing a body means a
187
+ # new name here.
188
+ _NOTIFY_TRIGGERS = ((DBT_READY_TRIGGER, "event"), (RUN_FINISH_TRIGGER, "run"))
189
+ _NOTIFY_DDLS = (DBT_READY_DDL, RUN_FINISH_DDL)
190
+
191
+
192
+ # How long the trigger-only migration may WAIT for its table before it gives up for this start.
193
+ # A shipper transaction holds RowExclusive on control.event / control.run; CREATE TRIGGER needs
194
+ # ShareRowExclusive and must queue behind it. Bounded so a writer's startup is delayed seconds at
195
+ # worst, never stalled -- and never part of a lock cycle, because each trigger is installed in its
196
+ # OWN transaction that takes no other table lock (#3884). Installing both in one transaction would
197
+ # lock control.event then control.run while a replaying shipper locks control.run (run_start) then
198
+ # control.event (events): a cycle, reproduced in the review of #4025 as DeadlockDetected.
199
+ _TRIGGER_LOCK_TIMEOUT = "5s"
200
+
201
+
202
+ def _schema_state(conn: psycopg.Connection) -> str:
203
+ """'current', 'trigger_missing' (every table and column present, a notification trigger --
204
+ #3884's on control.event or #4024's on control.run -- absent) or 'stale' -- pure catalog
205
+ reads, no lock on any control table."""
206
+ with conn.cursor() as cur:
207
+ cur.execute(
208
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'control'")
209
+ tables = {r[0] for r in cur.fetchall()}
210
+ if not set(_REQUIRED_TABLES) <= tables:
211
+ return "stale"
212
+ cur.execute(
213
+ "SELECT table_name, column_name FROM information_schema.columns "
214
+ "WHERE table_schema = 'control' AND table_name = ANY(%s)",
215
+ ([t for t, _ in _REQUIRED_COLUMNS],))
216
+ columns = {(r[0], r[1]) for r in cur.fetchall()}
217
+ if not set(_REQUIRED_COLUMNS) <= columns:
218
+ return "stale"
219
+ for trigger, table in _NOTIFY_TRIGGERS:
220
+ cur.execute(
221
+ "SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid "
222
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
223
+ "WHERE n.nspname = 'control' AND c.relname = %s AND t.tgname = %s",
224
+ (table, trigger))
225
+ if cur.fetchone() is None:
226
+ return "trigger_missing"
227
+ return "current"
228
+
229
+
230
+ def _schema_is_current(conn: psycopg.Connection) -> bool:
231
+ """True when every table, migrated column and both dbt-ready triggers exist — no locks.
232
+
233
+ This is the steady state on a live control plane, so the common path of ensure_schema() must
234
+ reach exactly here and stop: no CREATE, no ALTER, nothing that can block another session."""
235
+ return _schema_state(conn) == "current"
236
+
237
+
238
+ def ensure_schema(conn: psycopg.Connection) -> None:
239
+ """Create the control schema if it is not already there. Safe to call concurrently.
240
+
241
+ Called at the start of EVERY run by every writer, so its steady-state cost is the thing that
242
+ matters: a single catalog read and no lock at all. Before #3091 it ran `CREATE TABLE IF NOT
243
+ EXISTS` + two `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` unconditionally, and because the ALTER
244
+ takes an AccessExclusiveLock whether or not the column is missing, two writers starting in the
245
+ same moment deadlocked -- each holding the CREATE's weaker lock while requesting the exclusive
246
+ one. Postgres killed them, and under ADR 0028's hard dependency that aborts the run BEFORE it
247
+ reads its source: on 04-Sep-2026 two of PTP's three instances did not load at all, killed by
248
+ the featherbase drainer's startup 27 seconds earlier. With featherbase ticking every 5 minutes
249
+ that was a dice-roll at every startup, fleet-wide.
250
+ """
251
+ state = _schema_state(conn)
252
+ if state == "current": # @spec schema_ensured_without_locks
253
+ conn.rollback() # end the read's implicit transaction; hold no locks on the way out
254
+ return
255
+ if state == "trigger_missing":
256
+ # Every live plane on first start after #3884 (and again after #4024). NOT the full DDL
257
+ # path below: its CREATE INDEX (ShareLock on control.run) then ALTERs (AccessExclusive on
258
+ # control.event) form a cycle with a shipper holding control.event and updating
259
+ # control.run -- the #3091 deadlock again, reproduced 4/4 in review. Only the triggers,
260
+ # lock-bounded, each created only if absent (the one already there costs a catalog read
261
+ # and no table lock), and each in its OWN transaction so this path never holds two table
262
+ # locks at once (see _TRIGGER_LOCK_TIMEOUT); a start that cannot get a lock in time, or
263
+ # loses a deadlock race, carries on without that trigger and a later start installs it.
264
+ conn.rollback()
265
+ for trigger, ddl in zip((t for t, _ in _NOTIFY_TRIGGERS), _NOTIFY_DDLS):
266
+ try:
267
+ with conn.cursor() as cur:
268
+ cur.execute("SELECT pg_advisory_xact_lock(%s)", (_SCHEMA_ADVISORY_LOCK,))
269
+ cur.execute(f"SET LOCAL lock_timeout = '{_TRIGGER_LOCK_TIMEOUT}'")
270
+ cur.execute(ddl)
271
+ conn.commit()
272
+ except (psycopg.errors.LockNotAvailable, psycopg.errors.DeadlockDetected) as exc:
273
+ conn.rollback()
274
+ print(f"[control_plane] trigger {trigger} not installed this start "
275
+ f"({type(exc).__name__}: its table was busy > {_TRIGGER_LOCK_TIMEOUT}); "
276
+ "a later start will.", flush=True)
277
+ return
278
+
279
+ with conn.cursor() as cur:
280
+ # First run, or a genuine migration. Serialize the DDL so concurrent first-runs queue
281
+ # instead of deadlocking; the lock is released with the transaction below.
282
+ cur.execute("SELECT pg_advisory_xact_lock(%s)", (_SCHEMA_ADVISORY_LOCK,))
283
+ cur.execute(SCHEMA_DDL)
284
+ # Migration for control planes created before control.event.extra existed (e.g. the
285
+ # live GoFrugal table): CREATE TABLE IF NOT EXISTS above won't add a column to it.
286
+ cur.execute("ALTER TABLE control.event ADD COLUMN IF NOT EXISTS extra JSONB")
287
+ # Same for control.run.extra, added for dbt (#2640): dbt's run row carries fields no
288
+ # extractor has -- bronze_watermark, action, dbt_rc -- and bolting five dbt-specific
289
+ # columns onto the table five writers share would be worse than one JSONB bag.
290
+ cur.execute("ALTER TABLE control.run ADD COLUMN IF NOT EXISTS extra JSONB")
291
+ for ddl in _NOTIFY_DDLS: # #3884, #4024 — created only if absent, so a re-run takes no lock
292
+ cur.execute(ddl)
293
+ conn.commit()
294
+
295
+
296
+ # --- WAL-record projection (the shipper calls apply() per record, in file order) ---
297
+
298
+ class RunIdClash(ValueError):
299
+ """A run record met a `control.run` row of the same run_id owned by another extractor."""
300
+
301
+
302
+ def _refuse_other_extractors_run(cur: psycopg.Cursor, rec: dict) -> None: # @spec run_id_clash_refused
303
+ """Raise, rolling the shipper's transaction back, rather than drop this run's start or fold
304
+ its finish into another extractor's row: both hid a finished GoFrugal load from the build gate
305
+ (#4614). The record stays in the WAL, so nothing is lost."""
306
+ cur.execute("SELECT extractor FROM control.run WHERE run_id = %s", (rec["run_id"],))
307
+ row = cur.fetchone()
308
+ if row is not None and row[0] != rec["extractor"]:
309
+ raise RunIdClash(f"run_id {rec['run_id']!r} of {rec['extractor']!r} is already "
310
+ f"{row[0]!r}'s run; refusing to {rec['kind']} over it")
311
+
312
+ def apply(cur: psycopg.Cursor, rec: dict) -> None: # @spec shipping_idempotent
313
+ """Project one WAL record into Postgres. Idempotent; dispatched by `kind`."""
314
+ kind = rec["kind"]
315
+ if kind == "run_start":
316
+ run_extra = rec.get("extra")
317
+ cur.execute(
318
+ "INSERT INTO control.run "
319
+ "(run_id, extractor, mode, host, pid, started_at, status, wal_path, extra) "
320
+ "VALUES (%s,%s,%s,%s,%s,%s::timestamptz,%s,%s,%s) ON CONFLICT (run_id) DO NOTHING",
321
+ (rec["run_id"], rec["extractor"], rec.get("mode"), rec.get("host"),
322
+ rec.get("pid"), rec["started_at"], rec["status"], rec.get("wal_path"),
323
+ Json(run_extra) if run_extra is not None else None),
324
+ )
325
+ if cur.rowcount == 0:
326
+ _refuse_other_extractors_run(cur, rec)
327
+ elif kind == "run_finish":
328
+ # Upsert: if PG was down at start (no run_start row landed), still record the run.
329
+ fin_extra = rec.get("extra")
330
+ cur.execute(
331
+ "INSERT INTO control.run "
332
+ "(run_id, extractor, started_at, finished_at, status, stop_reason, n_ok, n_err, n_skipped, extra) "
333
+ "VALUES (%s,%s,%s::timestamptz,%s::timestamptz,%s,%s,%s,%s,%s,%s) "
334
+ "ON CONFLICT (run_id) DO UPDATE SET "
335
+ "finished_at=EXCLUDED.finished_at, status=EXCLUDED.status, " # @spec died_overwritten_by_late_finish
336
+ "stop_reason=EXCLUDED.stop_reason, n_ok=EXCLUDED.n_ok, n_err=EXCLUDED.n_err, "
337
+ "n_skipped=EXCLUDED.n_skipped, "
338
+ # COALESCE, not overwrite: a finish that carries no extra must never erase what
339
+ # run_start recorded (dbt's bronze_watermark lands at start).
340
+ "extra=COALESCE(EXCLUDED.extra, control.run.extra) " # @spec run_extra_preserved_on_finish
341
+ "WHERE control.run.extractor = EXCLUDED.extractor",
342
+ (rec["run_id"], rec["extractor"], rec.get("started_at", rec["finished_at"]),
343
+ rec["finished_at"], rec["status"], rec.get("stop_reason"),
344
+ rec.get("n_ok"), rec.get("n_err"), rec.get("n_skipped"),
345
+ Json(fin_extra) if fin_extra is not None else None),
346
+ )
347
+ if cur.rowcount == 0:
348
+ _refuse_other_extractors_run(cur, rec)
349
+ elif kind == "event":
350
+ extra = rec.get("extra")
351
+ cur.execute(
352
+ "INSERT INTO control.event "
353
+ "(extractor, run_id, seq, ran_at, entity, unit, business_date, status, "
354
+ " http_status, rows, source_attempts, sink_attempts, pull_ms, load_ms, error_detail, extra) "
355
+ "VALUES (%s,%s,%s,%s::timestamptz,%s,%s,%s::date,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
356
+ "ON CONFLICT (extractor, run_id, seq) DO NOTHING",
357
+ (rec["extractor"], rec["run_id"], rec["seq"], rec["ran_at"], rec.get("entity"),
358
+ rec.get("unit"), rec.get("business_date"), rec["status"], rec.get("http_status"),
359
+ rec.get("rows"), rec.get("source_attempts"), rec.get("sink_attempts"),
360
+ rec.get("pull_ms"), rec.get("load_ms"), rec.get("error_detail"),
361
+ Json(extra) if extra is not None else None),
362
+ )
363
+ elif kind == "budget":
364
+ cur.execute(
365
+ "INSERT INTO control.budget_usage "
366
+ "(run_id, extractor, budget_name, limit_val, used, remaining, hit, ran_at) "
367
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s::timestamptz) "
368
+ "ON CONFLICT (run_id, extractor, budget_name) DO UPDATE SET " # @spec budget_recorded_every_run
369
+ "limit_val=EXCLUDED.limit_val, used=EXCLUDED.used, remaining=EXCLUDED.remaining, "
370
+ "hit=EXCLUDED.hit, ran_at=EXCLUDED.ran_at",
371
+ (rec["run_id"], rec["extractor"], rec["budget_name"], rec.get("limit_val"),
372
+ rec.get("used"), rec.get("remaining"), rec.get("hit"), rec.get("ran_at")),
373
+ )
374
+ else:
375
+ raise ValueError(f"unknown WAL record kind: {kind!r}")
376
+
377
+
378
+ def apply_events_batch(cur: psycopg.Cursor, recs: list[dict]) -> None:
379
+ """Batch form of apply() for a homogeneous list of kind='event' records.
380
+
381
+ Same SQL and same idempotency key as the per-record path -- executemany just sends it in
382
+ one pipelined batch instead of one round-trip per row. Added for #2640: dbt's run_results
383
+ projection can carry tens of thousands of nodes in a catch-up window, and at ~250ms RTT over
384
+ the public proxy the per-record path turns 28k rows into two hours. executemany does it in
385
+ seconds, and on Railway's private network the difference is merely large.
386
+ """
387
+ if not recs:
388
+ return
389
+ bad = [r["kind"] for r in recs if r.get("kind") != "event"]
390
+ if bad:
391
+ raise ValueError(f"apply_events_batch only takes kind='event' records, got {bad[:3]}")
392
+ cur.executemany(
393
+ "INSERT INTO control.event "
394
+ "(extractor, run_id, seq, ran_at, entity, unit, business_date, status, "
395
+ " http_status, rows, source_attempts, sink_attempts, pull_ms, load_ms, error_detail, extra) "
396
+ "VALUES (%s,%s,%s,%s::timestamptz,%s,%s,%s::date,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
397
+ "ON CONFLICT (extractor, run_id, seq) DO NOTHING",
398
+ [(r["extractor"], r["run_id"], r["seq"], r["ran_at"], r.get("entity"),
399
+ r.get("unit"), r.get("business_date"), r["status"], r.get("http_status"),
400
+ r.get("rows"), r.get("source_attempts"), r.get("sink_attempts"),
401
+ r.get("pull_ms"), r.get("load_ms"), r.get("error_detail"),
402
+ Json(r["extra"]) if r.get("extra") is not None else None)
403
+ for r in recs],
404
+ )
405
+
406
+
407
+ # --- the liveness lease (the deterministic death-detection primitive) ---
408
+
409
+ def lease_acquire(conn: psycopg.Connection, extractor: str, run_id: str, ttl_s: int) -> bool:
410
+ """Acquire this extractor's lease. Steal-only-if-expired: the ON CONFLICT UPDATE fires
411
+ *only* when the existing lease has lapsed, so a genuinely-live run is never displaced.
412
+ Returns True if acquired (no live prior run), False if another run holds a fresh lease.
413
+ """
414
+ with conn.cursor() as cur:
415
+ cur.execute(
416
+ "INSERT INTO control.lease (extractor, run_id, lease_expires_at) "
417
+ "VALUES (%s, %s, now() + make_interval(secs => %s)) "
418
+ "ON CONFLICT (extractor) DO UPDATE SET "
419
+ "run_id = EXCLUDED.run_id, lease_expires_at = EXCLUDED.lease_expires_at "
420
+ "WHERE control.lease.lease_expires_at < now() " # @spec lease_steal_only_if_expired
421
+ "RETURNING run_id",
422
+ (extractor, run_id, ttl_s),
423
+ )
424
+ acquired = cur.fetchone() is not None
425
+ conn.commit()
426
+ return acquired
427
+
428
+
429
+ def lease_renew(conn: psycopg.Connection, extractor: str, run_id: str, ttl_s: int) -> bool:
430
+ """Push the lease forward (renewer thread). Returns False if we no longer own it."""
431
+ with conn.cursor() as cur:
432
+ cur.execute(
433
+ "UPDATE control.lease SET lease_expires_at = now() + make_interval(secs => %s) "
434
+ "WHERE extractor = %s AND run_id = %s",
435
+ (ttl_s, extractor, run_id),
436
+ )
437
+ ok = cur.rowcount > 0
438
+ conn.commit()
439
+ return ok
440
+
441
+
442
+ def lease_release(conn: psycopg.Connection, extractor: str, run_id: str) -> None:
443
+ """Expire our lease immediately so the next run can reclaim without waiting out the TTL."""
444
+ with conn.cursor() as cur:
445
+ cur.execute(
446
+ "UPDATE control.lease SET lease_expires_at = now() "
447
+ "WHERE extractor = %s AND run_id = %s",
448
+ (extractor, run_id),
449
+ )
450
+ conn.commit()
451
+
452
+
453
+ def reconcile_dead(conn: psycopg.Connection, extractor: str, run_id: str) -> int:
454
+ """Mark every unfinished prior run of this extractor as `died`. Safe to call only after
455
+ we hold the lease (⇒ no other run of this extractor is alive). The dead run's cause is
456
+ already in control.event via WAL replay. Returns the number reconciled."""
457
+ with conn.cursor() as cur:
458
+ cur.execute(
459
+ "UPDATE control.run SET status = 'died', stop_reason = 'died', "
460
+ "finished_at = now(), reconciled_by = %s "
461
+ "WHERE extractor = %s AND finished_at IS NULL AND run_id <> %s", # @spec unfinished_prior_runs_marked_died
462
+ (run_id, extractor, run_id),
463
+ )
464
+ n = cur.rowcount
465
+ conn.commit()
466
+ return n
control_plane/wal.py ADDED
@@ -0,0 +1,63 @@
1
+ """Local write-ahead log: the durability boundary (issue #204).
2
+
3
+ Every control record is appended here as one line of JSON, fsync'd, *before* any network
4
+ write to Postgres. On a crash the tail survives on disk (box: persistent; Railway: the
5
+ in-flight buffer), and the next run's recovery ships it. This already beats GoFrugal's old
6
+ "buffer events in memory, flush at end-of-date" — a mid-date death now loses nothing.
7
+
8
+ The file is `O_APPEND`, so concurrent appends never interleave a partial line, and a reader
9
+ that stops at the last newline never sees a half-written record (the durability invariant
10
+ recovery relies on).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ import json
16
+ import os
17
+
18
+
19
+ def _default(o):
20
+ if isinstance(o, (_dt.datetime, _dt.date)):
21
+ return o.isoformat()
22
+ raise TypeError(f"not JSON-serialisable for the WAL: {type(o).__name__}")
23
+
24
+
25
+ class Wal:
26
+ """Append-only newline-JSON log for one run."""
27
+
28
+ def __init__(self, path: str):
29
+ self.path = path
30
+ os.makedirs(os.path.dirname(path), exist_ok=True)
31
+
32
+ def append(self, record: dict) -> None:
33
+ """Serialise + fsync one record. Durable on return."""
34
+ line = (json.dumps(record, default=_default, separators=(",", ":")) + "\n").encode("utf-8")
35
+ fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
36
+ try:
37
+ os.write(fd, line)
38
+ os.fsync(fd)
39
+ finally:
40
+ os.close(fd)
41
+
42
+
43
+ def read_new(path: str, start_offset: int = 0) -> tuple[list[dict], int]:
44
+ """Parse complete (newline-terminated) records from `start_offset` onward.
45
+
46
+ Returns (records, new_offset). A partial trailing line (no newline yet — a crash
47
+ mid-write) is left unconsumed, so new_offset never advances past durable data.
48
+ """
49
+ if not os.path.exists(path):
50
+ return [], start_offset
51
+ with open(path, "rb") as f:
52
+ f.seek(start_offset)
53
+ data = f.read()
54
+ records: list[dict] = []
55
+ consumed = 0
56
+ for line in data.splitlines(keepends=True):
57
+ if not line.endswith(b"\n"): # @spec wal_torn_tail_not_consumed
58
+ break # partial trailing line — not durable, stop here
59
+ consumed += len(line)
60
+ s = line.strip()
61
+ if s:
62
+ records.append(json.loads(s))
63
+ return records, start_offset + consumed
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: feather-control-plane
3
+ Version: 0.1.0
4
+ Summary: Durable failure observability for an extractor fleet: WAL + Postgres control plane + liveness lease
5
+ Project-URL: Homepage, https://github.com/siraj-samsudeen/feather-flow
6
+ Project-URL: Repository, https://github.com/siraj-samsudeen/feather-flow
7
+ Project-URL: Issues, https://github.com/siraj-samsudeen/feather-flow/issues
8
+ Author-email: Siraj Samsudeen <mailsiraj@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: control-plane,etl,observability,postgres,write-ahead-log
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: System :: Monitoring
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: tzdata>=2026.1
21
+ Provides-Extra: postgres
22
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # feather-control-plane
26
+
27
+ Durable failure observability for an extractor fleet.
28
+
29
+ A long-running extractor that dies mid-load leaves nothing behind: no row, no error, no
30
+ "it got as far as table X". This library gives it three things that survive the death —
31
+
32
+ - a **write-ahead log** on local disk, fsynced before anything is shipped, so a crash leaves
33
+ the breadcrumbs of how far the run got;
34
+ - a **Postgres control plane** holding run lifecycle, per-unit events and budget telemetry,
35
+ shipped as the run goes rather than written at the end;
36
+ - a **liveness lease**, renewed by a background thread, so a process that stops renewing is
37
+ known to be dead instead of being assumed still running.
38
+
39
+ The data plane is somewhere else entirely — this records what happened to the load, not the
40
+ load's data.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install "feather-control-plane[postgres]" # the full facade
46
+ pip install feather-control-plane # pure-stdlib helpers only
47
+ ```
48
+
49
+ **`psycopg` is an optional extra, and that is deliberate.** Two of the modules here —
50
+ `control_plane.business_day` and `control_plane.status` — are pure stdlib, and consumers import
51
+ them on their own, with no database anywhere in the picture. Making a database driver mandatory
52
+ for a date helper is what drove nine services to copy `business_day.py` around by hand instead
53
+ of depending on the library. Anything that actually opens a connection needs the `postgres`
54
+ extra; `__init__.py` resolves those names lazily (PEP 562), so importing the stdlib half never
55
+ pulls the driver in.
56
+
57
+ ## Import name
58
+
59
+ The distribution is `feather-control-plane`; the **import name is `control_plane`**:
60
+
61
+ ```python
62
+ from control_plane import ControlPlane, status
63
+ from control_plane.business_day import ist_today
64
+ ```
65
+
66
+ They differ on purpose. The nine services adopting this already have ~40 call sites spelled
67
+ `from control_plane import ...`, so keeping the import name turns adoption into a dependency
68
+ substitution with no source edits.
69
+
70
+ ## Usage
71
+
72
+ ```python
73
+ from control_plane import ControlPlane, status
74
+
75
+ cp = ControlPlane("gofrugal", dsn=PG_DSN, wal_dir="control_wal")
76
+ with cp.run("nightly", run_id=run_id) as run:
77
+ run.event(entity="sales_item_wise", unit="1001", business_date=d,
78
+ status=status.EV_OK, http_status=200, rows=42,
79
+ source_attempts=1, sink_attempts=1, pull_ms=1200, load_ms=800)
80
+ run.budget(status.BUDGET_WALLTIME, limit=1800, used=1800, remaining=0, hit=True)
81
+ run.finish(status.RUN_PARTIAL, stop_reason=status.STOP_BUDGET_TIME,
82
+ n_ok=47, n_err=1, n_skipped=0)
83
+ ```
84
+
85
+ `dsn=None` is the explicit no-control-plane mode for dev and test: the WAL still records
86
+ everything, nothing ships. With a DSN configured, Postgres is a **hard dependency of the run** —
87
+ if it is unreachable at startup the run aborts rather than loading blind, because a load with no
88
+ provenance is worse than no load.
89
+
90
+ ## Tests
91
+
92
+ ```bash
93
+ cd packages/feather-control-plane && uv run --package feather-control-plane pytest
94
+ # or, from the repo root, naming the path:
95
+ uv run --package feather-control-plane pytest packages/feather-control-plane/tests
96
+ ```
97
+
98
+ `--package` picks the *environment*; it does not pick the tests. pytest takes its config from the
99
+ nearest `pyproject.toml` above the working directory, so a bare `pytest` run from the repo root
100
+ collects feather-flow's suite no matter what `--package` says — green, 1050 tests, and not one of
101
+ them this package's. Either `cd` in or name the path.
102
+
103
+ The pure-logic tests (WAL framing, status vocabulary, business day) run anywhere. The rest need a
104
+ real Postgres and find one in this order: `CONTROL_PG_DSN` if set → a `postgres:16` Docker
105
+ container → a local Postgres binary (`pg_ctl` on `PATH`, else Homebrew's keg-only
106
+ `postgresql@N`), `initdb`'d into a temp directory on a throwaway port and torn down afterwards.
107
+ With none of the three available they skip.
@@ -0,0 +1,11 @@
1
+ control_plane/__init__.py,sha256=eWZer0yYYWCZNL5gsO2T2sF5zvmHVl_ZRqjkamSr91s,3075
2
+ control_plane/business_day.py,sha256=IVH78mmBINa9jZC29AJTr-sGQ8ycAed_BTne7hIzXuI,2325
3
+ control_plane/recovery.py,sha256=iNkgroXBgHGGnFRBRkJA23c7T5CVguQaxpoHnOWmiqQ,1472
4
+ control_plane/run.py,sha256=vLVReod1OmJWJ9KD53Eo12sdpfQFrbvrB0q8QBCZwIM,11336
5
+ control_plane/shipper.py,sha256=Wq3VGRjskqC_7ao2LguuV_9wnApNmQLL8a2EKfTV4Hk,1750
6
+ control_plane/status.py,sha256=FWQjWDcNKSb-3E7PD3sv68Mx3UZ_t-TIsrHfGFs5iHQ,3649
7
+ control_plane/store.py,sha256=U_czsnV7yQOiMiMKHanTTmc9B0JmADoH7D0XbC9HxT4,23794
8
+ control_plane/wal.py,sha256=gvlYg_v5Ed7N0XkFYwVRxUunIlFgf4UmmWRVeGSJKYc,2341
9
+ feather_control_plane-0.1.0.dist-info/METADATA,sha256=o8hInhfCKAu-nhX_QLScU-zMnPG9g4o7Xk8z_IJZir0,4978
10
+ feather_control_plane-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ feather_control_plane-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any