jonnifyredis
Redis Patterns / R3 · Reliable Queues / The reliable queue

R3 · dive 1

The reliable queue

A reliable queue loses no job. The naive loop — pop, process, done — drops a job the instant a worker dies between the pop and the finish.

Three guarantees build a queue that survives a crash, and they build in order: move a job into an in-flight list atomically, so a worker that dies leaves it recoverable, not lost; accept that redelivery means at-least-once, so the consumer must be idempotent; and reclaim a job whose worker died — its lock expired — back to the waiting list. Toggle the worker on the right to crash mid-job, and watch the job stay parked in active until the stalled sweep returns it to wait. Each guarantee is EchoMQ's real worker path, below.

The in-flight move · job 7 across wait, active, done — and back

emq:{q}:wait (LIST) — emq:{q}:active (LIST) — finished — RPOPLPUSH LREM moveStalledJobsToWait RPOPLPUSH parked job 7 in active under a lock finish · LREM removes job 7 → done

The in-flight move

RPOP and process is the leak. The job leaves the queue the moment it is popped, before any work has happened; if the worker crashes mid-job — a deploy, an OOM kill, a power loss — the job is neither in Redis nor finished. It is lost, with nothing to recover it from.

The fix is to never let a job leave Redis until it is done. Instead of popping the job out, move it into a second list — an in-flight list — in one atomic step. RPOPLPUSH source dest (and its modern successor LMOVE source dest RIGHT LEFT) pops from one list and pushes to another as a single command: the job is in exactly one of the two lists at every instant, never in neither. A job parked in the in-flight list whose worker has died is still there — it can be found and returned for redelivery.

# the leak: a window where the job is in neither list
job = RPOP queue            # job is now out of Redis...
process(job)                # ...if the worker dies HERE, the job is gone

# the fix: the job is always in exactly one list
job = RPOPLPUSH queue processing   # atomic: leaves `queue`, lands in `processing`
process(job)                       # a crash here leaves the job in `processing`
LREM processing 1 job              # remove only after the work is truly done

RPOPLPUSH is the original move command; LMOVE (Redis 6.2+) generalises it with explicit directions and is the recommended form for new code. Either way the guarantee is the same: there is no instant at which the job exists nowhere.

in-flight list
A second list a job is moved into for processing — EchoMQ's emq:{queue}:active. A crashed worker leaves the job here, recoverable, not lost.
RPOPLPUSH
Pop from the tail of one list, push to the head of another, atomically. The job is in exactly one of the two lists at every instant.
LMOVE
The Redis 6.2+ successor with explicit RIGHT/LEFT directions; the recommended form for new code.

Never let a job leave Redis until it is finished: move it into an in-flight list in one atomic step, so a crash leaves it parked and recoverable, never in neither list.

At-least-once and idempotency

The in-flight move makes redelivery possible — that is the whole point. But it also makes redelivery inevitable in one case: a worker that finishes the work, then crashes before it can remove the job from the in-flight list. On recovery the job is still in-flight, so it is redelivered and the work runs again.

This is the honest cost of never losing a job. Exactly-once delivery is a lie — you cannot both guarantee a job is never lost and guarantee it is never delivered twice, because the acknowledgement itself can be lost. What a reliable queue offers is at-least-once delivery: a job is delivered one or more times, never zero. The responsibility moves to the consumer, which must be idempotent — running the same job twice must produce the same effect as running it once.

The discipline is to make the effect exactly-once even though the delivery is at-least-once. The standard move is a dedup marker keyed by a stable job id: before the effect runs, claim the marker; if it already exists, the effect has already happened, so skip it.

# at-least-once delivery + an idempotent effect = exactly-once effect
if SET dedup:{job_id} 1 NX:          # claim the marker; NX = only if absent
    apply_effect(job)                # first delivery: charge the card once
else:
    skip()                           # a redelivery: marker is set, do nothing

A non-idempotent consumer double-charges on every redelivery; an idempotent one charges once, no matter how many times the job arrives. The marker is the difference: the second delivery finds it set and skips the effect. Toggle the marker off below and slide the delivery count up to watch the effects multiply.

At-least-once vs effect-once · N deliveries, the de:{id} marker, how many effects

2
deliveries deliver 1 effect deliver 2 effect deliver 3 effect 2 deliveries → 1 effect de:{id} marker absorbs the redeliveries · card charged once

Exactly-once delivery is a lie; a reliable queue is at-least-once. A dedup marker keyed by the job id makes the effect happen once across every redelivery.

Stalled recovery

