R4.03 · Time, Delay & Priority
Priority with composite scores
A sorted set orders by score, then lexicographically within an equal score. Pack the priority tier and the arrival order into one numeric score and a single sorted set becomes a priority queue.
A sorted set offers one ordering axis: the score. A priority queue needs two — jobs in a
higher tier come out first, and within a tier the earliest arrival comes out first. Encode more than one field
into that ordering key and one sorted set becomes a multi-level index: bucket by the high-order field, order by
the low. This module takes the composite-key idea apart for the sharpest case, the priority queue, and shows the
one move EchoMQ makes: scoring by priority × 0x100000000 + (INCR pc) so a single
ZPOPMIN returns the right job — tier first, arrival second.
How a sorted set orders: score, then member
A Redis sorted set keeps its members in order by a numeric score. Read it low-to-high and the smallest score is at the head. When two members carry the same score, the set falls back to lexicographic order of the member to break the tie. The ordering is two-level by construction: the score first, the member second.
ZADD scores 100 "alice" 200 "bob" 150 "charlie" ZRANGE scores 0 -1 # Returns: alice, charlie, bob — sorted by score
The score is the whole ordering axis. Choose what number goes in it and the set's order becomes whatever that
number means. The interactive below loads a fixed set of six prioritized jobs, computes each composite score,
sorts ascending, and names the job a ZPOPMIN would return next.
The prioritized set · six jobs ordered by composite score
A sorted set orders by score, then by member; the score is the whole index, so the number chosen for it determines the order, and the head is the smallest score.
Composite keys: packing two fields into one ordering key
The composite-key pattern encodes several fields into one ordering key so a single sorted set answers a
hierarchical query. In its lexicographic form the fields go into the member string, read by
ZRANGEBYLEX.
ZADD events 0 "user:100:1706648400:login" ZADD events 0 "user:100:1706648500:click" ZRANGEBYLEX events [user:100: (user:100:\xff # all user:100 events, ordered by timestamp
The second encoding puts the high-order field in the score and the low in the member. Members sort by score first, then lexicographically within an equal score, so the score can carry a category or a tier while the member carries the order within it.
ZADD hybrid 1 "a:first" 1 "a:second" 2 "b:first" 2 "b:second" ZRANGE hybrid 0 -1 # Returns: a:first, a:second, b:first, b:second — bucket by score, order within
Bucketing falls out: group items by score (a category, a priority tier) and order within each bucket. A priority queue is the same composite key, encoded entirely in the score — both the tier and the position within it.
A composite key packs more than one field into one ordering key; pack into the member and read with
ZRANGEBYLEX, or pack into the score and bucket by tier with the member ordering within.
The numeric composite score: the priority queue
A priority queue packs both fields into the score. The score is
priority × 0x100000000 + counter. 0x100000000 is
232, so multiplying the priority by it shifts the tier into the high
thirty-two bits; the arrival counter sits in the low thirty-two bits. One IEEE double carries both fields
exactly — a double holds fifty-three bits of integer precision, ample for small priorities and counters.
-- getPriorityScore.lua (included by addPrioritizedJob-9) — real local prioCounter = rcall("INCR", priorityCounterKey) return priority * 0x100000000 + prioCounter % 0x100000000 -- high 32 bits = the priority tier · low 32 bits = the arrival counter
The counter is the value of INCR pc — an atomic increment of the priority-counter
key, taken at the moment the job is added. Every prioritized add reads a fresh, monotonically rising counter, so
two jobs in the same tier get two different low-bit fields: the earlier add gets the smaller counter. A
lower priority number means higher precedence — priority 1 produces a smaller high-bit
field than priority 2, so its composite score is smaller, and the set keeps it nearer the head.
The composite score puts the tier in the high thirty-two bits and the arrival counter in the low; because the high bits dominate, the score orders by tier first and arrival second, in one number.
FIFO within a tier
Two jobs of the same priority share the same high thirty-two bits. The tie is broken by the low thirty-two bits
— the arrival counter. Because INCR pc rises by one per add, the first job to
arrive in a tier carries the smaller counter and so the smaller composite score. The set orders same-tier jobs
by arrival, head first: strict FIFO within the tier, with no second structure and no scan.
Without the counter, a score of priority alone would tie every job in a tier, and the
set would fall back to member order — the job id — which is not arrival order. The counter is what
breaks the tie deterministically and in the order jobs arrived. The second dive takes this apart in full.
Same-tier jobs share the high bits; the INCR pc counter in the low bits
breaks the tie in arrival order, so the tier serves first-in-first-out without a second structure.
Consuming with ZPOPMIN
A worker takes the next job with ZPOPMIN — pop the member with the smallest
score. The smallest composite score is the highest-priority tier (the smallest priority number) and, within
that tier, the earliest arrival (the smallest counter). One command returns the right job and removes it
atomically.
ZPOPMIN emq:{queue}:prioritized # Returns the smallest-score member: highest-priority tier, earliest arrival — and removes it
In EchoMQ the popped job id is then pushed onto the active list with LPUSH.
ZPOPMIN is the reason a lower priority number wins: the formula makes a
higher-precedence job a smaller number, and ZPOPMIN always returns the smallest first.
The third dive walks the real consume in moveToActive-11.lua.
One ZPOPMIN returns the smallest score — highest tier, earliest
arrival — and removes it atomically; a lower priority number wins because it makes the smaller number.
When a composite score beats two structures, when lexicographic instead
A single composite score earns its place when the ordering is fully numeric and one pop must return the right
job: a priority queue, a numeric ranking, any case where the high-order field and the tie-breaker are both
numbers. One sorted set then replaces a per-tier structure plus a separate FIFO, and the consume is one
ZPOPMIN.
Reach for the member-packed lexicographic key instead when the fields are strings and the read is a prefix or
range query — autocomplete, hierarchical composite keys, time-series with string ids — where
ZRANGEBYLEX over the member is the natural read. Score-packing and member-packing are
the same composite-key idea; the choice is whether the read is a numeric pop or a lexicographic range.
| Pack into the score (numeric) | Pack into the member (lexicographic) |
|---|---|
| Priority queues, numeric rankings | Autocomplete, prefix search |
Read with ZPOPMIN / ZRANGEBYSCORE | Read with ZRANGEBYLEX |
| Tier in high bits, tie-breaker in low bits | Fields delimited in one string, zero-padded |
Pack into the score when the order is numeric and the read is a pop; pack into the member when the order is a string and the read is a lexicographic range — the same composite-key idea, two encodings.
The three dives
Each dive takes one layer of the composite priority score, building on the source spine and adding the EchoMQ grounding on top: the number that packs both fields, the counter that orders within a tier, and the pop that reads them. Read them in order — the packing first, the FIFO second, the pop third.
Packing two keys in one score
The core move: priority × 0x100000000 + counter puts the tier in the
high thirty-two bits and the arrival counter in the low; changing the tier dominates the counter.
FIFO within a tier
Two jobs of the same priority order by the INCR pc arrival counter; the
counter breaks the tie deterministically, in the order jobs arrived.
ZPOPMIN
Consumption: ZPOPMIN pops the smallest score (highest tier, earliest
arrival), then LPUSH to the active list; why a lower priority number wins.
A textbook sorted set buckets by score and orders within the bucket lexicographically by member — a composite ordering key, two levels.
EchoMQ.Keys.prioritized/1 → emq:{queue}:prioritized is
scored by getPriorityScore: priority × 0x100000000 + (INCR pc)
packs both the tier and the arrival order into one number, so a single ZPOPMIN yields
strict priority then FIFO, with no second structure and no scan.
A composite priority queue packs the tier and the arrival order into one score; EchoMQ scores
priority × 0x100000000 + (INCR pc) and pops the smallest with one
ZPOPMIN, so one sorted set serves both orderings.
A door, not a depth
This module cites one excerpt of EchoMQ's protocol — getPriorityScore in
addPrioritizedJob-9.lua and the ZPOPMIN consume in
moveToActive-11.lua — as proof the composite priority score ships. The intra-group
priority control plane, the fairness arc, and the polyglot concurrency models are the subject of the dedicated
EchoMQ course, built next with this toolkit. From here, open onto
E4 · Groups. This module teaches the priority score; that course teaches the
groups that prioritize within it.
After R4.03, the Time, Delay & Priority chapter continues with backoff-and-retry and leaderboards (a numeric ranking on the same machinery), closing with the EchoMQ scheduler workshop; fairness and starvation are R6.02 · Priorities and fairness, a later chapter. Return to the Time, Delay & Priority chapter to continue.
References
The Redis commands behind the composite priority score — the sorted set as a priority ladder, the score, the atomic counter, the smallest-score pop — the lexicographic contrast, and where the groups that prioritize within it are taught.
Sources
- Redis — Sorted sets — score-then-member ordering, the basis of the composite priority score.
- Redis — ZADD — score a member by its composite priority score; the write that adds a prioritized job.
- Redis — ZPOPMIN — pop the smallest score: highest-priority tier, earliest arrival, removed atomically.
- Redis — INCR — the atomic arrival counter that breaks the within-tier tie.
- Redis — ZRANGEBYLEX — the source's other half: member-packed lexicographic ordering, the contrast to score-packing.
- BullMQ — the queue protocol — the prioritized-jobs protocol EchoMQ ports, where the Lua scripts are the protocol.
Related in this course
- R4.03.1 · Packing two keys in one score — the tier in the high bits, the counter in the low.
- R4.03.2 · FIFO within a tier — the
INCR pccounter that breaks the tie. - R4.03.3 · ZPOPMIN — the smallest-score pop, and why a lower priority number wins.
- R4 · Score as meaning — the orientation dive that names
getPriorityScore. - R4.01 · The delayed queue — the sibling sorted set scored by fire-time.
- R4 · Time, Delay & Priority — the chapter.
- E4 · Groups — the dedicated EchoMQ course: intra-group priority and the control plane.