feat(jobs): the job worker process, jobs.prune and diagnostics (L-J-1 2/3)
Requested by Nikola · project thread
Summary
Before: after #769, backend/jobs/ could enqueue, claim and run one job, but no process ran jobs.
After: python -m backend.jobs.worker --root ROOT [--testing] is the prks worker process (the prks entry point itself comes with L-D-1, as for prks admin). This is PR 2 of 3 for L-J-1. PR 3 runs the same worker embedded in the web process.
worker.py: theWorkerloop, one job at a time.- Start (§4.4, §11.1).
prks workerjoins a root thatwebbinds: while the root is missing or has noprks-root.json, it waits on the poll (worker_waiting reason=root_missingorroot_unbound) and creates nothing there. Once the root is bound, it takes the root's sharedroot.lockfor its whole run. Before claiming anything it runs the schema check (verify_schema), thenroot_pairing.start_process(…, bump=True)under the lease. A schema that isnot_migratedor has apendingmigration, or an unreachable database, is waited for on the poll like an unfinished setup, with nothing bumped. Anewer_schemaor a checksum mismatch exits 1. While setup isunconfiguredorbootstrapping, it claims nothing and checks again on each poll. Once setup is ready, it reconciles if needed, bumps once and starts claiming. A non-retryable refusal (foreign root, stale copy, testing mismatch) exits with code 1 before any claim. A retryable one (a busy start key, a failed marker write) is retried on the poll, so a worker started after a failedwebreconcile gets past the start key and reconciles. - Wakeups.
LISTEN prks_jobsruns on a dedicated connection, since a pooled one can be recycled. Every enqueue NOTIFYs at COMMIT. A poll (default 5 s) is the fallback, and the listener reconnects on failure. - Idle tick. Each tick runs the reaper and, at most once per
prune_every(1 h), enqueuesjobs.prune. The prune job is deduplicated and singleton, so any number of workers run one prune at a time. - Graceful stop. On SIGTERM or SIGINT the worker claims nothing more (a job whose claim returns after the stop is released without starting), and the running job may finish within
stop_grace(30 s). Past that, the job is marked lost, thenreleased: it isqueuedagain at once without spending an attempt. The abandoned handler's later fenced writes are refused, and its end is never recorded as succeeded or failed, even when the release itself failed. - Errors. An unreachable database for a claim, the reaper or the prune enqueue is counted and logged as a code, then retried on the next poll. It never stops the worker.
- Start (§4.4, §11.1).
prune.py: thejobs.prunehandler. It deletessucceededandcancelledjobs that finished more than 7 days ago, in batches, each its own fenced transaction, withheartbeat(progress=…)after every batch, so even a run shorter than one batch reports what it deleted.failed,queuedandrunningjobs are never touched.diagnostics.py(§12.4):WorkerStats, the process-local counters (claimed, succeeded, retried, cancelled, failed, lost, released, reaped, errors), andqueue_depth(db), the queued/running/failed counts and the oldest age by kind. Neither holds a payload, an error text or Library metadata.
Related issues
Scope / non-goals
- No schema change:
030_jobs.sqlandsnapshot.jsonare untouched. - No
server.py,prks_app.pyor HTTP change. The embedded worker inweb,PRKS_EMBEDDED_WORKERand a diagnostics endpoint are PR 3/3. - Until the cutover, the SQLite web process holds
root.lockexclusively, soprks workercan't run beside it: it exits withroot_in_use(tested). - Shared files: one entry each in the Pyright typed-slice config, the invariant scope list with its test, and the AGENTS.md layout line for
backend/jobs/.
Decisions for review
jobs.prunealso deletes oldcancelledrows. §12.1 names only succeeded rows. Cancelled rows are history too (a twin did the work, or a restore superseded the job), and keeping them forever would let the table grow without bound.- The run's thread. A job runs on its own thread so a stop can give it a deadline. A handler past the deadline cannot be killed. Its lease is released and every later fenced transaction of its own is refused, and the process then exits.
Validation
Local (PostgreSQL 18 throwaway cluster via PRKS_PG_BINDIR=/usr/lib/postgresql/18/bin scripts/test-postgres):
tests.pg.test_jobs_worker: 30 tests, passing on repeated runs. The stop-race and stuck-listener tests each fail on the head before their fix. The previous signal handler hangs when a SIGTERM arrives while the main thread holds an Event lock, and the new handler-locks test covers that path. One of them starts a worker before its root exists: it waits throughroot_missing,root_unbound(the directory stays empty) andsetup_pending, then runs a job oncewebhas bound the root and bootstrap is done.- A NOTIFY wakes an idle worker whose poll is an hour long. The poll claims a job inserted without NOTIFY.
- Jobs run one after another and are counted. An unreachable database is counted and retried.
- The idle tick reaps exhausted runs. Prune is enqueued once over many idle ticks, and not at all without its handler.
- Prune deletes only old succeeded and cancelled rows, across several batches, and records progress.
- Graceful stop: the running job finishes and nothing more is claimed. A job past the deadline is released (
queued, attempt given back), reclaimed exactly once, and its abandoned handler's write is refused. A failed release still abandons the run. An abandoned handler that returns early or raises onrun.lostleaves the rowrunningwith no attempt spent. - A real SIGTERM to a worker process lets its running job finish, and the process exits 0.
- Queue depth holds no payload or Library id. Unknown counters and non-positive intervals are refused.
prks workerstarted before bootstrap claims nothing while setup isunconfigured, thenbootstrapping. Once ready it bumps exactly once (generation 2) and runs the job.- A worker for a foreign root exits 1, claims nothing, prints no connection string and releases its lease.
- A worker after a failed
webreconcile reconciles and bumps (generation 3). - A retryable refusal is retried until it clears. A root held exclusively exits 1 with
root_in_use. - A worker whose schema has a pending migration waits without bumping, then starts and bumps once
migratehas run. A database with a migration newer than this PRKS exits 1 and claims nothing.
- Full
scripts/test-postgres: 563 tests OK (after merging master at 79a7ad0).scripts/test-postgres --parity: OK. - Ruff on
backend/jobs,tests/pgandscripts: clean.python scripts/check_invariants.py: OK.tests.test_engineering_invariants: OK. Pyright typed slice: 0 errors. Pyrightpyrightconfig.json: 0 errors (3 existingpymupdfimport warnings). - No browser E2E locally: nothing the served app loads changed. GitHub Actions runs the Test Gate and the Full E2E Gate.
Review fixes
- Codex P2 (a stop raced a run that hadn't started its heartbeat): fixed in e4c8906.
- Schema check before the start check (Nikola, P2): 82d2ff0.
- Prune progress for runs shorter than one batch: 82d2ff0.
- An abandoned run recorded as succeeded or failed (Nikola, P3): 9c788cc.
- A job claimed after a stop started its handler (Nikola, P2): 4f5c933. It is now released at once without starting.
- Codex P2s on 4f5c933: a mistyped root is never written to, and a stop interrupts the startup wait for the listener: 2556f2d. Then 5f41320 (Nikola, P3): an unbound or missing root is waited for rather than refused, so a worker that starts before
webon a first run still waits (§13.3). - Two more stop edge cases (Nikola, P2): 9d2a806. A job's begin is atomic with
stop(), and a listener stuck connecting can't hold a stop (connect timeout, bounded close). 6924447 (Nikola, P3): signal handlers only record the request.prks workerruns the worker on its own thread and the main thread callsstop()as ordinary code, so a SIGTERM can't deadlock on the stop events' internal locks.
Risks / follow-up
-
AGENTS.mdconflicts with #775 on two adjacent layout lines. Whichever PR merges second keeps both lines. -
PR 3/3: the embedded worker thread in
web(after the same schema and start checks, no second bump),PRKS_EMBEDDED_WORKER, the diagnostics endpoint and the operator docs.
Checklist
- Relevant tests/checks were run and are listed above.
- Documentation/generated files were updated when required.
- UI evidence is included when applicable.
- No unrelated changes are intentionally included.
Generated by Claude Code
Summary by cubic
Adds the standalone job worker so queued PostgreSQL jobs run without manual claims. It verifies the schema before claiming and waits for pending migrations or unfinished setup; a missing or unbound root is waited for too, creating nothing, with the shared lease taken only once web has bound the root. Incompatible schemas and roots are refused.
- Uses
LISTENwith a polling fallback (its connect has a 10-second timeout), reaps expired jobs, and schedulesjobs.pruneon idle ticks; a stop interrupts a listener that cannot connect instead of waiting out the poll. - On shutdown, lets the running job finish within its grace period; otherwise marks it lost before release so its heartbeat stops and later writes are refused. A job claimed or started after the stop is released without starting, under a reentrant lock that makes the decision atomic with the stop and safe for a second signal.
- Prunes succeeded and cancelled jobs older than seven days in batches, reporting progress after each batch; adds privacy-safe worker counters and queue depth diagnostics.
No schema changes. Until the PostgreSQL cutover, the SQLite web process holds root.lock exclusively, so a standalone worker cannot run beside it.
Generated by Claude Code