Background work¶
Celery over Redis. One worker service, one beat.
Both build from ./backend, which is why
./deploy.sh rebuilds all three.
Scale the worker, never the beat¶
The worker used to be worker --beat — one process doing both — on the
reasoning that a single worker needs no lock. That holds exactly until there
are two, and then every scheduled task fires twice: the embedding sweep twice,
the topic claims twice, and purge_trashed_media twice, which is the one that
removes files from an un-versioned bucket.
So: celery runs no scheduler and can be scaled freely. celery-beat is
exactly one replica, always — in compose, and in whatever runs this next.
On Kubernetes that means replicas: 1 with a Recreate strategy, not a
rolling update: two beats overlapping for a few seconds during a deploy is the
same double-fire, just rarer and harder to explain.
Beat queues work and performs none, so it needs no volumes and no concurrency.
The schedule¶
| Task | Every | Does |
|---|---|---|
retry_missing_embeddings |
15 minutes | Sweeps up questions whose embedding failed at creation and would otherwise never be semantically searchable. Normally finds nothing. |
apply_topic_claims |
30 minutes | Applies topic claims that bypassed the two live sync points — bulk SQL, imports, restores. Also normally finds nothing. |
purge_trashed_media |
daily | The only scheduled job that destroys anything. Removes the stored bytes of images binned more than 30 days ago. |
Timezone UTC.
purge_trashed_media is daily rather than hourly because nothing about it is
urgent — the delay is the feature — and because a job that removes files should
run seldom enough that a bad deploy is caught before its second run.
The tasks that are queued on demand¶
| Task | Queued by |
|---|---|
process_pdf |
POST /documents/upload. Falls back to running inside the request if Celery or Redis is unreachable. |
extract_quiz |
POST /drafts/extract |
generate_article_draft |
POST /articles/ai-draft and POST /articles/{id}/ai-refine |
generate_article_cards |
POST /articles/{id}/ai-cards |
regenerate_embeddings |
Settings → embedding regeneration |
classify_question_difficulty |
Nothing. It exists and is registered but has no caller in the application. |
Watching a job¶
Progress steps are pushed to a Redis list per job and polled by the front end
from /jobs.
docker compose logs celery --tail=50
docker compose exec celery celery -A app.tasks inspect registered
docker compose exec celery celery -A app.tasks inspect active # before any restart
Never restart while a task is running. A killed extraction leaves its
document at processing for ever; see Troubleshooting.
The other singleton¶
Separately from Celery, the four uvicorn workers use a Redis SETNX lock
(startup:singleton_lock, 300 s) so that exactly one of them starts the
in-process scheduler and the embedding backfill thread at boot.