Production
The worker is a plain foreground process: manage.py ox_worker, run
under whatever supervises your other processes. It treats a lost database
connection as fatal rather than retrying blind, and relies on the
supervisor to restart it. Run it under Restart=always (as in the unit
below). This page covers systemd,
scaling, shutdown, the reaper, and monitoring.
Running under systemd
# /etc/systemd/system/ox-worker.service
[Unit]
Description=django-ox worker
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=exec
User=app
Group=app
WorkingDirectory=/srv/myproject
Environment=DJANGO_SETTINGS_MODULE=myproject.settings
ExecStart=/srv/myproject/.venv/bin/python manage.py ox_worker --concurrency 4
Restart=always
RestartSec=5
# systemd sends SIGTERM on stop; the worker drains in-flight tasks and
# exits 0. Give the drain at least as long as your longest task before
# systemd escalates to SIGKILL.
KillSignal=SIGTERM
TimeoutStopSec=300
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now ox-worker
journalctl -u ox-worker -f
To run several workers on one host, use a template unit
(ox-worker@.service with the same [Service] body) and start
ox-worker@1, ox-worker@2, and so on. No flag distinguishes the
instances; every worker generates its own unique id.
Running in containers
The worker is a foreground process that exits 0 on SIGTERM, so it needs no special entrypoint. The setting that matters is the grace period: give the runtime longer than your slowest task before it escalates to SIGKILL.
services:
worker:
image: myapp:latest
command: python manage.py ox_worker --concurrency 4
stop_grace_period: 5m
restart: unless-stopped
depends_on:
- db
healthcheck:
test: ["CMD", "python", "manage.py", "ox_health"]
interval: 60s
timeout: 15s
start_period: 30s
Docker's default grace period is 10 seconds, which will kill a worker mid-task
and leave the reaper to clean up. stop_grace_period is the container
equivalent of TimeoutStopSec. On Kubernetes it is
terminationGracePeriodSeconds on the pod spec.
With no flags, ox_health checks that the database answers, which is what a
per-container probe should test. Queue-wide checks belong in fleet alerting
rather than in a probe: see
which check goes where, and the
liveness probe example there for queues with steady traffic.
Run migrations before rolling workers, as an init container or a job, not from the worker itself. Several workers starting at once would race the same migration.
Graceful shutdown
On SIGTERM or SIGINT the worker:
- Stops claiming new tasks immediately.
- Waits for in-flight tasks to finish, however long they take.
- Closes its database connections and exits with code 0.
A second signal during the drain forces an immediate exit, code 130. Whatever was running is abandoned mid-flight. The reaper on a surviving worker reclaims it later, and it counts as a failed attempt.
This maps directly onto rolling deploys: send SIGTERM, wait, start the new
version. The only tuning point is the supervisor's kill escalation
(TimeoutStopSec above) relative to your longest task.
Scaling out
Run as many workers as you need, on as many hosts as you need, pointed at the same database. No coordinator, no leader election. Two things make concurrent workers safe:
- Claiming is atomic. On PostgreSQL, a claim is one
UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED) RETURNINGstatement, so workers never block each other on the head of the queue. On other databases withSKIP LOCKEDsupport (MySQL 8+), the worker usesSELECT ... FOR UPDATE SKIP LOCKEDin a short transaction. Databases without it, SQLite included, fall back to an optimistic compare-and-set UPDATE. That is atomic everywhere, but under contention workers can retry against each other for the head of the queue. - Recurring schedules need no dedicated node. Every worker dispatches; a unique constraint guarantees each tick fires once. See Recurring tasks.
Workers can also be split by queue: run
ox_worker --queues emails --concurrency 8 next to
ox_worker --queues default to isolate slow or bursty workloads.
Threads and processes
--concurrency N is a thread pool inside one process. That fits the
common Django task profile: email, HTTP calls to third parties, ORM work.
For CPU-bound tasks the GIL makes threads the wrong tool. Run multiple worker
processes with --concurrency 1 instead. That is also the natural shape for
systemd template units, or one container per worker.
The lease
A worker that claims a task takes a lease on it: the row records who holds it, when the lock was last refreshed, and a lease number that goes up by one every time the task changes hands. Three things follow from that number, and they are worth understanding together because they are what makes recovery safe.
The worker keeps its own lease alive. While a task is executing, its worker refreshes the lock timestamp on the rows it is running, one statement per interval however many are in flight, and it keeps doing so through a graceful drain. So a task that takes an hour does not look abandoned after five minutes. The reaper reclaims work from workers that stopped reporting, not from tasks that are merely slow.
A finish write only lands while the lease still holds. When a worker records success, failure or a retry, the UPDATE carries the lease number it was given at claim time. If the task was taken off it in the meantime, that number no longer matches and the write is dropped rather than applied, and no completion is signalled for it. This is arithmetic rather than timing: no pause is long enough to get around it, so a task that finished cannot be put back on the queue by a straggler.
Timestamps come from the database. With USE_TZ on, the lock time is
written by the database server and compared against the database server's
clock, so two hosts with drifting clocks do not produce false reclaims. If you
run workers on more than one host, that is the setting which gives them one
clock, and it is Django's default.
With USE_TZ off the worker's clock is used instead. A database's own clock
does not always match what these columns hold there: SQLite's is UTC while the
columns carry naive local time, and reading one against the other would make
ox_prune --older-than treat rows that finished seconds ago as hours old.
Under that setting, keep TIME_ZONE and the timezone your workers run in the
same, which is what Django assumes of it anyway.
The reaper
Workers still die: OOM kills, node failures, kill -9. A dead worker stops
refreshing its lock, and the reaper picks the task up. It runs inside every
worker, on an interval derived from the lock timeout.
A RUNNING task whose lock has not been refreshed for LOCK_TIMEOUT (default
300 seconds, per-worker override --lock-timeout) is taken back, and what
happens next depends on whether the task has attempts left:
- Attempts remaining. The task goes back to READY and the lease number goes up, so the old worker cannot write to it again. This is the ordinary case, and it is a guess the system already absorbs: at-least-once execution means the task may run twice, which is why task bodies must be idempotent.
- No attempts remaining. The row is marked LOST. LOST means what it says: the worker holding this task stopped reporting and nobody observed how the attempt ended. The reaper does not record a failure, because it did not see one. It has watched a lock go quiet, and that is all it writes down.
The attempt was already consumed when the task was claimed, so a
crash-looping task cannot retry forever; it stops after MAX_ATTEMPTS like
any other task.
The reclaim is a compare-and-set on the lease number, so a reaper running late cannot stomp a task that finished or was already reclaimed.
What LOST looks like from the outside
django.tasks has four result statuses and django-ox does not add a fifth to
them. A LOST task reads as FAILED through get_result(), and
result.errors ends with a TaskAbandoned record whose text says the lease
was lost and the outcome was never observed. is_finished is true, so code
that waits for a result terminates instead of waiting for a worker that is
not coming back.
The row keeps the distinction the four statuses cannot carry. Its status
column is LOST rather than FAILED, queue_stats() reports it in its own
lost column, and ox_prune --include-failed treats it like a failed row
for retention.
One case is worth knowing about before it surprises you. If the worker
holding a LOST task was starved rather than dead, and it comes back and
records a success, the row becomes SUCCESSFUL and a caller reading it twice
sees FAILED and then SUCCESSFUL. Only that one execution can do this, and
only while the row is still LOST. It is the honest cost of giving a
four-valued API an answer for a task whose outcome nobody saw, and the
alternative, reporting it as still running forever, hangs every caller that
waits on it.
It takes two things at once: the task's attempts spent, and its lease allowed
to lapse. Renewal holds the lease for as long as the worker is answering, so
what reaches this state is a worker that went unresponsive for longer than
LOCK_TIMEOUT and then came back, which is the case you asked the reaper to
act on in the first place. If your callers cannot tolerate seeing it, raise
LOCK_TIMEOUT until a merely slow worker is never reclaimed; the cost is that
a genuinely dead one takes that much longer to notice.
Tuning LOCK_TIMEOUT
Set LOCK_TIMEOUT above the longest gap you expect between a worker's lease
renewals, not above your longest task. Renewal runs every LOCK_TIMEOUT / 3
seconds, so two consecutive renewals can be missed before anything is
reclaimed. The value is really a statement about how long a worker may be
unresponsive before you want its work handed to somebody else: too low and a
paused or overloaded worker loses tasks it was going to finish, too high and
recovery after a real crash is slow.
Watch for task_lease_lost in the logs. It records an attempt whose result
was discarded because the lease had already been reclaimed, and a steady
trickle of it means the timeout is short relative to how long your workers
go unresponsive.
Tasks must be idempotent. Execution is at-least-once by design: a task is retried both when it raises and when its worker dies mid-run. Write task bodies so that running twice is harmless (upserts, idempotency keys, "already sent?" checks).
PostgreSQL or SQLite
Both are fully supported and both run the full worker suite (221 tests each). Guidance:
- PostgreSQL is the production recommendation. It gets the
single-statement
SKIP LOCKEDclaim path, and it handles many workers and high write concurrency the way you would expect. - SQLite is fine for development, tests, and small single-host deployments in the same situations where SQLite is fine as your Django database at all. Claiming uses the compare-and-set path and remains correct with multiple workers, but SQLite's single-writer nature makes many busy workers on one file a poor fit.
The queue lives in your default database, inside your existing backup and migration story. That is the point: one system of record, one thing to operate.
Monitoring
Monitoring has a dedicated page. The operational summary:
- The table is the queue.
django_ox.statsexposes queue depth, backlog age, throughput and failure rate as plain functions. Backlog depth and backlog age are the two numbers worth alerting on. manage.py ox_healthturns thresholds on those numbers into an exit code, for cron alerting and container probes.- Logs. The worker logs to the
django_oxlogger: lifecycle at INFO, retries and reaper reclaims at WARNING, terminal failures and unhandled worker errors at ERROR, each with stable extra keys for JSON log handlers. Under systemd this lands in the journal. - Per-task forensics. Each row keeps its attempts count, the id of every worker that ran it, timestamps for enqueue/start/finish, and the full traceback of every failed attempt.
Pruning on a timer
Finished rows accumulate; prune them on a schedule sized to how long you need results and tracebacks to stay queryable. With systemd:
# /etc/systemd/system/ox-prune.service
[Unit]
Description=Prune finished django-ox tasks
[Service]
Type=oneshot
User=app
WorkingDirectory=/srv/myproject
Environment=DJANGO_SETTINGS_MODULE=myproject.settings
ExecStart=/srv/myproject/.venv/bin/python manage.py ox_prune --older-than 7d
# /etc/systemd/system/ox-prune.timer
[Unit]
Description=Daily django-ox prune
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Or from cron, or as a recurring task pointed at a small wrapper task of
your own. Flag reference on the
Configuration page. FAILED rows are kept by
default so tracebacks survive until you have looked at them; add
--include-failed once that is not needed.