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.
- cdt_kit-0.1.0.dist-info/METADATA +180 -0
- cdt_kit-0.1.0.dist-info/RECORD +18 -0
- cdt_kit-0.1.0.dist-info/WHEEL +4 -0
- cdt_kit-0.1.0.dist-info/entry_points.txt +2 -0
- cdtkit/__init__.py +7 -0
- cdtkit/config.py +118 -0
- cdtkit/fastapi.py +35 -0
- cdtkit/http/__init__.py +39 -0
- cdtkit/http/logging.py +62 -0
- cdtkit/jobs/__init__.py +122 -0
- cdtkit/jobs/prune.py +59 -0
- cdtkit/jobs/recovery.py +108 -0
- cdtkit/jobs/worker.py +96 -0
- cdtkit/migrations.py +188 -0
- cdtkit/testing/__init__.py +7 -0
- cdtkit/testing/plugin.py +28 -0
- cdtkit/testing/postgres.py +93 -0
- cdtkit/testing/queue.py +35 -0
|
@@ -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,,
|
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
|
cdtkit/http/__init__.py
ADDED
|
@@ -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)
|
cdtkit/jobs/__init__.py
ADDED
|
@@ -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())
|
cdtkit/jobs/recovery.py
ADDED
|
@@ -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
|
+
"""
|
cdtkit/testing/plugin.py
ADDED
|
@@ -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
|
cdtkit/testing/queue.py
ADDED
|
@@ -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()))
|