Imported from gyrinx-app/gyrinx (
gyrinx/tasks/AGENTS.md). Install upstream withnpx skills add gyrinx-app/gyrinx --skill tasks. Copyright stays with the author.
Tasks (background jobs)
Django 6.0 django.tasks with a Gyrinx-specific TaskRoute (retry + schedule
config) and two backends that are never swapped: production runs on Pub/Sub;
dev and tests run on an in-process durable queue. DatabaseBackend is never
selected in production, and settings_prod.py forces PubSubBackend.
Human-facing docs live in docs/{explanation,technical-reference,how-to-guides}/task-framework.md.
Design notes for the local backend: .claude/notes/local-async-tasks-research.md.
Layout
backend.py—PubSubBackend(production only): fire-and-forget publish → Cloud Run push handler.local_backend.py—DatabaseBackend(dev + tests only): in-process durable queue. Modeseager/worker/manual(see below).executor.py—run_task(), the shared execution core. Both the prod push handler (views.py) andDatabaseBackendrun the task function through it, so they fire identicaltask_started/task_finishedsignals and the sameTaskExecutionbookkeeping. Keep it that way — the two delivery paths must not diverge.worker.py—deliver()(runs one claimedQueuedTaskand settles it: delete on success, reschedule with backoff on failure, give up aftermax_attempts) andTaskWorkerPool(dev-server daemon threads). Dev/test only.faults.py— probabilistic chaos (TASKS_FAULT_*) for the dev server.testing.py— thetask_queuepytest fixture +ManualTaskQueuedriver (re-exported intoconftest.py).models.py—TaskExecution(observability + state machine) andQueuedTask(the durable queue row).signals.py— lifecycle handlers that maintainTaskExecution.registry.py/route.py/discovery.py— task registration and per-task config. Apps declare their own routes in atask_routeslist in their<app>/tasks.py; the registry collects them, so platform code does not reference any edition task (#2093).provisioning.py/apps.py— Pub/Sub + Scheduler provisioning (Cloud Run only; skipped locally, in migrations, and in tests).
Modes (DatabaseBackend)
eager— base default insettings.py; the test default. Runs inline on enqueue, likeImmediateBackend. NoQueuedTaskrow, so the suite stays sync.worker— the dev server.settings_dev.pyswitches to it whenrunserveris on the command line. Durable row + daemon-thread delivery with retries/backoff/leases.manual— tests, via thetask_queuefixture. Durable row; the test drives delivery deterministically.
Redelivery is at-least-once — mind the invariants
Delivery can happen more than once (Pub/Sub, or the worker pool after a lease lapse). Two invariants live here:
TaskExecutionstate (signals.py).SUCCESSFULis the only sticky terminal state: a redelivery of a SUCCESSFUL task re-runs the function but leaves the record SUCCESSFUL. A redelivery of a FAILED task is a retry — the record is reset to READY before it starts, so the fresh attempt records its own outcome. So the two terminal states part company here: SUCCESSFUL must never go straight to RUNNING, while FAILED may — but only by way of that reset. Never lethandle_task_startedmark a terminal execution RUNNING directly; that is an illegal state transition that raises, and via the prod push handler becomes a 500 → Pub/Sub redelivery storm. Both handlers are@transaction.atomicand take aselect_for_updatelock on the execution row, because these guards are check-then-act: each step they take is a legal transition on its own, so the state machine's own row lock cannot catch two deliveries interleaving here.- Business-logic idempotency is the task's job. The propagation tasks take a
select_for_updatelock on theListrow so two concurrent duplicate deliveries don't double-apply. Regression coverage:n23/core/tests/test_task_chaos_concurrency.py.
Testing
- Default (eager):
enqueue()runs the task synchronously — assert on its effect. - Chaos: add the
task_queuefixture (manual mode). Wrap the trigger intask_queue.capture()(fireson_commitenqueues), thendeliver_all(),redeliver_last(),fail_next(),drop_next()to script adverse conditions. - Do not start the worker pool in tests — its threads use their own DB
connections and won't see the test transaction's uncommitted data.
manualmode runs delivery in the test's own thread/transaction for exactly this reason.