Distributed Task Queue

Celery

The industry-standard Python distributed task queue for offloading long-running AI agent workloads. Covers tasks, workers, brokers, result backends, retry strategies, periodic scheduling, and production monitoring.

1 Core Concepts

Tasks

Units of work decorated with @app.task or @shared_task. Tasks are serialized (JSON/pickle), pushed to a message broker, and executed asynchronously by workers. Perfect for LLM API calls, file processing, and email sending.

Broker

The message transport layer. Redis (lightweight, fast) and RabbitMQ (durable, feature-rich) are the primary choices. The broker holds task messages in queues until workers consume them. Redis is the most popular for agent backends.

Workers

Processes that consume tasks from the broker and execute them. Each worker runs a prefork pool (multiple child processes) by default. Workers can be scaled horizontally — add more workers to increase throughput linearly.

Result Backend

Optional store for task return values and status. Use Redis, PostgreSQL, or Django ORM as the backend. Callers can check result.status and result.get() to retrieve the outcome of an async task.

2 Setup Guide

📦 Installation & Configuration

# Install Celery with Redis support
pip install celery[redis] django-celery-results

# celery_app.py (project root)
import os
from celery import Celery

os.environ.setdefault(
    'DJANGO_SETTINGS_MODULE', 'agentapi.settings'
)

app = Celery('agentapi')
app.config_from_object('django.conf:settings',
                        namespace='CELERY')
app.autodiscover_tasks()  # auto-finds tasks.py in each app

# settings.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'django-db'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'

🚀 Starting Workers

# Start a worker (development)
celery -A agentapi worker --loglevel=info

# Start with concurrency control
celery -A agentapi worker \
    --concurrency=8 \         # 8 child processes
    --loglevel=info \
    --queues=default,agents   # listen to specific queues

# Start the beat scheduler (periodic tasks)
celery -A agentapi beat --loglevel=info

# Start Flower monitoring (web UI)
pip install flower
celery -A agentapi flower --port=5555

# Production: run as systemd service
# /etc/systemd/system/celery-worker.service
# ExecStart=/app/venv/bin/celery -A agentapi worker
# Restart=always

3 Key Patterns & Usage

Defining & Calling Tasks

# agents/tasks.py
from celery import shared_task
import httpx

@shared_task(bind=True, max_retries=3)
def run_agent_inference(self, agent_id, prompt):
    """Call LLM API asynchronously."""
    try:
        response = httpx.post(
            "https://api.openai.com/v1/chat/completions",
            json={"model": "gpt-4", "messages": [
                {"role": "user", "content": prompt}
            ]},
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=60,
        )
        return response.json()
    except httpx.TimeoutException as exc:
        raise self.retry(exc=exc, countdown=10)

# Calling the task from a view
result = run_agent_inference.delay(agent_id=42,
                                   prompt="Explain K8s")
print(result.id)      # task UUID
print(result.status)  # PENDING → STARTED → SUCCESS

Chains, Groups & Chords

# Chain: sequential pipeline
from celery import chain

pipeline = chain(
    fetch_document.s(doc_id),      # step 1
    extract_embeddings.s(),         # step 2 (gets step 1 result)
    store_in_vectordb.s(),          # step 3
)
pipeline.apply_async()

# Group: parallel fan-out
from celery import group

batch = group(
    run_agent_inference.s(id, prompt)
    for id, prompt in agent_prompts
)
results = batch.apply_async()  # runs all in parallel

# Chord: parallel + callback when all done
from celery import chord

chord(
    [embed_chunk.s(chunk) for chunk in chunks],  # parallel
    merge_embeddings.s()  # callback with all results
).apply_async()

Retry & Error Handling

# Automatic retry with exponential backoff
@shared_task(
    bind=True,
    autoretry_for=(httpx.TimeoutException,),
    retry_backoff=True,       # exponential: 1s, 2s, 4s...
    retry_backoff_max=300,    # cap at 5 minutes
    retry_jitter=True,        # add randomness
    max_retries=5,
)
def call_llm(self, prompt):
    response = httpx.post(LLM_URL, json={"prompt": prompt},
                          timeout=30)
    response.raise_for_status()
    return response.json()

# Time limits (prevent stuck workers)
@shared_task(
    time_limit=120,       # hard kill after 120s
    soft_time_limit=90,   # SoftTimeLimitExceeded at 90s
)
def long_running_task():
    try:
        do_work()
    except SoftTimeLimitExceeded:
        save_progress()  # graceful cleanup

Periodic Tasks (Celery Beat)

# settings.py — schedule periodic tasks
from celery.schedules import crontab

CELERY_BEAT_SCHEDULE = {
    'cleanup-stale-agents': {
        'task': 'agents.tasks.cleanup_inactive',
        'schedule': crontab(hour=2, minute=0),  # 2 AM daily
    },
    'refresh-embeddings': {
        'task': 'agents.tasks.refresh_embeddings',
        'schedule': 3600.0,  # every hour (seconds)
    },
    'health-check': {
        'task': 'agents.tasks.health_ping',
        'schedule': 60.0,  # every minute
        'options': {'expires': 30},  # skip if > 30s late
    },
}

