L-J-1 PR 3/3: embedded job worker in the web process and job diagnostics
Summary
Before: prks worker existed (#777), but nothing ran jobs in the process people actually start. Until the PostgreSQL cutover the SQLite web process holds the root exclusively, so a separate worker can't run beside it, and there was no way to see the queue.
After: with PRKS_AUTH set, python prks_app.py also runs one job worker thread, and the Library owner can read job diagnostics at GET /api/diagnostics/jobs. Without PRKS_AUTH (the SQLite runtime) there is no worker thread, /api/diagnostics/jobs answers 404 and psycopg is never imported. Only the static /api/openapi/job-diagnostics.json document is served there too, like the other slice documents. This is PR 3 of 3 for L-J-1.
How:
backend/jobs/embedded.py(new):web_jobs(db, environ)readsPRKS_EMBEDDED_WORKER: unset or1prepares oneEmbeddedWorkeron its ownworkerpool (application_nameprks-worker, 4 connections),0prepares none, and anything else is refused before a pool opens.WebJobs.serve(httpd)starts the worker, runsserve_forever(), and stops the worker gracefully once serving ends.- When serving ends, the listening socket closes at once (new connections are refused), before the worker's grace period.
- On the main thread, SIGTERM and SIGINT handlers only record the request (the #777
SignalStop), and a watcher thread callshttpd.shutdown(). - Graceful stop is the #777 behavior: nothing more is claimed, the running job gets
stop_grace(30 s), then it is released without spending an attempt. - A worker that is refused (for example a database newer than the code) or fails is logged as a code and shown in diagnostics. The web process keeps serving.
- Start check. The web process already ran the §11.1 pairing check and bump before it opened the root, and it holds that root for its lifetime. So the embedded worker's start check (
worker.schema_start_check) only verifies the schema: it waits while a file is pending and is refused when the database is newer. It never bumps a second time. prks_app.start_jobs(auth)runs right afterstart_identity. It imports the jobs code only whenauthis set, and exits 2 on a refused setting or an unusable worker pool.run_server(..., jobs=...)serves throughWebJobs.servewhenjobsis set; the SQLite path is unchanged.- Diagnostics (§12.4):
GET /api/diagnostics/jobsneedsADMIN_LIBRARY, so owners only: editors and readers get 403, and nobody signed in gets 401.- It returns the embedded worker's
state(starting,waiting,running,stopping,stoppedorrefused), a reason code, and the process-local counters, plus queued/running/failed counts and the oldest age by kind. - A database that cannot be read answers 503
unavailable. - Contract:
backend/api_contract/jobs.py(Pydantic,extra="forbid"), with the OpenAPI fragment atdocs/api/openapi-job-diagnostics.json, also served at/api/openapi/job-diagnostics.json. - Both routes are on the log-safe route list.
- Docs:
docs/job-worker.md(operator notes forPRKS_EMBEDDED_WORKER,prks worker, shutdown and diagnostics), the AGENTS.mdbackend/jobs/line, and a pointer in architecture §12.4.
Related issues
Scope / non-goals
- No schema change.
- No frontend client for the diagnostics. The OpenAPI fragment is not added to the TypeScript family manifest until a client needs it.
prks workerbeside an SQLitewebstill refuses withroot_in_useuntil the cutover; the embedded worker is the one-process path.- Shared files: one entry each in the Pyright typed slice, the invariant scope list with its test, and the AGENTS.md layout line.
tests/pg/http_fixtures.pygains ajobs=argument.
Decisions for review
backend/db/migrate.pyno longer skips browser E2E. The embedded worker's schema check imports it from the served app (behindPRKS_AUTH), andtest_no_e2e_skipped_python_file_is_reachable_from_the_served_appcorrectly flagged it. It is removed from thepostgres-migration-infrastructurerule; the.sqlfiles and the snapshot tooling stay exempt.- Signal handling only when jobs run. SIGTERM/SIGINT handlers are installed only on the
PRKS_AUTHpath, so the SQLite runtime keeps today's Ctrl-C behavior. - A refused worker does not stop
web. The web start check already decided the process may serve, so a worker problem shows in diagnostics and the log instead. - Diagnostics are owner-only. Queue depth spans the instance, though in 0.8 the instance has exactly one Library.
Validation
Local (PostgreSQL 18 throwaway cluster via PRKS_PG_BINDIR=/usr/lib/postgresql/18/bin scripts/test-postgres):
tests.pg.test_embedded_worker (22 tests):
- Lifecycle. The worker starts with the server and runs a job while it serves. After
shutdown()it reportsstoppingwhile the job finishes, thenstopped, with the success counted. Withstop_grace=0the job is released (queued, countedreleased). With no worker, the server just serves. - No second bump.
root_pairing.start_processis never called. - Schema. A pending schema file makes the worker
waiting(pending) while the server serves, and it runs the job oncemigrateapplies the file. A newer database makes itrefusedwhile the server keeps serving. - SIGTERM. A real SIGTERM to a child process serving through
WebJobs.servelets the running job finish, and the process exits 0. PRKS_EMBEDDED_WORKER. Unset, empty or1turns it on.0opens no pool and reportsembedded_worker: null. Other values are refused before a pool opens.prks_app.start_jobsexits 2 on a refused value or a missing worker URL, prints no connection string, and prepares the worker without starting a thread.- Endpoint, behind the real admission. The owner gets 200 with the queue by kind; the response is validated against the model and carries no payload text or Library id. Editors and readers get 403 and nobody signed in gets 401. An unreadable queue answers 503, and the SQLite runtime answers 404. The counters match
diagnostics.COUNTERSand the contract.
Other local checks:
tests.test_api_contract_jobs: the checked-in OpenAPI artifact matches the generator, the bodies validate withopenapi_core, unknown keys and states are refused, and the routes log as themselves.tests.test_embedded_worker_off: a fresh interpreter importingprks_appandbackend.serverloads no psycopg and nobackend.jobsmodule, and starts no thread.tests.test_e2e_policy,tests.test_logging_privacyandtests.test_engineering_invariantspass.- Related PG suites pass:
test_jobs_worker,test_jobs_runner,test_jobs_queue,test_http_session,test_http_admission,test_web_startandtest_identity_hardening. - Full
scripts/test-postgres: 602 tests OK. - Ruff is clean on
backend,prks_app.py,tests/pgandscripts. Pyright typed slice: 0 errors.pyright: 0 errors (3 existingpymupdfwarnings).python scripts/check_invariants.py: OK. - The default unit suite (
python run_tests.py) could not run in this container because PyMuPDF is not installed. No browser E2E ran locally. GitHub Actions runs the Test Gate and the Full E2E Gate (this diff touchesserver.py, so the full gate runs).
Migration / compatibility impact
- New env var
PRKS_EMBEDDED_WORKER(default on), read only whenPRKS_AUTHis set. - New
/api/diagnostics/jobsand/api/openapi/job-diagnostics.jsonon thePRKS_AUTHruntime. - Without
PRKS_AUTH, the only change is the static/api/openapi/job-diagnostics.jsondocument.
Risks / follow-up
PRKS_EMBEDDED_WORKER=0is only useful after the cutover, whenprks workercan join a root besideweb.- #627 criteria "worker graceful stop" and the §11.1 worker start check were delivered by #777; I'll tick them on the issue.
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 change.)
- No unrelated changes are intentionally included.
Generated by Claude Code
Summary by cubic
Runs one job worker thread inside the web process on the PostgreSQL runtime (PRKS_AUTH set), so background jobs run in the process operators actually start. Library owners can now read worker state and queue depth at GET /api/diagnostics/jobs; the SQLite runtime is unchanged (no thread, 404 on the route, never loads psycopg).
New Features
PRKS_EMBEDDED_WORKERcontrols the worker: unset or1runs one thread on its own worker pool,0runs none for a separateprks worker, anything else exits 2.- The worker starts with the server and stops gracefully on shutdown or SIGTERM/SIGINT: no new claims, the running job gets
stop_grace(30 s), then its lease is released without spending an attempt. - Once serving ends, the listening socket closes before the grace period, so new connections are refused at once instead of sitting in the backlog.
- Its start check only verifies the schema — the web already ran the §11.1 pairing check and bump — so it never bumps a second time.
- A refused or failed worker is logged as a code and shown in diagnostics; the web process keeps serving.
GET /api/diagnostics/jobsis owner-only (403 for editors/readers, 401 unsigned), returning the worker's state and counters plus queue depth by kind; an unreadable database answers 503.- The OpenAPI fragment is served at
/api/openapi/job-diagnostics.json; operator notes live indocs/job-worker.md.
Migration
PRKS_EMBEDDED_WORKER=0is only useful after the PostgreSQL cutover, when a separateprks workercan join the root besideweb.backend/db/migrate.pyis served code now, so it is no longer exempt from browser E2E runs.
Generated by Claude Code