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.
- feather_control_plane-0.1.0/.gitignore +21 -0
- feather_control_plane-0.1.0/PKG-INFO +107 -0
- feather_control_plane-0.1.0/README.md +83 -0
- feather_control_plane-0.1.0/pyproject.toml +67 -0
- feather_control_plane-0.1.0/src/control_plane/__init__.py +69 -0
- feather_control_plane-0.1.0/src/control_plane/business_day.py +54 -0
- feather_control_plane-0.1.0/src/control_plane/recovery.py +33 -0
- feather_control_plane-0.1.0/src/control_plane/run.py +237 -0
- feather_control_plane-0.1.0/src/control_plane/shipper.py +56 -0
- feather_control_plane-0.1.0/src/control_plane/status.py +60 -0
- feather_control_plane-0.1.0/src/control_plane/store.py +466 -0
- feather_control_plane-0.1.0/src/control_plane/wal.py +63 -0
- feather_control_plane-0.1.0/tests/conftest.py +127 -0
- feather_control_plane-0.1.0/tests/test_dbt_ready_notify.py +253 -0
- feather_control_plane-0.1.0/tests/test_integration.py +307 -0
- feather_control_plane-0.1.0/tests/test_packaging.py +99 -0
- feather_control_plane-0.1.0/tests/test_schema_concurrency.py +225 -0
- feather_control_plane-0.1.0/tests/test_unit.py +80 -0
|
@@ -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
|