feather-control-plane 0.1.0__tar.gz

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,21 @@
1
+ .worktrees/
2
+ .claude/worktrees/
3
+ __pycache__/
4
+ *.pyc
5
+ *.egg-info/
6
+ dist/
7
+ .venv/
8
+ *.duckdb.wal
9
+ feather_data.duckdb
10
+ feather_state.duckdb
11
+ feather_validation.json
12
+ feather_discover_state.json
13
+ .env
14
+ .DS_Store
15
+ .coverage
16
+ .testmondata
17
+ .testmondata-journal
18
+ .nvmrc
19
+ repomix-output.xml
20
+ .playwright-mcp/
21
+ .superpowers/
@@ -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,83 @@
1
+ # feather-control-plane
2
+
3
+ Durable failure observability for an extractor fleet.
4
+
5
+ A long-running extractor that dies mid-load leaves nothing behind: no row, no error, no
6
+ "it got as far as table X". This library gives it three things that survive the death —
7
+
8
+ - a **write-ahead log** on local disk, fsynced before anything is shipped, so a crash leaves
9
+ the breadcrumbs of how far the run got;
10
+ - a **Postgres control plane** holding run lifecycle, per-unit events and budget telemetry,
11
+ shipped as the run goes rather than written at the end;
12
+ - a **liveness lease**, renewed by a background thread, so a process that stops renewing is
13
+ known to be dead instead of being assumed still running.
14
+
15
+ The data plane is somewhere else entirely — this records what happened to the load, not the
16
+ load's data.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install "feather-control-plane[postgres]" # the full facade
22
+ pip install feather-control-plane # pure-stdlib helpers only
23
+ ```
24
+
25
+ **`psycopg` is an optional extra, and that is deliberate.** Two of the modules here —
26
+ `control_plane.business_day` and `control_plane.status` — are pure stdlib, and consumers import
27
+ them on their own, with no database anywhere in the picture. Making a database driver mandatory
28
+ for a date helper is what drove nine services to copy `business_day.py` around by hand instead
29
+ of depending on the library. Anything that actually opens a connection needs the `postgres`
30
+ extra; `__init__.py` resolves those names lazily (PEP 562), so importing the stdlib half never
31
+ pulls the driver in.
32
+
33
+ ## Import name
34
+
35
+ The distribution is `feather-control-plane`; the **import name is `control_plane`**:
36
+
37
+ ```python
38
+ from control_plane import ControlPlane, status
39
+ from control_plane.business_day import ist_today
40
+ ```
41
+
42
+ They differ on purpose. The nine services adopting this already have ~40 call sites spelled
43
+ `from control_plane import ...`, so keeping the import name turns adoption into a dependency
44
+ substitution with no source edits.
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from control_plane import ControlPlane, status
50
+
51
+ cp = ControlPlane("gofrugal", dsn=PG_DSN, wal_dir="control_wal")
52
+ with cp.run("nightly", run_id=run_id) as run:
53
+ run.event(entity="sales_item_wise", unit="1001", business_date=d,
54
+ status=status.EV_OK, http_status=200, rows=42,
55
+ source_attempts=1, sink_attempts=1, pull_ms=1200, load_ms=800)
56
+ run.budget(status.BUDGET_WALLTIME, limit=1800, used=1800, remaining=0, hit=True)
57
+ run.finish(status.RUN_PARTIAL, stop_reason=status.STOP_BUDGET_TIME,
58
+ n_ok=47, n_err=1, n_skipped=0)
59
+ ```
60
+
61
+ `dsn=None` is the explicit no-control-plane mode for dev and test: the WAL still records
62
+ everything, nothing ships. With a DSN configured, Postgres is a **hard dependency of the run** —
63
+ if it is unreachable at startup the run aborts rather than loading blind, because a load with no
64
+ provenance is worse than no load.
65
+
66
+ ## Tests
67
+
68
+ ```bash
69
+ cd packages/feather-control-plane && uv run --package feather-control-plane pytest
70
+ # or, from the repo root, naming the path:
71
+ uv run --package feather-control-plane pytest packages/feather-control-plane/tests
72
+ ```
73
+
74
+ `--package` picks the *environment*; it does not pick the tests. pytest takes its config from the
75
+ nearest `pyproject.toml` above the working directory, so a bare `pytest` run from the repo root
76
+ collects feather-flow's suite no matter what `--package` says — green, 1050 tests, and not one of
77
+ them this package's. Either `cd` in or name the path.
78
+
79
+ The pure-logic tests (WAL framing, status vocabulary, business day) run anywhere. The rest need a
80
+ real Postgres and find one in this order: `CONTROL_PG_DSN` if set → a `postgres:16` Docker
81
+ container → a local Postgres binary (`pg_ctl` on `PATH`, else Homebrew's keg-only
82
+ `postgresql@N`), `initdb`'d into a temp directory on a throwaway port and torn down afterwards.
83
+ With none of the three available they skip.
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "feather-control-plane"
3
+ version = "0.1.0"
4
+ description = "Durable failure observability for an extractor fleet: WAL + Postgres control plane + liveness lease"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Siraj Samsudeen", email = "mailsiraj@gmail.com" }
9
+ ]
10
+ # The distribution is `feather-control-plane`; the IMPORT name stays `control_plane`.
11
+ # ~40 call sites across nine services already say `from control_plane import ControlPlane`, so
12
+ # keeping the import name makes adopting this package a dependency substitution with no code edits.
13
+ # `>=3.12` is a CONSUMER constraint, not a language one: this code runs fine on 3.12, 3.13 and 3.14
14
+ # (44 passed on each). Every service in JeyaramaGroup/data-warehouse that will depend on this package
15
+ # declares `>=3.12`, and that repo's root is capped `>=3.12,<3.13` — so a floor of 3.13 here would
16
+ # make the package uninstallable by the nine services it exists to serve. Raise this only in step
17
+ # with them. uv locks a workspace at the INTERSECTION of its members' ranges, so this and
18
+ # feather-flow's own floor must move together (#85).
19
+ requires-python = ">=3.12"
20
+ keywords = ["observability", "etl", "postgres", "write-ahead-log", "control-plane"]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Database",
29
+ "Topic :: System :: Monitoring",
30
+ ]
31
+ dependencies = [
32
+ # business_day.py builds ZoneInfo("Asia/Kolkata") AT IMPORT. psycopg already requires tzdata but
33
+ # ONLY under `sys_platform == 'win32'`, so on Linux it is absent and `zoneinfo` would be left
34
+ # depending on the image's /usr/share/zoneinfo — which bookworm-slim does not guarantee.
35
+ "tzdata>=2026.1",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ # psycopg is NOT a base dependency: `control_plane.business_day` and `control_plane.status` are
40
+ # pure-stdlib and are imported on their own by consumers that have no database. Everything that
41
+ # actually talks to Postgres lives behind this extra, which the lazy `__getattr__` in
42
+ # `__init__.py` makes reachable without eagerly importing a driver.
43
+ postgres = ["psycopg[binary]>=3.1"]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/siraj-samsudeen/feather-flow"
47
+ Repository = "https://github.com/siraj-samsudeen/feather-flow"
48
+ Issues = "https://github.com/siraj-samsudeen/feather-flow/issues"
49
+
50
+ [build-system]
51
+ requires = ["hatchling"]
52
+ build-backend = "hatchling.build"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/control_plane"]
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ include = ["src/", "tests/", "README.md", "pyproject.toml"]
59
+
60
+ [dependency-groups]
61
+ dev = [
62
+ "pytest>=8.0",
63
+ "psycopg[binary]>=3.1",
64
+ ]
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
@@ -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
@@ -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