Redis Patterns/R4 · Time, Delay & Priority/The delayed queue
R4.01 · Time, Delay & Priority — module hub
The delayed queue.
Schedule tasks for future execution with a sorted set whose score is the time the task should run. Lists deliver FIFO but cannot defer; a sorted set gives time-ordering and a range query for the due head.
Grounding: echo/apps/echo_mq · EchoMQ.Jobs.enqueue_at/5 · enqueue_in/5 · promote/3 — the real emq:{queue}:schedule set, written and swept by two inline scripts on the server clock.
Static verdicts — a job whose run-at score is at or below the clock is due and sweeps to the pending set; the earliest job still in the future is the next-wake head; the schedule set orders by run-at, the pending set by the branded id's mint order.
Data model
delayed-queue.md.txt · echo_mq/keyspace.exThe structure is a single sorted set. Each member is a task identifier; each score is the time the task should run.
- Member — the task identifier or payload reference.
- Score — the timestamp at which the task should execute.
Tasks sort themselves by execution time, earliest first, with no extra index. The score is the whole
ordering: read the set low-to-high and the earliest-due task is at the head. In EchoMQ the set is
emq:{<queue>}:schedule (EchoMQ.Keyspace.queue_key(queue, "schedule")), and its
members are branded JOB ids.
A sorted set orders its members by run-at for free; the score is the whole index, and reading the head gives the earliest-due job.
Scheduling a task
delayed-queue.md.txt · jobs.ex @scheduleTo schedule a task to run at a future time, add it to the set with that time as the score. The payload can live separately, with the set holding only the reference — the set carries the schedule, a hash carries the data:
ZADD delayed_queue 1706649000 "task:abc123" # score = the run-at time
HSET task:abc123 type "send_email" recipient "user@example.com"
In EchoMQ both moves happen in one atomic script. EchoMQ.Jobs.enqueue_at/5 writes the job row
(HSET … state scheduled) and parks the id on the schedule set (ZADD schedule <run-at
ms> <JOB id>) in a single EVAL. The relative form enqueue_in/5
computes the run-at score wire-side from the server clock — score = now + delay where
now is read by TIME — so the delay is measured on the same clock the promote
and reap paths read, never the caller’s.
Notes on ValkeyFor a relative delay the run-at score is read off the
server clock inside the script: TIME returns the server’s seconds and microseconds, never the
caller’s, so a fast client cannot schedule a job into the wrong window —
valkey.io/commands/time.
Polling for ready tasks
delayed-queue.md.txt · PollingA worker reads the tasks whose time has passed with 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 1706648500 LIMIT 0 10 # the due head, up to ten
The -inf lower bound means “no floor” — from the start of the set up to the
clock. The LIMIT 0 10 caps the batch so one poll claims a bounded slice. The first dive scores the
jobs this poll reads; the second dive runs the sweep that claims them.
The poll is a range query bounded by the clock: ZRANGEBYSCORE -inf now returns the
due head, capped by LIMIT.
Claiming tasks atomically
delayed-queue.md.txt · ClaimingMany workers may poll the same set at once, so a worker must claim each task atomically before processing
it, or two workers process the same task. The claim is a ZREM:
ZREM delayed_queue "task:abc123" # 1 if this caller won the claim, 0 if already gone
ZREM returns 1 when this call removed the member — the caller won the claim
— or 0 when another worker already removed it. A worker processes a task only when its
ZREM returns 1. The removal is the claim; nothing else marks ownership.
The ZREM is the claim: a 1 means this worker owns the task, a
0 means another already took it.
The complete flow
delayed-queue.md.txt · jobs.ex @promoteThe four steps are poll, claim, process, clean up — a range query to read the due head, an atomic
ZREM per task, the work, and a delete of the payload.
| Step | Command |
|---|---|
| Poll | ZRANGEBYSCORE schedule -inf <now> LIMIT 0 <batch> |
| Claim | ZREM schedule <JOB id> per due id |
| Promote | ZADD pending 0 <JOB id> · HSET … state pending |
| Serve | a consumer claims the promoted job off the pending set |
In EchoMQ the poll and the claim are one server-side step. EchoMQ.Jobs.promote/3 runs the
@promote script: it reads the due range with ZRANGEBYSCORE schedule -inf <now>
against the server’s own TIME, then for each due id ZREMs it from the schedule
set and ZADDs it onto the pending set at score 0. No second worker can interleave
between the range and the move.
Poll, claim, promote, serve — the range reads the head, the ZREM claims each,
and the ZADD moves it onto the pending set, all in one atomic script.
Avoiding busy waiting
delayed-queue.md.txt · metronome.exPolling an empty set on a fixed tick wastes work. The avoidance is to read the head and wait until it is due:
ZRANGE delayed_queue 0 0 WITHSCORES # the earliest task and its scheduled time
When nothing is due yet, a worker waits until that time, or a bounded maximum interval, which cuts polling
overhead sharply when the set is empty or the next task is far out. EchoMQ holds one block per queue in
EchoMQ.Metronome — a single BLPOP emq:{<queue>}:wake <beat> that an
admit’s LPUSH releases — and runs the promote sweep on each beat. The third dive reads
that wake.
The head’s score is the next wake; holding one block instead of polling on a tick makes an idle set nearly free.
Combining with Lua
delayed-queue.md.txt · CombiningThe poll and the claim are two commands, and between them another worker can claim the same task. To make
poll-and-claim one atomic step, run both inside a Lua script — a range query, then a ZREM
per member, in one round trip:
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 finds and claims the ready tasks in a single round trip; no second worker can interleave between
the range and the removal. This is the shape EchoMQ’s promote sweep takes — a
ZRANGEBYSCORE, a ZREM per due id, and a move onto the pending set, all inside one
inline @promote script. The second dive walks the real sweep it becomes.
A Lua script folds the range and the ZREM into one atomic poll-and-claim, so no
worker interleaves between reading the due head and removing it.
Retry, and when to use
delayed-queue.md.txt · jobs.ex retry/7A failed task reschedules itself by re-adding with a future score — a retry is a delayed task like any
other. The delay can grow with the attempt count, but that math is a module of its own:
R4.04 · Backoff and retry owns the formula. EchoMQ’s own retry path re-uses this
same schedule set: EchoMQ.Jobs.retry/7 parks a failed job back on schedule at
now + delay, the very transition the promote sweep later releases. This module schedules the
deferred run; the formula that chooses the delay is the next reading of the chapter.
The sorted-set delayed queue fits scheduled work where the ordering axis is time and the read is a due-range: scheduled and deferred work, retry with backoff, and rate spacing. It fits less well where the queue needs consumer groups, message acknowledgement, or replay — those are Redis Streams territory.
| Use it for | Reach elsewhere when |
|---|---|
| Scheduled / deferred work: send at a time, notify after a delay, off-peak handling | The queue needs consumer groups or message acknowledgement — use Redis Streams |
| Retry with backoff: reschedule a failed task with a growing delay | The work needs replay of an acknowledged history — a stream’s log |
| Rate spacing: space calls over time by their fire-time | The ordering axis is priority, not time — the prioritized set, a later module |
A real deferred job: the rate-limited notification
The schedule set, the run-at score, and the promote sweep are not aspirations —
codemojex’s notification worker uses them in production. When a notification is over its
rate budget, Codemojex.NotificationWorker.requeue/2 mints a NOT-branded id and calls
EchoMQ.Jobs.enqueue_in/5 to re-schedule the same notice on the bus after the bucket’s
reported wait — deferred, not dropped, and durable across a crash. The same move backs a transient
delivery failure with capped exponential backoff. The full scheduler subsystem — the metronome, the
promote pump, the lifecycle controls — is the dedicated EchoMQ Queue course.
defp requeue(msg, delay_ms) do
job_id = EchoData.BrandedId.generate!("NOT")
payload = Jason.encode!(msg)
Jobs.enqueue_in(Bus.conn(), @queue, job_id, payload, max(delay_ms, 1)) ← deferred, durable on the bus
end
Reach for the delayed queue when the ordering axis is time and the read is a due-range; reach for a stream when the queue needs groups, acknowledgement, or replay.
The three dives
score → promote → wakeEach dive takes one layer of the delayed queue, building on the source spine and adding the EchoMQ refinement on top: the score that orders the set, the sweep that promotes the due head, and the wake that avoids polling an empty set. Read them in order — the score first, the sweep second, the wake third.
The score is the fire-time
The run-at score: enqueue_at/5 scores by the caller’s absolute ms,
enqueue_in/5 by now + delay on the server clock; the mint-ordered JOB
id is the sort key once promoted.
ZRANGEBYSCORE promotion
The due-sweep: ZRANGEBYSCORE schedule -inf now, ZREM each due id, and
ZADD it onto the pending set — EchoMQ’s @promote script, on the server
clock.
The next wake
Not busy-polling an empty set: the metronome holds one BLPOP emq:{q}:wake <beat>
per queue, and an admit’s LPUSH on the wake token runs promotion on demand.
A delayed queue scores each task by the timestamp at which it should run, and reads the due head with a range query bounded by the current clock.
enqueue_at/5 / enqueue_in/5 park a branded JOB id on
emq:{<queue>}:schedule at its run-at ms; promote/3 runs
ZRANGEBYSCORE schedule -inf now, ZREMs each due id, and ZADDs it onto
the pending set — the real schedule set and due-range sweep, on the server clock.
A delayed queue is a sorted set scored by run-at time; EchoMQ schedules and promotes it with two inline scripts on the server clock, and the promoted job inherits the mint order of its branded id.
After R4.01, the chapter continues with schedulers, composite priority scores, backoff and retry, and leaderboards, closing with the EchoMQ scheduler workshop. Return to the Time, Delay & Priority chapter to continue, or open the orientation dive for the score as meaning.
References
sources · relatedSources
- Redis — Sorted sets — the sorted set as a timer wheel: members ordered by a numeric score, read by score range.
- Valkey — ZADD — score a member by its fire-time; the single write that parks a delayed task on the schedule set.
- Redis — ZRANGEBYSCORE — the range query that reads the due head, the read inside the promote sweep.
- Redis — ZREM — the atomic claim:
1means this caller won the task,0means another already did.
Related in this course
- R4.01.1 · The score is the fire-timethe run-at score, the mint-ordered id
- R4.01.2 · ZRANGEBYSCORE promotionthe due-sweep onto pending
- R4.01.3 · The next wakethe metronome beat
- R4 · Time, Delay & Prioritythe chapter
- R4 · The sorted set as a clockthe orientation dive
- R3 · Reliable Queuesthe pending set the job feeds
- /echomq · Queuethe schedule set and promote pump in depth