Implement the background worker runtime and job state machine #2
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
POST /api/photos/{photo_id}/evidence/ai-rerunand.../ocr-rerunalready createBackgroundJobrows, and the Jobs page renders them, butbackend/app/workers/contains only
__init__.py. Nothing ever claims a job, so every rerun sits inqueuedforever. This is the largest functional gap left after v0.1.0.Scope
A standalone worker process that durably moves jobs through the
JobStatusstate machine (
queued->running->success|failed|cancelled),with a handler registry keyed by
JobType.Implementation notes
python -m app.workers.runner(add a console script inpyproject.toml).queuedjob, then claim it with a conditionalUPDATE ... WHERE status = 'queued'so a second worker cannot take the same row.SQLite has no
SELECT ... FOR UPDATE; rely on the affected-rowcount of theconditional update to decide whether the claim succeeded.
status = running, stampstarted_at.status = success, stampfinished_at, writeresult_summary.error_message, incrementretry_count,and return the job to
queuedwhileretry_count < max_retries; otherwise setfailed. Use backoff between retries so a persistently failing job cannot spin.dict[JobType, Callable]soai_analysis,ocr, and the laterduplicate_scan/constraint_rebuild/backup/exporttypes register uniformly.payloadis a JSONTextcolumn — define and document the per-JobTypepayload shape.idempotency_keyis alreadyunique=True; the enqueue path must not raise a500 on a duplicate enqueue, it should return the existing job.
a crashed handler leaves the job retryable with no half-written evidence.
runningwith no live worker (e.g. after acrash) should be requeued or failed rather than being stuck forever.
Done when
max_retrieslands infailedwith a readableerror_messageReferences
backend/app/workers/(currently empty)backend/app/api/routes/jobs.pybackend/app/models/models.py(BackgroundJob,JobType,JobStatus)docs/circa-phase1-plan.mdsection 5.7, section 9Depends on: nothing. Blocks the AI and OCR job handlers.
Amended by the audit of 2026-07-28.
Two additions to this issue's requirements:
The idempotency requirement described here is incomplete. This issue says a duplicate
enqueue "must not raise a 500," describing the concurrent case. The actual bug (#82) is the
terminal-predecessor case:
idempotency_keyisf"{job_type}:{photo_id}"with a uniqueconstraint, and the short-circuit only covers
queued/running. So the second-ever rerun of atype on a photo — the normal "the AI got it wrong, try again" — raises
IntegrityError.SQLite is not configured for a concurrent writer (#87).
synchronous=FULL, deferredBEGIN(so a read-then-write transaction racing a worker commit fails immediately with
SQLITE_BUSY_SNAPSHOT— the busy handler cannot rescue a lock upgrade), and an implicit 5 sbusy timeout. #87 should land before this issue.
Also relevant: #65 proposes moving Pillow EXIF parsing into this worker under resource limits, so a
malicious image cannot take down the API process.
Done in
b9b3a58, on top of #87 (4db9ad9), which this needed first.Done when
None), and four workers draining twelve jobs (every job handled exactly once)max_retrieslands infailedwith a readableerror_messagepython -m app.workers.runner, or thecirca-workerconsole script.--onceand--max-jobs Nfor scripted use. 27 tests intests/test_worker_runtime.py; full suite 542 passed.The design decision worth recording: a claim is a lease.
The issue says to "recover orphans on startup: any job left
runningwith no live worker should be requeued or failed". Doing that literally — requeue everyrunningrow when a worker starts — needs no schema and no heartbeat, and is wrong as soon as there are two workers: the second one starting cannot tell "abandoned" from "in progress on the other machine", so it takes the first one's jobs and runs them again. The requirement one line above it, that two workers never execute the same job, is what rules it out.So a claim carries an owner and a deadline (migration
008:claimed_by,lease_expires_at,run_after). The claiming worker renews from a heartbeat thread while it works, so a long handler is never at risk; the lease bounds recovery time, not job duration. Arunningrow whose lease has lapsed is genuinely abandoned and anyone may take it.Two consequences that are deliberate:
failedlike anything else that cannot be completed.The handler contract, which is where #87's rule went.
A run is three transactions — claim, handle, finalize — and the handler gets a way to open a transaction (
ctx.write()), not an open one. Handing over a live session would make holding the write lock across a network call the path of least resistance, and on SQLite that blocks every reviewer save for the length of the call.TestTheWorkerHoldsNoLockWhileHandlingasserts it rather than trusting the docstring: another connection writes and commits while a handler is running, and the test fails if it has to wait.Use one
ctx.write()block per handler. The runner guarantees a raising handler leaves the job retryable, but it cannot un-commit a block that already succeeded.On the amendments in the comment above
idempotency_keynow carries an attempt number, so sequential reruns are distinct while concurrent ones collide on the unique constraint. Nothing further needed here; the enqueue path already returns the winner rather than raising.app/workers/handlers/package this adds.Two kinds of failure. Anything raised is retried with exponential backoff (30 s doubling, capped at 15 min) until
max_retries;PermanentJobErroris not retried at all. The distinction is whether trying again could plausibly give a different answer — a timeout could, a photo that does not exist could not.What an AI or OCR rerun does today. It is claimed and fails with "No handler is registered for job type ai_analysis", because #3 and #4 have not landed. That is deliberate and I would like it reviewed as a choice: it is a true statement that shows up on the Jobs page, where the previous behaviour was an indefinite
queuedthat said nothing.app/workers/handlers/__init__.pyis where #3 and #4 register.Verified against a real database with the real process, not only in tests: two jobs enqueued the way the API enqueues them, both claimed, both refused with the message above, claims released, both recorded in the audit ledger as
job_failedwithretryable: false.Unrelated, found on the way. The #87 commit turned CI red on lint. My local ruff is 0.4.4 and CI pins 0.16.0, and they disagree on whether
alembicis first- or third-party — there is analembic/directory in the project, so the inferred answer depends on whether the linter resolves submodule paths against it. My ruff "fixed" two files into a state CI rejected.known-third-party = ["alembic"]is now stated inpyproject.tomland both versions agree; I verified against 0.16.0 in a throwaway venv rather than assuming.Follow-up in
cba2a0d, after CI went green onb9b3a58.Reviewing my own work I found the loop treated two different failures the same way. A handler raising is the job's failure and
run_onedeals with it. An exception from the machinery — the claim unwritable, the database unreachable, and #87 made the busy timeout finite — propagated out ofrun_foreverand ended the process. Defensible for a one-shot script, wrong for a daemon: the queue then stops silently until somebody notices, which for a background process can be days. It now backs off exponentially and carries on, so a fault that is not transient reads as a repeating log line rather than as silence.RunReport.errorscounts them, and there is a test that makes the claim fail twice and checks the worker still drains the queue.Also: the startup reclaim sweep did not record that it had swept, so the first idle poll swept again immediately.
543 tests pass; lint clean against the pinned ruff.