Delivery guarantees and idempotent tasks

Celery · Advanced · 14 min

Workers crash, deploys kill processes and brokers redeliver, so production tasks must be safe to run more than once and not lost.

A worker acknowledges a message to tell the broker it can be deleted. By default Celery acks just before executing the task (early ack): if the worker process dies mid-task, the task is lost. That's at-most-once.

With acks_late=True (per task) or task_acks_late=True, the ack happens after the task finishes. A crash before that means the broker redelivers the message to another worker: at-least-once. Add task_reject_on_worker_lost=True so a task whose process was killed (OOM, SIGKILL) is requeued too; beware that a task that always crashes its process then loops forever.

At-least-once delivery means duplicates are possible, so tasks must be idempotent: running twice has the same effect as once. Techniques:

  • Use natural keys and upserts instead of blind inserts.
  • Record a processed marker (unique task id or business key) in the same transaction as the work.
  • Check state before acting ("already charged?") and use idempotency keys with external APIs.

With the Redis or SQS brokers, unacknowledged messages become visible again after the visibility timeout (Redis default: one hour, broker_transport_options={"visibility_timeout": ...}). Tasks running longer than it get redelivered while still running, so set it above your longest task or ETA.

Late acks also mean the prefetched messages of a worker are not lost on crash, making worker_prefetch_multiplier=1 a common companion for long tasks.

Example

from celery import Celery

app = Celery("proj", broker="redis://localhost:6379/0")
app.conf.update(
    task_acks_late=True,                 # ack after the task finishes
    task_reject_on_worker_lost=True,     # requeue if the process is killed
    worker_prefetch_multiplier=1,        # take one message at a time
    broker_transport_options={"visibility_timeout": 4 * 3600},
)

PROCESSED: set[str] = set()   # stand-in for a DB table with a unique key

@app.task(bind=True)
def charge_order(self, order_id: int):
    key = f"charge:{order_id}"
    if key in PROCESSED:                 # idempotency check
        return "already charged"
    # payment_api.charge(order_id, idempotency_key=key)
    PROCESSED.add(key)                   # in real code: same DB transaction
    return "charged"

Remember

  • Default early ack = at-most-once; acks_late = at-least-once
  • task_reject_on_worker_lost requeues tasks killed mid-run
  • At-least-once means duplicates: make tasks idempotent
  • Keep Redis/SQS visibility_timeout longer than tasks and ETAs

Watch out for

  • Enabling acks_late on non-idempotent tasks like sending payments or emails without a dedupe key.

Check yourself

  1. A long task (2 h) on a Redis broker with the default visibility timeout keeps running twice. Why?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.celeryq.dev/en/stable/userguide/tasks.html#tips-and-best-practices