cdt-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.5
2
+ Name: cdt-kit
3
+ Version: 0.1.0
4
+ Summary: Server-side app kit of the Controlled Delivery Toolkit: config, migrations, jobs and HTTP basics
5
+ Project-URL: Source, https://github.com/fromerobr-ai/cdt-kit
6
+ Project-URL: Changelog, https://github.com/fromerobr-ai/cdt-kit/blob/main/CHANGELOG.md
7
+ Author: fromerobr-ai
8
+ Classifier: Framework :: FastAPI
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Database
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: psycopg<4,>=3.2
14
+ Provides-Extra: fastapi
15
+ Requires-Dist: fastapi>=0.115; extra == 'fastapi'
16
+ Provides-Extra: jobs
17
+ Requires-Dist: procrastinate<4,>=3.10; extra == 'jobs'
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
20
+ Requires-Dist: pytest>=8; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # cdt-kit
24
+
25
+ The server-side app kit of the Controlled Delivery Toolkit (CDT): a Python library every app pins for what the toolkit's
26
+ standard asks of its request path, so projects stop rebuilding it (claude-deploy-toolkit #422). Distribution `cdt-kit`
27
+ on PyPI, import name `cdtkit` (PyPI's `cdt` is another project's module).
28
+
29
+ 0.1.0 holds what the pilot, magic-minutes, needs first:
30
+
31
+ | Module | What it does |
32
+ |---|---|
33
+ | `cdtkit.config` | `*_FILE` secrets, the database from `DATABASE_*`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES` |
34
+ | `cdtkit.migrations` | forward-only SQL files at start (advisory lock, `schema_migrations`), the expand-only lint |
35
+ | `cdtkit.jobs` | procrastinate on the app's Postgres: one queue per job type, backoff with jitter, `NoRow` (`skipped=no-row`), the final-failure hook |
36
+ | `cdtkit.jobs.worker` | the worker process: one worker per queue, heartbeat health check, graceful stop |
37
+ | `cdtkit.jobs.recovery` | dead-worker and stuck-job recovery |
38
+ | `cdtkit.jobs.prune` | finished jobs removed after `failed_retention` (run from a declared schedule) |
39
+ | `cdtkit.http` | logfmt logging on stdout, the `/healthz` checks |
40
+ | `cdtkit.fastapi` | the FastAPI adapter: `health_router` |
41
+ | `cdtkit.testing` | test stand-ins: a Postgres (`cdt_postgres`, `cdt_database` fixtures) and the in-memory queue |
42
+
43
+ Identity, the account contract, legal, i18n and consent come in 0.2.0; `/metrics`, RFC 9457 problems, rate classes and
44
+ Idempotency-Key later (see #422).
45
+
46
+ ## Install and pin
47
+
48
+ ```
49
+ cdt-kit[jobs,fastapi]==0.1.0 # requirements.txt of an app; [test] for its test environment
50
+ ```
51
+
52
+ The core depends only on `psycopg` 3 (choose its C build in the app: `psycopg[binary]`); `jobs` adds procrastinate
53
+ 3.x, `fastapi` the adapter, `test` pytest and the fixtures. Python 3.11 or newer.
54
+
55
+ **Production pins only stable kit versions.** Every deployed kit candidate is published to PyPI at once, so its pilot
56
+ can test it before promotion; a version is stable once promoted (`RELEASES.md` of this repository, the GitHub Release
57
+ marked latest). An app may pin a candidate on staging; its production deployments pin a stable version. The toolkit's
58
+ rule `kit-version` (planned, claude-deploy-toolkit #423) will check it; until then it is a rule each app keeps.
59
+
60
+ Versions are semantic: a minor adds, a major breaks; anything deprecated keeps working for two minors with a warning.
61
+ At most one kit release per toolkit minor.
62
+
63
+ ## Use
64
+
65
+ Configuration, read once at start:
66
+
67
+ ```python
68
+ from cdtkit import config
69
+
70
+ db = config.database() # DATABASE_HOST/_PORT/_NAME/_USER + DATABASE_PASSWORD_FILE
71
+ conninfo = db.conninfo("web") # libpq string; repr(db) never shows the password
72
+ key = config.read_secret("KEYS_ENCRYPTION_KEY") # KEYS_ENCRYPTION_KEY_FILE, else the variable (local runs)
73
+ optional = config.read_secret("SMTP_PASSWORD", required=False) # "" when unset or an empty file
74
+ env_class, language = config.env_class(), config.language() # "staging"/"production", CDT_LANGUAGE
75
+ job_types = config.job_types(service="worker") # this worker's TOOLKIT_JOB_TYPES entry: {id: params}
76
+ ```
77
+
78
+ Missing or invalid configuration raises `config.ConfigError` (a `SystemExit`: the process stops with one line).
79
+
80
+ Migrations at start, in every process (web and workers may start together; one migrates, the others wait):
81
+
82
+ ```python
83
+ from cdtkit import migrations
84
+ migrations.migrate(conninfo, "app/migrations", queue_app=queue_app) # *.sql in name order, each once
85
+ ```
86
+
87
+ Each file runs in its own transaction and is recorded in `schema_migrations (name, applied_at)`; with a procrastinate
88
+ app, its schema is applied when missing. The lint reports contracting statements (drops, renames, type changes, new
89
+ `NOT NULL`, `UNIQUE` or `CHECK` on existing data):
90
+
91
+ ```
92
+ python -m cdtkit.migrations lint app/migrations [--after 0007_accounts_ui.sql] # exit 1 on findings
93
+ ```
94
+
95
+ A contract belongs in a cleanup release that declares it in `deploy/package.json` `"contracts"`; mark its line
96
+ `-- cdt: contract <reason>` and the lint accepts it.
97
+
98
+ Jobs (one queue per job type; parameters from `deploy/workers.json` through `TOOLKIT_JOB_TYPES`):
99
+
100
+ ```python
101
+ from cdtkit import jobs
102
+ from cdtkit.jobs import worker
103
+
104
+ DEFAULTS = {"convert": {"concurrency": 1, "max_attempts": 3, "backoff": "60s", "timeout_item": "3h"}}
105
+
106
+ async def convert(meeting_id):
107
+ meeting = load(meeting_id)
108
+ if meeting is None:
109
+ raise jobs.NoRow("meeting gone") # deleted while queued: the job ends, logged skipped=no-row, no retry
110
+ ...
111
+
112
+ app = jobs.make_app(conninfo, job_types, {"convert": convert}, DEFAULTS, pool_max=2, on_final_failure=mark_failed)
113
+ await jobs.defer_async(app, "convert", meeting_id=7) # web processes defer; payloads carry IDs only
114
+ await worker.run(app, ["convert"], job_types, DEFAULTS, ping=db_ping, on_given_up=mark_failed,
115
+ never_give_up=("account-delete",))
116
+ ```
117
+
118
+ - `max_attempts` counts runs in total (at most 25); the wait after the n-th failure is `backoff * 2^(n-1)`, capped at 6
119
+ hours, shortened by up to half at random (jitter).
120
+ - `on_final_failure(job_id, payload)` runs when the last attempt raises; `on_given_up` when the recovery gives a job up
121
+ (its worker died with no attempt left, or it ran past `timeout_item` plus 5 minutes). Both mark what the user sees.
122
+ - The worker's container health check: `python -m cdtkit.jobs.worker --health` (exit 0 while the heartbeat in
123
+ `/tmp/worker-heartbeat` is younger than 80 seconds; the heartbeat runs `ping` first).
124
+ - Finished jobs: successful ones are deleted at once; failed, cancelled and aborted ones after the entry's
125
+ `failed_retention` by `python -m cdtkit.jobs.prune --service worker`, which the app declares as a schedule
126
+ (`deploy/schedules.json`), never a loop in its code.
127
+
128
+ HTTP basics:
129
+
130
+ ```python
131
+ from cdtkit.http import Health
132
+ from cdtkit.http.logging import setup
133
+ from cdtkit.fastapi import health_router
134
+
135
+ setup() # logfmt on stdout, one line per event
136
+ app.include_router(health_router(Health(database=db.ping))) # GET /healthz: 200 "ok" or 503 "unavailable: database"
137
+ ```
138
+
139
+ Tests: with `cdt-kit[test]` installed the fixtures are there without imports:
140
+
141
+ ```python
142
+ def test_store(cdt_database): # a fresh empty database (a cdtkit.config.Database)
143
+ migrations.migrate(cdt_database.conninfo(), "app/migrations")
144
+ ```
145
+
146
+ They use the server named by `CDT_TEST_DATABASE_HOST` (`_PORT`, `_USER`, `_PASSWORD`, `_NAME`), else start a
147
+ throwaway cluster with the local `initdb`/`pg_ctl` (not as root), else skip. `cdtkit.testing.queue.memory_app`,
148
+ `drain` and `statuses` run job handlers on procrastinate's in-memory connector.
149
+
150
+ ## Layout
151
+
152
+ ```
153
+ src/cdtkit/ the library (docs/PROJECT_NOTES.md has the module map)
154
+ tests/ the test suite (pytest; Postgres tests use the fixtures above)
155
+ selftest/ the self-test stack's run script and the testdb sidecar's script
156
+ compose.yaml the self-test stack (below); Dockerfile its image
157
+ requirements-selftest.txt pinned dependencies of the self-test image (arm64 wheels checked)
158
+ .github/workflows/publish.yml publishing to PyPI
159
+ ```
160
+
161
+ ## The self-test stack
162
+
163
+ The toolkit deploys Compose stacks, and a library has no service, so cdt-kit's stack proves the kit on each server that
164
+ deploys it: `selftest` builds the wheel from the tree, installs it with the pinned dependencies and runs the test suite
165
+ once per build against `testdb`, a throwaway Postgres 18 on an internal network that stops (exit 0) after the run.
166
+ `selftest` then idles, healthy only when the tests passed, so a failing suite fails the deploy. The result is kept in
167
+ `data/selftest/` per build: a restart does not rerun the tests. It is a workaround until the toolkit offers a
168
+ check-only kind (claude-project-common#176); its two conformance exceptions point there.
169
+
170
+ Run it by hand on the primary: `cd /opt/stacks/cdt-kit && docker compose logs selftest`.
171
+
172
+ ## Publishing
173
+
174
+ `.github/workflows/publish.yml` publishes the wheel and the sdist to PyPI when the servers create the version's GitHub
175
+ Release, that is when the candidate is deployed on the primary; PyPI Trusted Publishing, no stored token. The one-time
176
+ setup on pypi.org is in `RUNBOOK.md`. The workflow's result shows on the release issue. A version that failed to
177
+ publish is published again with the workflow's "Run workflow" button and its tag.
178
+
179
+ Running the tests locally: `pip install -e .[jobs,fastapi,test]` then `pytest` (Postgres binaries on PATH or
180
+ `CDT_TEST_DATABASE_HOST`).
@@ -0,0 +1,18 @@
1
+ cdtkit/__init__.py,sha256=V42QnqEHQGbySWrPI0Q3UiOg8xTvb_4yEuVyAhWWjPA,445
2
+ cdtkit/config.py,sha256=zlGKnLchDbNISI54rG6XSwBcmeXj7xM1NL_yqJTt1Ic,5017
3
+ cdtkit/fastapi.py,sha256=fxkfpmgqG9rNYbAkP6MAycWH3ft1CblINlwOX5Rvi6E,1261
4
+ cdtkit/migrations.py,sha256=wGusSx8yqB6KjZMOHWljLn7jfDGypRw5HRuRQzxx3Es,8329
5
+ cdtkit/http/__init__.py,sha256=xWC1jepmCg1ghJbPoMojGpRORtfXHUT7xsHc2yOSFz4,1529
6
+ cdtkit/http/logging.py,sha256=-Yb5sTMdU_CAmuxR6EOB5Iz-kABUT5m2_joq00sx9tc,2433
7
+ cdtkit/jobs/__init__.py,sha256=3E2y6TpihQCX3u1hQZlUaeRFzYBFzAhKQ4Hw6WliAoY,6344
8
+ cdtkit/jobs/prune.py,sha256=g8UF32uWVe70IgBIw1uUhjJsvdtRCKPUDp41Qi4jDBU,2382
9
+ cdtkit/jobs/recovery.py,sha256=USjfPRyU6S8BEHEBomZSZNy9Pvwvl-_51f_ULaYJT_I,5902
10
+ cdtkit/jobs/worker.py,sha256=vLeqVGxF-POwysfeb8F0o6qyUNv-BcZpcydS4NSZA8A,4459
11
+ cdtkit/testing/__init__.py,sha256=araX4XyMO3wvDZVSLAEcsekvDTGpyVhuvCU12JQPBeU,481
12
+ cdtkit/testing/plugin.py,sha256=RNSOlMd7lPv7qzZTOgHplA1INek9rdClJNh7nSI-zWU,600
13
+ cdtkit/testing/postgres.py,sha256=JQVLr1hbTTMF87TvgUaz5dTIJe91tCf8UP9iHMFiqBA,4133
14
+ cdtkit/testing/queue.py,sha256=aC6y5lOft0ZSre45NCpEU6NcmY90vEPUUJNewijy3V0,1447
15
+ cdt_kit-0.1.0.dist-info/METADATA,sha256=8g07r29ch162yTh2ybqFAiLPHIr3g-KjzEQ_GsJwdQc,9443
16
+ cdt_kit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
17
+ cdt_kit-0.1.0.dist-info/entry_points.txt,sha256=kUsYjhFKWHOStFMm5Kd3bHoXrBXu-aWY740Y6F9Xj-8,42
18
+ cdt_kit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ cdtkit = cdtkit.testing.plugin
cdtkit/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """cdt-kit: the server-side app kit of the Controlled Delivery Toolkit (CDT).
2
+
3
+ Modules (0.1.0): `cdtkit.config` (environment and `*_FILE` secrets), `cdtkit.migrations` (forward-only SQL files),
4
+ `cdtkit.jobs` (procrastinate on the app's Postgres), `cdtkit.http` (logfmt logging and the health check) and the
5
+ FastAPI adapter `cdtkit.fastapi`; `cdtkit.testing` holds the test stand-ins. The core imports no web framework.
6
+ """
7
+ __version__ = "0.1.0"
cdtkit/config.py ADDED
@@ -0,0 +1,118 @@
1
+ """Configuration the toolkit gives every app: read once at start from the environment and the secret files.
2
+
3
+ Values come from `config/defaults.env`, `.toolkit/vars.env` (rendered by the toolkit) and `.env`, through Compose's
4
+ `env_file`; every secret from the file named by `<NAME>_FILE` (the toolkit mounts `.toolkit/secrets/<NAME>`). The
5
+ database is given in parts (`DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, the password in
6
+ `DATABASE_PASSWORD_FILE`), never as a URL (standard, chapter "database").
7
+ """
8
+ import json
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+ ENV_CLASSES = ("staging", "production")
13
+
14
+
15
+ class ConfigError(SystemExit):
16
+ """A configuration the app cannot start with: the process stops with this message (no traceback)."""
17
+
18
+
19
+ def _env(env):
20
+ return os.environ if env is None else env
21
+
22
+
23
+ def read_secret(name, env=None, required=True):
24
+ """The value of secret NAME: the file named by NAME_FILE (toolkit secrets), else NAME itself (local runs, tests).
25
+
26
+ Surrounding whitespace is removed. An empty value counts as unset: an optional secret with no value on a server
27
+ arrives as an empty file (toolkit 2.111.0). Returns "" for an unset optional secret; raises ConfigError for an
28
+ unset required one or an unreadable file.
29
+ """
30
+ env = _env(env)
31
+ path = env.get(name + "_FILE")
32
+ if path:
33
+ try:
34
+ with open(path, encoding="utf-8") as f:
35
+ value = f.read().strip()
36
+ except OSError as e:
37
+ raise ConfigError("%s: cannot read %s (%s)" % (name, path, e.strerror))
38
+ else:
39
+ value = (env.get(name) or "").strip()
40
+ if not value and required:
41
+ raise ConfigError("%s: missing or empty (written by the toolkit into .toolkit/secrets/)" % name)
42
+ return value
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Database:
47
+ host: str
48
+ port: int
49
+ name: str
50
+ user: str
51
+ password: str
52
+
53
+ def conninfo(self, application_name=""):
54
+ """A libpq connection string; values are quoted, so a password with spaces or quotes stays one value."""
55
+ parts = [("host", self.host), ("port", str(self.port)), ("dbname", self.name), ("user", self.user),
56
+ ("password", self.password)]
57
+ if application_name:
58
+ parts.append(("application_name", application_name))
59
+ return " ".join("%s=%s" % (k, _quote(v)) for k, v in parts)
60
+
61
+ def __repr__(self): # never the password in a log line or a traceback
62
+ return "Database(host=%r, port=%d, name=%r, user=%r)" % (self.host, self.port, self.name, self.user)
63
+
64
+
65
+ def _quote(value):
66
+ if value and not any(c in value for c in " '\\"):
67
+ return value
68
+ return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
69
+
70
+
71
+ def database(env=None):
72
+ """The app's database from DATABASE_* and the password file; ConfigError names every missing variable."""
73
+ env = _env(env)
74
+ missing = [n for n in ("DATABASE_HOST", "DATABASE_NAME", "DATABASE_USER") if not (env.get(n) or "").strip()]
75
+ if missing:
76
+ raise ConfigError("missing configuration: %s" % ", ".join(missing))
77
+ raw_port = (env.get("DATABASE_PORT") or "").strip() or "5432"
78
+ if not raw_port.isdigit():
79
+ raise ConfigError("DATABASE_PORT must be a port number")
80
+ return Database(env["DATABASE_HOST"].strip(), int(raw_port), env["DATABASE_NAME"].strip(),
81
+ env["DATABASE_USER"].strip(), read_secret("DATABASE_PASSWORD", env))
82
+
83
+
84
+ def env_class(env=None, default="staging"):
85
+ """ENV_CLASS: "staging" or "production" (the toolkit writes it); `default` when unset (local runs, tests)."""
86
+ value = (_env(env).get("ENV_CLASS") or "").strip() or default
87
+ if value not in ENV_CLASSES and value != default:
88
+ raise ConfigError("ENV_CLASS must be one of %s" % ", ".join(ENV_CLASSES))
89
+ return value
90
+
91
+
92
+ def is_production(env=None):
93
+ return env_class(env) == "production"
94
+
95
+
96
+ def language(env=None):
97
+ """CDT_LANGUAGE, the deployment's default language (a BCP 47 tag); "" means the app's own default."""
98
+ return (_env(env).get("CDT_LANGUAGE") or "").strip()
99
+
100
+
101
+ def worker_entry(env=None, service="worker"):
102
+ """This worker service's entry of TOOLKIT_JOB_TYPES (deploy/workers.json, rendered by the toolkit):
103
+ {"queue", "failed_retention", "job_types": {id: params}}; {} when the variable is unset or names no such service."""
104
+ raw = (_env(env).get("TOOLKIT_JOB_TYPES") or "").strip()
105
+ if not raw:
106
+ return {}
107
+ try:
108
+ entry = json.loads(raw).get(service) or {}
109
+ except (ValueError, AttributeError):
110
+ raise ConfigError("TOOLKIT_JOB_TYPES is not valid JSON")
111
+ if not isinstance(entry, dict) or not isinstance(entry.get("job_types", {}), (dict, type(None))):
112
+ raise ConfigError("TOOLKIT_JOB_TYPES: the entry of %s is not an object" % service)
113
+ return entry
114
+
115
+
116
+ def job_types(env=None, service="worker"):
117
+ """The worker service's job types from TOOLKIT_JOB_TYPES: {id: params}; {} when unset."""
118
+ return worker_entry(env, service).get("job_types", {}) or {}
cdtkit/fastapi.py ADDED
@@ -0,0 +1,35 @@
1
+ """The FastAPI adapter (the starter's framework): the health route of `cdtkit.http`.
2
+
3
+ from cdtkit.fastapi import health_router
4
+ app.include_router(health_router(Health(database=db.ping)))
5
+
6
+ GET /healthz answers 200 "ok" or 503 "unavailable: <checks>", plain text, never cached, outside the OpenAPI schema.
7
+ Sync checks run in the thread pool, so a slow database never blocks the event loop.
8
+ """
9
+ from fastapi import APIRouter
10
+ from fastapi.concurrency import run_in_threadpool
11
+ from fastapi.responses import PlainTextResponse
12
+ import inspect
13
+
14
+ from cdtkit.http import HEALTH_PATH, Health
15
+
16
+ NO_STORE = {"Cache-Control": "no-store"}
17
+
18
+
19
+ def _threaded(check):
20
+ if inspect.iscoroutinefunction(check):
21
+ return check
22
+ return lambda: run_in_threadpool(check)
23
+
24
+
25
+ def health_router(health=None, path=HEALTH_PATH):
26
+ """An APIRouter with the health route; `health` is a `cdtkit.http.Health` (none: always "ok")."""
27
+ checks = Health(**{name: _threaded(c) for name, c in (health.checks if health else {}).items()})
28
+ router = APIRouter()
29
+
30
+ @router.get(path, include_in_schema=False)
31
+ async def healthz():
32
+ ok, body = await checks.run()
33
+ return PlainTextResponse(body, status_code=200 if ok else 503, headers=NO_STORE)
34
+
35
+ return router
@@ -0,0 +1,39 @@
1
+ """The HTTP rules every app shares, framework-agnostic; `cdtkit.fastapi` adapts them to FastAPI.
2
+
3
+ 0.1.0: logfmt logging (`cdtkit.http.logging`) and the health check (`Health`, answered on `/healthz`, the path
4
+ deploy/web.json's `"health"` names). Later releases add `/metrics`, RFC 9457 problem details, rate-limit classes and the
5
+ Idempotency-Key store (claude-deploy-toolkit #422).
6
+ """
7
+ import inspect
8
+ import logging
9
+
10
+ log = logging.getLogger("cdtkit.http")
11
+ HEALTH_PATH = "/healthz"
12
+
13
+
14
+ class Health:
15
+ """Named checks (sync or async callables; a check passes when it returns without raising), e.g. the database's
16
+ `SELECT 1`. With no checks the process answers "ok" as soon as it serves requests."""
17
+
18
+ def __init__(self, **checks):
19
+ self.checks = dict(checks)
20
+
21
+ def add(self, name, check):
22
+ self.checks[name] = check
23
+ return self
24
+
25
+ async def run(self):
26
+ """(ok, body): body is "ok\\n", or "unavailable: <names>\\n" naming the failed checks (never their errors,
27
+ which can hold internals; those go to the log)."""
28
+ failed = []
29
+ for name, check in self.checks.items():
30
+ try:
31
+ result = check()
32
+ if inspect.isawaitable(result):
33
+ await result
34
+ except Exception as e:
35
+ failed.append(name)
36
+ log.warning("health check failed check=%s: %s", name, type(e).__name__)
37
+ if failed:
38
+ return False, "unavailable: %s\n" % ",".join(failed)
39
+ return True, "ok\n"
cdtkit/http/logging.py ADDED
@@ -0,0 +1,62 @@
1
+ """Operational logs on stdout in logfmt (standard, chapter "declarations", "Metrics and logs"): an ISO UTC time, the
2
+ level, the component, the message, then `key=value` context. Users by internal ID only; never secrets or tokens.
3
+
4
+ One event is one line: an exception adds `error="<Type>: <first message line>"`, and its causes for chained exceptions
5
+ (`<-`), never the multi-line traceback (magic-minutes #56: the traceback's last line was often blank, so `error` said
6
+ nothing).
7
+ """
8
+ import logging
9
+ import sys
10
+ import time
11
+
12
+ MAX_ERROR = 500
13
+ CAUSES = 3 # the exception and at most two of its causes
14
+ QUIET = ("httpx", "httpcore", "psycopg.pool")
15
+
16
+
17
+ def _first_line(exc):
18
+ try:
19
+ text = str(exc)
20
+ except Exception: # an exception whose str() fails still gets its line, with the type alone
21
+ return ""
22
+ for line in text.splitlines():
23
+ if line.strip():
24
+ return " ".join(line.split())
25
+ return ""
26
+
27
+
28
+ def error_text(exc):
29
+ """The exception's type and first non-empty message line, then its causes', on one line; "" without one."""
30
+ parts, seen = [], set()
31
+ while exc is not None and id(exc) not in seen and len(parts) < CAUSES:
32
+ seen.add(id(exc))
33
+ first = _first_line(exc)
34
+ parts.append(type(exc).__name__ + (": " + first if first else ""))
35
+ exc = exc.__cause__ if exc.__cause__ is not None else (None if exc.__suppress_context__ else exc.__context__)
36
+ return " <- ".join(parts)[:MAX_ERROR]
37
+
38
+
39
+ def _clean(text):
40
+ return " ".join(str(text).replace('"', "'").splitlines())
41
+
42
+
43
+ class Logfmt(logging.Formatter):
44
+ def format(self, record):
45
+ line = 'time=%s level=%s component=%s msg="%s"' % (
46
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)), record.levelname.lower(), record.name,
47
+ _clean(record.getMessage()))
48
+ exc = record.exc_info[1] if isinstance(record.exc_info, tuple) else None
49
+ if exc is not None:
50
+ line += ' error="%s"' % _clean(error_text(exc))
51
+ return line
52
+
53
+
54
+ def setup(level=logging.INFO, quiet=QUIET):
55
+ """One handler on the root logger, whatever ran before (calling it again replaces it, never adds a second)."""
56
+ handler = logging.StreamHandler(sys.stdout)
57
+ handler.setFormatter(Logfmt())
58
+ root = logging.getLogger()
59
+ root.handlers[:] = [handler]
60
+ root.setLevel(level)
61
+ for noisy in quiet:
62
+ logging.getLogger(noisy).setLevel(logging.WARNING)
@@ -0,0 +1,122 @@
1
+ """The job queue: procrastinate in the app's own Postgres, one queue per job type (standard, chapter "declarations").
2
+
3
+ The parameters (concurrency, attempts, backoff, timeouts, stop grace) come from TOOLKIT_JOB_TYPES, which the toolkit
4
+ renders from deploy/workers.json (`cdtkit.config.job_types`); the app's `defaults` only cover local runs and tests.
5
+ Payloads carry internal IDs only, never personal data.
6
+
7
+ - `make_app(conninfo, job_types, handlers, defaults)`: the procrastinate app with one task per job type, each on its own
8
+ queue (so its concurrency holds) with the kit's `Backoff`.
9
+ - A handler whose row is gone (deleted while the job waited, an account erased) raises `NoRow`: the job ends
10
+ successfully and logs `skipped=no-row`, with no retry and no failure hook. Generic on purpose: what the row belongs to
11
+ (an owner, a group) is the app's business until cdt-kit 0.2.0.
12
+ - `on_final_failure(job_id, payload)` runs when a job's last attempt raises, so what the user sees never stays "running".
13
+ - The worker loop, heartbeat and health are in `cdtkit.jobs.worker`; dead-worker recovery in `cdtkit.jobs.recovery`;
14
+ pruning finished jobs in `cdtkit.jobs.prune`.
15
+ """
16
+ import logging
17
+ import random
18
+ import re
19
+
20
+ from procrastinate import App, BaseRetryStrategy, PsycopgConnector, RetryDecision
21
+
22
+ log = logging.getLogger("cdtkit.jobs")
23
+
24
+ DEFAULT_PARAMS = {"concurrency": 1, "max_attempts": 3, "backoff": "30s", "timeout_item": "1h", "stop_grace": "60s"}
25
+ BACKOFF_CAP = 6 * 3600 # seconds: the longest wait between two attempts
26
+ MAX_ATTEMPTS = 25 # the standard's upper bound for max_attempts
27
+ POLL_SECONDS = 5.0 # workers poll for new jobs instead of holding one LISTEN connection per queue
28
+ UNITS = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}
29
+
30
+
31
+ class NoRow(Exception):
32
+ """Raised by a handler when the row its job works on no longer exists: nothing left to do, the job succeeds."""
33
+
34
+
35
+ def seconds(value, default=60):
36
+ """'90s', '15m', '2h', '1d' (or a bare number of seconds) -> seconds; `default` when empty or not a duration."""
37
+ m = re.match(r"^\s*(\d+)\s*([smhd]?)\s*$", str(value if value is not None else ""))
38
+ if not m:
39
+ return default
40
+ return int(m.group(1)) * UNITS[m.group(2)]
41
+
42
+
43
+ def params(job_types, job_id, defaults=None):
44
+ """A job type's parameters: the kit's defaults, then the app's `defaults[job_id]`, then TOOLKIT_JOB_TYPES."""
45
+ p = dict(DEFAULT_PARAMS)
46
+ p.update((defaults or {}).get(job_id) or {})
47
+ p.update((job_types or {}).get(job_id) or {})
48
+ return p
49
+
50
+
51
+ class Backoff(BaseRetryStrategy):
52
+ """procrastinate retry strategy: `max_attempts` runs in total (deploy/workers.json's meaning, at most 25), an
53
+ exponential wait from `backoff` (backoff, 2x, 4x, ...) capped at `cap` seconds, each shortened by up to `jitter`
54
+ (a share of it), so jobs that failed together do not retry together."""
55
+
56
+ def __init__(self, max_attempts=3, backoff=30, cap=BACKOFF_CAP, jitter=0.5, rng=None):
57
+ self.max_attempts = max(1, min(int(max_attempts), MAX_ATTEMPTS))
58
+ self.backoff, self.cap = max(0, int(backoff)), max(0, int(cap))
59
+ self.jitter = min(max(float(jitter), 0.0), 1.0)
60
+ self._rng = rng or random.Random()
61
+
62
+ def wait(self, failed):
63
+ """Seconds before the next attempt after `failed` failed attempts (1, 2, ...)."""
64
+ full = min(self.cap, self.backoff * (2 ** max(0, failed - 1)))
65
+ return int(full * (1 - self.jitter * self._rng.random()))
66
+
67
+ def get_retry_decision(self, *, exception, job):
68
+ """None (no retry) when this was the job's last attempt; procrastinate's `job.attempts` counts earlier ones."""
69
+ failed = int(job.attempts or 0) + 1
70
+ if failed >= self.max_attempts:
71
+ return None
72
+ return RetryDecision(retry_in={"seconds": self.wait(failed)})
73
+
74
+
75
+ def make_app(conninfo, job_types, handlers=None, defaults=None, job_ids=None, pool_max=2, on_final_failure=None,
76
+ connector=None):
77
+ """The procrastinate app with one task per job type (`job_ids`, else the keys of `defaults` and `job_types`).
78
+
79
+ `handlers` maps job type -> async function taking the payload as keyword arguments (worker processes only; a web
80
+ process defers and never runs them). The connection pool holds 1 to `pool_max` connections; `connector` replaces it
81
+ (tests: `cdtkit.testing.queue`)."""
82
+ ids = list(job_ids or dict.fromkeys(list((defaults or {}).keys()) + list((job_types or {}).keys())))
83
+ app = App(connector=connector or PsycopgConnector(conninfo=conninfo, min_size=1, max_size=pool_max))
84
+ for job_id in ids:
85
+ p = params(job_types, job_id, defaults)
86
+ retry = Backoff(max_attempts=int(p.get("max_attempts", 3)), backoff=seconds(p.get("backoff"), 30))
87
+ func = (handlers or {}).get(job_id) or _not_here
88
+ app.task(name=job_id, queue=job_id, retry=retry, pass_context=True)(_guarded(job_id, func, retry, on_final_failure))
89
+ return app
90
+
91
+
92
+ def _guarded(job_id, func, retry, on_final_failure):
93
+ """The handler, with NoRow ending the job and the final-failure hook when its last attempt raises."""
94
+ async def run(context, **payload):
95
+ try:
96
+ return await func(**payload)
97
+ except NoRow as gone:
98
+ log.info("job done job_type=%s id=%s skipped=no-row%s", job_id, context.job.id,
99
+ " reason=%s" % "_".join(str(gone).split()) if str(gone) else "")
100
+ return None
101
+ except Exception as error:
102
+ if on_final_failure is not None and retry.get_retry_decision(exception=error, job=context.job) is None:
103
+ try:
104
+ await on_final_failure(job_id, payload)
105
+ except Exception as hook_error: # never hides the job's own error
106
+ log.warning("final failure hook failed job_type=%s: %s", job_id, type(hook_error).__name__)
107
+ raise
108
+ run.__name__ = run.__qualname__ = getattr(func, "__name__", job_id)
109
+ return run
110
+
111
+
112
+ async def _not_here(**kwargs):
113
+ raise RuntimeError("job handlers run in the worker")
114
+
115
+
116
+ def defer(app, job_id, **payload):
117
+ """Queue a job on its own queue; returns its ID."""
118
+ return app.configure_task(job_id, queue=job_id).defer(**payload)
119
+
120
+
121
+ async def defer_async(app, job_id, **payload):
122
+ return await app.configure_task(job_id, queue=job_id).defer_async(**payload)
cdtkit/jobs/prune.py ADDED
@@ -0,0 +1,59 @@
1
+ """Finished jobs are removed after `failed_retention` (deploy/workers.json; hours or days, default 30 days).
2
+
3
+ Successful jobs are deleted as they finish (`delete_jobs="successful"` in `cdtkit.jobs.worker`); failed, cancelled and
4
+ aborted jobs stay for `failed_retention` so their errors can be read, then go. Time-driven work is a declared job, so
5
+ the app runs this from a schedule of its own (deploy/schedules.json), never from a loop in its code:
6
+
7
+ python -m cdtkit.jobs.prune [--service worker] # DATABASE_*, DATABASE_PASSWORD_FILE and TOOLKIT_JOB_TYPES
8
+ """
9
+ import asyncio
10
+ import logging
11
+ import sys
12
+
13
+ from procrastinate import App, PsycopgConnector
14
+
15
+ from cdtkit import config, jobs
16
+
17
+ log = logging.getLogger("cdtkit.prune")
18
+ DEFAULT_RETENTION = "30d"
19
+
20
+
21
+ def retention_hours(entry):
22
+ """`failed_retention` of a TOOLKIT_JOB_TYPES entry in whole hours (at least 1)."""
23
+ value = (entry or {}).get("failed_retention") or DEFAULT_RETENTION
24
+ return max(1, jobs.seconds(value, jobs.seconds(DEFAULT_RETENTION)) // 3600)
25
+
26
+
27
+ async def prune_finished(app, job_ids, entry):
28
+ """Delete the jobs of `job_ids` that finished (failed, cancelled, aborted; successful ones too) more than
29
+ `failed_retention` ago. Returns the hours used."""
30
+ hours = retention_hours(entry)
31
+ for job_id in job_ids:
32
+ await app.job_manager.delete_old_jobs(nb_hours=hours, queue=job_id, include_failed=True,
33
+ include_cancelled=True, include_aborted=True)
34
+ log.info("finished jobs pruned queues=%s older_than_hours=%d", ",".join(job_ids), hours)
35
+ return hours
36
+
37
+
38
+ async def _main(service):
39
+ entry = config.worker_entry(service=service)
40
+ job_ids = list((entry.get("job_types") or {}).keys())
41
+ if not job_ids:
42
+ raise config.ConfigError("TOOLKIT_JOB_TYPES names no job types for %s" % service)
43
+ conninfo = config.database().conninfo("cdtkit-prune")
44
+ app = App(connector=PsycopgConnector(conninfo=conninfo, min_size=1, max_size=1))
45
+ async with app.open_async():
46
+ await prune_finished(app, job_ids, entry)
47
+
48
+
49
+ def main(argv=None):
50
+ from cdtkit.http.logging import setup
51
+ args = list(sys.argv[1:] if argv is None else argv)
52
+ service = args[args.index("--service") + 1] if "--service" in args else "worker"
53
+ setup()
54
+ asyncio.run(_main(service))
55
+ return 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
@@ -0,0 +1,108 @@
1
+ """Jobs survive a dead worker (magic-minutes #54, made generic).
2
+
3
+ procrastinate tracks its workers with heartbeats but never re-queues the job of a worker that died or lost the database:
4
+ the job stays `doing` forever. Each worker service therefore checks its own queues itself, at start and every few
5
+ minutes (part of the queue's health, not a scheduled job):
6
+ - a `doing` job whose worker stopped beating (or is gone) is put back in the queue, as its retry would have been; when it
7
+ has no attempt left it ends failed and `on_given_up(job_id, payload)` runs, so what the user sees says it was
8
+ interrupted;
9
+ - a `doing` job running past its own time limit (`timeout_item`) plus a margin ends failed the same way: the handler's
10
+ timeout should have ended it, so its worker is stuck;
11
+ - job types in `never_give_up` (an account's erasure must happen) are re-queued whatever their attempts.
12
+ Jobs are idempotent (deploy/workers.json), so running one again is safe.
13
+ """
14
+ import asyncio
15
+ import inspect
16
+ import logging
17
+ from datetime import datetime, timezone
18
+
19
+ from procrastinate.jobs import Status
20
+
21
+ from cdtkit import jobs
22
+
23
+ log = logging.getLogger("cdtkit.recovery")
24
+ HEARTBEAT_EVERY = 10 # seconds between a worker's heartbeats (procrastinate's update_heartbeat_interval)
25
+ STALLED_AFTER = 120 # seconds without a heartbeat before a worker counts as dead (stalled_worker_timeout)
26
+ CHECK_EVERY = 300 # seconds between two checks
27
+ TIMEOUT_MARGIN = 300 # seconds a job may run past its timeout_item before it counts as stuck
28
+ OVERDUE_SQL = (
29
+ "SELECT j.id, j.args, j.attempts FROM procrastinate_jobs j WHERE j.status = 'doing' AND j.queue_name = %(queue)s"
30
+ " AND (SELECT max(e.at) FROM procrastinate_events e WHERE e.job_id = j.id AND e.type = 'started')"
31
+ " < now() - make_interval(secs => %(seconds)s)")
32
+
33
+
34
+ class Interrupted(Exception):
35
+ """Stands for the death of a job's worker when its retry strategy is asked whether another attempt is left."""
36
+
37
+
38
+ class Recovery:
39
+ """The checks of one worker service over the queues it runs (one queue per job type).
40
+
41
+ `on_given_up(job_id, payload)` (sync or async) marks what the user sees about a job that was given up; it may be
42
+ None. `defaults` are the app's job type defaults (as for `cdtkit.jobs.make_app`)."""
43
+
44
+ def __init__(self, app, job_ids, job_types, defaults=None, on_given_up=None, never_give_up=()):
45
+ self.app, self.job_ids, self.job_types, self.defaults = app, list(job_ids), job_types, defaults
46
+ self.on_given_up, self.never_give_up = on_given_up, tuple(never_give_up)
47
+
48
+ def _out_of_attempts(self, job_id, job):
49
+ """As if the job had raised: its own retry strategy decides whether an attempt is left."""
50
+ retry = self.app.tasks[job_id].retry_strategy
51
+ return retry is None or retry.get_retry_decision(exception=Interrupted(), job=job) is None
52
+
53
+ async def _mark(self, job_id, payload):
54
+ if self.on_given_up is None:
55
+ return
56
+ result = self.on_given_up(job_id, payload)
57
+ if inspect.isawaitable(result):
58
+ await result
59
+
60
+ async def _give_up(self, job_id, job_db_id, payload, why):
61
+ await self.app.job_manager.finish_job_by_id_async(job_db_id, status=Status.FAILED, delete_job=False)
62
+ await self._mark(job_id, payload)
63
+ log.warning("job given up id=%s queue=%s reason=%s", job_db_id, job_id, why.replace(" ", "_"))
64
+
65
+ async def _settle(self, action, job_db_id, job_id):
66
+ """One job's action; a job that left `doing` meanwhile (it finished) is skipped, never the whole pass."""
67
+ try:
68
+ await action()
69
+ return True
70
+ except Exception as e:
71
+ log.info("recovery skipped id=%s queue=%s: %s", job_db_id, job_id, type(e).__name__)
72
+ return False
73
+
74
+ async def check(self):
75
+ """One pass over the service's queues; returns (requeued, given_up)."""
76
+ manager, requeued, given_up = self.app.job_manager, 0, 0
77
+ now = datetime.now(timezone.utc)
78
+ for job_id in self.job_ids:
79
+ for job in await manager.get_stalled_jobs(queue=job_id, seconds_since_heartbeat=STALLED_AFTER):
80
+ if job_id not in self.never_give_up and self._out_of_attempts(job_id, job):
81
+ why = "its worker died, no attempt left"
82
+ if await self._settle(lambda j=job: self._give_up(job_id, j.id, j.task_kwargs, why), job.id, job_id):
83
+ given_up += 1
84
+ elif await self._settle(lambda j=job: manager.retry_job(j), job.id, job_id):
85
+ log.warning("stalled job requeued id=%s queue=%s attempts=%d", job.id, job_id, job.attempts + 1)
86
+ requeued += 1
87
+ p = jobs.params(self.job_types, job_id, self.defaults)
88
+ limit = jobs.seconds(p.get("timeout_item"), 3600) + TIMEOUT_MARGIN
89
+ rows = await self.app.connector.execute_query_all_async(OVERDUE_SQL, queue=job_id, seconds=limit)
90
+ for row in rows:
91
+ if job_id in self.never_give_up:
92
+ action = (lambda r=row: manager.retry_job_by_id_async(r["id"], retry_at=now))
93
+ requeued += await self._settle(action, row["id"], job_id)
94
+ continue
95
+ action = (lambda r=row: self._give_up(job_id, r["id"], r["args"], "past its time limit"))
96
+ given_up += await self._settle(action, row["id"], job_id)
97
+ return requeued, given_up
98
+
99
+ async def watch(self, every=CHECK_EVERY):
100
+ """At start, then every `every` seconds, until cancelled; a failed pass is logged and tried again later."""
101
+ while True:
102
+ try:
103
+ await self.check()
104
+ except asyncio.CancelledError:
105
+ raise
106
+ except Exception as e:
107
+ log.warning("recovery check failed: %s", type(e).__name__)
108
+ await asyncio.sleep(every)
cdtkit/jobs/worker.py ADDED
@@ -0,0 +1,96 @@
1
+ """A worker service's process: one procrastinate worker per queue, a heartbeat for the container health check and the
2
+ dead-worker recovery, until SIGTERM (graceful within each job type's `stop_grace`).
3
+
4
+ async def main():
5
+ app = jobs.make_app(conninfo, job_types, HANDLERS, DEFAULTS, job_ids=MY_JOB_IDS)
6
+ migrations.migrate(conninfo, MIGRATIONS, queue_app=app)
7
+ await worker.run(app, MY_JOB_IDS, job_types, DEFAULTS, ping=db.ping, on_given_up=mark_failed)
8
+
9
+ python -m cdtkit.jobs.worker --health # exit 0 while the heartbeat is fresh (the container's health check)
10
+ """
11
+ import asyncio
12
+ import inspect
13
+ import logging
14
+ import os
15
+ import signal
16
+ import sys
17
+ import time
18
+
19
+ from procrastinate.worker import Worker
20
+
21
+ from cdtkit import jobs
22
+ from cdtkit.jobs import recovery
23
+
24
+ log = logging.getLogger("cdtkit.worker")
25
+ HEARTBEAT = "/tmp/worker-heartbeat"
26
+ HEARTBEAT_EVERY = 20 # seconds; the health check fails after four missed beats
27
+
28
+
29
+ async def heartbeat(ping=None, path=HEARTBEAT, every=HEARTBEAT_EVERY):
30
+ """Writes the time into `path` every `every` seconds after `ping()` (sync or async, e.g. the database's SELECT 1)
31
+ answered; a failed ping skips the beat, so the health check fails while the database is unreachable."""
32
+ while True:
33
+ try:
34
+ if ping is not None:
35
+ result = await asyncio.to_thread(ping) if not inspect.iscoroutinefunction(ping) else await ping()
36
+ if inspect.isawaitable(result):
37
+ await result
38
+ with open(path, "w") as f:
39
+ f.write(str(time.time()))
40
+ except asyncio.CancelledError:
41
+ raise
42
+ except Exception as e: # a missed beat makes the health check fail; the loop keeps trying
43
+ log.warning("heartbeat failed: %s", type(e).__name__)
44
+ await asyncio.sleep(every)
45
+
46
+
47
+ def health(path=HEARTBEAT, every=HEARTBEAT_EVERY):
48
+ """0 while the heartbeat is fresh (younger than four beats), else 1."""
49
+ try:
50
+ age = time.time() - os.path.getmtime(path)
51
+ except OSError:
52
+ return 1
53
+ return 0 if age < every * 4 else 1
54
+
55
+
56
+ def workers(app, job_ids, job_types, defaults=None, name_prefix="w-"):
57
+ """One procrastinate Worker per job type, with its concurrency and stop grace, polling (no LISTEN connection), the
58
+ successful jobs deleted, and the heartbeat timings the recovery check uses."""
59
+ out = []
60
+ for job_id in job_ids:
61
+ p = jobs.params(job_types, job_id, defaults)
62
+ out.append(Worker(app, queues=[job_id], name=name_prefix + job_id, concurrency=int(p.get("concurrency", 1)),
63
+ shutdown_graceful_timeout=jobs.seconds(p.get("stop_grace"), 60),
64
+ install_signal_handlers=False, delete_jobs="successful",
65
+ listen_notify=False, fetch_job_polling_interval=jobs.POLL_SECONDS,
66
+ # a worker counts as dead after the recovery check's quiet time, never procrastinate's default
67
+ # 30 s, when a starting worker would prune a live worker's row
68
+ update_heartbeat_interval=recovery.HEARTBEAT_EVERY,
69
+ stalled_worker_timeout=recovery.STALLED_AFTER))
70
+ return out
71
+
72
+
73
+ async def run(app, job_ids, job_types, defaults=None, ping=None, on_given_up=None, never_give_up=(), name_prefix="w-",
74
+ heartbeat_path=HEARTBEAT):
75
+ """Run the service's queues until SIGTERM or SIGINT, with the heartbeat and the recovery watch."""
76
+ async with app.open_async():
77
+ ws = workers(app, job_ids, job_types, defaults, name_prefix)
78
+ log.info("worker ready: queues %s", ",".join(job_ids))
79
+ loop = asyncio.get_running_loop()
80
+ for sig in (signal.SIGTERM, signal.SIGINT): # one stop for every queue's worker (graceful, stop_grace)
81
+ loop.add_signal_handler(sig, lambda: [w.stop() for w in ws])
82
+ beat = asyncio.create_task(heartbeat(ping, heartbeat_path))
83
+ watch = asyncio.create_task(recovery.Recovery(app, job_ids, job_types, defaults, on_given_up,
84
+ never_give_up).watch())
85
+ try:
86
+ await asyncio.gather(*(w.run() for w in ws))
87
+ finally:
88
+ beat.cancel()
89
+ watch.cancel()
90
+
91
+
92
+ if __name__ == "__main__":
93
+ if "--health" in sys.argv:
94
+ sys.exit(health())
95
+ print(__doc__.strip().splitlines()[-1].strip(), file=sys.stderr)
96
+ sys.exit(2)
cdtkit/migrations.py ADDED
@@ -0,0 +1,188 @@
1
+ """Forward-only SQL migrations, applied at app start, and the expand-only lint (standard, chapter "database").
2
+
3
+ `migrate(conninfo, directory)` applies `<directory>/*.sql` in name order, each once, each in its own transaction, and
4
+ records it in `schema_migrations (name, applied_at)`. A session advisory lock serialises the processes of a stack that
5
+ start together (web and workers): the second waits, then finds nothing left to do. Idempotent; never rolls back.
6
+
7
+ `lint(directory)` reports lines that contract the schema (drops, renames, type changes, new NOT NULL, UNIQUE or CHECK
8
+ constraints on existing data). A contract is allowed only in a cleanup release that declares it in
9
+ `deploy/package.json` "contracts"; marking the statement's line with `-- cdt: contract <reason>` says it was declared.
10
+
11
+ python -m cdtkit.migrations lint <directory> [--after <name>] # exit 1 on findings
12
+ python -m cdtkit.migrations apply <directory> # DATABASE_* and DATABASE_PASSWORD_FILE
13
+ """
14
+ import logging
15
+ import os
16
+ import re
17
+ import sys
18
+
19
+ log = logging.getLogger("cdtkit.migrations")
20
+
21
+ LOCK_ID = 7316001 # the advisory lock's key; one database per app, so one fixed key is enough (magic-minutes' value)
22
+ TABLE_SQL = ("CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY,"
23
+ " applied_at timestamptz NOT NULL DEFAULT now())")
24
+ CONTRACT_MARK = re.compile(r"--\s*cdt:\s*contract\b", re.I)
25
+ CONTRACTS = ( # (pattern on one statement, what it does)
26
+ (r"\bDROP\s+(?P<k>TABLE|COLUMN|INDEX|VIEW|MATERIALIZED\s+VIEW|TYPE|SCHEMA|SEQUENCE|CONSTRAINT|DEFAULT|TRIGGER|FUNCTION)\b",
27
+ "drops %s"),
28
+ (r"\bRENAME\b", "renames"),
29
+ (r"\bALTER\s+(COLUMN\s+)?\S+\s+(SET\s+DATA\s+)?TYPE\b", "changes a column type"),
30
+ (r"\bSET\s+NOT\s+NULL\b", "adds NOT NULL to an existing column"),
31
+ (r"\bADD\s+(CONSTRAINT\s+\S+\s+)?(?P<k>UNIQUE|CHECK|PRIMARY\s+KEY|EXCLUDE)\b", "adds a %s constraint to an existing table"),
32
+ (r"\bCREATE\s+UNIQUE\s+INDEX\b", "adds a unique index"),
33
+ (r"\bTRUNCATE\b", "empties a table"),
34
+ (r"\bALTER\s+TYPE\s+\S+\s+DROP\s+VALUE\b", "removes an enum value"),
35
+ )
36
+ ADD_COLUMN = re.compile(r"\bADD\s+(COLUMN\s+)?(IF\s+NOT\s+EXISTS\s+)?(\S+)\s+[^,;]*", re.I)
37
+
38
+
39
+ def files(directory):
40
+ return sorted(f for f in os.listdir(directory) if f.endswith(".sql"))
41
+
42
+
43
+ def migrate(conninfo, directory, queue_app=None, lock_id=LOCK_ID):
44
+ """Apply the pending migrations of `directory`; then, with a procrastinate app, its queue schema when missing.
45
+ Returns the names applied (empty when the database was current)."""
46
+ import psycopg # the core's only database driver; imported here so `lint` runs without it
47
+
48
+ applied = []
49
+ with psycopg.connect(conninfo, autocommit=True) as c:
50
+ c.execute("SELECT pg_advisory_lock(%s)", (lock_id,))
51
+ try:
52
+ c.execute(TABLE_SQL)
53
+ done = {r[0] for r in c.execute("SELECT name FROM schema_migrations").fetchall()}
54
+ for name in files(directory):
55
+ if name in done:
56
+ continue
57
+ with open(os.path.join(directory, name), encoding="utf-8") as f:
58
+ sql = f.read()
59
+ with c.transaction():
60
+ c.execute(sql)
61
+ c.execute("INSERT INTO schema_migrations (name) VALUES (%s)", (name,))
62
+ applied.append(name)
63
+ log.info("migration applied: %s", name)
64
+ if queue_app is not None:
65
+ exists = c.execute("SELECT to_regclass('procrastinate_jobs') IS NOT NULL").fetchone()[0]
66
+ if not exists:
67
+ with c.transaction():
68
+ c.execute(queue_app.schema_manager.get_schema())
69
+ log.info("job queue schema applied (procrastinate)")
70
+ finally:
71
+ c.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
72
+ return applied
73
+
74
+
75
+ def _strip_comments(sql):
76
+ """The SQL without comments and string literals (their text is not schema), keeping the line breaks."""
77
+ out, i, n = [], 0, len(sql)
78
+ while i < n:
79
+ if sql.startswith("--", i):
80
+ j = sql.find("\n", i)
81
+ i = n if j < 0 else j
82
+ elif sql.startswith("/*", i):
83
+ j = sql.find("*/", i + 2)
84
+ j = n if j < 0 else j + 2
85
+ out.append("\n" * sql.count("\n", i, j))
86
+ i = j
87
+ elif sql[i] == "'":
88
+ j = i + 1
89
+ while j < n and not (sql[j] == "'" and not sql.startswith("''", j)):
90
+ j += 2 if sql.startswith("''", j) else 1
91
+ out.append("''" + "\n" * sql.count("\n", i, j))
92
+ i = j + 1
93
+ else:
94
+ out.append(sql[i])
95
+ i += 1
96
+ return "".join(out)
97
+
98
+
99
+ def _statements(sql):
100
+ """(first line number, statement text, raw lines) per statement; `$$` bodies are kept whole."""
101
+ clean = _strip_comments(sql)
102
+ raw_lines = sql.splitlines()
103
+ result, start, i, line, depth = [], 0, 0, 1, False
104
+ begin_line = 1
105
+ while i < len(clean):
106
+ if clean.startswith("$$", i):
107
+ depth = not depth
108
+ i += 2
109
+ continue
110
+ ch = clean[i]
111
+ if ch == "\n":
112
+ line += 1
113
+ if ch == ";" and not depth:
114
+ text = clean[start:i]
115
+ if text.strip():
116
+ first = begin_line + text[:len(text) - len(text.lstrip())].count("\n")
117
+ result.append((first, text, raw_lines[first - 1:line]))
118
+ start, begin_line = i + 1, line
119
+ i += 1
120
+ text = clean[start:]
121
+ if text.strip():
122
+ first = begin_line + text[:len(text) - len(text.lstrip())].count("\n")
123
+ result.append((first, text, raw_lines[first - 1:]))
124
+ return result
125
+
126
+
127
+ def _findings(statement):
128
+ s = " ".join(statement.split())
129
+ upper = s.upper()
130
+ found = []
131
+ if upper.startswith(("CREATE FUNCTION", "CREATE OR REPLACE FUNCTION", "CREATE PROCEDURE", "CREATE OR REPLACE PROCEDURE")):
132
+ return found
133
+ for pattern, what in CONTRACTS:
134
+ m = re.search(pattern, s, re.I)
135
+ if m:
136
+ kind = " ".join((m.groupdict().get("k") or "").lower().split())
137
+ if kind == "default" and what.startswith("drops"):
138
+ continue # dropping a default relaxes nothing the stable version needs
139
+ found.append(what % kind if "%s" in what else what)
140
+ if upper.startswith("ALTER TABLE"):
141
+ for m in ADD_COLUMN.finditer(s):
142
+ if m.group(3).upper() in ("CONSTRAINT", "UNIQUE", "CHECK", "PRIMARY", "FOREIGN", "EXCLUDE"):
143
+ continue
144
+ column = m.group(0).upper()
145
+ if "NOT NULL" in column and " DEFAULT " not in " " + column + " ":
146
+ found.append("adds a NOT NULL column without a default")
147
+ return found
148
+
149
+
150
+ def lint(directory, after=None):
151
+ """Contract lines of `directory`'s migrations (only files named after `after`, when given): a list of
152
+ (file, line, what) for statements not marked `-- cdt: contract <reason>`."""
153
+ out = []
154
+ for name in files(directory):
155
+ if after and name <= after:
156
+ continue
157
+ with open(os.path.join(directory, name), encoding="utf-8") as f:
158
+ sql = f.read()
159
+ for line, statement, raw in _statements(sql):
160
+ if any(CONTRACT_MARK.search(r) for r in raw):
161
+ continue
162
+ for what in _findings(statement):
163
+ out.append((name, line, what))
164
+ return out
165
+
166
+
167
+ def main(argv=None):
168
+ args = list(sys.argv[1:] if argv is None else argv)
169
+ if len(args) < 2 or args[0] not in ("lint", "apply"):
170
+ print(__doc__.strip().splitlines()[-2].strip(), file=sys.stderr)
171
+ print(__doc__.strip().splitlines()[-1].strip(), file=sys.stderr)
172
+ return 2
173
+ if args[0] == "lint":
174
+ after = args[args.index("--after") + 1] if "--after" in args else None
175
+ found = lint(args[1], after)
176
+ for name, line, what in found:
177
+ print("%s:%d: %s (expand only: declare it in deploy/package.json \"contracts\" and mark the line"
178
+ " -- cdt: contract <reason>)" % (name, line, what))
179
+ return 1 if found else 0
180
+ from cdtkit import config
181
+ from cdtkit.http.logging import setup
182
+ setup()
183
+ migrate(config.database().conninfo("cdtkit-migrate"), args[1])
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())
@@ -0,0 +1,7 @@
1
+ """Test stand-ins (extra `cdt-kit[test]`): a Postgres (`cdtkit.testing.postgres`), the queue (`cdtkit.testing.queue`)
2
+ and the pytest fixtures (`cdtkit.testing.plugin`, registered as a pytest plugin by the extra).
3
+
4
+ Fixtures: `cdt_postgres` (one server per test session) and `cdt_database` (a fresh empty database per test, a
5
+ `cdtkit.config.Database`; skipped when no Postgres is available). The broker stand-in arrives with identity (0.2.0),
6
+ the ClamAV stub with `cdtkit.scan`.
7
+ """
@@ -0,0 +1,28 @@
1
+ """pytest fixtures of the kit (entry point `pytest11` of the extra `cdt-kit[test]`)."""
2
+ import pytest
3
+
4
+ from cdtkit.testing.postgres import PostgresServer, Unavailable
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def cdt_postgres():
9
+ try:
10
+ server = PostgresServer()
11
+ except Unavailable as e:
12
+ pytest.skip(str(e))
13
+ try:
14
+ yield server
15
+ finally:
16
+ server.close()
17
+
18
+
19
+ @pytest.fixture
20
+ def cdt_database(cdt_postgres):
21
+ db = cdt_postgres.database()
22
+ try:
23
+ yield db
24
+ finally:
25
+ try:
26
+ cdt_postgres.drop(db)
27
+ except Exception:
28
+ pass
@@ -0,0 +1,93 @@
1
+ """A Postgres for tests: an existing server named by CDT_TEST_DATABASE_* , else a throwaway local cluster.
2
+
3
+ - `CDT_TEST_DATABASE_HOST` (and optional `_PORT`, `_USER`, `_PASSWORD`, `_NAME`): a server the tests may create and
4
+ drop databases on (the kit's selftest service uses its sidecar this way).
5
+ - Otherwise `initdb` and `pg_ctl` (from PATH, `PG_BIN`, or /usr/lib/postgresql/<major>/bin) start a cluster in a temporary
6
+ folder, listening only on a Unix socket there, stopped and removed afterwards.
7
+
8
+ `PostgresServer().database()` creates a fresh, empty database per test and returns a `cdtkit.config.Database`.
9
+ """
10
+ import glob
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import uuid
16
+
17
+ from cdtkit.config import Database
18
+
19
+
20
+ class Unavailable(RuntimeError):
21
+ """No server configured and no local Postgres binaries: tests that need a database are skipped."""
22
+
23
+
24
+ def _bin_dir():
25
+ if os.environ.get("PG_BIN"):
26
+ return os.environ["PG_BIN"]
27
+ found = shutil.which("pg_ctl")
28
+ if found:
29
+ return os.path.dirname(found)
30
+ dirs = sorted(glob.glob("/usr/lib/postgresql/*/bin"), key=lambda d: int(d.split("/")[-2]) if d.split("/")[-2].isdigit() else 0)
31
+ if dirs:
32
+ return dirs[-1]
33
+ raise Unavailable("no Postgres: set CDT_TEST_DATABASE_HOST or install the server (initdb, pg_ctl)")
34
+
35
+
36
+ class PostgresServer:
37
+ def __init__(self):
38
+ self.tmp, self.created = None, []
39
+ env = os.environ
40
+ if env.get("CDT_TEST_DATABASE_HOST"):
41
+ self.admin = Database(env["CDT_TEST_DATABASE_HOST"], int(env.get("CDT_TEST_DATABASE_PORT") or 5432),
42
+ env.get("CDT_TEST_DATABASE_NAME") or "postgres",
43
+ env.get("CDT_TEST_DATABASE_USER") or "postgres",
44
+ env.get("CDT_TEST_DATABASE_PASSWORD") or "")
45
+ else:
46
+ self.admin = self._start()
47
+
48
+ def _start(self):
49
+ bindir = _bin_dir()
50
+ self.tmp = tempfile.mkdtemp(prefix="cdtkit-pg-")
51
+ data, sock = os.path.join(self.tmp, "data"), os.path.join(self.tmp, "sock")
52
+ os.mkdir(sock)
53
+ user = "cdtkit"
54
+ run = dict(check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
55
+ subprocess.run([os.path.join(bindir, "initdb"), "-D", data, "-U", user, "-A", "trust", "--no-sync"], **run)
56
+ opts = "-c listen_addresses='' -k %s -c fsync=off -c synchronous_commit=off -c full_page_writes=off" % sock
57
+ subprocess.run([os.path.join(bindir, "pg_ctl"), "-D", data, "-o", opts, "-w", "-l",
58
+ os.path.join(self.tmp, "log"), "start"], **run)
59
+ self._pg_ctl = (os.path.join(bindir, "pg_ctl"), data)
60
+ return Database(sock, 5432, "postgres", user, "")
61
+
62
+ def conninfo(self, db=None):
63
+ d = db or self.admin
64
+ info = "host=%s port=%d dbname=%s user=%s" % (d.host, d.port, d.name, d.user)
65
+ return info + (" password=%s" % d.password if d.password else "")
66
+
67
+ def database(self):
68
+ """A new empty database owned by the admin user."""
69
+ import psycopg
70
+ name = "t_" + uuid.uuid4().hex[:12]
71
+ with psycopg.connect(self.conninfo(), autocommit=True) as c:
72
+ c.execute('CREATE DATABASE "%s"' % name)
73
+ self.created.append(name)
74
+ return Database(self.admin.host, self.admin.port, name, self.admin.user, self.admin.password)
75
+
76
+ def drop(self, db):
77
+ import psycopg
78
+ with psycopg.connect(self.conninfo(), autocommit=True) as c:
79
+ c.execute('DROP DATABASE IF EXISTS "%s" WITH (FORCE)' % db.name)
80
+
81
+ def close(self):
82
+ if self.tmp:
83
+ pg_ctl, data = self._pg_ctl
84
+ subprocess.run([pg_ctl, "-D", data, "-m", "immediate", "stop"], stdout=subprocess.DEVNULL,
85
+ stderr=subprocess.DEVNULL)
86
+ shutil.rmtree(self.tmp, ignore_errors=True)
87
+ self.tmp = None
88
+ else:
89
+ for name in self.created:
90
+ try:
91
+ self.drop(Database(self.admin.host, self.admin.port, name, self.admin.user, self.admin.password))
92
+ except Exception:
93
+ pass
@@ -0,0 +1,35 @@
1
+ """The queue stand-in: the kit's procrastinate app on procrastinate's in-memory connector, and a helper that runs the
2
+ queued jobs until none is left (retries made due at once), so a test checks a handler's outcome without a worker.
3
+
4
+ app = memory_app(job_types, HANDLERS, DEFAULTS)
5
+ async with app.open_async():
6
+ await jobs.defer_async(app, "convert", meeting_id=1)
7
+ await drain(app)
8
+ assert statuses(app) == {"succeeded": 1}
9
+ """
10
+ from collections import Counter
11
+
12
+ from procrastinate.testing import InMemoryConnector
13
+
14
+ from cdtkit import jobs
15
+
16
+
17
+ def memory_app(job_types=None, handlers=None, defaults=None, job_ids=None, on_final_failure=None):
18
+ return jobs.make_app("", job_types or {}, handlers, defaults, job_ids, on_final_failure=on_final_failure,
19
+ connector=InMemoryConnector())
20
+
21
+
22
+ async def drain(app, rounds=30):
23
+ """Run every queue's jobs until none is due, making retries due at once (at most `rounds` passes)."""
24
+ for _ in range(rounds):
25
+ pending = [j for j in app.connector.jobs.values() if j["status"] == "todo"]
26
+ if not pending:
27
+ return
28
+ for j in pending:
29
+ j["scheduled_at"] = None
30
+ await app.run_worker_async(wait=False, install_signal_handlers=False, listen_notify=False)
31
+
32
+
33
+ def statuses(app):
34
+ """{status: count} of the jobs the connector holds."""
35
+ return dict(Counter(j["status"] for j in app.connector.jobs.values()))