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.
- control_plane/__init__.py +69 -0
- control_plane/business_day.py +54 -0
- control_plane/recovery.py +33 -0
- control_plane/run.py +237 -0
- control_plane/shipper.py +56 -0
- control_plane/status.py +60 -0
- control_plane/store.py +466 -0
- control_plane/wal.py +63 -0
- feather_control_plane-0.1.0.dist-info/METADATA +107 -0
- feather_control_plane-0.1.0.dist-info/RECORD +11 -0
- feather_control_plane-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
control_plane/shipper.py
ADDED
|
@@ -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)
|
control_plane/status.py
ADDED
|
@@ -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,,
|