Jobs¶
Reference
Work that must not block a response — exports, imports, sending a batch of emails — runs
in the background. ninja_devx.contrib.jobs tracks each run as a Job row, so clients poll
its status instead of holding a connection open.
Starting a job¶
from ninja_devx.contrib.jobs.runner import JobContext, accepted, job, start_job
@job("reports.export")
def export_report(ctx: JobContext, report_id: int) -> dict[str, object]:
rows = Report.objects.filter(pk=report_id).count()
ctx.set_progress(50)
if ctx.cancelled():
return {"cancelled": True}
write_report_file(report_id)
return {"rows": rows}
class ReportController(Controller):
@post("/{pk}/export", response={202: dict})
def export(self, request: HttpRequest, pk: int) -> JsonResponse:
row = start_job(request, "Export report", export_report, report_id=pk)
return accepted(request, row, prefix="/jobs")
POST /reports/42/export returns:
HTTP/1.1 202 Accepted
Location: https://api.example.com/jobs/6f9c...
{"id": "6f9c...", "status": "queued", "url": "https://api.example.com/jobs/6f9c..."}
start_jobcreates theJobrow (queued,created_bythe caller) and enqueuesrun_jobthrough aTaskQueue: the container's registered singleton if there is one, otherwiseOnCommitTaskQueue(). Passqueue=to use another one (Celery, RQ...).@job("reports.export")registers the function under a stable name.run_jobresolves it by that name, or by a dotted import path for a plain module-level function, never by pickling it, so the call survives process restarts and framework upgrades.*args/**kwargspassed tostart_jobare recorded on the row (JSON-normalized) and forwarded to the function after itsJobContext.accepted(request, row, prefix="/jobs")builds the 202 response: aLocationheader and a body withid,statusandurl, matching whereverJobsControlleris mounted.
Polling¶
curl https://api.example.com/jobs/6f9c...
# {"id": "...", "status": "running", "progress": 50, "result": {}, "error": "", ...}
The client polls GET /jobs/{id} until status leaves queued/running:
| Route | Description |
|---|---|
GET / |
the caller's own jobs, paginated (20 per page, 100 max) |
GET /{id} |
one job; the owner or staff |
POST /{id}/cancel |
mark a queued or running job cancelled; the owner or staff |
Cancelling is cooperative: it flips status to cancelled immediately, but a running
function only stops if it calls ctx.cancelled() between steps. run_job never overwrites
a cancellation with a later success or failure.
Inside run_job¶
- Sets
status = running,started, and incrementsattempts, then calls the function with a freshJobContext. ctx.set_progress(0..100)updatesprogressimmediately, so polling clients see it mid-run.- An exception is retried in the same call up to
retrytimes, then stored aserrorwithstatus = failed.retryis not part ofstart_job's signature; pass it when wiringrun_jobinto your own task framework, or usedevx_jobs retrybelow. - On success,
status = succeeded,progress = 100, and the return value (JSON normalized) is stored asresult.
Housekeeping¶
prune deletes terminal jobs (succeeded, failed, cancelled) older than the given
number of days. retry replays a failed or cancelled job synchronously, in the current
process, with the function and arguments recorded when it was started; --retry-count
gives it its own in-process retry budget (default 0).
Admin¶
With django.contrib.admin installed, jobs are listed with their status, progress and
owner; rows cannot be created there (only through start_job), and the resolved function
and arguments are read-only.