jonnify·redis /redis-patterns/time-delay-priority/delayed-queue

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.

The schedule set — six jobs scored by run-at time, swept by the clock. Drag the clock or select a job to light it; the readout names the due head.
emq:{queue}:schedule — six jobs by run-at (ms) now
readout · Drag the clock or select a job.

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.

§1

Data model

delayed-queue.md.txt · echo_mq/keyspace.ex

The 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.

§2

Scheduling a task

delayed-queue.md.txt · jobs.ex @schedule

To 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.

§3

Polling for ready tasks

delayed-queue.md.txt · Polling

A 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.

§4

Claiming tasks atomically

delayed-queue.md.txt · Claiming

Many 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.

§5

The complete flow

delayed-queue.md.txt · jobs.ex @promote

The 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.

StepCommand
PollZRANGEBYSCORE schedule -inf <now> LIMIT 0 <batch>
ClaimZREM schedule <JOB id> per due id
PromoteZADD pending 0 <JOB id> · HSET … state pending
Servea 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.

§6

Avoiding busy waiting

delayed-queue.md.txt · metronome.ex

Polling 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.

§7

Combining with Lua

delayed-queue.md.txt · Combining

The 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.

§8

Retry, and when to use

delayed-queue.md.txt · jobs.ex retry/7

A 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 forReach elsewhere when
Scheduled / deferred work: send at a time, notify after a delay, off-peak handlingThe queue needs consumer groups or message acknowledgement — use Redis Streams
Retry with backoff: reschedule a failed task with a growing delayThe work needs replay of an acknowledged history — a stream’s log
Rate spacing: space calls over time by their fire-timeThe ordering axis is priority, not time — the prioritized set, a later module
the bus · codemojex defers a notification

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.

echo/apps/codemojex/lib/codemojex/notification_worker.ex · defer on the busverbatim
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.

§9

The three dives

score → promote → wake

Each 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 pattern

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.

its EchoMQ application

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.

§10

References

sources · related

Sources

  • 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: 1 means this caller won the task, 0 means another already did.

Related in this course