A job in the in-flight list whose worker has died must come back. The signal that a worker died is a lock that expired: each in-flight job holds a lock with a TTL, and a healthy worker renews it on a heartbeat. When the worker crashes, the heartbeat stops, the lock expires, and the job is now stuck in-flight with no live worker.

A separate recovery sweep finds these jobs and returns them to wait for another worker. It checks the in-flight list, and for each job whose lock has expired, moves it back to the waiting list. The job re-enters the normal pickup path and is delivered again — at-least-once in action — to a worker that can finish it. A job is only truly lost if both the recovery sweep and every worker fail at once, which is the failure the lock TTL plus the sweep are designed to bound.

The recovery sweep must itself be careful. If it runs the check and the move as two separate steps, two recovery processes can both see the same stalled job and both move it, redelivering it twice over. The atomic form runs the detect-and-move as one step — one Lua script over the in-flight list, the lock, and the waiting list — so each stalled job is reclaimed once per sweep. The non-atomic form, where the check and the move are separate round trips, is the cautionary contrast: correct only until two sweeps overlap.

detect

A stalled job is one in the in-flight list whose lock has expired — the heartbeat stopped, so no live worker holds it. The expired lock is the death signal.

then reclaim, atomically

Move it back to wait in one Lua step over the in-flight list, the lock, and the waiting list — so two overlapping sweeps cannot redeliver the same job twice.

A job whose worker died is one in the in-flight list with an expired lock; an atomic detect-and-move sweep returns it to wait, so the job is reclaimed, not abandoned.

In EchoMQ — the worker path that loses no job

EchoMQ's worker fetch loop is all three guarantees in real code, in echo/apps/echomq. The in-flight move is moveToActive-11.lua: its body runs rcall("RPOPLPUSH", waitKey, activeKey), where waitKey is emq:{queue}:wait and activeKey is emq:{queue}:active (EchoMQ.Keys.wait/1 and EchoMQ.Keys.active/1). The job leaves wait and lands in active under a lock in one atomic script — a crash leaves it in active, recoverable, never lost.

-- moveToActive-11.lua — the atomic in-flight move (real)
local waitKey = KEYS[1]      -- emq:{queue}:wait
local activeKey = KEYS[2]    -- emq:{queue}:active
local jobId = rcall("RPOPLPUSH", waitKey, activeKey)  -- wait -> active, atomically

At-least-once with an idempotent effect is the dedup marker EchoMQ.Keys.dedup/2, which builds emq:{queue}:de:{id} ("#{base(ctx)}:de:#{dedup_id}"). A job carrying a deduplication id is dropped if the marker already exists, so the effect runs once across redeliveries. Stalled recovery is moveStalledJobsToWait-8.lua: it operates over the stalled, wait, and active keys, and for each job in active whose lock has expired it moves the job back to wait for another worker. The Go port's non-atomic stalled path — a separate check followed by a separate move — is the cautionary contrast to this atomic form, not the recommended one.

the pattern

A reliable queue moves a job into an in-flight list under a lock, redelivers it at-least-once to an idempotent consumer, and reclaims a crashed job whose lock expired.

in EchoMQ

moveToActive-11.lua's RPOPLPUSH wait→active, EchoMQ.Keys.dedup/2's emq:{queue}:de:{id} marker, and moveStalledJobsToWait-8.lua — the iconic Elixir worker path.

The naive pop-and-process loses a job on a crash; EchoMQ's atomic move into active, its de:{id} dedup marker, and its stalled sweep are the three guarantees that make the queue lose none.

Door · not depth The full worker fetch loop — the heartbeat manager that renews the lock, the stalled-check coordination across a whole worker pool, and the polyglot concurrency models — is the dedicated EchoMQ course, a living agile-spec course that teaches the protocol in depth. From here, R3 opens onto E2 · The lifecycle, components & runtimes — the as-built library and the worker fetch loop. This dive teaches the reliable-queue pattern; that course teaches the engine that runs it.

References

Where the atomic move that parks a job in the in-flight list is specified, its modern successor, the scripting that runs the multi-key reliable move as one step, and the queue protocol EchoMQ ports.

Sources

  • Redis — RPOPLPUSH — the original atomic move that parks a job in the in-flight list, the heart of moveToActive-11.lua.
  • Redis — LMOVE — the modern successor (Redis 6.2+), with explicit RIGHT/LEFT directions, recommended for new code.
  • Redis — EVAL / scripting — how the multi-key reliable move and the stalled sweep run as one atomic Lua script.
  • BullMQ — the queue protocol — the wait/active/stalled worker path EchoMQ ports, where the Lua scripts are the protocol.

Related in this course