R3.01 · Reliable Queues
Processing list
Guarantee at-least-once message delivery using LMOVE to atomically transfer messages to a processing list, enabling recovery if consumers crash before completing work.
A naive queue pops a message off with RPOP and is one crash away from
losing it: between the pop and the finish the message exists nowhere. This module is the move that prevents that.
Atomically transfer the message into a second, in-flight list, so it is in exactly one list at every instant and a
crashed worker leaves it parked, recoverable. The three dives take the move apart — the list as a queue, the
atomic transfer command, and the in-flight list as the recovery ledger.
The Problem with Simple Queues
A naive queue runs four steps. A producer pushes a message with
LPUSH queue "message". A consumer takes it with RPOP queue,
which removes it from Redis. The consumer then crashes during processing. The message is gone
permanently — not in the queue, not finished, with nothing to recover it from.
The flaw is the RPOP. It removes the message before any work has run, so between the
pop and the finish there is a window in which the message is in neither place. A deploy, an OOM kill, or a power
loss in that window drops the message. The toggle below runs the same pickup two ways — a plain
RPOP that loses the message on a crash, and the atomic move that does not — over a
fixed job set, and reports which list holds the job and whether it survives.
The in-flight move · job 7 from wait to active — RPOP loses it, RPOPLPUSH parks it
A simple queue loses a message on a crash because RPOP removes it before the
work is done; the cure is to move the message, not remove it.
The Solution: Atomic Transfer
Instead of removing the message, atomically move it to a "processing" list. The message stays in Redis until it
is explicitly deleted after successful processing. The transfer is one command —
RPOPLPUSH or the modern LMOVE — so there is no instant
at which the message is in neither list.
A crashed worker therefore leaves the message parked in the processing list, where it can be found and returned. The naive form removes the message and bets the worker survives; the atomic form moves the message and keeps it recoverable whether the worker survives or not. That single change — move, do not remove — is the whole pattern.
Move the message into a processing list rather than removing it from the queue; it stays in Redis, recoverable, until the work is acknowledged.
How It Works
The consumer uses LMOVE (or the older RPOPLPUSH) to atomically
transfer a message from the main queue to a processing queue. The one command removes the message from the right
of work_queue, adds it to the left of processing:worker1, and
does both atomically — no message loss is possible.
LMOVE work_queue processing:worker1 RIGHT LEFT # atomic dequeue into the processing list
The older RPOPLPUSH source dest is the same move in fixed directions:
RPOPLPUSH src dst is exactly LMOVE src dst RIGHT LEFT. After
successful processing, remove the message from the processing queue with
LREM processing:worker1 1 "message". For consumption without polling, the blocking form
BLMOVE work_queue processing:worker1 RIGHT LEFT 30 waits up to 30 seconds for a message
rather than returning immediately on an empty queue. The second dive walks RPOPLPUSH vs
LMOVE and the blocking variant.
LREM processing:worker1 1 "message" # the ack, after the work is truly done
One command — LMOVE or RPOPLPUSH — does
the pop and the push atomically; the ack is a separate LREM only after the work is done.
The Processing Lifecycle
A message moves through three steps. Dequeue:
LMOVE work_queue processing:worker1 RIGHT LEFT. Process: the
application handles the message. Acknowledge:
LREM processing:worker1 1 "message".
If the worker crashes between steps 1 and 3, the message remains in
processing:worker1. That parked message is the seam reliability rests on: it is still
in Redis, attributable to a worker, and returnable to the main queue for another attempt. Delivery is therefore
at-least-once — a message is delivered one or more times, never zero — which the
next module makes the consumer idempotent for, and the module after reclaims a stalled message with. This module
stops at the move; the parked message is the third dive.
| Step | Command |
|---|---|
| Dequeue | LMOVE work_queue processing:worker1 RIGHT LEFT |
| Process | the application handles the message |
| Acknowledge | LREM processing:worker1 1 "message" |
Dequeue, process, acknowledge — a crash between the dequeue and the ack leaves the message in the processing list, recoverable, which is what makes delivery at-least-once.
Per-Worker Processing Queues
Each worker should have its own processing queue — processing:worker1,
processing:worker2. A per-worker list lets a recovery monitor attribute a parked
message to the worker that holds it, and return it cleanly. The processing list is not only a buffer; it is the
recovery ledger, a list of exactly the messages currently in flight per worker.
Reading a worker's list answers what work that worker holds right now, without any extra bookkeeping. When a worker dies, a monitor reads its list, finds the parked messages, and returns them to the main queue. The third dive reads the in-flight list as that ledger and pairs it with the lock that marks the holding worker.
Give each worker its own processing list so a recovery monitor can attribute a parked message to the worker that holds it; the list is the recovery ledger.
The three dives
Each dive takes one layer of the move, in source order: the list as a queue underneath everything, the atomic transfer command that closes the gap, and the in-flight list read as the recovery ledger. Read them in order — the primitive first, the command second, the ledger third.
List as wait + active
The LIST as a queue: LPUSH at the head, RPOP at
the tail, FIFO. wait and active are two named lists; the job
moves between them.
LMOVE / RPOPLPUSH
The atomic transfer command: RPOP then LPUSH (a
gap that loses a job) vs RPOPLPUSH / LMOVE (one step); the
blocking BLMOVE.
The in-flight list
active as the recovery ledger: a parked job whose worker died is still
there; per-worker lists; the LREM active 1 job ack only after the work is done.
Move a message into a processing list in one atomic step, so it is in exactly one list at every instant and a crashed worker leaves it parked, recoverable, never lost.
move_to_active/4 runs moveToActive-11.lua, whose move is
rcall("RPOPLPUSH", waitKey, activeKey) —
emq:{queue}:wait → emq:{queue}:active under a lock,
the iconic Elixir worker path.
Redis offers one move that never loses a message; EchoMQ's queue chose it, and proves the pattern is real.
A door, not a depth
This module cites one excerpt of EchoMQ's protocol — the RPOPLPUSH inside
moveToActive-11.lua — as proof the move ships. The full worker fetch loop, the
eleven-key moveToActive include graph, the heartbeat that renews the lock, and the
polyglot concurrency models are the subject of the dedicated EchoMQ course, built next with
this toolkit. From here, open onto E2 · the engine. This module teaches the
move; that course teaches the engine that runs it.
After R3.01, the Reliable Queues chapter continues with at-least-once delivery and an idempotent consumer, then stalled recovery, closing with the EchoMQ worker-path workshop. Return to the Reliable Queues chapter to continue.
References
The Redis commands behind the atomic transfer — the list primitive, the move, its modern successor, and the ack — and where the worker path that runs them is taught.
Sources
- Redis — RPOPLPUSH — the original atomic move that parks a message in the in-flight list, the heart of
moveToActive-11.lua. - Redis — LMOVE — the modern successor (Redis 6.2+), with explicit
RIGHT/LEFTdirections, recommended for new code. - Redis — Lists — the list-as-queue primitive the whole move rests on.
- Salvatore Sanfilippo — RPOPLPUSH, the reliable queue pattern — the Redis creator's design note on the reliable-queue move.
- BullMQ — the queue protocol — the wait/active worker path EchoMQ ports, where the Lua scripts are the protocol.
Related in this course
- R3.01.1 · List as wait + active — the list as a queue.
- R3.01.2 · LMOVE / RPOPLPUSH — the atomic transfer command.
- R3.01.3 · The in-flight list — the recovery ledger.
- R3 · The reliable queue — the family in one place: the three guarantees.
- R3 · Reliable Queues — the chapter.
- R2.01 · Atomic updates — the atomic move this rests on.
- E2 · the engine — the dedicated EchoMQ course: the worker fetch loop in depth.