R4.01.2 · dive 2
ZRANGEBYSCORE promotion
The due-sweep: range the delayed set from 0 to now, claim each member
with ZREM, and move it to the wait list.
A delayed job scored by its fire-time sits in the set until its time comes; something has to
notice it is due and move it to where workers fetch. That move is the sweep — a range query for everything
at or below the current clock, an atomic ZREM per due member, and a push to the wait
list. EchoMQ folds it into the scripts the workers already run. Step the clock on the right to sweep the due head
and watch the set shrink.
Step the clock, sweep the due head · ZRANGEBYSCORE 0 → now, then ZREM
The due-range read
The read is a range query bounded above by the current clock. Every member from the start of the set up to now is due.
ZRANGEBYSCORE delayed_queue -inf <now> LIMIT 0 10 # the due head, earliest first
-inf is "no floor" — start from the lowest score. The upper bound is the current
time. LIMIT 0 10 caps the batch to ten so one sweep claims a bounded slice and a
backlog drains over several sweeps rather than one giant move. The result is the due head: the members whose
fire-time has passed, earliest first. The range is a read only — it names the due jobs but does not remove
them. Between the read and the removal another worker can read the same members, which is why the claim must be
atomic and separate.
The due-range read is ZRANGEBYSCORE -inf now: it names the members whose
fire-time has passed, earliest first, capped by LIMIT — a read, not a claim.
The atomic claim
Each due member is claimed with a ZREM. The removal is the claim.
ZREM delayed_queue "task:abc123" # 1 if this caller promoted it, 0 if another already did
ZREM returns 1 when this call removed the member — the
caller owns the task — or 0 when another worker already removed it. A worker
proceeds with a task only when its ZREM returns 1. With many
workers sweeping the same set, the range may hand the same member to several of them, but only one
ZREM returns 1; the rest get 0 and drop
it. The removal establishes the owner, atomically, with no extra lock.
Range the set to read the head, then ZREM each member to claim it; the
removal is the claim, so only one worker promotes each due job.
Folding it into one script
The range and the ZREM are two commands, and between them a second worker can
interleave. To make poll-and-claim a single atomic step, run both inside a Lua script.
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) for i, task in ipairs(tasks) do redis.call('ZREM', KEYS[1], task) end return tasks
The script reads the due head and removes every member in one round trip; no other worker can claim a job
between the range and the ZREM. This is the shape EchoMQ's sweep takes — a range, a
ZREM, and a move to the target list, all inside one script, on the worker path.
A Lua script folds the range and the ZREM into one atomic step, so no second
worker interleaves between reading the due head and removing it.
In EchoMQ — promoteDelayedJobs, in real code
In EchoMQ the sweep is promoteDelayedJobs, an included function — an identical
copy compiled into moveToActive-11.lua, retryJob-11.lua, and
moveToFinished-14.lua. It is not a standalone script and is never called as one; it
runs as part of the scripts the workers already execute, so a due delayed job is promoted on the path of normal
work, not by a separate poller. Its body is the range-then-claim, with the shifted upper bound the fire-time
score requires.
-- promoteDelayedJobs (included in moveToActive-11 / retryJob-11 / moveToFinished-14) — the due-sweep (real) local jobs = rcall("ZRANGEBYSCORE", delayedKey, 0, (timestamp + 1) * 0x1000 - 1, "LIMIT", 0, 1000) if #jobs > 0 then rcall("ZREM", delayedKey, unpack(jobs)) -- claim the whole due head atomically for _, jobId in ipairs(jobs) do -- priority 0 → LPUSH onto the wait list; priority > 0 → ZADD onto the prioritized set end end
The upper bound is (timestamp + 1) × 0x1000 - 1, not
timestamp, because the score is the fire-time shifted twelve bits: that bound is the top
of the current millisecond's band, so the range catches every job due in this millisecond including its
within-millisecond tie-breaks. The LIMIT 0 1000 caps each sweep at a thousand jobs. Each
promoted job goes to the wait list when its priority is 0, or onto the prioritized set
when it carries a priority — the priority reading a later module takes up. A distinct script promotes a
single named job on demand: EchoMQ.Scripts.promote/3 runs
promote-9.lua, which ZREMs one job from the delayed set
(KEYS[1]) and moves it to the wait list (KEYS[2]). The bulk
sweep moves the whole due head by score; promote-9.lua moves one job by name, now.
The sweep bound · (now + 1) × 0x1000 - 1, capped by LIMIT 0 1000
Sweep the delayed set by reading the due head with a range query and claiming each member with an atomic
ZREM, then move it to the queue workers fetch from.
promoteDelayedJobs is an included function in moveToActive-11.lua,
retryJob-11.lua, and moveToFinished-14.lua — it runs
ZRANGEBYSCORE delayedKey 0 (timestamp + 1) × 0x1000 - 1 LIMIT 0 1000,
ZREMs the due head, and moves each to wait or prioritized;
EchoMQ.Scripts.promote/3 promotes one named job on demand.
The due-sweep is a range to read the head and a ZREM to claim it; EchoMQ
folds it into the worker scripts as promoteDelayedJobs, so a delayed job is promoted on
the path of normal work, never by a separate poller.
References
The range query that reads the due head, the ZREM that claims each member, the sorted set behind
the sweep, and the queue protocol EchoMQ ports.
Sources
- Redis — ZRANGEBYSCORE — read the due head: every member from the start of the set up to the current clock.
- Redis — ZREM — the atomic claim:
1means this caller promoted the job,0means another already did. - Redis — Sorted sets — the data type the sweep ranges over, ordered by fire-time score.
- BullMQ — the queue protocol — the delayed-promotion sweep EchoMQ ports, where the Lua scripts are the protocol.
Related in this course
- R4.01 · The delayed queue — the module hub.
- R4.01.1 · The score is the fire-time — the fire-time score the sweep ranges over.
- R4.01.3 · The next wake — not sweeping an empty set, waking on the marker instead.
- R3.01 · The processing list — the wait/active lists the swept job lands in.
- R4 · Time, Delay & Priority — the chapter.
- E6 · Lifecycle controls — the dedicated EchoMQ course: the promote and retry scheduler in depth.