Skip to main content

django-task-ferry

A reusable Django package for background job management with real-time progress tracking.

Jobs are polymorphic (each type has its own model with extra fields), progress is written to Redis mid-transaction so API clients can poll without waiting for a DB commit, and cancellation is cooperative — a running job checks for cancellation on every progress tick.


Features

  • Pluggable executors — Celery, django-tasks (DEP-0014), or synchronous (great for tests)
  • Redis progress cache — progress updates are visible immediately, even inside a long transaction
  • Hierarchical progress — create child progress objects for pipeline stages with unequal weights
  • Cooperative cancellation — jobs check for cancellation on every progress update
  • DRF API — built-in list / detail / cancel endpoints, ready to mount
  • Type registry — job types self-register; no central list to maintain

Installation

# Base (ImmediateExecutor only — good for tests/scripts)
pip install django-task-ferry

# With Celery support
pip install django-task-ferry[celery]

# With django-tasks support (DEP-0014)
pip install django-task-ferry[django-tasks]

# Development / test
pip install django-task-ferry[dev]

Quick start

1. Add to INSTALLED_APPS

# settings.py
INSTALLED_APPS = [
    ...
    "task_ferry",
]

2. Configure the executor

All configuration lives under a single TASK_FERRY dict in your Django settings.

Settings reference

Key Default Description
EXECUTOR "task_ferry.executors.immediate.ImmediateExecutor" Dotted path to the executor class. See Executors below.
CELERY_QUEUE "default" Global Celery queue for all jobs. Individual job types can override this with their queue attribute.
PROGRESS_CACHE_TIMEOUT 3600 Seconds to keep progress data in the cache. Should be longer than your longest expected job.
JOB_EXPIRY_DAYS 7 Ended jobs older than this many days are removed by JobHandler.cleanup_old_jobs().
MAX_JOBS_PER_USER_PER_TYPE 5 Global default for JobType.max_count. Individual job types can override this.

Executors

Three executors are included:

Executor When to use
task_ferry.executors.celery.CeleryExecutor Production — requires pip install django-task-ferry[celery]
task_ferry.executors.django_tasks.DjangoTasksExecutor Production — requires pip install django-task-ferry[django-tasks]
task_ferry.executors.immediate.ImmediateExecutor Tests and scripts — runs jobs synchronously in the current process
# settings.py

# Celery (recommended for production)
TASK_FERRY = {
    "EXECUTOR": "task_ferry.executors.celery.CeleryExecutor",
    "CELERY_QUEUE": "default",
    "PROGRESS_CACHE_TIMEOUT": 3600,
    "JOB_EXPIRY_DAYS": 7,
    "MAX_JOBS_PER_USER_PER_TYPE": 5,
}

# django-tasks (DEP-0014)
TASK_FERRY = {
    "EXECUTOR": "task_ferry.executors.django_tasks.DjangoTasksExecutor",
}
TASKS = {
    "default": {
        "BACKEND": "django_tasks.backends.database.DatabaseBackend",
    }
}

# Synchronous — tests / management commands
TASK_FERRY = {
    "EXECUTOR": "task_ferry.executors.immediate.ImmediateExecutor",
}

Per-job-type queue routing (Celery only)

Set queue on a JobType to send that type to a specific Celery queue instead of the global CELERY_QUEUE default. This has no effect when using DjangoTasksExecutor or ImmediateExecutor.

class HeavyExportJobType(JobType):
    type = "heavy_export"
    model_class = HeavyExportJob
    queue = "heavy"  # routed to the "heavy" worker pool


class QuickReportJobType(JobType):
    type = "quick_report"
    model_class = QuickReportJob
    # queue not set — falls back to TASK_FERRY["CELERY_QUEUE"]

3. Run migrations

python manage.py migrate

4. Mount the API (optional)

# urls.py
from django.urls import include, path

urlpatterns = [
    ...
    path("api/jobs/", include("task_ferry.api.urls")),
]

Defining a job type

Step 1 — Create the model

Add domain-specific fields by subclassing Job:

# myapp/models.py
from task_ferry.models import Job


class ExportJob(Job):
    table_id = models.IntegerField()
    output_path = models.CharField(max_length=500, blank=True)
    
    class Meta:
        app_label = "myapp"

Create and apply a migration:

python manage.py makemigrations myapp
python manage.py migrate

Step 2 — Implement the JobType

# myapp/job_types.py
from task_ferry.registry import JobType
from .models import ExportJob


