R3.01.2 · dive 2
LMOVE / RPOPLPUSH.
Pop, then push, is two commands with a gap in the middle. One command — RPOPLPUSH, or the modern LMOVE — closes the gap.
Moving a job from the waiting queue to the in-flight place is one logical step, so it must be
one Redis command. Done as two — RPOP, then LPUSH — it has a window in
the middle where the job is in neither place, and a crash there drops it. RPOPLPUSH and its
successor LMOVE do the pop and the push as one atomic command. Select the move between two steps
and one, then read which form survives a crash at the gap.
Two steps leave a gap
reliable-queue.md.txt · the gapThe obvious way to take a job is to pop it from the waiting list and push it to the in-flight list. Between the
RPOP and the LPUSH, the job exists nowhere: it has left wait and has not
yet reached active. A crash in that window — a deploy, an out-of-memory kill, a dropped
connection — drops the job with nothing to recover it from.
# two commands: a gap in the middle job = RPOP wait # job leaves wait — it is now in neither place # ---- if the worker dies HERE, the job is gone ---- LPUSH active job # job lands in active
The window is small, but a busy queue runs the move millions of times, and small windows fire. Two commands cannot be made atomic by running them quickly; the only fix is to make the move a single command.
Pop-then-push is two commands with a gap in which the job is in neither place; a crash there loses it, and running faster cannot close the gap.
One command closes it
reliable-queue.md.txt · the atomic transferRPOPLPUSH source destination pops from the tail of source and pushes to the head of
destination as a single atomic command. The job leaves wait and lands in
active with no instant in between in which it is in neither place.
job = RPOPLPUSH wait active # atomic: pop tail of wait, push head of active process(job) # a crash here leaves the job parked in active LREM active 1 job # remove only after the work is truly done
LMOVE source destination RIGHT LEFT (Redis 6.2+) is the same move with explicit directions.
RPOPLPUSH src dst is exactly LMOVE src dst RIGHT LEFT — pop the right (tail),
push the left (head). LMOVE generalises it to any pair of ends and is the recommended form for new
code; RPOPLPUSH remains for compatibility. Either way the guarantee is identical: the job is in
exactly one place at every instant.
RPOPLPUSH src dst == LMOVE src dst RIGHT LEFT
LMOVE src dst LEFT RIGHT # pop the head of src, push the tail of dst
- RPOPLPUSH
- Pop the tail of
source, push the head ofdestination, atomically. The original move, fixed directions. - LMOVE
- The Redis 6.2+ successor with explicit
RIGHT/LEFTends;RPOPLPUSH src dst==LMOVE src dst RIGHT LEFT. The recommended form for new code.
One command — RPOPLPUSH, or LMOVE — does the pop and the push
atomically, so there is no instant at which the job exists nowhere.
The blocking variant — block, do not poll
reliable-queue.md.txt · consumer.exA worker with nothing to do can poll: run the take in a loop, get nil on an empty queue, sleep, try again.
Polling wastes round trips when the queue is idle and adds latency between a job arriving and a worker taking
it. The blocking variants remove the loop. BLMOVE source destination RIGHT LEFT timeout (and the
older BRPOPLPUSH source destination timeout) do the same atomic move, but on an empty source they
block the connection for up to timeout seconds, waking the instant a job is pushed.
BRPOPLPUSH wait active 30 # block up to 30s; wake the moment a job arrives BLMOVE wait active RIGHT LEFT 30 # the modern form, same blocking move
A timeout of 0 blocks indefinitely. The block is server-side: no round trips while waiting, and the worker takes the job with no polling delay. The move is still atomic — blocking changes only when the command returns, not what it does. Slide the queue's idle gap and compare the round trips a poll loop makes against the single blocked call.
A poll loop makes one wasted round trip per idle second; a blocking BLMOVE makes one
call and waits, the same atomic move with no polling delay.
In EchoMQ — the move is one script
echo_mq/jobs.ex · consumer.exEchoMQ's in-flight move is one indivisible step, not two, in real code in echo/apps/echo_mq.
EchoMQ.Jobs.claim/3 runs one inline EchoMQ.Script.new(:claim, …) script:
ZPOPMIN emq:{queue}:pending takes the oldest id, HINCRBY increments the row's
attempts (the fencing token), HSET marks the row state = active, and
ZADD emq:{queue}:active records the id at the lease deadline — all inside one EVALSHA, so
there is no instant at which the id is in neither set. The script is loaded once and run EVALSHA-first; a
NOSCRIPT reply re-loads it.
-- the @claim script (echo/apps/echo_mq/lib/echo_mq/jobs.ex) — pop-and-record, one EVAL local popped = redis.call('ZPOPMIN', KEYS[1]) -- KEYS[1] = emq:{queue}:pending if #popped == 0 then return {} end local id = popped[1] local att = redis.call('HINCRBY', jk, 'attempts', 1) -- the fencing token redis.call('HSET', jk, 'state', 'active') redis.call('ZADD', KEYS[2], now + tonumber(ARGV[2]), id) -- KEYS[2] = emq:{queue}:active, at the lease deadline
EchoMQ runs the move inside one script rather than a bare BLMOVE because the take also has to mint
the lease and the fencing token — work that has to be atomic with the pop-and-record, so it goes in one
script. The consumer follows the blocking-variant discipline: EchoMQ.Consumer parks on
BLPOP emq:{queue}:wake and a producer pushes the wake token on enqueue, so an idle worker costs
the wire nothing and wakes on arrival. The bare RPOPLPUSH and BLMOVE are the same
closing-the-gap move at the command level; the @claim script is that move plus the lease, all or
nothing.
Notes on ValkeyZPOPMIN pops the lowest-scored member of a
sorted set atomically — the take inside EchoMQ's @claim script, the sorted-set form of the
atomic transfer —
valkey.io/commands/zpopmin.
The move from waiting to in-flight is one logical step and must be one Redis command;
RPOPLPUSH / LMOVE closes the gap a pop-then-push leaves.
EchoMQ.Jobs.claim/3 runs one inline @claim script — ZPOPMIN the
pending set, then ZADD the active set — one EVALSHA, no gap, and the consumer parks on
BLPOP instead of polling.
Two commands leave a gap; one command closes it. EchoMQ's move is the single ZPOPMIN
inside the @claim script, run as one EVALSHA.
A door, not a depth
Why the move lives in a Lua script and not a bare BLMOVE — the lease it mints, the fencing
token it carries, the EVALSHA-first dispatch, and the parked BLPOP wake the consumer rides
— is the dedicated EchoMQ course. From here, this dive opens onto
the Queue pillar — the as-built claim and its EVAL path. This dive teaches
the atomic move; that course teaches the whole script.
References
sources · relatedSources
- Redis — RPOPLPUSH — the original atomic move: pop the tail of source, push the head of destination, in one command.
- Redis — LMOVE — the modern successor (Redis 6.2+), explicit
RIGHT/LEFT, recommended for new code. - Redis — BLMOVE — the blocking variant: the same atomic move, block on an empty source instead of polling.
- Valkey — ZPOPMIN — pop the lowest-scored member of a sorted set atomically, the take inside EchoMQ's leased claim.
Related in this course
- R3.01 · Processing listthe module hub: the in-flight move
- R3.01.1 · List as wait + activethe two named places the command moves a job between
- R3.01.3 · The in-flight listwhat the atomic move enables: a recoverable parked job
- R2.01 · Atomic updatesthe broader atomic read-modify-write family
- /echomq · Queuethe worker fetch loop in depth