jonnifyredis
Redis Patterns / R4 · Time, Delay & Priority / The sorted set as a clock

R4 · orientation

The sorted set as a clock

One data structure, read two ways. Score a job by a future time and the sorted set is a timer wheel; score it by a packed priority and the same set is a priority ladder.

A sorted set is a member, a numeric score, and the ordering the score implies. The member stays the job id and the read commands — ZADD, ZRANGEBYSCORE, ZPOPMIN — never change. Only the meaning assigned to the score moves: a fire-time makes a delayed queue, a packed priority makes a priority queue. Slide the clock on the right and watch the range query pull out exactly the jobs whose time has come; the second reading, and what EchoMQ really stores, follow below.

The delayed-queue simulator · a moving clock over six jobs scored by fire-time

+0s
emq:{q}:delayed · score = fire-time (s) a+5s b+12s c+30s d+45s e+70s f+95s now +0s ZRANGEBYSCORE delayed 0 now · due jobs ZRANGE delayed 0 0 · the next wake

The textbook delayed queue

The classic delayed-queue pattern scores each task by the Unix timestamp at which it should run, and stores it in a sorted set. The tasks order themselves by execution time, earliest first, with no extra bookkeeping: the member is the task id, the score is the timestamp it is due.

A worker polls for tasks whose time has passed with a range query bounded above by the current clock. ZRANGEBYSCORE delayed_queue -inf <now> LIMIT 0 10 returns up to ten tasks with scores at or below now; the -inf lower bound means no floor. Because many workers may poll the same set at once, a worker claims each task atomically before processing it. 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 on a 1.

# schedule a task for a future time
ZADD delayed_queue 1706649000 "task:abc123"   # score = the Unix timestamp it is due

# poll the due head, then claim each atomically
ZRANGEBYSCORE delayed_queue -inf now LIMIT 0 10 # up to 10 tasks at or below now
ZREM delayed_queue "task:abc123"           # 1 = this worker claimed it; 0 = lost the race

Constant polling of an empty set wastes work. The avoidance is to read the head and sleep until it is due: ZRANGE delayed_queue 0 0 WITHSCORES returns the earliest task and its scheduled time, so a worker sleeps until that time (or a bounded maximum) instead of spinning. A failed task reschedules itself by re-adding with a future score — a retry is a delayed task like any other. Slide the clock in the figure above: every job at or left of the now line is in the ZRANGEBYSCORE 0 now due set, and the first job to its right is the next wake the head returns.

member
The job id stored in the set — constant across both readings of the score.
score
The fire-time, as a Unix timestamp; the set orders by it, earliest first.
ZRANGEBYSCORE 0 now
The range query that returns the due head — every job whose fire-time is at or below the current clock.
ZREM
The atomic claim: returns 1 to the one caller that removed the member, 0 to a caller that lost the race.

Score a job by its fire-time and a range query bounded by the current clock returns exactly the jobs whose time has come; reading the head gives the next wake, so a worker sleeps instead of spinning.

The second reading: a priority ladder

The same set shape carries a second pattern. Leave the member as the job id, but score it by a composite of two numbers packed into one: a priority tier in the high bits and an arrival counter in the low bits, so the score is priority × 0x100000000 + arrival. Now the sorted set is a priority queue: the smallest score is the most important job, and within one tier the smallest score is the earliest arrival. A pop of the smallest, ZPOPMIN, returns the next job in priority-then-FIFO order.

Packing two fields into one score is what lets a single read command do the ordering. The priority tier occupies the high 32 bits, so any job in a more important tier sorts ahead of every job in a less important one. The arrival counter occupies the low 32 bits, so two jobs in the same tier break their tie by arrival order — first in, first out, inside the tier. One number, two meanings, read by one ZPOPMIN. Toggle the reading below: the same six jobs reorder, because the score is computed and read differently, while the structure underneath stays one sorted set.

# the composite priority score — high bits = tier, low bits = arrival
score = tier * 0x100000000 + arrival   # 0x100000000 = 2^32
ZADD prioritized score jobId          # same insert as the delayed set
ZPOPMIN prioritized                  # smallest score = top tier, oldest-first within it

Two readings of one set · the same six jobs, ordered by fire-time or by composite priority

emq:{q}:delayed · ordered by fire-time read: ZRANGEBYSCORE #1job:a— #2job:b— #3job:c— #4job:d— #5job:e— #6job:f—

The member is constant and the commands are a fixed vocabulary; the score is the entire semantic axis. Read it as a fire-time for a timer wheel, as a packed priority for a priority ladder — one structure, two queues.

What EchoMQ really stores

The textbook pattern scores by a raw Unix timestamp. EchoMQ keeps the structure and sharpens the score. Its delayed set is EchoMQ.Keys.delayed/1 → emq:{queue}:delayed, scored by (timestamp + delay) × 0x1000 — the fire-time millisecond shifted left twelve bits so the low twelve bits can pack an ordering discriminator within a single millisecond (addDelayedJob-6.lua's getDelayedScore, minScore = delayedTimestamp * 0x1000). The recovery is exact, not lossy: getNextDelayedTimestamp reads the head and returns nextTimestamp / 0x1000, dividing the twelve-bit shift back out to recover the real millisecond.

-- addDelayedJob-6.lua — getDelayedScore (real)
local delayedTimestamp = timestamp + delay
local minScore = delayedTimestamp * 0x1000   -- ms shifted left 12 bits
rcall("ZADD", delayedKey, minScore, jobId)   -- emq:{queue}:delayed
-- getNextDelayedTimestamp: nextTimestamp / 0x1000 recovers the real ms

Where the textbook worker sleeps and re-polls, EchoMQ runs a sweep. The included promoteDelayedJobs Lua function ranges the due head — ZRANGEBYSCORE delayedKey 0 (timestamp + 1) * 0x1000 - 1 LIMIT 0 1000 — then ZREMs each member and moves it to the wait list. The priority reading is EchoMQ.Keys.prioritized/1 → emq:{queue}:prioritized, scored by getPriorityScore = priority × 0x100000000 + (INCR pc) % 0x100000000 — the priority tier in the high 32 bits, the pc arrival counter in the low 32 bits — and read by ZPOPMIN. Two named sets, one sorted-set shape, two readings of the score.

the pattern

One sorted set scores a job by a future timestamp to make a timer wheel, or by a packed priority to make a priority ladder. The member is the job id; the score is the meaning.

in EchoMQ

emq:{queue}:delayed scores by (timestamp + delay) * 0x1000 and a promoteDelayedJobs sweep ranges the due head to wait; emq:{queue}:prioritized scores by getPriorityScore and is read by ZPOPMIN — the real Elixir time-and-priority sets.

A delayed queue and a priority queue are the same sorted set under two readings of the score — ZADD to insert, a range query or a ZPOPMIN to read — and the chapter that follows takes each reading apart.

References

The data type both readings stand on, the single insert they share, and the two reads — the range over the due head and the pop of the smallest — plus the queue protocol EchoMQ ports.

Sources

  • Redis — Sorted sets — the data type both readings stand on: members ordered by a numeric score, with range and pop by score.
  • Redis — ZADD — insert or update a member's score; the single write the timer wheel and the priority ladder both use.
  • Redis — ZRANGEBYSCORE — the range query that reads the due head of the delayed set, the read inside promoteDelayedJobs.
  • Redis — ZPOPMIN — pop the smallest-score member; the read that pulls the next job from a priority ladder.
  • BullMQ — the queue protocol — the delayed and prioritized sets EchoMQ ports, where the Lua scripts are the protocol.

Related in this course