# Or use django-celery-beat for DB-backed
# schedules editable via Django Admin UI
pip install django-celery-beat
# Add 'django_celery_beat' to INSTALLED_APPS
# Run: celery -A agentapi beat -l info \
#      --scheduler django_celery_beat.schedulers:DatabaseScheduler

4 Queues & Task Routing

Named Queues

Route tasks to dedicated queues: high_priority, agent_inference, bulk_processing. Run separate worker processes per queue. Prevents slow bulk jobs from blocking latency-sensitive agent responses.

Task Routing

Use CELERY_TASK_ROUTES to auto-route tasks by name pattern: 'agents.*': {'queue': 'agents'}. Or route explicitly: task.apply_async(queue='high_priority'). Keeps queues focused and workers specialized.

Priority & Rate Limiting

Set per-task rate limits: @shared_task(rate_limit='10/m') caps at 10 executions/minute per worker. Use priority queues (RabbitMQ) for urgent agent tasks. Combine with countdown for delayed execution.

5 Monitoring & Observability

Flower Dashboard

Real-time web monitor at :5555. Shows active workers, task throughput, success/failure rates, and queue depths. Supports basic auth. Essential for debugging stuck or failing agent tasks in production.

Prometheus Metrics

Export Celery metrics with celery-exporter. Track task duration, queue lengths, worker pool utilization, and retry counts in Grafana. Set alerts on queue depth > 1000 or failure rate > 5%.

Dead Letter Queues

Tasks that exhaust all retries should be routed to a dead letter queue (DLQ) instead of being silently dropped. Inspect DLQ periodically to identify systematic failures (API outages, data corruption). Replay tasks after fixing the root cause.

6 Scaling & Production

Horizontal Worker Scaling

Add more worker processes across machines — they all consume from the same broker. Use Kubernetes Deployments or autoscaling groups to scale workers based on queue depth. Each worker runs 4-16 concurrent child processes (prefork pool).

Prefork vs. Gevent Pool

Default prefork pool uses OS processes — best for CPU-bound tasks. For I/O-bound tasks (API calls, DB queries), use --pool=gevent --concurrency=100 to run 100 green threads per worker. 10× throughput for agent inference workloads.

Broker Scaling (Redis)

A single Redis instance handles ~100K tasks/sec. For higher throughput, use Redis Sentinel (HA) or Redis Cluster. Alternatively, use RabbitMQ with mirrored queues for durable message delivery and built-in dead-letter exchanges.

Task Idempotency

Tasks may execute more than once (broker redelivery, worker crash). Design tasks to be idempotent — running twice produces the same result. Use unique task IDs as dedup keys in Redis. Check if not already_processed(task_id) before executing.

Memory Leak Prevention

Set worker_max_tasks_per_child=1000 to restart child processes after 1000 tasks. Prevents memory leaks from accumulating in long-running workers. Also set worker_max_memory_per_child=400000 (400 MB) for a hard memory limit.

Graceful Shutdown

Send SIGTERM for warm shutdown — workers finish current tasks before exiting. Set worker_cancel_long_running_tasks_on_connection_loss=True for Kubernetes pod termination. Use acks_late=True to requeue unfinished tasks on crash.

7 FAQ

Redis vs. RabbitMQ — which broker should I use?
Redis is simpler, faster to set up, and handles most use cases (agent backends, moderate throughput). RabbitMQ is better when you need message durability guarantees, priority queues, dead-letter exchanges, or complex routing topologies. For most AI agent projects, Redis is the pragmatic choice.
How do I handle tasks that take 30+ minutes?
Set time_limit=3600 (1 hour) and soft_time_limit=1800 (30 min). Use acks_late=True so the task is requeued if the worker crashes mid-execution. Emit progress updates via self.update_state(state='PROGRESS', meta={'percent': 50}) so the caller can poll status.
Can I use Celery without Django?
Yes. Celery is framework-agnostic. Create a Celery() app directly, configure the broker URL, and define tasks. Works with Flask, FastAPI, or standalone Python scripts. Django integration (django-celery-results, django-celery-beat) is optional — it adds admin UI and ORM-backed result storage.
How do I ensure exactly-once task execution?
Celery guarantees at-least-once delivery, not exactly-once. To achieve effectively exactly-once, make tasks idempotent: use a unique task_id as a deduplication key in Redis or your database. Check if the task was already processed before executing the main logic. Combine with acks_late=True for crash safety.
How do I run Celery in Kubernetes?
Deploy workers as a Kubernetes Deployment (not a Job). Set terminationGracePeriodSeconds: 120 for warm shutdown. Scale replicas based on queue depth using KEDA (Kubernetes Event-Driven Autoscaler). Run Celery Beat as a single-replica Deployment to avoid duplicate scheduled tasks. Use acks_late=True so tasks survive pod restarts.