jonnifyredis

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

emq:{queue}:wait (LIST) 7 worker — emq:{queue}:active (LIST) — RPOPLPUSH wait active RPOPLPUSH parked job 7 in active under a lock crash · job 7 still in active → recoverable

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.

StepCommand
DequeueLMOVE work_queue processing:worker1 RIGHT LEFT
Processthe application handles the message
AcknowledgeLREM 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.

the pattern

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.

in EchoMQ

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

Related in this course