jonnifyredis

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 list to the in-flight list 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 list, and a crash there drops it. RPOPLPUSH and its successor LMOVE do the pop and the push as one atomic command. Toggle the move on the right between two steps and one, then crash at the gap and watch only the two-step form lose the job.

Two steps vs one · crash at the gap, job 7

emq:{queue}:wait — the gap — emq:{queue}:active — RPOP LPUSH crash in the gap — job 7 is in neither list two steps · job 7 lost

Two steps leave a gap

The 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 OOM 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 list
# ---- 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 list; a crash there loses it, and running faster cannot close the gap.

One command closes it

RPOPLPUSH 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 list.

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 and is what older protocols, EchoMQ included, run. Either way the guarantee is identical: the job is in exactly one list 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 of destination, atomically. The original move, fixed directions.
LMOVE
The Redis 6.2+ successor with explicit RIGHT/LEFT ends; 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

A worker with nothing to do can poll: run RPOPLPUSH 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 below and compare the round trips a poll loop makes against the single blocked call.

Poll vs block · round trips over an idle gap

4
round trips over the idle gap 4 wasted round trips poll · one RPOPLPUSH per second, each returns nil

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 command

EchoMQ's in-flight move is one command, not two, in real code in echo/apps/echomq. EchoMQ.Scripts.move_to_active/4 dispatches moveToActive-11.lua, whose move is rcall("RPOPLPUSH", waitKey, activeKey) — the RPOPLPUSH form, inside a larger Lua script that also sets the lock and writes an event, the whole thing atomic. The job leaves emq:{queue}:wait and lands in emq:{queue}:active with no gap.

-- moveToActive-11.lua — the move is RPOPLPUSH, not RPOP + LPUSH (real)
local waitKey = KEYS[1]      -- emq:{queue}:wait
local activeKey = KEYS[2]    -- emq:{queue}:active
local jobId = rcall("RPOPLPUSH", waitKey, activeKey)  -- one command, atomic

EchoMQ runs the move inside a Lua script rather than as a bare BLMOVE, because the move also has to set the job's lock and write to the event stream — work that has to be atomic with the pop-and-push, so it goes in one script. The bare RPOPLPUSH and BLMOVE are the same closing-the-gap move at the command level; the script is that move plus the lock and the event, all-or-nothing.

the pattern

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.

in EchoMQ

move_to_active/4 runs moveToActive-11.lua, whose move is rcall("RPOPLPUSH", waitKey, activeKey) — one command, inside one atomic script that also sets the lock.

Two commands leave a gap; one command closes it. EchoMQ's move is the single RPOPLPUSH inside moveToActive-11.lua.

Door · not depth Why the move lives in a Lua script and not a bare BLMOVE — the lock it sets, the event it writes, the rate-limit and delayed-set branches it folds in, the eleven keys it touches — is the dedicated EchoMQ course. From here, this dive opens onto E2 · the engine — the as-built moveToActive include graph. This dive teaches the atomic move; that course teaches the whole script.

References

The atomic move, its modern successor, and the blocking variant that lets a worker consume without polling.

Sources

Related in this course