A formal TLA+ model of a race condition in Celery's Redis broker:
when visibility_timeout expires before a worker acknowledges an ETA task,
restore_visible creates a duplicate copy, leading to double execution.
In Celery with Redis broker, each fetched task enters a "reserved" state
(invisible to other workers) for visibility_timeout seconds. If the worker
does not acknowledge (ACK) the task within that window, restore_visible
returns the task to the ready queue — while the original worker still holds
and processes its copy.
Redis Queue ──[pickup]──► Worker A (processing...)
▲ │
└── restore_visible ─────┘ (visibility timeout expires)
│
└──[pickup]──► Worker B (processing...)
──► Both workers execute → double execution
The model uses two sets to track task copies:
readySet— tasks available for workers to pick upactiveSet— tasks currently in-flight (being processed by a worker)execCount[id]— number of times a task has been executed
Key insight: both readySet and activeSet can contain the same task ID
simultaneously. That is the duplication.
Trace:
State 1: <Init>
State 2: PublishTask(0) — tasks={0}, readySet={0}
State 3: PickupTask(0) — readySet={}, activeSet={0}
State 4: ExecuteTask(0) — execCount[0]=1
State 5: VisTimeoutExpired(0) — readySet={0}, activeSet={}
State 6: PickupTask(0) — readySet={}, activeSet={0}
State 7: ExecuteTask(0) — execCount[0]=2
✗ Invariant VIOLATED
TLC output:
execCount = (0 :> 2 @@ 1 :> 0 @@ 2 :> 0)
The invariant execCount[id] <= 1 is violated: task 0 executed twice.
The fix adds a guard: execute only if execCount[id] = 0. A second copy
detects the prior execution and acknowledges without re-executing.
ExecuteTask ==
/\ \E id \in activeSet :
/\ execCount[id] = 0 \* Idempotent guard
/\ execCount' = [execCount EXCEPT ![id] = 1]
AckWithoutExecute ==
/\ \E id \in activeSet :
/\ execCount[id] > 0 \* Already executed
/\ activeSet' = activeSet \ {id}
TLC confirms no invariant violations — the guard prevents double execution
even when restore_visible creates duplicate copies.
| Model | States | Depth | Invariant Violation |
|---|---|---|---|
| FAULTY | 53 | 5 | YES — execCount[0]=2 |
| IDEMPOTENT | 240 | 8 | No — guard holds |
| Mitigation | Description |
|---|---|
| Idempotency guard | Check for existing result before executing (e.g., idempotency key in DB) |
| Reduce visibility_timeout | Shorter window reduces the risk of duplicates |
| ETA vs visibility_timeout | If ETA > visibility_timeout, duplication is nearly guaranteed |
| Exactly-once delivery | Use Redis Streams or transactional outbox for stronger guarantees |
Requires TLC (tla2tools.jar):
java -cp tla2tools.jar tlc2.TLC celery_eta_visibility_timeout_faulty.tla \
-config CeleryETA.cfg
java -cp tla2tools.jar tlc2.TLC celery_eta_visibility_timeout_idempotent.tla \
-config CeleryETA.cfgCreate CeleryETA.cfg:
SPECIFICATION Spec
CONSTANT MaxTasks = 2
INVARIANT InvariantNoDoubleExecution