# django-ox documentation
Every page of https://oxpull.com/django-ox/ in navigation order. The short index is at https://oxpull.com/django-ox/llms.txt.
---
# Source: https://oxpull.com/django-ox/
# django-ox
A database-backed worker backend for Django's Tasks framework
(`django.tasks`, Django 6.0+).
Django 6.0 ships the Tasks API but no production backend: the built-in
`ImmediateBackend` runs tasks inline and `DummyBackend` runs nothing.
django-ox stores background tasks in the database you already run and executes
them with a worker process. You get a durable queue with retries, priorities,
scheduling and a result store, and no broker to provision, secure, upgrade or
back up.
## One fewer service to run
A broker-based task queue adds a second datastore to your deployment. Redis or
RabbitMQ has to be provisioned, monitored, secured and upgraded, and it has to
be running before a single task executes. For an application that already
depends on a database, that is a full operational surface added for one
feature.
django-ox uses the database you already run. A deployment is your application,
a worker process, and one migration. Backups already cover the queue, because
the queue is a table, and there is no second datastore that can fail on its
own.
## Transactional enqueue
The queue lives in your database, so enqueueing a task is a single INSERT
on your default connection. That gives you a guarantee no broker-based
queue can offer: **the task and your data commit or roll back together.**
```python
from django.db import transaction
with transaction.atomic():
order = Order.objects.create(...)
send_confirmation.enqueue(order_id=order.pk)
# If anything below raises, the order AND the task vanish together.
charge(order)
```
With a broker, the enqueue leaves your process the moment you call it. If
the transaction then rolls back, a worker races to process an order that
does not exist. The standard workaround is wrapping every enqueue in
`transaction.on_commit()`, and remembering to, everywhere, forever. With
django-ox there is nothing to remember: a task enqueued inside
`transaction.atomic()` becomes visible to workers only when the
transaction commits, and disappears on rollback. There is no window where
business data exists without its task, or a task without its data.
Execution is at-least-once. Workers claim tasks atomically (`SKIP LOCKED`
on databases that support it, with a single-statement fast path on
PostgreSQL; an atomic compare-and-set elsewhere, including SQLite),
failed tasks retry with
exponential backoff, and a reaper returns tasks whose worker died to the
queue. Details in [Production](production.md).
## What happens when a worker dies
A worker that claims a task takes a lease on it, and the attempt is counted at
that moment. While the task runs, the worker renews the lease every
`LOCK_TIMEOUT / 3` seconds, so a slow task on a live worker is never reclaimed.
If the worker is killed, the lease goes stale. After `LOCK_TIMEOUT` (default
300 seconds) the reaper in any surviving worker takes the task back: to READY
if attempts remain, or to LOST if they are spent. LOST reads as `FAILED`
through the result API, so nothing waits forever on a worker that is not
coming back. The mechanics, and the one case worth knowing about, are in
[Production](production.md#the-lease).
## What you get
- Transactional enqueue, as above. No `on_commit` boilerplate.
- Retries with exponential backoff and the full traceback of every attempt.
- A reaper that reclaims tasks from dead workers, and a lease that keeps it
away from live slow ones.
- Graceful drain on SIGTERM: in-flight tasks finish before the worker exits.
- Priorities (-100 to 100) and deferred tasks (`run_after`).
- [Recurring tasks](recurring-tasks.md): cron schedules declared in
settings, no separate scheduler process.
- A result store: status, return value and errors readable through the
standard `django.tasks` result API.
- A [prune command](configuration.md#ox_prune) to keep the table small.
- [Monitoring](monitoring.md): a queue-stats API, an `ox_health` command
for probes and cron alerting, a Prometheus endpoint, structured log
events, and an admin page with retry and discard.
## Install
Requires Python 3.12+ and Django 6.0+.
```
pip install django-ox
```
Add the app and point the Tasks framework at the backend:
```python
INSTALLED_APPS = [
# ...
"django_ox",
]
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
}
}
```
Create the tables:
```
python manage.py migrate django_ox
```
## Quickstart
Tasks are plain `django.tasks` tasks. django-ox adds nothing to learn on
the producer side.
```python
# myapp/tasks.py
from django.tasks import task
@task
def send_welcome_email(user_id): ...
```
Enqueue one:
```python
from myapp.tasks import send_welcome_email
result = send_welcome_email.enqueue(user_id=42)
```
Run a worker in a second terminal:
```
python manage.py ox_worker
```
Check on the result later:
```python
result.refresh()
result.status # READY, RUNNING, FAILED, or SUCCESSFUL
result.return_value # once SUCCESSFUL
result.errors # per-attempt tracebacks, if any
```
That is the whole integration. Next steps:
- [Configuration](configuration.md) for every setting, option and command
flag.
- [Recurring tasks](recurring-tasks.md) for cron schedules.
- [Production](production.md) for systemd units, scaling and shutdown
semantics.
## Scope
The core is deliberately small: a durable queue, a worker, recurring
schedules, and monitoring, with nothing extra to operate. Design
decisions worth knowing before you commit:
- A queued task can be discarded before a worker claims it, and a failed
one retried, from the admin or with `django_ox.actions`. `TASK_TIMEOUT`
bounds how long any attempt may run; a particular running task cannot be
interrupted on demand.
- Tasks are stored on the default database for the model; multi-database
routing is not part of the current scope.
- Worker concurrency is a thread pool, which fits I/O-bound tasks. For
CPU-bound work, run `--processes N --concurrency 1`, which is N worker
processes under one supervisor. See
[Production](production.md#threads-and-processes).
Batches and unique tasks are in [Oxpull Pro](pro.md), a paid add-on that is
not on sale yet; the waitlist is at . Metrics stay in
this package: `django_ox.stats` and `ox_health` are free and stay free.
## License
BSD 3-Clause.
---
# Source: https://oxpull.com/django-ox/configuration/
# Configuration
Everything django-ox reads lives in the standard `TASKS` setting, plus
three management commands. A full entry with every option spelled out:
```python
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
"QUEUES": ["default", "emails"],
"OPTIONS": {
"MAX_ATTEMPTS": 3,
"LOCK_TIMEOUT": 300,
"BACKOFF_INITIAL": 5,
"BACKOFF_MAX": 600,
"TASK_TIMEOUT": None,
"TASK_TIMEOUTS": {},
"TASK_TIMEOUT_GRACE": 30,
"SCHEDULES": {}, # see the Recurring tasks page
},
}
}
```
## The smallest working entry
Every option has a default. This is enough to run tasks:
```python
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
}
}
```
That gives you the `default` queue, three attempts, and a five second first
retry. Add options when you have a reason to.
## Backend entry
| Key | Default | Meaning |
| --- | --- | --- |
| `BACKEND` | required | `"django_ox.backend.OxBackend"`. |
| `QUEUES` | `["default"]` | Queue names tasks may be enqueued to. An empty list (`[]`) allows any queue name. Read by Django's Tasks framework itself. |
| `OPTIONS` | `{}` | Backend options, below. |
## OPTIONS
| Key | Default | Meaning |
| --- | --- | --- |
| `MAX_ATTEMPTS` | `3` | Executions a task gets before it is marked FAILED. An attempt is consumed when a worker claims the task, so a worker dying mid-run counts too and retries stay bounded. |
| `LOCK_TIMEOUT` | `300` | Seconds a RUNNING task's lock may go unrefreshed before the reaper takes the task back. A worker refreshes the lock every `LOCK_TIMEOUT / 3` seconds while it is executing, so this is a limit on how long a worker may be unresponsive, not on how long a task may run. |
| `BACKOFF_INITIAL` | `5` | Delay in seconds before the first retry. |
| `BACKOFF_MAX` | `600` | Ceiling on the retry delay, in seconds. |
| `TASK_TIMEOUT` | `None` | Seconds one attempt may run. `None` means no limit. At the deadline the worker raises `django_ox.exceptions.TaskTimeout` inside the task, on the task's own thread, and records the attempt as failed: retried on the usual backoff, or FAILED when attempts are spent. An async task is cancelled at the deadline instead. A sync task on a thread a coverage tool or debugger is watching is left alone, and `TASK_TIMEOUT_GRACE` is the whole enforcement for it. See [Task timeouts](production.md#task-timeouts). |
| `TASK_TIMEOUTS` | `{}` | Per-queue timeouts, `{"queue name": seconds}`. A queue in the mapping uses its own value instead of `TASK_TIMEOUT`; `None` as a value exempts that queue. Every key must be a queue named in `QUEUES`, unless `QUEUES` is `[]`. Per queue rather than per task because `django.tasks` gives a task no field a backend could read a timeout from, and a queue is its unit of routing. |
| `TASK_TIMEOUT_GRACE` | `30` | Seconds a timed-out attempt gets to stop. A thread still running after that is treated as stuck, which usually means it is in a call that never returns to Python, where the exception cannot land: the worker records the attempt as failed, stops claiming, drains its other tasks and exits with code 75 so its supervisor restarts it. A task that catches `TaskTimeout` has the same deadline to return or raise, and so does a task on a watched thread, where nothing was raised at all. |
| `SCHEDULES` | `{}` | Recurring task definitions. Documented on the [Recurring tasks](recurring-tasks.md) page. |
The retry delay after attempt *n* fails is
`BACKOFF_INITIAL * 2 ** (n - 1)`, capped at `BACKOFF_MAX`. With the
defaults: 5 s, 10 s, 20 s, 40 s, and so on up to 600 s. There is no
jitter.
## ox_worker
```
python manage.py ox_worker [options]
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--backend` | `default` | Backend alias from the `TASKS` setting. |
| `--queues` | all configured queues | Comma-separated queue names this worker processes. |
| `--concurrency` | `1` | Tasks executed concurrently, as a thread pool inside each worker process. |
| `--processes` | `1` | Worker processes to run. At `1` the command is the worker. Above `1` it supervises that many copies of itself, each a full worker with its own connections, lease renewal, reaper and `--concurrency` thread pool, so `--processes 2 --concurrency 4` runs eight tasks at once. See [Threads and processes](production.md#threads-and-processes). |
| `--interval` | `1.0` | Polling interval in seconds when idle. When tasks are in flight the worker wakes as soon as one finishes, so this does not bound throughput. |
| `--lock-timeout` | backend `LOCK_TIMEOUT`, or 300 | Seconds a RUNNING task's lock may go unrefreshed before the task is reclaimed. |
The command also honors Django's standard `-v/--verbosity`: at the default
verbosity it logs worker lifecycle and warnings to stderr, and `-v 2`
enables debug logging. `-v 0` attaches no log handler. With `--processes`
above 1 every flag is passed on to each worker process unchanged, including
`--settings` and `--pythonpath`, and each worker process is started the way
the supervisor was (`manage.py` by absolute path, or `python -m django`), so
the command works from any working directory.
Two intervals are derived rather than flagged:
- The reaper runs every `min(30, max(lock_timeout / 2, 1))` seconds.
- Lease renewal runs every `max(lock_timeout / 3, 0.1)` seconds, on its own
thread, and keeps running until the last in-flight task has drained.
- Schedule dispatch (when `SCHEDULES` is configured) runs every
`max(1, min(interval, 30))` seconds, about once a second at the default
polling interval.
### Routing a queue to its own worker
Declare every queue on the backend, then give each worker a subset. Slow work
stops blocking fast work without a second backend or a second database.
```python
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
"QUEUES": ["default", "emails", "exports"],
}
}
```
```
python manage.py ox_worker --queues default,emails --concurrency 4
python manage.py ox_worker --queues exports --concurrency 1 --lock-timeout 3600
```
The exports worker runs one task at a time and tolerates hour-long jobs. The
other worker keeps short work moving at four at a time. A queue with no worker
assigned to it accumulates tasks and never runs them, so make sure every queue
in `QUEUES` is covered by some worker.
### Tasks that run longer than the lock timeout
A long task is not by itself a problem. A worker refreshes the lock on the
tasks it is running every `LOCK_TIMEOUT / 3` seconds, so an hour-long task on
a healthy worker keeps its lease for the hour.
What `LOCK_TIMEOUT` bounds is how long a worker may stop refreshing before its
work is handed to somebody else. Set it above the longest pause you are
willing to tolerate from a worker: a long garbage-collection pause, a
throttled container, a slow database, a host that swapped. If a queue runs on
hardware that stalls, give it its own worker with its own timeout rather than
raising the global value and delaying recovery for everything else:
```
python manage.py ox_worker --queues exports --lock-timeout 7200
```
If you embed the worker programmatically, `django_ox.worker.Worker`
accepts `reap_interval`, `renew_interval`, `schedule_interval`,
`backoff_initial`, `backoff_max`, `task_timeout` and `task_timeout_grace`
keyword overrides, which have no flag; they win over the `OPTIONS` values.
## ox_prune
Finished task rows stay in the table until pruned; the queue table doubles
as the result store, and django-ox does not guess at your retention
needs. Run `ox_prune` on your own schedule (cron or a systemd timer;
there is an example unit on the [Production](production.md#pruning-on-a-timer)
page):
```
python manage.py ox_prune --older-than 7d
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--older-than` | `7d` | Minimum time since the task finished. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
| `--include-failed` | off | Also delete FAILED and LOST rows. By default they are kept, because they hold the per-attempt tracebacks and can be retried. |
| `--batch-size` | `1000` | Rows per DELETE statement, so pruning a large table never takes a long lock or builds a giant IN clause. Must be at least 1. |
| `--dry-run` | off | Report how many rows would be deleted without deleting any. |
Only SUCCESSFUL and DISCARDED rows (and, with `--include-failed`, FAILED and
LOST rows) whose `finished_at` is past the cutoff are deleted. READY and RUNNING rows are
never touched, whatever their age. Rows from the recurring-schedule tick
log are pruned with the same cutoff, always keeping each schedule's most
recent tick; that row anchors missed-tick recovery and deleting it would
make the schedule re-anchor. The latest tick row of a schedule that has
been removed from settings is kept by the same rule; such rows are
harmless and can be deleted by hand if unwanted. See
[Recurring tasks](recurring-tasks.md#missed-ticks).
## ox_health
A health check for cron alerting and container probes: exits 0 when
every enabled check passes, non-zero with a one-line reason otherwise.
With no flags it verifies only that the database answers.
```
python manage.py ox_health --max-backlog 1000 --max-age 600
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Tasks deferred to a future `run_after` do not count. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this many seconds since becoming eligible. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this many seconds, or no claim was ever recorded. |
Check semantics, probe examples, and guidance on which check fits which
alert are on the
[Monitoring](monitoring.md#health-checks-ox_health) page.
## System checks
`manage.py check` validates the setup:
- `django_ox.E001`: `django_ox` is missing from `INSTALLED_APPS`. The app
itself registers these checks with Django, so a project that also lacks
any other import of `django.tasks` gets no `E001` from `manage.py check`;
the reliable symptom is `Unknown command: 'ox_worker'`, because the
command ships with the app.
- `django_ox.E002`: a `SCHEDULES` entry is invalid (task path does not
import, cron expression does not parse or can never fire, arguments not
JSON-serializable, bad queue name or priority).
- `django_ox.E003`: the same schedule name is defined on more than one
backend; schedule names must be unique across backends.
- `django_ox.E004`: `TASK_TIMEOUT`, a `TASK_TIMEOUTS` value or
`TASK_TIMEOUT_GRACE` is not a positive, finite number of seconds, at most
a thousand years (the first two may also be `None`, which means no limit;
`float("inf")` does not), or `TASK_TIMEOUTS` is not a mapping keyed by
queue name. Every bad value is reported in one run.
- `django_ox.E005`: a `TASK_TIMEOUTS` key names a queue that is not in
`QUEUES`, so the entry would never apply.
The worker performs the same schedule and timeout validation at startup, so
a bad deploy fails loudly rather than skipping dispatches.
---
# Source: https://oxpull.com/django-ox/recurring-tasks/
# Recurring tasks
django-ox runs cron-style schedules with no separate scheduler process. You
declare them in settings, next to the backend they enqueue through, so they are
versioned and deployed with your code. The database holds a dispatch log and
nothing else. There are no schedule rows to edit by hand.
```python
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
"QUEUES": ["default", "emails"],
"OPTIONS": {
"SCHEDULES": {
"nightly-report": {
"task": "reports.tasks.build_report",
"cron": "0 3 * * *",
"kwargs": {"full": True},
},
"warm-cache": {
"task": "core.tasks.warm_cache",
"cron": "*/15 * * * *",
},
},
},
}
}
```
Each tick enqueues an ordinary task. Workers claim it through the normal queue,
so retries, backoff, priorities and the result store all work as usual.
## Schedule keys
The dictionary keys are the schedule names. They must be non-empty strings of at
most 128 characters.
**Names must be unique across every backend, not just within one.** The dispatch
log is keyed by name alone, so two backends sharing a name would suppress each
other's ticks. A duplicate is rejected at worker startup and by
`manage.py check`, as `django_ox.E003`.
Each entry accepts exactly these keys. Anything else is rejected at startup:
| Key | Required | Meaning |
| --- | --- | --- |
| `task` | yes | Dotted path to a `@task` callable, e.g. `"reports.tasks.build_report"`. |
| `cron` | yes | Five-field cron expression or `@` shortcut, syntax below. |
| `args` | no | Positional arguments, as a list. Must be JSON-serializable. |
| `kwargs` | no | Keyword arguments, as a dict. Must be JSON-serializable. |
| `queue_name` | no | Queue override; defaults to the task's own queue. |
| `priority` | no | Priority override, -100 to 100. |
### Overriding the queue and priority
A schedule can put its task somewhere other than the task's own queue, which is
useful when a nightly job would otherwise sit behind interactive work:
```python
"SCHEDULES": {
"nightly-export": {
"task": "exports.tasks.rebuild",
"cron": "0 2 * * *",
"queue_name": "exports",
"priority": -50,
"kwargs": {"full": True},
},
}
```
The queue must be listed in that backend's `QUEUES`, and some worker must be
processing it. Priority runs from -100 to 100, and lower runs later.
A schedule enqueues through the backend it is declared under. That holds even if
the task itself was declared against a different backend alias.
## Cron syntax
Five fields, in order: minute (0-59), hour (0-23), day of month (1-31), month
(1-12), day of week (0-7, where 0 and 7 both mean Sunday).
| Form | Example | Meaning |
| --- | --- | --- |
| Any | `*` | Every value. |
| Value | `30` | That value. |
| List | `1,15` | Any listed value. Elements can be ranges or steps. |
| Range | `9-17` | Every value in the range, inclusive. Descending ranges are rejected. |
| Step | `*/15`, `1-10/2` | Every Nth value across the range. |
| Value with step | `5/15` | From that value to the field maximum. In the minute field, `5/15` means 5, 20, 35, 50. |
| Month names | `jan`, `DEC` | Case-insensitive, three letters, month field only. |
| Weekday names | `sun`, `Mon-Fri` | Case-insensitive, three letters, day-of-week field only. |
Shortcuts work in place of a full expression: `@hourly`, `@daily` and
`@midnight`, `@weekly`, `@monthly`, `@yearly` and `@annually`.
### Common schedules
```python
"SCHEDULES": {
# Every fifteen minutes.
"warm-cache": {"task": "core.tasks.warm_cache", "cron": "*/15 * * * *"},
# 03:00 every day.
"nightly-report": {"task": "reports.tasks.build", "cron": "0 3 * * *"},
# Every weekday at 09:30.
"weekday-digest": {"task": "core.tasks.digest", "cron": "30 9 * * mon-fri"},
# 00:00 on the 1st and 15th.
"twice-monthly": {"task": "billing.tasks.invoice", "cron": "0 0 1,15 * *"},
# Top of every hour.
"hourly-sync": {"task": "sync.tasks.pull", "cron": "@hourly"},
# 02:15 on the first of the month.
"monthly-prune": {"task": "core.tasks.prune_archive", "cron": "15 2 1 * *"},
}
```
Expressions that can never fire are rejected when the configuration loads. So
`0 0 30 2 *` (February 30th) is a startup error, not a schedule that silently
never runs.
### Day-of-month and day-of-week combine with OR
This follows vixie cron. When both fields are restricted, a day matches if
*either* one matches.
`0 0 1,15 * mon` fires on the 1st, on the 15th, and every Monday. It does not
fire only on Mondays that land on the 1st or 15th.
One deliberate divergence from vixie: a stepped star like `*/2` counts as
restricted here, which follows the croniter interpretation common in Python. So
`0 0 */2 * 1` fires on every odd day of the month *and* every Monday. Vixie
would require both.
### Timezones
Cron fields are wall-clock time in your project's timezone. With `USE_TZ = True`,
`0 3 * * *` means 03:00 local all year. With `USE_TZ = False` it is naive local
time.
Across a DST transition, a tick whose wall-clock time is missing or ambiguous is
resolved by zoneinfo fold handling. It shifts rather than raising. If a job must
not land inside a transition window, schedule it outside your zone's transition
hours.
**On the fall-back day a schedule can fire twice.** Take `30 1 * * *` in a zone
that replays 01:00 to 02:00: it fires once per pass. That is at most one extra
run per year, and it is consistent with the at-least-once delivery the rest of
the system already assumes. Hourly and more frequent schedules in the repeated
hour fire on each pass by design.
Changing `TIME_ZONE` to a zone behind the old one can also re-fire one
wall-clock label on the day you change it, because ticks are stored as distinct
UTC instants.
## Many workers, one tick
Every worker is also the scheduler. Alongside its polling, each one checks about
once a second whether a tick is due.
Coordination is a database unique constraint on (schedule name, tick time). It
works like this:
1. Every worker derives the same tick datetimes from the cron expression.
2. Dispatch wraps the tick-log `INSERT` and the task enqueue in one transaction.
3. When workers race the same tick, exactly one `INSERT` commits.
4. The losers hit the constraint and roll back, task row included.
So each tick fires exactly once, whatever the worker count, and dispatch keeps
working as long as one worker is alive. Scheduling is a property of the workers
you already run. There is nothing extra to deploy, monitor or fail over.
## Missed ticks
**If every worker was down when a tick passed, the most recent missed tick fires
once on recovery. Older ones are skipped.**
A nightly job due during an unlucky deploy still runs when workers return. A
weekend of downtime on a five-minute schedule does not replay hundreds of stale
runs.
**A new schedule never fires for times before it existed.** The first time a
worker sees a schedule with no history, it records the current tick as an anchor
and enqueues nothing. The schedule first fires at its next tick.
## Checking a schedule is live
`manage.py check` validates every schedule without starting a worker. It reports
a bad task path, an unparseable or never-firing cron expression, arguments that
are not JSON-serializable, and duplicate names across backends:
```
python manage.py check
```
To see what has actually dispatched, read the tick log:
```python
from django_ox.models import OxScheduleTick
OxScheduleTick.objects.filter(schedule_name="nightly-report").order_by(
"-scheduled_for"
)[:5]
```
Each row is one dispatched tick. A row whose `task` is `None` is the anchor
written the first time a worker saw the schedule; it enqueued nothing and marks
the tick before the first real fire.
The recovery baseline is each schedule's most recent tick row, which is why
`ox_prune` always keeps it. See
[Configuration](configuration.md#ox_prune).
---
# Source: https://oxpull.com/django-ox/patterns/
# Common patterns
Worked examples for the things people actually reach for. Every snippet uses the
standard `django.tasks` API, so it works on any backend; the notes point out
where django-ox behaves differently from a broker.
## Send an email after signup
The common case, and the one where a database queue behaves differently from a
broker.
```python
from django.db import transaction
from django.tasks import task
@task
def send_welcome_email(user_id):
user = User.objects.get(pk=user_id)
send_mail("Welcome", "...", None, [user.email])
def register(request):
with transaction.atomic():
user = User.objects.create_user(...)
send_welcome_email.enqueue(user.pk)
```
No `transaction.on_commit()` here. The enqueue is an `INSERT` on the same
connection, so the task becomes visible to workers only when the transaction
commits. If `create_user` is rolled back further up, the email task disappears
with it.
Pass the id, not the object. Arguments are stored as JSON, and a stale copy of a
model is a bug waiting to happen.
## Retry a flaky third-party call
Retries are automatic. Raise, and the worker schedules the next attempt with
exponential backoff.
```python
@task
def sync_to_crm(order_id):
order = Order.objects.get(pk=order_id)
response = httpx.post("https://crm.example.com/orders", json=order.payload)
response.raise_for_status() # a 5xx raises, so the task retries
```
Tune the envelope on the backend, not per task:
```python
"OPTIONS": {
"MAX_ATTEMPTS": 5,
"BACKOFF_INITIAL": 10, # seconds before the second attempt
"BACKOFF_MAX": 600, # ceiling
}
```
Every attempt keeps its own traceback, so a task that failed four times shows
all four. After `MAX_ATTEMPTS` the task is `FAILED` and stays in the table:
`ox_prune` keeps failed rows unless you pass `--include-failed`.
## Answer a webhook fast
Do the minimum in the request, then hand off. The sender gets its `200` straight
away, however long the work behind it takes.
```python
@task
def process_payment_event(event_id): ...
@csrf_exempt
def stripe_webhook(request):
event = WebhookEvent.objects.create(payload=json.loads(request.body))
process_payment_event.enqueue(event.pk)
return HttpResponse(status=200)
```
Both writes are in the same transaction, so you cannot acknowledge an event you
failed to record, or record one that never gets processed.
## Run a job and check on it later
`enqueue()` returns a result you can look up by id.
```python
result = build_report.enqueue(month="2026-08")
request.session["report_task_id"] = result.id
```
```python
from django.tasks import TaskResultStatus
result = build_report.get_result(request.session["report_task_id"])
result.refresh()
if result.status == TaskResultStatus.SUCCESSFUL:
return redirect(result.return_value)
if result.status == TaskResultStatus.FAILED:
return render(request, "report_failed.html", {"errors": result.errors})
return render(request, "report_pending.html", {"attempts": result.attempts})
```
`refresh()` re-reads from the database, so call it before checking status.
`status` is one of `READY`, `RUNNING`, `SUCCESSFUL` or `FAILED`, and
`is_finished` covers the last two. A task whose worker vanished without
reporting also reads as `FAILED`; [the reaper](production.md#the-reaper)
explains what that record contains. Return values are stored as JSON, so
return a URL or an id rather than a file or a model.
## Defer work to a specific time
Use `.using(run_after=...)`. It returns a copy of the task with that setting
applied.
```python
from datetime import timedelta
from django.utils import timezone
send_reminder.using(run_after=timezone.now() + timedelta(days=1)).enqueue(booking.pk)
```
Workers ignore the row until then. For anything on a repeating clock, use a
[schedule](recurring-tasks.md) instead of enqueueing the next one from inside
the task.
## Enqueuing many tasks at once
A loop of `enqueue()` calls is one `INSERT` per task. For a few dozen that is
fine; for a mailing run or a nightly fan-out it is the slow part of the
request. `django_ox.bulk.enqueue_many()` writes them in one statement per
thousand rows.
```python
from django_ox.bulk import enqueue_many
results = enqueue_many(
send_digest,
[((user.pk,), {}) for user in User.objects.filter(digest=True)],
)
```
Each element of the list is an `(args, kwargs)` pair, the same two values
`enqueue(*args, **kwargs)` passes to the backend. The return value is one
`TaskResult` per pair, in the order given. Queue, priority and `run_after`
belong to the task, so set them once with `.using(...)`:
```python
results = enqueue_many(
send_digest.using(queue_name="emails", run_after=tonight),
[((user.pk,), {}) for user in users],
)
```
The task and every argument are checked before the first row is written: a
queue the backend does not accept, a task bound to another backend, or an
argument that will not serialise to JSON raises with nothing inserted. The
rows go in one `INSERT` per 1,000 (SQLite caps the variables a statement can
bind) inside one transaction, so a call of 5,000 commits all 5,000 or none,
and inside your own `transaction.atomic()` it commits or rolls back with
the rest of your work, as `enqueue()` does.
This is a bulk insert and nothing more. Grouping the
tasks, reading their progress as one number and firing a callback when the
last one settles is a [batch](pro.md), in Oxpull Pro.
## Keep slow work off the fast queue
Give slow tasks their own queue and run a separate worker for it, so a batch of
report builds cannot delay password resets.
```python
@task(queue_name="reports")
def build_report(month): ...
@task(queue_name="emails", priority=50)
def send_password_reset(user_id): ...
```
```
python manage.py ox_worker --queues emails --concurrency 4
python manage.py ox_worker --queues reports --concurrency 1
```
Priority runs from -100 to 100, higher first, and applies within a queue rather
than across queues. List every queue you use in the backend's `QUEUES`, or set
`QUEUES: []` to accept any name.
## Clean up on a schedule
```python
"OPTIONS": {
"SCHEDULES": {
"expire-carts": {
"task": "shop.tasks.expire_abandoned_carts",
"cron": "*/30 * * * *",
},
},
}
```
Nothing extra to run: the workers you already have dispatch the ticks. Details
in [Recurring tasks](recurring-tasks.md).
Task rows are not deleted for you. Run `ox_prune` on your own schedule, from
cron, a systemd timer, or a django-ox schedule:
```
python manage.py ox_prune --older-than 7d
```
## Make a task safe to run twice
The one habit worth building. Execution is at-least-once, so a task retries both
when it raises and when its worker dies mid-run. Assume every task can run
again.
```python
from django.db import transaction
@task
def charge_order(order_id):
with transaction.atomic():
order = Order.objects.select_for_update().get(pk=order_id)
if order.charged_at:
return # a previous attempt already did this
charge(order, idempotency_key=f"order-{order.pk}")
order.charged_at = timezone.now()
order.save(update_fields=["charged_at"])
```
Guard on state you have written, not on a flag you set in memory. The row lock
serialises concurrent attempts, and the idempotency key covers the gap where the
charge succeeds but the transaction does not commit. Use one whenever the
external system offers it.
**Your task is not run inside a transaction.** The worker manages its own for
claiming and bookkeeping, but your function is called outside them, so
`select_for_update()` and anything else needing an open transaction must open
one, as above.
## Test without a worker
Point the test settings at Django's own backends. No django-ox tables, no worker
process.
```python
# runs tasks inline, so an assertion right after enqueue sees the effect
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
```
```python
# records tasks without running them, for asserting what was enqueued
TASKS = {"default": {"BACKEND": "django.tasks.backends.dummy.DummyBackend"}}
```
Keep django-ox in the settings you use for integration tests, where the point is
to exercise claiming and retries for real.
## Not in the core
Batches and unique or deduplicated tasks are in [Oxpull Pro](pro.md), a paid
add-on that is not on sale yet. Metrics are in the free tier: `django_ox.stats`
and `manage.py ox_health` ship in the core. Chains and workflows are on the
Pro roadmap, undated.
---
# Source: https://oxpull.com/django-ox/migrating/
# Migrating to django-ox
Your task code stays the same. django-ox implements Django's `django.tasks` API,
so `@task` functions and `.enqueue()` calls do not change.
Three things do change: the backend in your `TASKS` setting, the worker command,
and the table the queue lives in.
Find your section below, then read [Switching over](#switching-over). That last
part is where migrations go wrong.
## From another `django.tasks` backend
Configuration only. Here it is with `django-tasks-db`, the most common one:
```python
# before
INSTALLED_APPS = ["django_tasks_db", ...]
TASKS = {
"default": {
"BACKEND": "django_tasks_db.DatabaseBackend",
"QUEUES": ["default"],
}
}
```
```python
# after
INSTALLED_APPS = ["django_ox", ...]
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
"QUEUES": ["default"],
}
}
```
Run `python manage.py migrate django_ox` to create the table.
| | Before | After |
| --- | --- | --- |
| Worker | `manage.py db_worker` | `manage.py ox_worker` |
| Clean up finished rows | `manage.py prune_db_task_results` | `manage.py ox_prune --older-than 7d` |
Nothing else moves. Same decorator, same `.enqueue()`, same result API.
## From Celery
Celery needs a broker and its own workers. django-ox uses the database you
already have, so there is no broker to run.
```python
# before
from celery import shared_task
@shared_task
def send_confirmation(order_id): ...
send_confirmation.delay(order_id=42)
```
```python
# after
from django.tasks import task
@task
def send_confirmation(order_id): ...
send_confirmation.enqueue(order_id=42)
```
| Celery | django-ox |
| --- | --- |
| Broker URL (Redis, RabbitMQ) | none. The queue is a table on your default database. |
| `celery -A proj worker` | `manage.py ox_worker` |
| `celery -A proj beat` | nothing to run. Schedules go in `TASKS` and every worker dispatches them. See [Recurring tasks](recurring-tasks.md). |
| `.delay(...)`, `.apply_async(...)` | `.enqueue(...)` |
| `apply_async(countdown=..., eta=...)` | `run_after` |
| `autoretry_for`, `self.retry` | automatic. Tune with `MAX_ATTEMPTS`, `BACKOFF_INITIAL`, `BACKOFF_MAX`. |
| Result backend | the same table, read through the standard result API. |
| Flower | the [stats API, `ox_health`, the Prometheus endpoint and the admin page](monitoring.md) |
One difference in behaviour is worth reading before you switch. With a broker,
`enqueue` leaves your process immediately. If the surrounding transaction then
rolls back, a worker can pick up an order that no longer exists. The usual fix
is to wrap every call in `transaction.on_commit()`.
Here the enqueue is an `INSERT` on your own connection. It commits or rolls back
with the row it belongs to, so there is nothing to wrap.
Queues, priorities and `run_after` are what exist today. Celery's chains,
groups and chords, and routing across multiple brokers, are outside the
package; chains and workflows are on the [Oxpull Pro](pro.md) roadmap,
undated.
## From huey
Closer to django-ox than Celery is, since huey can already store tasks in
SQLite or Postgres. What changes is the API, and where schedules live.
```python
# before
from huey.contrib.djhuey import task, periodic_task
from huey import crontab
@task()
def send_confirmation(order_id): ...
@periodic_task(crontab(minute="0", hour="3"))
def nightly_report(): ...
```
```python
# after
from django.tasks import task
@task
def send_confirmation(order_id): ...
@task
def nightly_report(): ...
```
The schedule moves off the function and into settings:
```python
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
"OPTIONS": {
"SCHEDULES": {
"nightly-report": {
"task": "reports.tasks.nightly_report",
"cron": "0 3 * * *",
},
},
},
}
}
```
Schedules in settings deploy with your code, so there are no rows to edit by
hand. A typo fails at `manage.py check` instead of at dispatch time.
| huey | django-ox |
| --- | --- |
| `manage.py run_huey` | `manage.py ox_worker` |
| `@periodic_task(crontab(...))` | a `SCHEDULES` entry, same five-field cron syntax |
| `.schedule(delay=...)` | `run_after` |
| `retries`, `retry_delay` | `MAX_ATTEMPTS` and the backoff options |
| `huey.immediate` in tests | Django's `ImmediateBackend` or `DummyBackend` |
## Switching over
The two systems use different tables. Neither reads the other's rows. So if you
flip the setting and deploy, anything still queued in the old table has nothing
left to run it.
1. **Stop enqueueing to the old system.** Leave its workers running.
2. **Let it drain.** Watch until pending work hits zero. Check scheduled tasks
too: a job due in six hours still counts.
3. **Deploy django-ox.** Run `migrate django_ox`, then switch `TASKS`.
4. **Start `ox_worker`** and check it picks up work. `manage.py ox_health` will
tell you, and the worker logs every claim to the `django_ox` logger.
5. **Retire the old worker,** then its tables and broker.
No drain window available? Run both. Old workers keep serving the old table
while new work goes to django-ox. They cannot see each other's rows.
Both systems run tasks at least once, so your tasks should already be
idempotent. Worth confirming before you start rather than halfway through.
## Migrating away
Task functions are portable. django-ox adds nothing to the producer side, so
tasks stay ordinary `django.tasks` tasks and moving to another backend is a
settings change and a drain, run in the same order as above with the roles
reversed.
One behaviour does not travel, and it is worth deciding about on the way in
rather than on the way out. Enqueueing inside `transaction.atomic()` ties the
task to that transaction, so it disappears on rollback. A broker-based backend
cannot do this: the enqueue leaves your process the moment you call it. Code
that depends on a rollback removing a task will behave differently once the
queue lives in a broker, and it will do so quietly.
If you want to keep that option open, wrap enqueues in
`transaction.on_commit()`, the way a broker-based backend requires. django-ox
runs correctly either way, and the task is enqueued after the commit instead of
inside it. You give up the guarantee and keep the portability.
If you would rather have the guarantee, take it, and write the dependency down
somewhere the next person will find it.
---
# Source: https://oxpull.com/django-ox/choosing/
# Choosing a task backend
This page compares django-ox with the task queues a Django team is most likely
to shortlist: django-tasks-db, huey, Celery, django-q2, dramatiq and
procrastinate. Every cell about another project comes from that project's own
documentation, source or issue tracker, with the link in the footnotes and the
date it was read. Where a project's pages do not say, the cell says so rather
than guessing.
The comparison is about fit, not ranking. A team that already runs RabbitMQ and
needs to stop running tasks has a different answer from a team that wants one
fewer service. The last section says where django-ox is not the right fit.
## The table
| | django-ox | django-tasks-db | huey | Celery | django-q2 | dramatiq | procrastinate |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `django.tasks` backend (Django 6) | Yes, native | Yes, native [^tdb-readme] | No. Issue #870 closed 2025-11-14 [^huey-870] | No. Issue #10062 closed 2026-02-03 [^celery-10062] | Pull request #315 open since 2026-02-04, last updated 2026-05-15 [^q2-315] | Not documented [^dq-guide] | Not documented; has its own Django integration [^proc-index] |
| Broker needed | None. The queue is a table in your database | None. Django ORM [^tdb-readme] | Redis, SQLite, PostgreSQL, file or memory storage [^huey-guide] | RabbitMQ, Redis or SQS (stable); Zookeeper, Kafka, Pub/Sub (experimental). SQL databases are result backends only [^celery-brokers]. Issue #5149, PostgreSQL as a broker, open since 2018-10-25 with 95 upvotes [^celery-5149] | Redis (default), IronMQ, SQS, MongoDB or Django ORM [^q2-brokers] | RabbitMQ or Redis [^dq-guide] | None. PostgreSQL is the queue [^proc-index] |
| Transactional enqueue | Yes. Enqueue is an INSERT on your connection; it commits or rolls back with your data | Not documented [^tdb-readme] | Not documented [^huey-guide] | No. Django's own docs name a background task as the case for `on_commit()` [^dj-oncommit] | Not documented [^q2-brokers] | No. Enqueue is a broker send [^dq-guide] | Not documented [^proc-django] |
| Retries and backoff | Exponential backoff, `MAX_ATTEMPTS`, `BACKOFF_INITIAL`, `BACKOFF_MAX`; every attempt's traceback kept | No retry option in the README or the worker flags [^tdb-readme] [^tdb-worker] | `retries`, `retry_delay`, `retry_backoff` [^huey-guide] | `autoretry_for`, `retry_backoff`, `retry_backoff_max` (default 600 s), `retry_jitter` [^celery-tasks] | `max_attempts` (default 0, meaning unlimited) and a `retry` interval; backoff not documented [^q2-configure] | Exponential backoff; `max_retries` default 20, `min_backoff` 15 s, `max_backoff` 7 days [^dq-guide] | Retry strategy per task [^proc-index] |
| Recurring schedules | Cron in settings; every worker dispatches, no scheduler process | None. Issue #259 open since 2026-08-10 [^tasks-259] | `periodic_task(crontab(...))` [^huey-guide] | `celery beat`, a separate process; "ensure only a single scheduler is running" [^celery-beat] | `Schedule` model, editable in admin; cron via croniter [^q2-schedules] | None built in; APScheduler recommended [^dq-cookbook] | `@app.periodic` [^proc-index] |
| Priorities | -100 to 100 | Yes [^tdb-backend] | Yes; on Redis needs 5.0+ and `PriorityRedisHuey` [^huey-guide] | 0 to 255 on RabbitMQ and Redis [^celery-calling] | Not documented [^q2-configure] | Per actor; lower number runs first [^dq-guide] | Yes [^proc-index] |
| Time limit on a running task | `TASK_TIMEOUT`, per queue in `TASK_TIMEOUTS`: `TaskTimeout` raised inside the task at the deadline, and a worker recycle when the thread does not stop. Two caveats leave the grace backstop as the whole enforcement: an interpreter with no facility for raising an exception in another thread, and a thread a coverage tool or debugger is watching. See [Production](production.md#task-timeouts) | Not documented in the README or the worker flags [^tdb-readme] [^tdb-worker] | `timeout` per task or per call; "a `TaskTimeout` is raised and returned to the caller via the result handle" [^huey-guide] | `soft_time_limit` raises `SoftTimeLimitExceeded` inside the task; `time_limit` terminates the process running it, which is replaced. "Time limits don't currently work on platforms that don't support the `SIGUSR1` signal" [^celery-workers] | `timeout`, default `None`: "the number of seconds a worker is allowed to spend on a task before it's terminated" [^q2-configure] | `time_limit` per actor, default 10 minutes, raises `TimeLimitExceeded`. "Time limits are best-effort. They cannot cancel system calls or any function that doesn't currently hold the GIL under CPython" [^dq-guide] | Not documented on the page checked [^proc-index] |
| Worker dies mid-task | Lease renewed every `LOCK_TIMEOUT / 3`; a task whose lease goes stale for `LOCK_TIMEOUT` is put back to READY, or marked LOST when attempts are spent. See [Production](production.md#the-reaper) | Issue #5, open since 2024-06-11: the task 'remains marked as "PROCESSING", and thus is never picked up for re-processing nor marked as completed / failed' [^tdb-5] | "tasks that are mid-execution are lost and will not be retried automatically" [^huey-guide] | `acks_late` re-delivers; the worker still acknowledges "if the child process executing the task is terminated" [^celery-tasks] | Issue #327, open since 2026-05-05: worker death not reported to the monitor, `MAX_ATTEMPTS` ignored [^q2-327] | Not stated on the pages checked [^dq-guide] [^dq-cookbook] | Heartbeat every 10 s; jobs stay in `doing` until a `retry_stalled_jobs` periodic task you define picks them up [^proc-stalled] |
| Health and metrics | `ox_health` command, `django_ox.stats`, a Prometheus endpoint (`/ox/metrics`), structured log events, admin retry and discard | Issue #44, container healthchecks, open since 2026-06-08 [^tdb-44] | Signals [^huey-guide] | Flower, a separate process, with Prometheus integration [^flower] | `qmonitor`, `qinfo`, `Stat` [^q2-monitor] | Prometheus middleware; not in the default middleware list [^dq-prom] | Statistics via events [^proc-index] |
| Databases | PostgreSQL, SQLite and MySQL 8 tested in CI; MariaDB 10.6+ untested | Any Django database [^tdb-readme] | Redis, SQLite, PostgreSQL, file, memory [^huey-guide] | Broker, not a database [^celery-brokers] | Any Django database through the ORM broker [^q2-brokers] | Broker, not a database [^dq-guide] | PostgreSQL 13+ [^proc-index] |
| Licence | BSD 3-Clause | BSD 3-Clause [^tdb-repo] | MIT [^huey-repo] | BSD 3-Clause [^celery-license] | MIT [^q2-pyproject] | LGPL 3.0 [^dq-repo] | MIT [^proc-repo] |
Async tasks: django-ox sets `supports_async_task`, so `async def` tasks
enqueue and run. Celery's most-upvoted open issue is #6552, "Support async
function", open since 2020-12-19 with 98 upvotes [^celery-6552].
## When not to use django-ox
- **You need to stop one chosen task while it runs.** django-ox bounds every
attempt with `TASK_TIMEOUT`, discards a queued task and retries a failed
one, but has no call that interrupts a particular running task on demand.
Celery can revoke and terminate a running task, from Flower or the control
API [^flower].
- **The queue must live on a different database from your models.** Tasks are
stored on the default database. Multi-database routing is outside the
current scope, and a separate queue database would also give up the
transactional enqueue.
- **Throughput beyond what one database comfortably serves.** The
[benchmarks](benchmarks.md) page gives measured numbers with the method.
If your workload is above them, a broker-based queue is the right tool, and
the cost is the second datastore.
- **Chains, groups and chords.** Not in django-ox. Batches are in
[Oxpull Pro](pro.md), a paid add-on that is not on sale yet; chains and
workflows are on the Pro roadmap, undated.
- **CPU-bound tasks in one process.** Worker concurrency is a thread pool.
Run `ox_worker --processes N --concurrency 1` for N interpreters, or pick
a queue with a process pool.
## Maintenance
Rows are re-checked each release. If a cell is out of date, open an issue with
the link that shows it, and it will be corrected in the next release.
[^tdb-readme]: https://github.com/RealOrangeOne/django-tasks-db README, checked 2026-08-23.
[^tdb-worker]: https://github.com/RealOrangeOne/django-tasks-db/blob/master/django_tasks_db/management/commands/db_worker.py, `add_arguments`, checked 2026-08-23.
[^tdb-backend]: https://github.com/RealOrangeOne/django-tasks-db/blob/master/django_tasks_db/backend.py, `supports_priority = True`, checked 2026-08-23.
[^tdb-5]: https://github.com/RealOrangeOne/django-tasks-db/issues/5, open, checked 2026-08-23.
[^tdb-44]: https://github.com/RealOrangeOne/django-tasks-db/issues/44, open, checked 2026-08-23.
[^tdb-repo]: https://github.com/RealOrangeOne/django-tasks-db, licence field, checked 2026-08-23.
[^tasks-259]: https://github.com/RealOrangeOne/django-tasks/issues/259, open, checked 2026-08-23.
[^huey-guide]: https://huey.readthedocs.io/en/latest/guide.html, checked 2026-08-23.
[^huey-870]: https://github.com/coleifer/huey/issues/870, closed, checked 2026-08-23.
[^huey-repo]: https://github.com/coleifer/huey, licence field, checked 2026-08-23.
[^celery-tasks]: https://docs.celeryq.dev/en/stable/userguide/tasks.html, checked 2026-08-23.
[^celery-brokers]: https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/index.html, checked 2026-08-23.
[^celery-workers]: https://docs.celeryq.dev/en/stable/userguide/workers.html, Time Limits, checked 2026-08-23.
[^celery-beat]: https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html, checked 2026-08-23.
[^celery-calling]: https://docs.celeryq.dev/en/stable/userguide/calling.html, checked 2026-08-23.
[^celery-5149]: https://github.com/celery/celery/issues/5149, open, checked 2026-08-23.
[^celery-6552]: https://github.com/celery/celery/issues/6552, open, checked 2026-08-23.
[^celery-10062]: https://github.com/celery/celery/issues/10062, closed, checked 2026-08-23.
[^celery-license]: https://github.com/celery/celery/blob/main/LICENSE, checked 2026-08-23.
[^flower]: https://flower.readthedocs.io/en/latest/features.html, checked 2026-08-23.
[^dj-oncommit]: https://docs.djangoproject.com/en/6.0/topics/db/transactions/#performing-actions-after-commit, checked 2026-08-23.
[^q2-brokers]: https://django-q2.readthedocs.io/en/master/brokers.html, checked 2026-08-23.
[^q2-configure]: https://django-q2.readthedocs.io/en/master/configure.html, checked 2026-08-23.
[^q2-schedules]: https://django-q2.readthedocs.io/en/master/schedules.html, checked 2026-08-23.
[^q2-monitor]: https://django-q2.readthedocs.io/en/master/monitor.html, checked 2026-08-23.
[^q2-315]: https://github.com/django-q2/django-q2/pull/315, open, checked 2026-08-23.
[^q2-327]: https://github.com/django-q2/django-q2/issues/327, open, checked 2026-08-23.
[^q2-pyproject]: https://github.com/django-q2/django-q2/blob/master/pyproject.toml, checked 2026-08-23.
[^dq-guide]: https://dramatiq.io/guide.html, checked 2026-08-23.
[^dq-cookbook]: https://dramatiq.io/cookbook.html, checked 2026-08-23.
[^dq-prom]: https://github.com/Bogdanp/dramatiq/blob/master/dramatiq/middleware/__init__.py, `default_middleware`, and `middleware/prometheus.py`, checked 2026-08-23.
[^dq-repo]: https://github.com/Bogdanp/dramatiq, licence field, checked 2026-08-23.
[^proc-index]: https://procrastinate.readthedocs.io/en/stable/, checked 2026-08-23.
[^proc-django]: https://procrastinate.readthedocs.io/en/stable/howto/django/basic_usage.html, checked 2026-08-23.
[^proc-stalled]: https://procrastinate.readthedocs.io/en/stable/howto/production/retry_stalled_jobs.html, checked 2026-08-23.
[^proc-repo]: https://github.com/procrastinate-org/procrastinate, licence field, checked 2026-08-23.
---
# Source: https://oxpull.com/django-ox/production/
# 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
```ini
# /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 --processes 2 --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. KillMode=mixed sends that SIGTERM to the
# supervisor alone, which forwards it once; the default of sending it to
# every process in the group would reach each worker twice, and a second
# signal is the force-exit.
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=300
[Install]
WantedBy=multi-user.target
```
```
sudo systemctl enable --now ox-worker
journalctl -u ox-worker -f
```
There are two ways to put several worker processes on one host. The unit
above uses `--processes 2`: one unit, one supervisor, two workers, and one
place to set the flags. The other is a template unit (`ox-worker@.service`
with the same `[Service]` body and `--processes 1`), started as
`ox-worker@1`, `ox-worker@2`, and so on, which makes each worker its own
unit with its own journal entry and restart counter.
Use `--processes` unless you need to stop, restart or give flags to one
worker at a time. A supervisor that restarts a dead worker in a second, with
one command to edit, is the common case. The template unit is the right
shape when the workers differ, for instance one unit per queue with its own
`--lock-timeout`, and for that a queue flag per unit says more than a
process count.
## 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.
```yaml
services:
worker:
image: myapp:latest
command: python manage.py ox_worker --processes 2 --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.
`--processes` inside one container, or one worker per container with the
replica count doing the scaling, both work. The runtime restarts a container,
the supervisor restarts a worker process, and each takes about a second. One
container per worker keeps the runtime's own health and restart accounting
per worker, which is worth having on an orchestrator; `--processes` keeps the
number of containers down on a single host.
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](monitoring.md#health-checks-ox_health), 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.
Roll every process before using a status the old version cannot read. 0.3.0
adds DISCARDED: a 0.2.1 process that reads a discarded row raises
`ValueError` from `get_result()` and `refresh()`, and its `ox_prune` cannot
delete the row. Migrate, finish the rollout, then discard. A rollback to
0.2.1 with discarded rows present keeps the crash until those rows are
deleted by hand (`DELETE FROM django_ox_oxtask WHERE status = 'DISCARDED'`);
reversing the migration does not remove them.
## Graceful shutdown
On SIGTERM or SIGINT the worker:
1. Stops claiming new tasks immediately.
2. Waits for in-flight tasks to finish, however long they take.
3. 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.
One other exit code exists. A worker exits 75 when it recycles itself after
a task thread that its timeout could not stop; see
[Task timeouts](#task-timeouts). A process manager on `Restart=always` or
`Restart=on-failure` restarts it either way.
This maps directly onto rolling deploys: send SIGTERM, wait, start the new
version. The only tuning point is the process manager's kill escalation
(`TimeoutStopSec` above) relative to your longest task.
With `--processes` above 1, the signal goes to the supervisor, and SIGHUP
counts as well as SIGTERM and SIGINT. The sequence is:
1. First signal: the supervisor forwards SIGTERM to every worker process
and waits for each to drain. It exits 0 when all of them did, otherwise
with the first non-zero code. A worker process cannot act on a signal
until it has installed its handler, which is after Django is imported,
so a stop that lands in that window kills it outright. It had claimed
no work, so the supervisor logs `worker_process_stopped_early` and
still exits 0. A restart, or a deploy that rolls twice, is not a
failure to report to the process manager.
2. Second signal: forwarded again, which is the force-exit on each worker.
A worker that cannot act on it (stopped, stuck in a C call) gets five
seconds, then SIGKILL, logged as `supervisor_killed_workers` at ERROR.
3. A third signal sends the SIGKILL at once.
Send the signal to the supervisor only. A worker that also receives the
terminal's copy of a Ctrl-C has seen two signals. That is why each worker
runs in its own process group, and why the systemd unit above sets
`KillMode=mixed`.
A worker whose supervisor dies without signalling it (SIGKILL, an OOM kill)
does not run on as an orphan. On Linux the kernel sends it SIGTERM the
moment the supervisor exits (`PR_SET_PDEATHSIG`), so it drains through its
ordinary signal path. Everywhere else, and on Linux in the window before
that flag is set, the worker notices within one poll interval that its
parent pid has changed, logs `worker_orphaned` at WARNING, drains and
exits.
## 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) RETURNING`
statement, so workers never block each other on the head of the queue.
On other databases with `SKIP LOCKED` support (MySQL 8+), the worker
uses `SELECT ... FOR UPDATE SKIP LOCKED` in 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](recurring-tasks.md#many-workers-one-tick).
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; use
`--processes N` there, with `--concurrency 1`, and the same command on one
host gives you N interpreters.
```
python manage.py ox_worker --processes 4 --concurrency 1
```
Each process is a complete worker with its own database connections, lease
renewal and reaper, and its own worker id with the slot number on the end, so
`worker_ids` on a task row says which process ran it. Nothing is shared
across the processes except the database: the supervisor starts each one as a
fresh interpreter running `ox_worker --processes 1` with the same flags, so a
worker under the supervisor is the same code as a worker started by hand. The
supervisor itself never opens a database connection.
The children start the way the supervisor was started. `python
/srv/app/manage.py ox_worker --processes 2` from any working directory runs
`/srv/app/manage.py` again for each child; `django-admin` or `python -m
django` runs `python -m django` with `DJANGO_SETTINGS_MODULE` set in the
child's environment. `--settings` and `--pythonpath` are passed on, and the
children inherit the supervisor's working directory.
A worker process that exits, whatever the cause and whatever the code, is a
death: a crash, a kill, and a clean exit 0 all count the same, because a
worker is meant to run until told to stop. The supervisor restarts the slot
and logs `worker_process_restarted` with the exit code at WARNING. Its
in-flight tasks go through the ordinary lease path: the reaper on a
surviving process takes them back after `LOCK_TIMEOUT`. The restart policy
is per slot:
- The first restart comes after one second. Each further death within 60
seconds of the slot's last start doubles the delay (1, 2, 4, 8, 16, 30
seconds, capped at 30). A slot that has run for 60 seconds starts the
sequence over at one second.
- More than five deaths of one slot inside one minute stops the supervisor:
it logs `supervisor_restart_cap` at ERROR with the slot index, drains the
other workers, and exits 1 whatever the children's own exit codes were, so
a unit on `Restart=on-failure` restarts it too. A worker that cannot stay
up hands the fault to the process manager and its restart policy rather
than logging a restart a second forever.
- Because the count is per slot, every worker dying at once (a database
restart, a deploy that changes a connection string) is one restart each,
not a trip. Six workers that all die in the same second all come back a
second later.
Two things `--processes` does not do. It does not run on Windows, where
there are no POSIX signals to forward; run one `ox_worker` per process there.
And it does not replace a process manager: the supervisor is a foreground
process that expects to be restarted itself, like the single 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.
### Task timeouts
`TASK_TIMEOUT` bounds how long one attempt may run. It is off by default.
With it set, the worker raises `django_ox.exceptions.TaskTimeout` inside the
task when the deadline passes:
- **A sync task** gets the exception on its own thread, at the next line of
Python it executes. `finally` blocks run, an open `transaction.atomic()`
rolls back, and the thread returns to the pool. The worker then drops the
thread's database connections, since the exception may have landed inside
the driver with a statement in flight, and records the outcome on a fresh
one. The task may catch `TaskTimeout` to clean up and then re-raise it.
Inside the task the exception is bare: `str(exc)` is empty and
`exc.timeout` is `None`, and the worker fills both in when it records the
attempt. A task that catches it and returns is allowed, and the attempt
is recorded as whatever the task went on to do, provided it returns or
raises within `TASK_TIMEOUT_GRACE`; one still running then is treated as
a thread that did not stop, below. An exception raised while the task
unwinds from `TaskTimeout` (a cleanup that fails, say) is recorded as the
timeout, with that exception in the traceback. `raise ... from None`
breaks that chain, and the attempt is then recorded as the exception it
names, with no `task_timed_out` event.
- **An async task** is cancelled inside its event loop at the deadline. The
coroutine sees `asyncio.CancelledError` at the `await` it was on, as any
cancelled coroutine does, and should let it propagate; the worker records
the attempt with `TaskTimeout`. `except TaskTimeout` inside an async task
never fires, because nothing can raise another class at a running
coroutine's `await`.
The attempt is recorded as failed with a `TaskTimeout` error that names the
timeout. The attempt was consumed at claim time, so the retry rule is the
ordinary one: back to READY on the backoff while attempts remain, FAILED
when they are spent. The worker logs `task_timed_out` at WARNING, then the
usual `task_retrying` or `task_failed`. `TaskTimeout` subclasses
`TimeoutError`, so code written for one treats it as one.
A long loop can check the clock instead of being interrupted between two
steps. `django_ox.deadline()` returns the attempt's deadline as a `datetime`,
and `django_ox.remaining()` the seconds left; both return `None` when no
timeout applies.
```python
import django_ox
from django.tasks import task
@task
def export(report_id):
for chunk in chunks_of(report_id):
left = django_ox.remaining()
if left is not None and left < 5:
return {"paused_at": chunk.offset}
write(chunk)
return {"done": True}
```
**A thread that does not stop.** The exception is delivered when the thread
next executes Python, so under a pool of CPU-bound threads it can lag the
deadline by a few multiples of the interpreter's 5 ms switch interval. A
thread blocked in a C call stays blocked until the call returns: a socket
read with no timeout, `time.sleep()`, a lock, a long statement waiting on the
database. The exception lands when the call returns, and if that is within
the grace the attempt is an ordinary timeout. `TASK_TIMEOUT_GRACE` (default
30 seconds) is how long the worker waits for the thread after the deadline.
A task that caught `TaskTimeout` and is still running then looks the same
from outside, and is treated the same way. If the thread is still running at
the grace, the worker:
1. Records the attempt as failed, with a `TaskTimeout` whose message says
the thread did not stop within the grace, and moves the lease number in
the same write, the
way the reaper does when it takes a row off a worker that went quiet.
The outcome the thread eventually reports is refused by that number.
2. Logs `task_stuck` at ERROR and `worker_recycling` at WARNING.
3. Stops claiming, drains its other in-flight tasks, and exits with code 75
(`EX_TEMPFAIL`). The stuck thread dies with the process.
Under `--processes` (the unit above) the supervisor restarts the slot after
one second, logs `worker_process_recycled`, and does not count the exit
against the restart cap; systemd sees nothing. A single-process worker
under systemd comes back on `Restart=always`, and on `Restart=on-failure`,
since 75 is non-zero. A container runtime on `restart: unless-stopped` does
the same.
Between the stuck record and the process exit the task may run twice: its
retry is claimable the moment the record lands, and the stuck thread keeps
executing until the worker's other in-flight tasks have drained. The thread
cannot write its outcome to the row, but its side effects are real. That is
the at-least-once contract every task already lives under: write tasks to be
safe to run twice.
Put timeouts on sockets and HTTP clients where you can. A task that returns
to Python regularly is one the soft timeout stops cleanly; the recycle is the
backstop.
Set per-queue values where one number does not fit:
```python
"QUEUES": ["default", "exports", "webhooks"],
"OPTIONS": {
"TASK_TIMEOUT": 60,
"TASK_TIMEOUTS": {"exports": 3600, "webhooks": 10},
"TASK_TIMEOUT_GRACE": 30,
},
```
A queue in `TASK_TIMEOUTS` uses its own value; `None` there exempts the
queue from the global limit. Every queue named there must be in `QUEUES`
(`django_ox.E005` otherwise), unless `QUEUES` is `[]`. A timeout longer than
`LOCK_TIMEOUT` is fine: the lease is renewed for as long as the task runs.
Timeouts use CPython's own facility for raising an exception in another
thread, which every supported Python has. On an interpreter without it, the
worker logs `timeouts_backstop_only` once at startup and enforces timeouts
by the grace backstop alone.
**Under a coverage tool or a debugger.** A tool that watches a thread runs a
callback between one bytecode and the next, and those callbacks hold locks
of their own. An exception raised into such a thread can land inside one,
leaving a lock held with nobody to release it. So the worker does not raise
into a watched thread. It asks on the task's own thread as it registers the
attempt, and two things count:
- a trace function on that thread (`sys.settrace`), which is what
`coverage run` installs before Python 3.14, and what `pdb` and most
debuggers install;
- a registered `sys.monitoring` tool with events enabled, which is what
`coverage run` uses from Python 3.14 on.
A profile hook (`sys.setprofile`) is not consulted, so a sampling profiler
that installs one leaves timeouts alone.
For a **sync task** on a watched thread, `TaskTimeout` is not raised and the
grace backstop is the whole enforcement:
- A task that returns before `TASK_TIMEOUT` plus `TASK_TIMEOUT_GRACE` is
recorded as whatever it did, however long it ran. There is no
`task_timed_out` event, and nothing else says a deadline passed. With the
default 30-second grace this is the common case.
- A task still running then is recorded as failed with `TaskTimeout`, and
the worker recycles: `task_stuck` at ERROR, `worker_recycling` at WARNING,
exit code 75. Every timeout that reaches the backstop costs a worker
restart.
An **async task** is not affected. It is cancelled inside its event loop at
the deadline, watched or not.
The worker logs `timeouts_backstop_only` once, on the first attempt it
registers under the tool rather than at startup, with `reason=tracing_tool`
and `tracer` naming the mechanism. That line is the check: if it is in the
log, this is what is happening; if it is not, timeouts are being raised
inside tasks as usual.
A tool that starts watching a thread after that thread's attempt was
registered is not seen until the next attempt, since a thread's trace hook
cannot be read from outside it.
A test suite that measures coverage over its own task bodies sees this
shape. To run an attempt unwatched, take the calling thread's trace hook off
for the length of the call and put it back afterwards; a `sys.monitoring`
tool cannot be taken off a single thread.
## 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. Like a failed row it can be retried or discarded, from the
admin or with `django_ox.actions`; see
[Retrying and discarding](monitoring.md#retrying-and-discarding).
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, MySQL or SQLite
All three run the full worker suite in CI. Guidance:
- **PostgreSQL** is the production recommendation. It gets the
single-statement `SKIP LOCKED` claim path, and it handles many workers
and high write concurrency the way you would expect.
- **MySQL 8** claims with `SELECT ... FOR UPDATE SKIP LOCKED` in a short
transaction and runs the full suite in CI on the oldest and newest Python
and Django corners.
- **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](monitoring.md). The operational
summary:
- **The table is the queue.** `django_ox.stats` exposes 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_health`** turns thresholds on those numbers into an
exit code, for cron alerting and container probes.
- **The Prometheus endpoint.** Mounting `django_ox.urls` serves the same
numbers as gauges at `GET /ox/metrics`.
- **Logs.** The worker logs to the `django_ox` logger: 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:
```ini
# /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
```
```ini
# /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](configuration.md#ox_prune) page. FAILED rows are kept by
default so tracebacks survive until you have looked at them; add
`--include-failed` once that is not needed.
---
# Source: https://oxpull.com/django-ox/monitoring/
# Monitoring
The queue and the result store are one database table. That means metrics are
just queries, with no agent or exporter process to run. There are four ways in:
- **`django_ox.stats`**, plain functions returning queue metrics.
- **`manage.py ox_health`**, the same numbers as an exit code, for cron
alerting and container probes.
- **A Prometheus endpoint**, the same numbers as gauges, rendered by a view
you mount where your scraper can reach it.
- **Structured log events** on the `django_ox` logger, with stable extra
keys for JSON log handlers.
## Queue statistics
`django_ox.stats` is a small module of read-only functions. Each one is a single
ORM query over the task table. No extra state, no signals. Safe to call from a
request, a shell or a metrics collector, on every tested database.
```python
from datetime import timedelta
from django_ox import stats
stats.queue_stats()
# [QueueStats(queue_name="default", ready=3, running=1, failed=0, successful=214),
# QueueStats(queue_name="emails", ready=0, running=0, failed=2, successful=560)]
stats.ready_count() # tasks eligible to run right now
stats.oldest_ready_age() # timedelta, or None when nothing waits
stats.throughput(timedelta(minutes=5)) # terminal outcomes per minute
stats.failure_rate(timedelta(minutes=5)) # 0.0 to 1.0, or None
stats.last_claim_age() # time since a worker last claimed
```
| Function | Returns | Semantics |
| --- | --- | --- |
| `queue_stats()` | `list[QueueStats]` | Raw row counts per queue and status (`ready`, `running`, `failed`, `successful`, `lost`, `discarded`), one entry per queue with any rows. The `ready` column counts every READY row, including tasks deferred to a future `run_after`. `lost` counts tasks whose worker stopped reporting with no attempts left; see [the reaper](production.md#the-reaper). `discarded` counts tasks an operator closed without running; see [Retrying and discarding](#retrying-and-discarding). |
| `ready_count()` | `int` | READY tasks eligible to run now, mirroring the worker's dequeue predicate: deferred tasks do not count until `run_after` passes. This is the backlog number. |
| `oldest_ready_age()` | `timedelta \| None` | Age of the oldest task waiting to run, measured from when it became eligible (`run_after` when set, `enqueued_at` otherwise), so a task deferred by a week does not read as a week of backlog. |
| `throughput(window)` | `float` | Tasks reaching a terminal state (SUCCESSFUL or FAILED) per minute over the trailing window (default 5 minutes). |
| `failure_rate(window)` | `float \| None` | Fraction of terminal outcomes in the window that FAILED, or `None` when nothing finished. Retries still pending are not outcomes and do not count. |
| `last_claim_age()` | `timedelta \| None` | Time since any worker last claimed a task, or `None` if none ever was. This is claim activity, not a heartbeat: idle workers over an empty queue record nothing. |
Every function except `queue_stats()` accepts a `queue_name` keyword to
scope the metric to one queue.
**Alert on two numbers: backlog depth (`ready_count`) and backlog age
(`oldest_ready_age`).** Neither works alone. Depth looks fine while one poisoned
task starves the queue. Age looks fine during a flood of fresh work.
## Health checks: ox_health
`ox_health` turns thresholds on those metrics into an exit code. Zero when every
enabled check passes. Non-zero with a one-line reason on stderr when one fails.
With no flags, it checks only that the database answers.
```
python manage.py ox_health --max-backlog 1000 --max-age 600
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Deferred tasks do not count. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this many seconds since becoming eligible. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this many seconds, or no claim was ever recorded. |
On success it prints the measured values, which is useful in cron mail
and probe logs:
```
OK: backlog=3 oldest_age=12s last_claim_age=2s
```
Which check goes where:
- **`--max-backlog` and `--max-age` measure the whole queue.** Put them in
fleet-level alerting, from cron or a monitoring agent. Do not put them in a
per-worker probe: a shared backlog would fail every worker's probe and restart
healthy workers without shifting the backlog.
- **`--worker-timeout` is the closest thing to a liveness check here.** Claiming
is the only trace a worker leaves, so it works on queues with steady traffic
and will false-alarm on ones that are legitimately idle.
- **For bursty queues, prefer `--max-age`.** It only fires when work exists and
is not being picked up.
As a Kubernetes liveness probe on the worker container, for a queue with
steady traffic:
```yaml
livenessProbe:
exec:
command:
["python", "manage.py", "ox_health", "--worker-timeout", "300"]
periodSeconds: 60
timeoutSeconds: 10
failureThreshold: 3
```
From cron, for alerting on the queue itself:
```
*/5 * * * * cd /srv/myproject && .venv/bin/python manage.py ox_health \
--max-backlog 1000 --max-age 600 || /usr/local/bin/page-someone
```
## Prometheus
`django_ox.metrics` renders the stats functions in the Prometheus text
format, from the standard library alone. Mount the view and point a scrape
job at it:
```python
# urls.py
from django.urls import include, path
urlpatterns = [
path("ox/", include("django_ox.urls")), # GET /ox/metrics
]
```
```yaml
# prometheus.yml
scrape_configs:
- job_name: django_ox
metrics_path: /ox/metrics
static_configs:
- targets: ["app.internal:8000"]
```
The response is `text/plain; version=0.0.4`. A scraper that sends
`Accept: application/openmetrics-text` gets the OpenMetrics form of the
same text, and `HEAD` is answered for load-balancer checks. Each scrape is
five aggregate queries over the task table, however many queues there are.
**The endpoint has no authentication of its own.** The numbers are not
secret, but the queue names and the shape of your traffic are yours to keep,
so put the route behind the project's policy before it goes near the public
side of a load balancer. Two one-line ways:
```python
from django.contrib.auth.decorators import login_required
from django_ox.views import metrics
path("ox/metrics", login_required(metrics)) # session auth, for a human
```
```python
from django.http import HttpResponseForbidden
def from_prometheus(view):
def guard(request, *args, **kwargs):
if request.META["REMOTE_ADDR"] not in {"10.0.0.12"}:
return HttpResponseForbidden()
return view(request, *args, **kwargs)
return guard
path("ox/metrics", from_prometheus(metrics)) # the scraper's address only
```
A network policy that only admits the scraper to the path does the same
job without code.
Every metric is a gauge, with one sample per queue that has any row:
| Metric | Labels | Value |
| --- | --- | --- |
| `django_ox_tasks` | `queue`, `status` | Rows by status, one of `ready`, `running`, `failed`, `successful`, `lost`, `discarded`. The same numbers as `queue_stats()`, so `ready` includes deferred tasks. |
| `django_ox_ready_tasks` | `queue` | `ready_count()`: READY tasks eligible to run now. The backlog number. |
| `django_ox_oldest_ready_age_seconds` | `queue` | `oldest_ready_age()` in seconds. Absent when nothing waits. |
| `django_ox_last_claim_age_seconds` | `queue` | `last_claim_age()` in seconds. Absent until a worker has claimed on that queue. |
| `django_ox_throughput_per_minute` | `queue` | `throughput()` over the default five-minute window. |
| `django_ox_failure_rate` | `queue` | `failure_rate()` over the same window, 0 to 1. Absent when nothing finished. |
There are no counters. The table is pruned, so a monotonic count of finished
tasks cannot be read from it; throughput and the failure rate are
trailing-window readings instead, and `rate()` in PromQL is not the tool
for them. Alert on `django_ox_ready_tasks` and
`django_ox_oldest_ready_age_seconds` the same way as on the functions.
The metric names and label names above are public API from the release that
ships them; see [API stability](stability.md). The help text is not.
### With an existing registry
A project that already runs `prometheus_client` (on its own or through
django-prometheus) can register the same numbers with its registry instead
of mounting a second endpoint. `prometheus_client` is not a dependency of
django-ox; `collector()` imports it when called and raises `ImportError`
when it is missing.
```python
from prometheus_client import REGISTRY
from django_ox import metrics
REGISTRY.register(metrics.collector())
```
### OpenTelemetry
django-ox does not ship an OpenTelemetry exporter. The stats functions fit
an observable gauge callback, so a project that already has an OTel meter
can read the queue through it in a few lines. This is the whole recipe;
nothing in django-ox imports `opentelemetry`.
```python
from opentelemetry import metrics as otel
from opentelemetry.metrics import Observation
from django_ox import stats
def observe_backlog(options):
for row in stats.queue_stats():
yield Observation(row.ready, {"queue": row.queue_name})
meter = otel.get_meter("django_ox")
meter.create_observable_gauge("django_ox.ready_tasks", callbacks=[observe_backlog])
```
The same shape reads `oldest_ready_age()` or `failure_rate()` per queue.
## Log events
The worker logs through the standard library logger named `django_ox`. No
logging dependency, no imposed format. Configure handlers and formatters in
`LOGGING` as usual.
Lifecycle events carry an `extra` dictionary with stable keys, so a JSON
formatter that serialises record attributes gets consistent fields to index.
The message text is not part of the contract. The keys are.
| Event | Level | When |
| --- | --- | --- |
| `worker_started` | INFO | The run loop starts. |
| `task_claimed` | DEBUG | A task was claimed from the queue. |
| `task_started` | DEBUG | Execution of an attempt begins. |
| `task_succeeded` | INFO | The task reached SUCCESSFUL. |
| `task_timed_out` | WARNING | An attempt ran past its `TASK_TIMEOUT` and is recorded as failed; a `task_retrying` or `task_failed` record follows. It counts timeouts recorded as failures, not deadlines that passed: a task that catches `TaskTimeout` and returns produces no event, and neither does a timeout on a worker logging `timeouts_backstop_only`, where the attempt ends in `task_stuck` or in whatever the task went on to do. |
| `task_stuck` | ERROR | A timed-out attempt's thread did not stop within `TASK_TIMEOUT_GRACE`. The attempt is recorded as failed and the worker is recycling. On a worker logging `timeouts_backstop_only` nothing is raised inside the task, so this is the ordinary end of a timeout there rather than a pathological one. |
| `worker_recycling` | WARNING | The worker stopped claiming after a stuck thread; it drains its other tasks and exits with code 75. One follows every `task_stuck`, so on a worker logging `timeouts_backstop_only` every timeout that reaches the backstop costs a worker restart. |
| `timeouts_backstop_only` | WARNING | Once per worker: `TaskTimeout` is not raised inside a running sync task, because the interpreter cannot raise an exception inside another thread (`reason=interpreter`, logged at startup) or a coverage tool or debugger is watching the worker's threads (`reason=tracing_tool`, logged on the first attempt registered under it). `TASK_TIMEOUT_GRACE` is the whole enforcement while it stands. See [Task timeouts](production.md#task-timeouts). |
| `task_retrying` | WARNING | An attempt failed with retries remaining. |
| `task_failed` | ERROR | The task reached FAILED, out of attempts. |
| `task_reclaimed` | WARNING | The reaper took a task back from a worker that stopped refreshing its lock. |
| `task_lease_lost` | WARNING | A worker finished an attempt whose lease had already been reclaimed, so its write was dropped and no result was signalled. |
| `lease_renew_failed` | WARNING | A lease renewal statement failed. The worker keeps going and tries again on the next interval. |
| `schedule_dispatched` | INFO | A recurring tick enqueued its task. |
| `worker_error` | ERROR | The execution wrapper itself raised (an internal worker error, not a task failure). |
| `worker_draining` | INFO | Shutdown began with tasks still in flight. |
| `worker_stopped` | INFO | The run loop exited. |
| `supervisor_started` | INFO | `ox_worker --processes N` started its worker processes. |
| `worker_process_restarted` | WARNING | A worker process exited on its own and is being restarted. |
| `worker_process_recycled` | WARNING | A worker process exited with code 75 after a stuck task thread and is being restarted. Not counted against the restart cap. |
| `worker_process_stopped_early` | INFO | A stop signal reached a worker process before it had finished starting, so it died on the signal rather than draining. It had claimed no work, and the supervisor does not count it as a failure. |
| `supervisor_restart_cap` | ERROR | More than five deaths of one slot in a minute; the supervisor is stopping with exit code 1. |
| `supervisor_killed_workers` | ERROR | Worker processes still running five seconds after the second stop signal were sent SIGKILL. |
| `supervisor_stopped` | INFO | Every worker process has exited. |
| `worker_orphaned` | WARNING | A worker process found its supervisor gone and is draining. |
| Key | Present on | Meaning |
| --- | --- | --- |
| `event` | all events | The event name from the table above. |
| `worker_id` | all worker events | Unique id of the worker emitting the record. With `--processes`, the slot number is the last part of the id. |
| `task_id` | task events | The task's UUID, as a string. |
| `task_path` | task events | Dotted path of the task function. |
| `queue` | task events | Queue name. |
| `attempt` | task events | Attempts consumed so far, including the current one. |
| `duration_ms` | `task_succeeded`, `task_retrying`, `task_failed`, `task_timed_out`, `task_stuck`, `task_lease_lost` | Wall-clock duration of the attempt, in milliseconds. |
| `timeout_s` | `task_timed_out`, `task_stuck` | The timeout that applied, in seconds. |
| `grace_s` | `task_stuck`, `timeouts_backstop_only` | `TASK_TIMEOUT_GRACE`, in seconds. |
| `reason` | `timeouts_backstop_only` | Why the backstop is the whole enforcement: `interpreter` or `tracing_tool`. |
| `tracer` | `timeouts_backstop_only` with `reason=tracing_tool` | How the worker's threads are being watched: `sys.settrace` when a trace function is installed, which does not say which tool installed it, or `sys.monitoring (NAME)` for a registered tool, which names itself. |
| `exception` | `task_retrying`, `task_failed` | Exception class name of the failure. |
| `status` | `task_reclaimed` | Status after reclaim: `READY` (requeued) or `LOST` (out of attempts). |
| `dropped_status` | `task_lease_lost` | Status the dropped write would have set: `SUCCESSFUL`, `FAILED` or `READY`. |
| `schedule` | `schedule_dispatched` | Schedule name from `SCHEDULES`. |
| `queues`, `concurrency` | `worker_started` | The worker's configuration. |
| `pending` | `worker_draining` | In-flight tasks at shutdown. |
| `processes` | `supervisor_started` | Worker processes the supervisor runs. |
| `worker_index`, `exit_code` | `worker_process_restarted`, `worker_process_recycled`, `supervisor_restart_cap` | Which slot exited and how. A negative code is the signal that killed it. |
| `delay` | `worker_process_restarted`, `worker_process_recycled` | Seconds until the slot is started again. |
| `task_id`, `exit_code` | `worker_recycling` | The stuck task that started the recycle, and the code the worker will exit with, 75. |
| `restarts` | `supervisor_restart_cap` | Deaths of that slot inside the window. |
| `worker_indexes` | `supervisor_killed_workers` | The slots that were killed. |
| `parent_pid` | `worker_orphaned` | The supervisor pid the worker was started under. |
| `exit_code` | `supervisor_stopped` | The code the supervisor exits with. |
`task_claimed` and `task_started` are DEBUG because they fire once per
attempt; run `ox_worker -v 2` (or set the logger to DEBUG) when you want
them. Everything a dashboard usually wants survives at INFO.
`task_lease_lost` should be rare. It means a worker went unresponsive long
enough for the reaper to take its task away, and the worker's own result was
dropped when it finally finished, because the row no longer belonged to it.
Treat a steady trickle as a signal that `LOCK_TIMEOUT` is short relative to
how long your workers stall, rather than as noise; the
[Production](production.md#tuning-lock_timeout) page covers the tuning.
`throughput()` and `failure_rate()` count SUCCESSFUL and FAILED rows only. A
LOST task is not an outcome, so it is in neither number; read the `lost`
column from `queue_stats()` for it.
## Monitoring recipes
- **Alerting.** Alert on `ready_count` and `oldest_ready_age` (via
`ox_health` thresholds or the functions directly), and on
`failure_rate` rising above your normal baseline. Throughput is better
as a dashboard line than an alert: its healthy value depends entirely
on offered load.
- **Prometheus.** Mount `django_ox.urls` and scrape `/ox/metrics`, or
register `django_ox.metrics.collector()` with a registry you already run.
Both are covered [above](#prometheus).
- **journald.** Under systemd, WARNING and above maps onto journal
priorities, so `journalctl -u ox-worker -p warning` shows exactly
retries, reclaims and failures. Pair it with `ox_health` in a timer for
active checks.
- **Poisoned-task triage.** When `failure_rate` spikes, the rows have the
forensics: filter FAILED rows and read `errors` (per-attempt
tracebacks), `attempts` and `worker_ids` to see what died where. The
admin page below shows the same fields, and the two actions close the
loop once the cause is fixed.
## Retrying and discarding
Two operator actions live in `django_ox.actions`. Each is one
compare-and-set UPDATE on the row's status and lease number, so it either
moves the row from the state it read or does nothing and says so. Neither
touches a RUNNING row: that row belongs to the worker holding its lease,
and only the reaper takes a lease away.
```python
from django_ox import actions
actions.retry(result.id) # True if the row was requeued
actions.discard(result.id) # True if the row was closed
```
| Function | Accepts | Does |
| --- | --- | --- |
| `retry(result_id)` | FAILED, LOST | Sets the row back to READY for one more attempt, clears `run_after` so it is eligible at once, and raises `max_attempts` to `attempts + 1`. The count, `worker_ids` and every per-attempt traceback stay as they were, so the record still says what happened before. The lease number goes up, so a LOST row's last worker, if it is still alive somewhere, writes nothing over the retry. |
| `discard(result_id)` | READY, FAILED, LOST | Marks the row DISCARDED. A READY task that is discarded never runs; a discarded FAILED or LOST task is not retried. The attempt records stay. |
`retry_many(selection)` and `discard_many(selection)` make the same move
for a queryset or a list of ids. They run one conditional UPDATE per
thousand rows inside one transaction and return `(changed, skipped)`. The
admin actions use them, so a select-across of a hundred thousand rows is a
hundred statements, and either all of it lands or none does.
The actions write the table directly and send no `django.tasks` signal: a
discard finishes the result without `task_finished`, and a retry requeues
it without `task_enqueued`.
Both single-row functions return `False` for any other state, for an id
that is not in the table, and for a malformed id. `RUNNING` and `SUCCESSFUL` rows are never
matched. A retry that races a second retry of the same row, or a discard
that races a worker's claim, resolves to exactly one winner: the UPDATE
pins the lease number it read, and the loser matches zero rows.
A retried task is one more attempt, not a fresh set. If the new attempt
fails, the row is FAILED again with one more traceback, and can be retried
again. A task retried while its worker is still missing gets the same
treatment as any at-least-once task: make the body idempotent.
DISCARDED is the sixth value in the row's status column and reads as
`FAILED` through `django.tasks`, which has four statuses, so `is_finished`
is true and callers waiting on the result return. `queue_stats()` reports
it in its own `discarded` column, and `ox_prune` deletes discarded rows
with successful ones.
### The admin page
When `django.contrib.admin` is installed, django-ox registers the task
table with it. Nothing is added to a project without the admin. The
change list shows id, task path, queue, status, attempts, and the enqueue
and finish times, filters on status and queue, and searches by id and
path. The detail page is read-only and lays out every attempt's
traceback. The two actions, **Retry selected tasks** and **Discard
selected tasks**, call `retry_many` and `discard_many` on the selection and
report how many moved and how many were skipped for being in a state the
action does not accept.
The admin does not add, edit or delete rows. A hand-edited status would
bypass the lease, and a delete could take a row from under a running
worker; `ox_prune` is the way rows leave the table. The actions need the
`change_oxtask` permission; viewing needs `view_oxtask`.
---
# Source: https://oxpull.com/django-ox/benchmarks/
# Benchmarks
django-ox 0.1.0 against django-tasks-db 0.12.0 (the other database
backend for the Tasks API) on identical no-op workloads, PostgreSQL 16.
Full methodology, raw JSON with every sample, and per-process logs are in
the `benchmarks/` directory of the repository. Every run is reported;
nothing was discarded.
## Results
Final 3-run matrix, runs interleaved between backends, all runs shown,
measured on django-ox 0.1.0.
| Metric | django-ox (r1 / r2 / r3) | django-tasks-db (r1 / r2 / r3) |
| --- | --- | --- |
| Enqueue throughput (tasks/sec, higher better) | 657 / 528 / 557 | 447 / 450 / 454 |
| Enqueue latency in `transaction.atomic()`, p50 ms | 0.53 / 0.43 / 0.55 | 0.55 / 0.54 / 0.43 |
| End-to-end, 2,000 tasks, concurrency 1 (tasks/sec) | 90.1 / 88.1 / 90.1 | 69.2 / 69.9 / 69.6 |
| End-to-end, 2,000 tasks, concurrency 4 (tasks/sec) | 393 / 373 / 397 | 366 / 373 / 376 |
Reading: django-ox wins enqueue throughput and concurrency-1 end-to-end
in all three runs, and edges or ties concurrency 4. In-transaction enqueue
latency is a tie at roughly half a millisecond for both; run-to-run drift
on the machine is larger than the difference between the backends.
A diagnostic cell at a non-default `--interval 0.1` produced the same throughput
as the defaults: 87.8, 86.6 and 88.5 tasks/sec. So the poll interval does not
bound throughput. With tasks in flight, the worker wakes on task completion
rather than on the polling clock.
## Where the numbers come from
Two properties of the worker's claim path drive the end-to-end results:
- **On PostgreSQL, claiming a task is a single statement**:
`UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING`,
with all per-attempt bookkeeping folded into the same UPDATE. A full
claim-and-execute cycle costs 2 SQL statements per task. On a network
link where every round trip costs real milliseconds, statement count
matters even more than it does on this localhost setup.
- **The worker wakes on task completion.** When executor slots are busy,
the run loop waits on the in-flight futures rather than sleeping the
poll interval, so `--interval` only governs how often an idle worker
checks for new work.
Both properties above are proven by published cells:
the single-statement claim by the concurrency-1 rows, the completion wake-up by
the diagnostic cell. A regression in either shows up as a changed number rather
than a changed claim.
## How to read these numbers
- **No-op task bodies.** The tasks do nothing, so these numbers measure
framework overhead only. Real tasks do work; with realistic bodies the
differences here shrink as a fraction of total runtime. If your tasks
average even 100 ms, both backends will feel identical.
- **Single machine, one day.** An Apple M1 Max running both the workers
and the database; no controlled thermal or background-load environment.
The numbers are indicative of relative behavior, not absolute claims.
- **Localhost database.** PostgreSQL 16 in Docker on the same machine,
sub-millisecond round trips. Real deployments have network latency
between app and database, which changes end-to-end numbers materially
and increases the weight of per-task statement count.
- **The concurrency-4 comparison is imperfect by construction.**
django-tasks-db's worker has no concurrency option, so its
"concurrency 4" is four separate processes: four interpreters without a
shared GIL, four Django boots inside the timed window. django-ox's is
four threads in one process. This is the fairest available mapping, though
still an approximation. (It also means django-ox wins that cell
while sharing one interpreter.)
- **Small N.** 2,000 tasks and 500 latency samples separate the backends
here but say nothing about p99+ tails or sustained load. Sustained load
and worker-failure behavior are covered separately by the
[soak and chaos run](#reliability-under-load) below.
## Reliability under load
Speed is not the product claim; the durability construction is. A
separate soak and chaos harness ran django-ox 0.1.0 for 21.5 minutes of
sustained mixed load on PostgreSQL 16: 37,802 tasks across three
scenarios, including 9 minutes in which a random worker was SIGKILLed
every 20 to 45 seconds (16 kills total, 39 interrupted claims). Every
task reached a terminal state, every interrupted claim was reclaimed and
re-executed inside the documented bound, retry counts stayed bounded on
every row, and zero tasks were lost or double-completed. Latency under
kill-chaos was within noise of the undisturbed baseline (p50 0.217 s vs
0.211 s).
The full report, including the harness design, every assertion, and the
caveats, is in
[`benchmarks/SOAK-2026-08-16.md`](https://github.com/oxpull/django-ox/blob/main/benchmarks/SOAK-2026-08-16.md).
## What to take from this
The shipped worker beats the reference implementation on every measured
cell or ties it, and holds its documented guarantees under sustained load
and repeated worker kills. But throughput on no-op tasks is not why you
choose django-ox. The claim is the durability construction: transactional
enqueue, at-least-once execution with a reaper, bounded retries with
per-attempt tracebacks. The benchmark exists to show that choosing those
semantics costs you nothing on worker performance.
---
# Source: https://oxpull.com/django-ox/stability/
# API stability
This page states what counts as django-ox's public API, how versions
change, and which Python and Django versions are supported. It is a
promise about compatibility, so you can pin `django-ox` with confidence.
## Public API
These are the supported surfaces. Changes to them are versioned and
announced in the [changelog](changelog.md). This page, not a module's
`__all__`, is the statement of what is public; today the two agree.
- **The backend path** `django_ox.backend.OxBackend`, referenced as a
string in the `TASKS` setting, `QUEUES` beside `OPTIONS`, and every
`OPTIONS` key it reads: `MAX_ATTEMPTS`, `LOCK_TIMEOUT`,
`BACKOFF_INITIAL`, `BACKOFF_MAX`, `TASK_TIMEOUT`, `TASK_TIMEOUTS`,
`TASK_TIMEOUT_GRACE`, and `SCHEDULES` (with its documented
per-schedule keys).
- **The management commands** and their flags: `ox_worker`, `ox_prune`,
`ox_health`. `ox_worker`'s exit codes: 0 after a drain, 130 on a forced
exit, 75 when the worker recycles itself after a stuck task thread.
Under `--processes`, the supervisor exits 0 when every worker drained
or recycled, 1 when a slot hit the restart cap, and otherwise with the
first other non-zero worker code.
- **The timeout helpers** `django_ox.deadline()` and `django_ox.remaining()`,
callable from inside a task.
- **The metrics module** `django_ox.stats`: `queue_stats`, `ready_count`,
`oldest_ready_age`, `throughput`, `failure_rate`, `last_claim_age`, the
`QueueStats` dataclass, and `DEFAULT_WINDOW`, the trailing window the
rate functions default to.
- **The Prometheus surface**: `django_ox.metrics.render_prometheus`,
`render_openmetrics` and `collector`, the view `django_ox.views.metrics`,
the `django_ox.urls` module with its `metrics` route name, and the metric
names and label names listed on the [Monitoring](monitoring.md#prometheus)
page. `METRIC_NAMES` is that list in code; `CONTENT_TYPE_PROMETHEUS` and
`CONTENT_TYPE_OPENMETRICS` are the content types the view serves. A
scraped name is a contract with every dashboard that reads it, so a
rename is a breaking change. Help text is not part of the contract.
- **The actions module** `django_ox.actions`: `retry` and `discard`, their
accepted states, and their return values; `retry_many` and
`discard_many`, the selections they accept and their `(changed, skipped)`
return. `RETRYABLE_STATUSES` and `DISCARDABLE_STATUSES` are those
accepted states in code; `UPDATE_CHUNK_SIZE` is exported for reading and
its value may change. The admin page that calls
them is a convenience over this module; its layout is not a contract,
the two action names are.
- **The bulk module** `django_ox.bulk`: `enqueue_many(task, calls)`, its
`(args, kwargs)` call shape, the input-order return and the
all-or-nothing write. `INSERT_CHUNK_SIZE` is exported for reading; its
value may change.
- **The exceptions** `django_ox.exceptions.TaskAbandoned`, recorded against
tasks whose worker stopped reporting with no attempts left (it records the
lost lease, not a cause of failure), and `django_ox.exceptions.TaskTimeout`,
a `TimeoutError` raised inside a task that ran past its `TASK_TIMEOUT` and
recorded against the attempt.
- **The structured-log contract**: the event names and stable `extra` keys
documented on the [Monitoring](monitoring.md) page.
- **The database schema** of `OxTask` and `OxScheduleTick`, evolved only
through shipped migrations.
- **`django_ox.__version__`.**
The producer-side API is `django.tasks` itself (`@task`, `.enqueue()`,
`get_result()`); django-ox adds nothing there and follows Django's
contract.
### Not public
Everything else is an implementation detail and may change in any release
without notice. That covers the `django_ox.worker.Worker` internals, the cron
parser (`django_ox.cron`), the row-to-dataclass conversion
(`django_ox.results`), the schedule loader (`django_ox.schedules`), the
supervisor behind `--processes` (`django_ox.supervisor`) and the hidden
`--worker-index` flag it starts each child with, and any name starting
with an underscore. The exact SQL a claim emits and the
model's non-schema helper methods are not part of the contract.
## Versioning
django-ox follows [Semantic Versioning](https://semver.org/). Before 1.0,
the pre-1.0 rule applies:
- **0.x minor releases may contain breaking changes.** Any
break to a public surface above is called out in the changelog under a
`Changed` or `Removed` heading, with the migration step.
- **0.x.y patch releases are bug fixes only** and never break a public
surface.
Pin accordingly: `django-ox~=0.3.1` accepts patch releases only;
`django-ox>=0.3,<0.4` accepts the current minor line.
Once 1.0 ships, breaking changes to the public API will require a major
version bump, in the usual SemVer way.
## Deprecation policy
When a public surface is going to be removed or changed incompatibly, and
a compatible path exists, it is deprecated before removal rather than
dropped outright:
- The deprecation is documented in the changelog and, where it can be,
surfaced at runtime (a `DeprecationWarning` or a `manage.py check`
message).
- A deprecated surface keeps working for **at least one full minor release**
(pre-1.0) or one major release (post-1.0) before it is removed.
Security fixes are exempt. A surface that cannot be kept without leaving a
vulnerability open may change in a patch release. That is documented in the
changelog, and in a security advisory where relevant.
## Supported Python and Django
Each django-ox release is tested against the matrix below in CI, on SQLite
and PostgreSQL 16 across the grid and MySQL 8 on the oldest and newest
corners; these are the supported combinations.
| | Django 6.0 | Django 6.1 |
| --- | --- | --- |
| **Python 3.12** | tested | tested |
| **Python 3.13** | tested | tested |
| **Python 3.14** | tested | tested |
The support floor tracks Django's own: when a Python or Django version
reaches end of life upstream, a later django-ox minor release may drop it,
announced in the changelog. Databases: PostgreSQL, SQLite and MySQL 8 are
tested in CI. MariaDB 10.6+ uses the same claim path, since Django's own
floor guarantees `SELECT ... FOR UPDATE SKIP LOCKED` there, but it is not
part of the tested matrix.
---
# Source: https://oxpull.com/django-ox/pro/
# Pro
Everything documented on this site is free, open source (BSD 3-Clause), and
stays that way. The durable queue, transactional enqueue, retries, reaper,
graceful drain, priorities, deferred tasks, recurring tasks and pruning are
the free tier, permanently. Nothing that works today moves behind the paid
tier.
**Oxpull Pro** is a paid add-on for two problems that show up once a queue is
carrying real volume. Both are built and tested. It is not on sale yet: the
purchase and delivery path is still being set up, and the waitlist below is
how to hear when it opens.
## What Pro adds
- **Unique tasks.** Deduplicate at enqueue time, so the same job cannot be
queued twice. The lock is written in the same transaction as the task, so
the two commit or roll back together, and a lock whose task died is
released rather than stranded.
- **Batches.** Enqueue a group, read its progress as a count, and fire a
callback once every member has settled. Completion is computed by querying
the task rows rather than by counting signals, so a worker dying mid-task
cannot strand a batch: the reconciler picks it up on the next tick.
Both run on the databases the free tier tests in CI: SQLite, PostgreSQL and
MySQL 8. MariaDB 10.6+ takes the same claim path but is not part of the tested
matrix. Batches are tested to 100,000 members in a single batch.
## What Pro is not
Workflows and chains are on the roadmap, undated. Rate limiting, a web
dashboard and encrypted payloads are not in Pro and are not dated. Metrics
stay free: the stats API and the health command are in the open source
package and remain there.
## Delivery
Pro will install from a private package index using credentials issued per
company. There is no licence key and no runtime check. A licence check is one
more thing of ours that can break your production, so we did not build one.
The credential controls access to the index rather than to code you have
already installed, so if it lapses, what is deployed keeps running.
## Pricing
Planned at **$399 per year, per company**, flat. One licence to cover a whole
organisation and every environment, with a seven-day money-back period.
## Waitlist
If Pro would earn its keep in your deployment, join the waitlist and say which
of the two features matters to you. That ordering decides what gets built
after these.
[Join the Pro waitlist](https://oxpull.com/#waitlist){ .md-button }
---
# Source: https://oxpull.com/django-ox/agents/
# For AI assistants
Exact steps for setting up django-ox in an existing Django project, the facts
to get right while doing it, and how to prove it works. Machine-readable
copies: [llms.txt](llms.txt) (facts and links) and
[llms-full.txt](llms-full.txt) (every page of this site in one file).
Context7 library id: `/oxpull/django-ox`.
## Set up django-ox in this project
Requires Python 3.12+ and Django 6.0+. Check before installing:
```
python -c "import django, sys; print(django.__version__, sys.version.split()[0])"
```
Install:
```
pip install django-ox
```
or, with uv:
```
uv add django-ox
```
Edit `settings.py`:
```python
INSTALLED_APPS = [
# ...
"django_ox",
]
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
}
}
```
Create the table:
```
python manage.py migrate django_ox
```
Verify. The expected output is the second line:
```
python manage.py ox_health
OK: backlog=0 oldest_age=none last_claim_age=none
```
`manage.py check` also runs the django-ox system checks, so a bad schedule
or timeout option fails here, as `django_ox.E002` to `E005`, before anything
deploys.
Start a worker in its own process, next to the web server, under the same
supervisor:
```
python manage.py ox_worker
```
Settings with every option named, for when the defaults need changing:
```python
TASKS = {
"default": {
"BACKEND": "django_ox.backend.OxBackend",
"QUEUES": ["default"], # [] allows any queue name
"OPTIONS": {
"MAX_ATTEMPTS": 3, # claims per task before FAILED
"LOCK_TIMEOUT": 300, # seconds a worker may stop renewing its lease
"BACKOFF_INITIAL": 5, # first retry delay, seconds; doubles each attempt
"BACKOFF_MAX": 600, # retry delay ceiling, seconds
"TASK_TIMEOUT": None, # seconds one attempt may run; None is no limit
"TASK_TIMEOUTS": {}, # per-queue values, {"queue": seconds}
"TASK_TIMEOUT_GRACE": 30, # seconds a timed-out thread gets to stop
"SCHEDULES": {}, # recurring tasks, see Recurring tasks
},
}
}
```
## Facts to get right
- `QUEUES` sits beside `OPTIONS`, not inside it. Inside `OPTIONS` it is
ignored without warning; the symptom is `InvalidTask: Queue 'X' is not
valid for backend.`
- Tasks are plain `django.tasks` tasks. `from django.tasks import task`,
decorate with `@task`, call `.enqueue(...)`. Nothing is imported from
`django_ox` in task code.
- The worker imports a task by its dotted path, so the module must be
importable in the worker process and the worker runs the same code as the
producer; nothing is registered and there is no autodiscovery. `async def`
tasks run.
- Priority and deferral are Django API: `task.using(priority=N)` with N from
-100 to 100, higher first, and `task.using(run_after=...)` with a timedelta
or datetime.
- Tasks run only while `ox_worker` is running. It is a separate process.
- SIGTERM and SIGINT both drain and exit 0; a second signal forces an
immediate exit with code 130.
- `enqueue()` is one INSERT on the default connection. Inside
`transaction.atomic()` the task is visible to workers only after commit and
is gone on rollback. Do not add `transaction.on_commit()` around it.
- Many calls of one task go through `django_ox.bulk.enqueue_many(task,
[(args, kwargs), ...])`: one INSERT per 1,000 rows, one transaction, results
in input order. Set queue, priority and `run_after` once with `.using(...)`.
- Execution is at-least-once. Write tasks to be safe to run twice: guard on
state already in the database, not on a flag in memory.
- The task function runs outside any transaction. Open
`transaction.atomic()` inside the task when it needs `select_for_update()`.
- An attempt is consumed at claim time, so a worker dying mid-run uses one.
Retry delay after attempt n is `BACKOFF_INITIAL * 2 ** (n - 1)`, capped at
`BACKOFF_MAX`.
- `LOCK_TIMEOUT` bounds an unresponsive worker, not task length. The lease is
renewed every `LOCK_TIMEOUT / 3` seconds while the task runs.
- The worker polls; `--interval` (default 1.0 s) is the idle sleep, so a task
starts within one interval of its commit. There is no LISTEN/NOTIFY.
- `TASK_TIMEOUT` bounds one attempt. At the deadline `TaskTimeout` is raised
inside the task on its own thread (an async task is cancelled) and the
attempt is recorded as failed and retried on the backoff. A thread that
has not stopped `TASK_TIMEOUT_GRACE` seconds later is recorded as failed
and the worker exits 75 so its supervisor restarts it. Per-queue values go
in `TASK_TIMEOUTS`; there is no per-task value. `django_ox.remaining()`
reads the seconds left from inside a task. On a thread a coverage tool or
a debugger is watching (a `sys.settrace` hook, or a `sys.monitoring` tool
with events enabled) nothing is raised inside a sync task: the worker logs
`timeouts_backstop_only`, a task that returns within `TASK_TIMEOUT_GRACE`
is recorded as whatever it did, and one still running then is recorded as
failed and recycles the worker. An async task is cancelled at the deadline
either way.
- Claiming: one `UPDATE ... SKIP LOCKED ... RETURNING` statement on
PostgreSQL; `SELECT ... FOR UPDATE SKIP LOCKED` on MySQL 8+; an atomic
compare-and-set UPDATE on SQLite and other databases without `SKIP LOCKED`.
- `--concurrency N` is a thread pool in one process. `--processes N` runs N
such workers under one supervisor; a worker process that dies is restarted
after one second, doubling to 30 s, and more than five deaths of one slot
in a minute stops the supervisor with exit 1. CPU-bound work wants
`--processes N --concurrency 1`.
- Recurring tasks go in `OPTIONS["SCHEDULES"]`. Every worker dispatches them;
there is no scheduler process to start.
- `ox_prune --older-than 7d` deletes finished rows; FAILED rows stay unless
`--include-failed`. READY and RUNNING rows are never deleted.
- `path("ox/", include("django_ox.urls"))` mounts `GET /ox/metrics`, the
queue stats as Prometheus gauges. It has no authentication of its own;
wrap it with `login_required` or restrict it by network.
- Run `migrate` before rolling workers, not from the worker.
- `django_ox.actions.retry(result_id)` requeues a FAILED or LOST task for
one more attempt. `django_ox.actions.discard(result_id)` closes a READY,
FAILED or LOST task without running it. Neither touches a RUNNING task.
With `django.contrib.admin` installed, the task table appears in the admin
with the same two actions.
- A particular running task cannot be interrupted on demand; `TASK_TIMEOUT`
bounds every attempt. Tasks live on the default database.
- In tests use `django.tasks.backends.immediate.ImmediateBackend` or
`django.tasks.backends.dummy.DummyBackend` for `TASKS`.
- Batches and unique tasks are in [Oxpull Pro](pro.md), a paid add-on that
is not on sale yet. `django_ox.stats` and `ox_health` are in django-ox.
## How to verify it works
Define a task in any installed app:
```python
# myapp/tasks.py
from django.tasks import task
@task
def add(a, b):
return a + b
```
Enqueue one from `python manage.py shell`:
```python
>>> from myapp.tasks import add
>>> result = add.enqueue(1, 2)
>>> result.status
TaskResultStatus.READY
```
Start `python manage.py ox_worker` in another terminal. The worker logs
`Worker starting: queues=['default'] concurrency=1 poll=1.0s schedules=0`
to stderr, then `Task id= path=myapp.tasks.add succeeded in ms`.
With `DEBUG = True`, Django's own `Task id=... state=RUNNING` and
`state=SUCCESSFUL` lines appear between them.
Back in the shell:
```python
>>> result.refresh()
>>> result.status
TaskResultStatus.SUCCESSFUL
>>> result.return_value
3
```
`python manage.py ox_health --worker-timeout 60` now exits 0 and reports a
recent `last_claim_age`. Stop the worker with Ctrl-C; it drains and exits 0.
## Links
- [llms.txt](llms.txt): the facts above with links, in the llms.txt shape.
- [llms-full.txt](llms-full.txt): the whole site in one file.
- [Configuration](configuration.md), [Production](production.md),
[Monitoring](monitoring.md), [Common patterns](patterns.md).
- Context7: `/oxpull/django-ox`. Source:
[github.com/oxpull/django-ox](https://github.com/oxpull/django-ox).
---
# Source: https://oxpull.com/django-ox/changelog/
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.1] - 2026-08-24
### Fixed
- `ox_worker --processes N` exits 0 when a stop signal arrives while a
worker process is still starting up. A worker cannot act on a signal
until it has installed its handler, and that is after Django has been
imported. A signal landing before then killed the worker outright, and
the supervisor reported that worker's 143 as its own exit code. A unit
on `Restart=on-failure` reads that as a fault and starts the service
again. The worker had claimed no work, so there is nothing to report:
the supervisor now logs `worker_process_stopped_early` and exits 0.
### Changed
- A worker enforces `TASK_TIMEOUT` on a sync task through the grace backstop
alone while a coverage tool or a debugger is watching the thread the
attempt runs on. Two things count: a trace function (`sys.settrace`, which
`coverage run` installs before Python 3.14, and which `pdb` and most
debuggers install), and a registered `sys.monitoring` tool with events
enabled (which `coverage run` uses from Python 3.14 on). Nothing is raised
inside the task. A task that returns within `TASK_TIMEOUT` plus
`TASK_TIMEOUT_GRACE` is recorded as whatever it did, however long it ran,
with no `task_timed_out` event; one still running then is recorded as
failed and recycles the worker. An async task is cancelled at its deadline
either way, and a profile hook (`sys.setprofile`) is not consulted.
- The worker logs `timeouts_backstop_only` once under such a tool, on the
first attempt it registers rather than at startup. That event now carries
`reason` (`interpreter` or `tracing_tool`) and, for a tool, `tracer`:
`sys.settrace`, or `sys.monitoring (NAME)`.
## [0.3.0] - 2026-08-24
### Upgrading
- Run `python manage.py migrate django_ox`, and roll every process, web and
worker, to 0.3.0 before the first discard. This release adds the
DISCARDED status: a 0.2.1 process that reads a DISCARDED row raises
`ValueError` from `get_result()` and `refresh()`, and 0.2.1's `ox_prune`
cannot delete such rows. Rolling back with DISCARDED rows present keeps
that crash until the rows are removed by hand
(`DELETE FROM django_ox_oxtask WHERE status = 'DISCARDED'`); reversing
the migration does not remove them.
### Added
- `TASK_TIMEOUT`, `TASK_TIMEOUTS` and `TASK_TIMEOUT_GRACE` backend options, a
limit on how long one attempt may run. At the deadline the worker raises
`django_ox.exceptions.TaskTimeout` inside the task, on the task's own
thread, so `finally` blocks run and an open `transaction.atomic()` rolls
back; an async task is cancelled inside its event loop instead. The attempt
is recorded as failed with the `TaskTimeout` error and retried on the usual
backoff, or marked FAILED when attempts are spent. A thread that has not
stopped `TASK_TIMEOUT_GRACE` seconds later (default 30) is treated as
stuck: the worker records the attempt as failed, moves the lease number so
the thread can write nothing to the row, stops claiming, drains its other
tasks and exits with code 75, which `--processes` restarts without counting
it against the restart cap. `TASK_TIMEOUTS` maps a queue name to its own
value, and a key that is not in `QUEUES` fails `manage.py check` as
`django_ox.E005`; a bad value fails it as `django_ox.E004`. Off by default.
`django_ox.deadline()` and `django_ox.remaining()` read the attempt's
deadline from inside a task. `TaskTimeout` subclasses `TimeoutError`. New
log events: `task_timed_out`, `task_stuck`, `worker_recycling`,
`worker_process_recycled` and `timeouts_backstop_only`.
- `ox_worker --processes N`. Above 1, the command supervises N copies of
itself, each a full worker with its own database connections, lease
renewal, reaper and `--concurrency` thread pool, so `--processes 2
--concurrency 4` runs eight tasks at once. Every worker id ends in its slot
number. SIGTERM, SIGINT or SIGHUP to the supervisor is forwarded once and
the supervisor exits 0 when every worker drained, or with the first
non-zero code; a second signal is the force-exit, and a worker that has
not exited five seconds later is SIGKILLed. A worker process that dies,
by any exit code, is restarted with `worker_process_restarted` at
WARNING, after one second and then with a doubling delay up to 30
seconds; more than five deaths of one slot in a minute stops the
supervisor with exit code 1 and `supervisor_restart_cap` at ERROR. A
worker whose supervisor dies drains and exits (`worker_orphaned`). The
children are started the way the supervisor was, `manage.py` by absolute
path or `python -m django`, with `--settings` and `--pythonpath` passed
on, so the command works from any working directory. `--processes 1`, the
default, is the worker as before. POSIX only.
- A Prometheus endpoint. `path("ox/", include("django_ox.urls"))` mounts
`GET /ox/metrics`, which renders the `django_ox.stats` numbers as gauges in
the Prometheus text format (OpenMetrics on request), from the standard
library alone. The metric names are `django_ox_tasks{queue,status}`,
`django_ox_ready_tasks`, `django_ox_oldest_ready_age_seconds`,
`django_ox_last_claim_age_seconds`, `django_ox_throughput_per_minute` and
`django_ox_failure_rate`, and they are public API from this release. The
view has no authentication of its own. `django_ox.metrics.collector()`
returns a collector for a `prometheus_client` registry when that package is
installed; it is not a dependency.
- `django_ox.actions.retry(result_id)` and `django_ox.actions.discard(result_id)`.
A retry puts a FAILED or LOST task back to READY for one more attempt, keeping
its attempt count, worker ids and every traceback, and clearing the backoff so
it is eligible at once. A discard closes a READY, FAILED or LOST task without
running it. Each is one compare-and-set on the row's status and lease number,
so two retries of one row requeue it once, a discard that races a claim loses
to it, and a LOST row's missing worker cannot write over its retry. Neither
touches a RUNNING task. `retry_many(selection)` and `discard_many(selection)`
take a queryset or a list of ids and make the same move in one conditional
UPDATE per thousand rows inside one transaction, returning
`(changed, skipped)`.
- The task table in the Django admin, registered only when
`django.contrib.admin` is installed: a list with status and queue filters and
search by id or path, a read-only detail page with every attempt's traceback,
and **Retry selected tasks** and **Discard selected tasks** actions that
report how many rows moved and how many were skipped. The actions call
`retry_many` and `discard_many`, so a select-across of any size is a few
statements in one transaction. The admin does not add, edit or delete rows.
- `django_ox.bulk.enqueue_many(task, calls)`, the bulk form of `enqueue()`.
`calls` is a list of `(args, kwargs)` pairs; the rows are written with one
INSERT per 1,000 inside one transaction and the `TaskResult` list comes back
in input order. The task is validated and every argument serialised before
the first write, so a rejected call inserts nothing. Each row is built by the
same code as `enqueue()`, so workers see no difference.
- `OxTask.Status.DISCARDED`, a sixth value in django-ox's own status column. It
reads as `FAILED` through `django.tasks` and `is_finished` is true for it.
`queue_stats()` reports it in a `discarded` column, and `ox_prune` deletes
discarded rows with successful ones.
### Fixed
- `manage.py check` runs the django-ox checks, `django_ox.E001` to `E005`,
in a project that imports `django.tasks` nowhere else. Django registers its
tasks check when that module is first imported, and a project without the
admin or a task module on its import path reached `check` without it, so
every django-ox check passed silently. The worker's own startup check was
unaffected.
### Changed
- **A migration ships with this release.** Run `python manage.py migrate
django_ox` when you upgrade. It adds the new status choice.
- `QueueStats` has a sixth field, `discarded`, keyword-defaulted like `lost`.
- A queued task can now be discarded and a failed or lost one retried, and
every attempt can be bounded with `TASK_TIMEOUT`; interrupting one chosen
running task on demand stays outside scope.
- A worker that is already draining, because it is recycling, treats the
operator's first signal as the drain it is doing rather than as the
force-exit; the second signal is still the force-exit.
## [0.2.1] - 2026-08-20
### Fixed
- A task that succeeded after its lease was lost no longer keeps the reaper's
lost-lease record in `errors`. That record says the outcome was never
observed, and the success write is that observation, so anything reading
`result.errors` was handed an exception nobody raised on a task that worked.
A failure resolving the same way already dropped it, and the two now agree. A task that is still LOST keeps the record: it is
the only thing on the row that says why the result reads as failed.
### Added
- `tools/check_release.py --dist` opens the built wheel and sdist and checks
that each one carries every migration in the source tree, along with the
licence and the package modules. A packaging rule that stops shipping a
migration leaves a distribution that imports and passes its tests, and fails
on somebody's upgrade against a column that is not there. The release
workflow runs it after the build.
## [0.2.0] - 2026-08-20
### Fixed
- A worker whose task had been taken back by the reaper could still write its
own outcome over the row, so a task that had already finished could be moved
back to READY and run a second time after its result had been reported. Every
claim now stamps the row with a lease number, and every finish write carries
that number in its WHERE clause, so a write from a worker that no longer
holds the task matches nothing and is dropped instead of applied. No
completion is signalled for a dropped write.
- The reaper no longer records a failure it did not observe. When a lock aged
out with no attempts left it wrote FAILED and invented a `TaskAbandoned`
exception to explain it, on no evidence beyond a clock. It now records the
task as LOST, which says the worker stopped reporting and the outcome was
never seen, and nothing more.
- Lock timestamps are written and compared using the database server's clock
rather than each worker's own, so two hosts with drifting clocks no longer
produce false reclaims. This applies when `USE_TZ` is on. With `USE_TZ` off
the worker's clock is used instead, because the database's clock does not
always match what these columns hold: 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.
- On databases without `SELECT ... FOR UPDATE SKIP LOCKED`, which includes
SQLite, a claim read its row back in a second statement and could come away
holding a lease granted to a different worker, if the reaper reclaimed the
row in the gap between the two. The read is now pinned to the lease the claim
was granted, so a worker that lost the row inside that gap comes back with
nothing rather than with someone else's lease.
### Added
- **Lease renewal.** A worker refreshes the lock on the tasks it is running,
one statement per interval however many are in flight, and keeps doing so
through a graceful drain. A long task on a healthy worker is no longer
reclaimed while it is still running. `LOCK_TIMEOUT` now bounds how long a
worker may go unresponsive, not how long a task may take. The renewal
interval is `LOCK_TIMEOUT / 3`, overridable as `renew_interval` when
embedding `Worker` directly.
- `OxTask.Status.LOST`, a fifth value in django-ox's own status column. It
reads as `FAILED` through `django.tasks`, which has four statuses and gets no
fifth from us, and `is_finished` is true for it, so callers waiting on a
result still terminate. The row keeps the distinction: `queue_stats()`
reports a `lost` column and `ox_prune --include-failed` covers it. If the
worker holding a LOST task comes back and records a real outcome, that
outcome replaces LOST; only that one execution can.
- `task_lease_lost` and `lease_renew_failed`, two WARNING log events. Both are
documented on the Monitoring page.
### Changed
- **A migration ships with this release.** Run `python manage.py migrate
django_ox` when you upgrade. It adds the `lease_epoch` column and the new
status choice.
- `task_reclaimed` now reports `status` as `READY` or `LOST`, where it
previously reported `READY` or `FAILED`.
- `QueueStats` has a fifth field, `lost`. It is keyword-defaulted, so existing
code that constructs one keeps working.
## [0.1.2] - 2026-08-18
The worker, the public API and the database schema are unchanged. This
release updates the project description that appears on the package page,
and the documentation that ships with it.
### Changed
- README now leads with what the backend removes from a deployment: the
queue lives in the database the application already runs, so there is no
broker to provision, secure, upgrade or back up. The transactional
guarantee follows it rather than opening.
### Added
- Migration guidance now covers moving *away* from django-ox as well as to
it: which behaviour carries over to a broker-backed backend, which does
not, and how to keep the option open.
- Worked examples for routing a queue to its own worker, choosing a lock
timeout for long tasks, overriding a schedule's queue and priority,
verifying that a schedule is live, and running the worker in containers.
- `context7.json`, so documentation indexers read the project description,
the supported versions and the setup steps rather than inferring them.
## [0.1.1] - 2026-08-17
The worker, the public API and the database schema are unchanged. This
release updates the packaging metadata and the project description that
appears on the package page.
### Changed
- Packaging metadata now carries a `Documentation` URL, so the
documentation site is linked directly from the package page.
- README now carries release and CI status badges, a link to the
documentation site, and a scope statement: what the core covers, what is
deliberately outside it, and which features belong to the commercial
tier.
## [0.1.0] - 2026-08-16
Initial release.
### Added
- `OxBackend`, a database-backed backend for Django's Tasks framework
(`django.tasks`, Django 6.0+). Tasks are stored in the application database;
no broker required.
- Transactional enqueue: `enqueue()` is a single INSERT on the caller's
connection, so a task enqueued inside `transaction.atomic()` commits or
rolls back with the business data.
- `ox_worker` management command: claims tasks with
`SELECT ... FOR UPDATE SKIP LOCKED` where supported (PostgreSQL, MySQL 8+)
and an atomic compare-and-set UPDATE elsewhere (including SQLite).
Configurable via `--backend`, `--queues`, `--concurrency` (thread pool),
`--interval`, and `--lock-timeout`.
- Retries with exponential backoff (`MAX_ATTEMPTS`, `BACKOFF_INITIAL`,
`BACKOFF_MAX`), keeping the full traceback of every attempt.
- Reaper: tasks whose worker died are returned to the queue after
`LOCK_TIMEOUT` and count as a failed attempt.
- Graceful drain: on SIGTERM/SIGINT the worker stops claiming, finishes
in-flight tasks, then exits; a second signal forces an immediate exit.
- Priorities (-100 to 100, higher first) and deferred tasks (`run_after`),
with the corresponding `supports_*` flags declared on the backend.
- Result store: `get_result()`, `refresh()`, and the async variants, with
status, return value, and per-attempt errors readable from the database.
- `ox_prune` management command: batched deletion of finished task rows
(`--older-than`, `--include-failed`, `--batch-size`, `--dry-run`).
- `django_ox.stats`: read-only queue metrics as plain ORM queries, on
both supported databases: per-queue status counts, backlog depth and
age, throughput and failure rate over a trailing window, and time
since the last task claim.
- `ox_health` management command: exits non-zero with a one-line reason
when the database is unreachable or a `--max-backlog`, `--max-age` or
`--worker-timeout` threshold is breached; built for cron alerting and
container probes.
- Structured logging: worker lifecycle events (claim, start, success,
retry, failure, reclaim, dispatch, shutdown) log to the `django_ox`
logger with stable extra keys (`event`, `task_id`, `queue`, `attempt`,
`duration_ms`, ...) for JSON log handlers.
- Recurring tasks: cron schedules declared in the `TASKS` setting
(`SCHEDULES` option), dispatched by the workers themselves; a unique
constraint on (schedule, tick) makes each tick fire exactly once across
any number of workers. Five-field cron syntax plus `@hourly`-style
shortcuts; misconfigured schedules fail at startup and in
`manage.py check`. On recovery after downtime, only the latest missed
tick fires.
- System check `django_ox.E003`: a schedule name defined on more than
one backend is rejected, at worker startup and in `manage.py check`,
because the tick log is keyed by schedule name alone and shared names
would let the backends suppress each other's ticks.
- Strict cron validation: expressions that can never fire and step values
larger than a field's range (such as `*/61` in the minute field) are
rejected at parse time rather than misfiring silently. Schedule
dispatch is robust to clock skew between workers: a tick row dated in
the future cannot suppress ticks that are due.
### Security
- A stored `task_path` must resolve to a `django.tasks` Task (a function
registered with `@task`). A row naming any other importable callable is
rejected as an un-runnable task instead of being executed, so the
worker never invokes an arbitrary dotted path pulled from the table.
`SECURITY.md` documents the full trust model, the JSON-only
serialization, and the guidance to keep secrets out of task arguments.
- An API stability and deprecation policy (`docs/stability.md`) covers
the public API surface, the pre-1.0 SemVer rule, the deprecation
window, and the supported Python and Django matrix.
[0.3.1]: https://github.com/oxpull/django-ox/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/oxpull/django-ox/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/oxpull/django-ox/releases/tag/v0.2.1
[0.2.0]: https://github.com/oxpull/django-ox/releases/tag/v0.2.0
[0.1.2]: https://github.com/oxpull/django-ox/releases/tag/v0.1.2
[0.1.1]: https://github.com/oxpull/django-ox/releases/tag/v0.1.1
[0.1.0]: https://github.com/oxpull/django-ox/releases/tag/v0.1.0