Anything that takes more than a few seconds does not belong in the HTTP request cycle. Celery runs that work in the background; message brokers move work and events between systems. Deployment context: Django on ECS Fargate - Architecture and Lessons.
Parts at a glance
| Part | Role |
|---|---|
| Producer | Your Django code calling task.delay(...) |
| Broker | Holds queued messages: Redis, RabbitMQ or SQS |
| Worker | Process that executes tasks |
| Result backend | Optional store for task results (Redis, database) |
| Celery Beat | Scheduler that enqueues periodic tasks |
flowchart LR
V[Django view] -- delay --> B[(Broker)]
BE[Celery Beat] -- schedule --> B
B --> W1[Worker]
B --> W2[Worker]
W1 --> DB[(Database / S3)]
W2 --> DB
Task Patterns
from celery import shared_task
@shared_task(
bind=True,
autoretry_for=(ConnectionError,),
retry_backoff=True, # 1s, 2s, 4s ...
retry_backoff_max=600,
max_retries=5,
soft_time_limit=300,
acks_late=True,
)
def send_receipt(self, payment_id):
payment = Payment.objects.get(pk=payment_id) # pass IDs, not objects
...
| Practice | Why |
|---|---|
| Pass IDs, not model instances | Data may change; messages stay small and serialisable |
| Make tasks idempotent | Retries and duplicates happen; running twice must be safe |
| Retry with backoff | Avoid hammering a failing service |
| Time limits | A hung task cannot block a worker forever |
acks_late |
Message is acknowledged after success, so a crashed worker's task is redelivered |
| Separate queues | Slow reports must not starve email; route with task_routes |
| Small, focused tasks | Easier to retry and monitor |
| Enqueue after commit | Use transaction.on_commit(lambda: task.delay(id)) so the task sees the saved row |
Long Jobs: Trigger, Poll, Download
Report generation (charts, spreadsheets, PDFs) first ran inside the request and began timing out as data grew. The fix:
sequenceDiagram
participant F as Frontend
participant V as Django view
participant C as Celery worker
participant S as S3
F->>V: POST /reports (filters)
V->>C: generate_report.delay(params)
V-->>F: 202 + job id
loop poll
F->>V: GET /reports/{job}
V-->>F: status: running
end
C->>S: Save finished file
C->>V: Mark job done (database)
F->>V: GET /reports/{job}
V-->>F: status: done + download link
- The view returns immediately with a job ID
- The frontend polls (or listens over SSE: Async Django - ASGI, Channels and SSE)
- The result file goes to S3; the user gets a short-lived presigned URL
- Rule: anything longer than a few seconds is a background job
Libraries typically used for report output: Matplotlib for charts, openpyxl / XlsxWriter for spreadsheets, WeasyPrint for PDFs.
Scheduled Work with Celery Beat
from celery.schedules import crontab
app.conf.beat_schedule = {
"nightly-stats": {
"task": "stats.tasks.publish_nightly_stats",
"schedule": crontab(hour=1, minute=30),
},
"sla-escalation": {
"task": "cases.tasks.escalate_overdue",
"schedule": crontab(minute="*/15"),
},
}
- Run exactly one Beat process (a separate ECS service), or tasks fire multiple times
- Timezone: set
CELERY_TIMEZONEand keep stored times in UTC - SLA escalation: a frequent Beat task finds overdue items and escalates them (Django Data Modelling Lessons)
Efficient nightly aggregation
A nightly statistics task (counts by type, active vs static users, stalled items, daily and weekly totals) ran against a live database inside a narrow window. Poorly written aggregation locks rows or triggers slow scans.
- Aggregate in the database:
.values("type").annotate(n=Count("id")), withfilter=Q(...)for conditional counts, not Python loops - Index the columns used in filters and group-bys
- Avoid N+1 queries (
select_related,prefetch_related); stream big sets with.iterator() - Prefer a read replica when available; run off-peak
- Measure with
EXPLAIN ANALYZE; keep the task inside its time limit - Build the payload to match the consumer's schema exactly and validate before publishing
Brokers Compared
| Redis | RabbitMQ (Amazon MQ) | SQS | |
|---|---|---|---|
| Model | In-memory lists / pub-sub | AMQP: exchanges, queues, bindings | Managed queue, HTTP API |
| Connection | Long-lived | Long-lived | HTTP polling |
| Routing | Basic | Rich (direct, topic, fanout, headers) | Per queue (use SNS for fan-out) |
| Delivery | At-most / at-least once (cautious) | At-least once with acks | At-least once, visibility timeout, dead-letter queue |
| Ops | Easy, but durability needs care | More to run (or managed) | No servers |
| Fits | Celery broker, cache, pub/sub | Integrations with other systems, routing | Simple decoupling on AWS |
SQS first, then RabbitMQ
A mobile app and a web portal needed to exchange messages. SQS came first. As the integration matured it became clear that the middleman service already ran RabbitMQ and expected both sides to publish and consume there. Migration meant:
- Absorbing the protocol difference: HTTP-polled SQS vs long-lived AMQP connections
- Re-implementing publisher and consumer logic against the middleman's schema and queue names, using the pika library
- Handling connection heartbeats and reconnects
Lesson: agree the integration contract and protocol first; choose the broker the other party already runs.
flowchart LR
M[Mobile app] --> BR[Middleman: RabbitMQ]
BR -- inbound queues --> D[Django portal consumers]
D -- status changes, responses --> BR
BR --> M
D --> L[(Message log tables)]
Consumer sketch (pika)
import json, pika
params = pika.URLParameters(settings.AMQP_URL) # heartbeat set in the URL / params
conn = pika.BlockingConnection(params)
ch = conn.channel()
ch.queue_declare(queue="inbound_cases", durable=True)
ch.basic_qos(prefetch_count=10)
def on_message(ch, method, props, body):
try:
handle(json.loads(body))
ch.basic_ack(method.delivery_tag)
except Exception:
ch.basic_nack(method.delivery_tag, requeue=False) # dead-letter exchange handles it
ch.basic_consume(queue="inbound_cases", on_message_callback=on_message)
ch.start_consuming()
Run consumers as their own process / ECS service, not inside web workers.
Traceability: log every message
Record each inbound message and each outbound one in log tables (queue, payload, status, timestamps). Benefits: debugging, replay after a bug fix, audit, and proof of what was sent. The same idea as the outbox pattern: write the message to the database in the same transaction as the change, then publish from the table.
Operating Celery
- Monitoring: Flower, CloudWatch metrics, queue length alarms
- Concurrency: processes (prefork) for CPU-bound, threads / gevent for I/O-bound
- Graceful shutdown: ECS stop timeout longer than the longest task, or tasks resume via
acks_late - Dead-letter handling: failed messages parked, inspected, replayed
- Keep secrets and broker URLs in environment variables (5. Secure Coding and Supply Chain)
Checklist
- [ ] Long work in Celery; request returns a job ID
- [ ] Tasks take IDs, are idempotent, retry with backoff, have time limits
- [ ] Beat runs once; schedules in code
- [ ] Aggregations done in SQL, indexed, off-peak
- [ ] Broker matches the integration contract
- [ ] Messages logged for traceability and replay