class ExportJobType(JobType):
    type = "export_table"  # unique string identifier
    model_class = ExportJob
    max_count = 2  # max concurrent jobs per user
    queue = "exports"  # optional: route to a specific Celery queue
    
    def prepare_values(self, values: dict, user) -> dict:
        """Validate and transform kwargs before the Job row is created."""
        if "table_id" not in values:
            raise ValueError("table_id is required")
        return values
    
    def run(self, job: ExportJob, progress) -> None:
        """
        Do the actual work. Called inside the executor task.
        progress is a Progress object rooted at 100.
        """
        rows = fetch_rows(job.table_id)  # your code here
        progress.increment(10, state="Fetched rows")
        
        # Use a child for a sub-stage with its own step count.
        write_stage = progress.create_child(represents=80, total=len(rows))
        for row in rows:
            write_to_file(row)
            write_stage.increment(state=f"Writing row {row.id}...")
        
        progress.increment(10, state="Finalising")
        job.output_path = "/exports/result.csv"
        job.save(update_fields=["output_path"])

Step 3 — Register in AppConfig.ready()

# myapp/apps.py
from django.apps import AppConfig


class MyAppConfig(AppConfig):
    name = "myapp"
    
    def ready(self):
        from task_ferry.registry import job_type_registry
        from .job_types import ExportJobType
        
        job_type_registry.register(ExportJobType())

Job model reference

Every concrete job model inherits the following fields from Job:

Field Type Description
state str Current lifecycle state. One of pending, started, finished, failed, cancelled.
progress_percentage int 0–100. Written to Redis mid-run; persisted to DB at completion.
progress_state str Human-readable description of the current step.
error str Short error string (exception message). Empty unless the job failed.
human_readable_error str Longer, user-facing error description.
user User The user who triggered the job. None for system jobs.
created_at datetime When the job was created.
updated_at datetime Last state change timestamp.

Convenience properties (read from cache, falling back to the DB):

Property Description
is_pending True if state is pending.
is_running True if state is started.
is_finished True if state is finished.
is_failed True if state is failed.
is_cancelled True if state is cancelled.
has_ended True if state is finished, failed, or cancelled.
specific Returns the concrete subclass instance. Use this when you have a base Job queryset and need type-specific fields.

Dispatching a job

from task_ferry.handler import JobHandler

# Authenticated user context (e.g. from a DRF view)
job = JobHandler.create_and_start(
    user=request.user,
    job_type_name="export_table",
    table_id=42,
)

# System / automated context (no user)
job = JobHandler.create_and_start(
    user=None,
    job_type_name="export_table",
    table_id=42,
)

create_and_start returns the saved Job instance immediately. The actual work runs asynchronously inside the configured executor.

Listing jobs for a user

jobs = JobHandler.get_jobs_for_user(
    user=request.user,
    states=["pending", "started"],  # optional filter
    job_type_name="export_table",  # optional filter
    limit=20,
    offset=0,
)

Polling progress

Poll GET /api/jobs/<id>/ from your frontend. The response is served from Redis so it reflects mid-transaction progress without waiting for a DB commit:

{
  "id": 17,
  "state": "started",
  "progress_percentage": 43,
  "progress_state": "Writing row 430...",
  "error": "",
  "human_readable_error": "",
  "created_at": "2026-05-28T10:00:00Z",
  "updated_at": "2026-05-28T10:00:05Z"
}

Possible state values: pending, started, finished, failed, cancelled.

A simple polling loop in JavaScript:

async function pollJob(jobId, onProgress) {
    while (true) {
        const res = await fetch(`/api/jobs/${jobId}/`);
        const job = await res.json();
        onProgress(job);
        if (["finished", "failed", "cancelled"].includes(job.state)) break;
        await new Promise(r => setTimeout(r, 1500));  // 1.5 s interval
    }
}

Cancelling a job

# From Python
from task_ferry.handler import JobHandler

JobHandler.cancel(user=request.user, job_id=job.id)

Or via the API:

POST /api/jobs/<id>/cancel/

Cancellation is cooperative — a running job checks for cancellation on every progress.increment() call and raises JobCancelled when detected. The handler catches this and calls JobType.on_cancelled(job) so you can clean up.

Override on_cancelled to remove partial output files, release locks, etc.:

def on_cancelled(self, job: ExportJob) -> None:
    if job.output_path:
        os.unlink(job.output_path)

JobType reference

Attributes

