feat(jobs): PostgreSQL job queue core and runner (L-J-1 1/3)
Requested by Nikola · project thread
Summary
Before: prks.jobs existed (PG-2, 030_jobs.sql), but nothing could enqueue, claim or run a job.
After: backend/jobs/ implements the §12.1 runtime on that table. The running SQLite app doesn't import it yet. This is PR 1 of 3 for L-J-1. PR 2 adds the prks worker process and PR 3 adds the embedded worker in web.
queue.py:enqueue/enqueue_library_jobrun inside the command's transaction, so the job commits only with its change (the outbox). A Library job records the epoch itslibrary_commandchecked.NOTIFY prks_jobsis delivered at COMMIT only, and a coalesced enqueue (dedupe hit) notifies nobody.claimis the single-statement claim/reclaim from §12.1. A23505onjobs_singletonmeans the worker lost the race, so it claims again. Any other error is raised.heartbeat,complete,failandreleaseall matchid,lease_idandstate = 'running', so a stale worker updates nothing.failsetsfailedwhen attempts are exhausted (checked first), elsecancelledwhen a queued twin exists, elsequeuedwith exponential backoff. A23505onjobs_deduperepeats the statement.reapfails expired runs that have no attempts left, which frees their singleton key.last_erroris a short code (an exception type or PRKS error code), never a message.
handlers.py: the registry that maps a kind to its handler. Each lane registers its own kinds; this PR registers none.runner.py:run_jobruns one claimed job with a heartbeat thread and records the outcome (succeeded / retrying / cancelled / failed / lost).- Every transaction a handler opens is fenced. A Library job writes through
library_command(run.context()), which isRequestContext.for_jobplus L-ID-1's lease and epoch checks. An instance job writes throughJobRun.command, which first locks the job row with its leaseFOR SHARE. - If the lease is lost, nothing is recorded and the result is discarded.
- Every transaction a handler opens is fenced. A Library job writes through
Related issues
Scope / non-goals
- No schema change:
030_jobs.sqlandsnapshot.jsonare untouched. - No
server.py,prks_app.pyor HTTP change. - No job handlers.
- No worker loop, LISTEN, graceful stop,
prks workerstart check,jobs.pruneor diagnostics. Those are in PR 2/3. - Shared files: each gets one entry for
backend/jobs(the Pyright typed-slice config, the invariant scope list with its test, and the AGENTS.md layout list). #628 (lane B) will likely add lines next to them. Whichever PR merges second rebases and keeps both.
Decision for review
release (used by graceful stop in PR 2) returns the job to queued and gives back the attempt its claim counted, because a stop is not a crash. A job killed without a release still spends its attempt, as §12.1 requires.
Validation
Local (PostgreSQL 18.x throwaway cluster via PRKS_PG_BINDIR=/usr/lib/postgresql/18/bin scripts/test-postgres):
tests.pg.test_jobs_queue: 25 tests.tests.pg.test_jobs_runner: 11 tests. All passed, five consecutive runs.- Each #627 criterion in this slice has a test:
- A worker SIGKILLed after claiming and committing its write leads to lease expiry, then a 4-way race in which exactly one worker reclaims, then an idempotent rerun and completion.
- After a stale worker loses its lease, its heartbeat, complete, fail and release update nothing.
- Repeated crashes end the job
failed(lease_expired) and free its singleton key. - A handled failure with a queued twin cancels A and B runs once. Without a twin, A is requeued with backoff.
- A twin that commits during the failure update forces a real
23505onjobs_dedupe, which the code retries intocancelled. - A failure on the last attempt ends
failed, neverqueued, and frees the dedupe key. - A held singleton claim forces a real
23505onjobs_singleton. The loser retries and claims another key with no error, and a two-worker stress run never has two jobs of one key running. - Jobs of a Library in maintenance or of an older epoch are not claimed.
- A restore during a running job refuses the job's later command and completion.
- Error messages and paths never reach
last_erroror the logs.
- I checked that the two forced-race tests are real: with the 23505 handling removed, both fail with the raw
23505. - Full
scripts/test-postgres: 491 tests OK.scripts/test-postgres --parity: OK. ruff check --config ruff.toml prks_app.py backend+ new tests: clean.python scripts/check_invariants.py: OK. Pyright typed slice: 0 errors. Pyrightpyrightconfig.json: 0 errors.- Default-suite modules touched by the shared files (
tests.test_engineering_invariants,test_pg_facade_registry,test_e2e_policy,test_dependency_gate): 320 tests OK. - I didn't run browser E2E locally because nothing the served app loads changed. GitHub Actions runs the Test Gate and the Full E2E Gate. Their results will be reported separately on this PR.
Risks / follow-up
- PR 2/3: worker loop with LISTEN/NOTIFY and poll fallback, the idle-tick reaper, graceful stop,
prks workerwith the §11.1 start check under the sharedroot.lock(waiting while setup is pending),jobs.pruneand diagnostics counters. - PR 3/3: embedded worker in
web(the onlyserver.pychange in either lane).
Checklist
- Relevant tests/checks were run and are listed above.
- Documentation/generated files were updated when required.
- UI evidence is included when applicable. (No UI.)
- No unrelated changes are intentionally included.
Generated by Claude Code
Summary by cubic
Adds backend/jobs/, the PostgreSQL job runtime on prks.jobs (§12.1), so jobs can now be enqueued, claimed, and run. The running SQLite app doesn't import it yet. This is PR 1 of 3 for L-J-1 (#627): PR 2 adds the prks worker process, PR 3 the embedded worker in web.
queue.pyenqueues inside the caller's transaction (the job commits only with its change;NOTIFY prks_jobsfires at COMMIT only), claims and reclaims in a single statement with retry on a lost singleton-race23505, and fences heartbeat/complete/fail/release onlease_idplusstate = 'running'.fail,releaseand the reaper end a Library job whose epoch moved on ascancelled(a restore), never back toqueued; a Library in maintenance is not stale.failalso ends the jobfailedwhen attempts are exhausted,cancelledwhen a queued twin exists, elsequeuedwith exponential backoff; the reaper fails expired runs with no attempt left, freeing singleton keys.last_errorholds a code, never a message.runner.pyruns one claimed job with a heartbeat thread and records the outcome only while the lease is held — a lost lease discards the result. Handler transactions are fenced: instance jobs lock the job row with their leaseFOR SHAREand re-read the lease with the lock againstclock_timestamp()(a singleFOR SHAREcomputes its output before waiting on the row lock); Library jobs write throughJobRun.library_command, fenced by the lease and the epoch recorded at enqueue.heartbeat()inside a fenced transaction is refused at once; progress is reported between transactions. A non-positive lease or heartbeat interval raisesValueErrorbefore the handler runs.handlers.pyis the kind-to-handler registry; each lane registers its own kinds, none yet.
Scope
- No schema change and no job handlers; the worker loop, LISTEN handling, graceful stop, and
jobs.pruneare PRs 2/3. - A graceful stop's
releasereturns the job toqueuedwithout spending the attempt (a stop isn't a crash); a kill still spends it. - The typed-slice Pyright config, the invariant scope list and AGENTS.md each gain the new files; lane B's PR (#628) adds lines next to them — whichever merges second rebases and keeps both.
Summary by CodeRabbit
-
New Features
- Added a PostgreSQL-backed job processing runtime with job queuing, handler registration, and execution.
- Jobs support deduplication, prioritized claiming, retries, and worker leases to prevent stale workers from committing changes.
- Library jobs are fenced against outdated library state.
-
Documentation
- Documented the job runtime and its responsibilities, including that it is not yet used by the running application.