jonnifyredis
Redis Patterns / R4 · Time, Delay & Priority / Priority with composite scores

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

emq:{queue}:prioritized — six jobs by composite score (smallest first) —

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 rankingsAutocomplete, prefix search
Read with ZPOPMIN / ZRANGEBYSCORERead with ZRANGEBYLEX
Tier in high bits, tie-breaker in low bitsFields 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.

the pattern

A textbook sorted set buckets by score and orders within the bucket lexicographically by member — a composite ordering key, two levels.

in EchoMQ

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