Attribute Type Default Description
type str — Required. Unique string identifier, e.g. "export_table".
model_class Type[Job] — Required. The Job subclass whose DB table stores this type's fields.
max_count int 5 Maximum pending-or-running jobs of this type per user. Set to 1 for types where duplicates make no sense.
queue str or None None Celery queue to route this job type to. When None, falls back to the global TASK_FERRY["CELERY_QUEUE"].

Hooks

All hooks have no-op defaults. Override only what you need:

Hook Called when
prepare_values(values, user) Before the Job row is created. Validate and transform kwargs. Must return the dict of model field values.
after_job_creation(job, values) Immediately after the row is saved, before dispatch.
run(job, progress) Inside the executor task. Implement your work here.
on_error(job, exc) After the job is marked failed. Log or alert.
on_cancelled(job) After the job is marked cancelled. Clean up partial state.
before_delete(job) Before cleanup_old_jobs deletes an expired job row.

Progress API reference

A Progress object is passed to run(job, progress). It is rooted at 100 — i.e. calling increment until all steps are done brings it to 100%.

Method / Property Description
increment(by=1, state="") Advance by by steps and fire the progress callback. Clamped — will not exceed 100%.
create_child(represents, total) Return a child Progress with total steps. When the child completes, the parent advances by represents units.
percentage Current completion as an integer 0–100.

Multiple children with unequal weights

def run(self, job, progress):
    # 10% — fetch
    progress.increment(10, state="Fetching...")
    
    # 70% — process rows (5 items, each worth 70/5 = 14% of total)
    process = progress.create_child(represents=70, total=5)
    for item in items:
        process_item(item)
        process.increment(state=f"Processing {item}...")
    
    # 20% — finalise
    progress.increment(20, state="Done")

Multiple children can be created up front and advanced independently — they do not overwrite each other's contribution.


Periodic cleanup

Ended jobs older than JOB_EXPIRY_DAYS (default 7) can be deleted by calling:

from task_ferry.handler import JobHandler

deleted_count = JobHandler.cleanup_old_jobs()

Wire this up as a Celery beat task or a management command on a daily schedule.


Testing

Use ImmediateExecutor in your test settings to run jobs synchronously — no broker or worker needed:

# settings_test.py
TASK_FERRY = {
    "EXECUTOR": "task_ferry.executors.immediate.ImmediateExecutor",
}

Then test directly:

def test_export_creates_file(db, user):
    job = JobHandler.create_and_start(user, "export_table", table_id=1)
    job.refresh_from_db()
    assert job.state == "finished"
    assert job.output_path != ""

To test cancellation, patch enqueue so the job stays pending, then cancel before it runs:

def test_cancel_cleans_up(db, user, monkeypatch):
    from task_ferry.executors.immediate import ImmediateExecutor
    monkeypatch.setattr(ImmediateExecutor, "enqueue", lambda self, job_id, queue=None: None)
    
    job = JobHandler.create_and_start(user, "export_table", table_id=1)
    JobHandler.cancel(user, job.id)
    assert job.state == "cancelled"

Metadata

Release files for django-task-ferry 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-task-ferry 0.1.1
File Size Uploaded Provenance Actions
django_task_ferry-0.1.1.tar.gz 25.8 kB With provenance Details for django_task_ferry-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for django-task-ferry 0.1.1
File Interpreter ABI Platform Actions
django_task_ferry-0.1.1-py3-none-any.whl Python 3 none any Details for django_task_ferry-0.1.1-py3-none-any.whl

Total release size: 53.2 kB

Release files / django_task_ferry-0.1.1.tar.gz

Download URL django_task_ferry-0.1.1.tar.gz
Size 25.8 kB
Tags Source
SHA-256 checksum
How to use checksums
506d1f1ea622b11f7ea268cdd71213576381aa7b5f1c66719c7f4cbadc8ef062
BLAKE2b-256 checksum
How to use checksums
5ea9575867bb239b2ca71f1caa9b1a49f0813c7b976746ce4c147bd95275dafb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 4, 2026.

Transparency log

Release files / django_task_ferry-0.1.1-py3-none-any.whl

Download URL django_task_ferry-0.1.1-py3-none-any.whl
Size 27.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dd666f346b0d4d276c689c551a1aaad6185df9a5d5937f164824623fd43bed25
BLAKE2b-256 checksum
How to use checksums
0ef2c0cda0e2e158fa4b2903d639cbd47f935d29087fa3c104b7e0494f2a30cd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 4, 2026.

Transparency log

Security for django-task-ferry 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_task_ferry-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 4, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page