jonnifyredis

R4 · Time, Delay & Priority

One sorted set, five readings

Fold five patterns into one scenario: EchoMQ schedules a Portal LMS's notification and digest jobs — a reminder, a digest, a reset email, a failed send, a ranking. Every move is one sorted set read a different way.

The chapter taught five patterns over the sorted set: the delayed queue (R4.01), the scheduler registry (R4.02), the composite priority score (R4.03), backoff-and-retry (R4.04), and the leaderboard ranking (R4.05). This capstone is the one scenario that uses all five at once. Picture a Portal LMS — courses, lessons, learners — and the notifications it would send. EchoMQ would schedule those jobs; Portal ships no built notification system, so the scenario is a worked application of the chapter's machinery, not a claim about a Portal feature. A reminder is a one-shot delayed job, the digest a repeatable one, the reset email a priority one, the failed send a backed-off retry, and the digest carries a ranking — three sorted sets driving one worker, all on the same ZADD / range / pop machinery.

The scheduling board · five jobs on three sorted sets, swept by the clock

three sets, one worker — clock in hours from Mon 00:00 :delayed :repeat :prioritized now
Press a clock button to sweep the three sets.

Three sorted sets, one worker: the delayed set fires by time, the repeat set rewrites each cycle, and the prioritized set pops smallest-score first — the same machinery, read three ways.

The scenario — five jobs about Portal entities

Portal models the entities the jobs are about, each carrying its own branded namespace. The five jobs are scheduled about these entities; none of them asserts Portal runs a scheduler.

Portal entityNamespace & structThe job about it
Portal.Catalog.LessonLSN · [:id, :course_id, :title]the lesson reminder (delayed)
Portal.Accounts.UserUSR · [:id, :email, :name]the password-reset email (priority); the digest recipient
Portal.Enrollment.ProgressPRG · percent :: 0..100the data the digest's ranking sorts

The scenario schedules jobs about real Portal entities — a lesson, a learner, a progress percent — applying the chapter's patterns to Portal's data, not asserting a Portal feature.

The lesson reminder — a one-shot delayed job (R4.01)

A learner enrols in a lesson that starts tomorrow. EchoMQ schedules a reminder twenty-four hours out — a one-shot job that runs once and is gone. It lands in the delayed sorted set scored by its fire-time. EchoMQ builds the key with EchoMQ.Keys.delayed/1 ("#{base}:delayed" → emq:{queue}:delayed) and scores it with getDelayedScore in addDelayedJob-6.lua.

-- addDelayedJob-6.lua — the fire-time score (real)
local score = delayedTimestamp * 0x1000   -- low 12 bits discriminate jobs due the same millisecond
ZADD delayedKey score jobId

The score is delayedTimestamp × 0x1000, so the low twelve bits separate jobs due in the same millisecond. When the clock reaches the score, the included promoteDelayedJobs sweep — ZRANGEBYSCORE delayedKey 0 (timestamp + 1) × 0x1000 - 1 LIMIT 0 1000, then ZREM the due head — moves the reminder to the wait list and a worker delivers it.

The reminder is one member in :delayed, scored by when it should fire; the sweep promotes it when due, and the shift by 0x1000 keeps a millisecond able to hold more than one ordered job.

The weekly digest — a repeatable job, upserted (R4.02)

Every Monday at 09:00 each learner gets a progress digest. That is a recurring schedule, so it lives in the repeat registry: a sorted set scored by the next run, one entry per scheduler. EchoMQ builds the key with EchoMQ.Keys.repeat/1 ("#{base}:repeat" → emq:{queue}:repeat). Registering the schedule runs storeRepeatableJob in addRepeatableJob-2.lua: ZADD repeatKey nextMillis customKey, guarded by a ZSCORE repeatKey customKey probe above it.

-- addRepeatableJob-2.lua — the upsert (real)
local prevMillis = ZSCORE repeatKey customKey   -- already registered?
if prevMillis then -- reconcile, do not add a second end
ZADD repeatKey nextMillis customKey         # same member rewrites its score

The probe is what makes a reboot safe — a service that boots re-registers all its schedules, and re-adding the same member rewrites its score rather than appending a second row. One schedule key, one registry entry, across any number of boots; no duplicate digest on restart.

The digest is one entry in :repeat, scored by its next run and rewritten each fire; the ZSCORE-then-ZADD upsert keeps the registry at one row per scheduler, so a boot adds no duplicate.

The password-reset email — a priority job (R4.03)

A learner requests a password reset. That email cannot wait behind a backlog of bulk marketing mail — it jumps ahead. EchoMQ enqueues it on the prioritized sorted set, built with EchoMQ.Keys.prioritized/1 ("#{base}:prioritized" → emq:{queue}:prioritized), and scores it with getPriorityScore in addPrioritizedJob-9.lua.

-- getPriorityScore (included by addPrioritizedJob-9) — real
local prioCounter = rcall("INCR", priorityCounterKey)
return priority * 0x100000000 + prioCounter % 0x100000000
-- high 32 bits = the tier · low 32 bits = the arrival counter

0x100000000 is 232, so the priority tier sits in the high thirty-two bits and the arrival counter in the low thirty-two. The counter is the value of INCR on the priority-counter key — EchoMQ.Keys.pc/1 ("#{base}:pc") — taken when the job is added, so two jobs in the same tier order by arrival. A worker pops the next job with ZPOPMIN in moveToActive-11.lua: the smallest score is the highest-precedence tier and, within it, the earliest arrival. A lower priority number wins, because the formula makes a higher-precedence job a smaller number and ZPOPMIN always returns the smallest first.

The reset email is one member in :prioritized, scored by priority × 0x100000000 + (INCR pc); one ZPOPMIN returns the right job — tier first, arrival second — so it pops ahead of the bulk mail.

The failed send — a backed-off retry (R4.04)

A digest email fails: the mail provider returns a transient error. The worker does not drop the job and does not loop on it. It re-schedules the job to run again later, with a delay that grows on each attempt, so a struggling provider is not hammered. The delay is sized by EchoMQ.Backoff.calculate/4 in Elixir — the :exponential clause computes delay = trunc(:math.pow(2, attempt - 1) × base_delay), then apply_jitter(delay, jitter). The doc example is exact: calculate(:exponential, 3, 1000, jitter: 0.2) returns about 4000.

Jitter widens each delay into a band — [3200, 4800] for that example — and draws an independent point per job, so a batch that failed together does not re-fire in one synchronized spike. The reschedule itself adds no new structure: retryJob-11.lua re-adds the failed job to emq:{queue}:delayed at the backoff fire-time, and the included promoteDelayedJobs — R4.01's sweep — brings it back when due. The formula chooses the delay; the Lua never computes 2^(n-1).

The failed send is a delayed job whose fire-time is a backoff delay; EchoMQ.Backoff.calculate/4 sizes it, the same :delayed set holds it, and the same sweep promotes it — one structure, two uses.

The ranking the digest carries — a sorted-set ranking (R4.05)

The digest's content names the week's top learners. That is a ranking: order learners by their progress, read the top-N. Portal records a learner's progress through a lesson as a percent — the Portal.Enrollment.Progress struct, the field percent :: 0..100, the namespace PRG, held as a row in an in-memory, namespace-partitioned store. Portal runs no Redis sorted set and has no progress ranking; the percent is a plain stored value.

A leaderboard that ranks learners by progress is exactly this pattern applied to that data. Score each learner by their percent — ZADD board <percent> <learner> — and the top-N learners are a ZREVRANGE, a learner's standing is a ZREVRANK, and an around-me view is a rank-range read. The rank is computed on read in O(log N), never stored. The interactive below ranks a fixed roster of six learners by their progress percent and reads the top-N a digest would carry.

The digest ranking · six learners by progress percent, top-N read on the fly

3
ranking by Portal progress percent — ZREVRANGE board 0 N-1 (the digest's top learners)
Move the slider to read the top-N the digest would carry.

The ranking is the leaderboard pattern over the progress percent Portal already records; Portal stores the percent and the ranked view over it is one sorted set away — no rank is stored, the order is the rank.

Five patterns, one sorted set

Read back through the five moves and one structure stands behind all of them. The reminder scores by a fire-time, the digest by a next-run, the reset email by a packed priority, the retry by a backoff delay, and the ranking by a learner's percent — five different numbers, one sorted set. The score is a fire-time clock, a recurrence registry, a priority ladder, a retry timer, or a player's points, and the ZADD that writes it, the range that reads the due head, and the pop that takes the smallest are the same three operations every time.

MoveSetThe score meansThe read
Reminder (R4.01):delayedfire-time × 0x1000ZRANGEBYSCORE sweep
Digest (R4.02):repeatthe next runZSCORE probe + ZADD upsert
Reset (R4.03):prioritizedpriority × 0x100000000 + (INCR pc)ZPOPMIN
Retry (R4.04):delayeda backoff delaythe same sweep
Ranking (R4.05)a ranking seta learner's percentZREVRANGE / ZREVRANK
the chapter

Five patterns: the delayed queue (R4.01), the scheduler (R4.02), the composite priority score (R4.03), backoff-and-retry (R4.04), and the leaderboard (R4.05) — each a different reading of one structure.

this workshop

One Portal LMS scenario reads the sorted set five ways: a fire-time clock (:delayed), a recurrence registry (:repeat), a priority ladder (:prioritized), a retry timer (:delayed reused), and a ranking — all the same ZADD / range / pop machinery.

The chapter's five patterns are one sorted set read five times. The score is a fire-time, a next-run, a packed priority, a backoff delay, or a player's points — and the structure never changes, only the meaning poured into the score.

Where this is heading — EchoMQ 2.0

Today every job in this scenario rides the v1.x BullMQ-compatible wire: the reminder and the retry on emq:{queue}:delayed, the digest on emq:{queue}:repeat, the reset email on emq:{queue}:prioritized. The chapter's machinery sits one protocol break ahead of a rename.

Where this is heading — EchoMQ 2.0

The settled EchoMQ 2.0 design — the protocol break on the first EMQ rung, emq.1 — drops BullMQ compatibility (the v1 line freezes at 1.3.0), renames the whole keyspace to the native emq: prefix (emq:{queue}:delayed, emq:{queue}:repeat, emq:{queue}:prioritized; the {queue} hashtag applied transparently in the core, the braced {emq}: base reserved for the core's own keys), declares every Lua key in KEYS[] — the inherited undeclared-keys flaw resolved at the root — and bumps meta.version from bullmq:5.65.1 to echomq:2.0.0 behind a two-way typed boot fence, DragonflyDB-native.

The whole scenario is unchanged by the break. The fire-time score, the upsert, the composite priority score, the backoff delay, and the ranking are each a property of the sorted set — the score, the sweep, the pop, the rank — not of the prefix it rides on. The break renames the keyspace and declares the keys; every pattern in this workshop carries over byte-for-byte. That is why the chapter is safe to teach before EchoMQ 2.0 ships: it teaches the sorted set, and the prefix it rides on is the part that changes.

EchoMQ 2.0 renames the keyspace to emq: and declares every Lua key, version echomq:2.0.0; every pattern in this workshop is a property of the sorted set, not of the prefix, so the rename leaves all five unchanged.

Grounded in EchoMQ's real scheduler

Every symbol in this scenario is a real surface in echo/apps/echomq and echo/apps/portal, cited as proof the patterns ship:

  • The delayed key and score — EchoMQ.Keys.delayed/1, getDelayedScore (score = delayedTimestamp × 0x1000) in addDelayedJob-6.lua, swept by the included promoteDelayedJobs.
  • The repeat registry — EchoMQ.Keys.repeat/1, storeRepeatableJob's ZADD repeatKey nextMillis customKey probed by ZSCORE in addRepeatableJob-2.lua.
  • The priority score — EchoMQ.Keys.prioritized/1, getPriorityScore (priority × 0x100000000 + (INCR pc)) in addPrioritizedJob-9.lua, the counter key EchoMQ.Keys.pc/1, consumed by ZPOPMIN in moveToActive-11.lua.
  • The backoff delay — EchoMQ.Backoff.calculate/4 (:exponential → trunc(:math.pow(2, attempt - 1) × base_delay), then apply_jitter); retryJob-11.lua re-adds onto :delayed.
  • The ranking data — Portal.Enrollment.Progress (percent :: 0..100, namespace PRG); Portal runs no sorted set, so the ranking is the applied pattern, phrased counterfactually.

The keys are illustrated in the emq:{queue}:* v1 form the library speaks today; the EchoMQ 2.0 section above names the emq: rename ahead. The ranking is drawn as raw ZADD/ZREVRANGE over a percent — the worked application of the leaderboard pattern, not a Portal API; Portal stores the percent and runs no sorted set.

Each of the five moves cites one real EchoMQ or Portal surface as a door, not a depth — the excerpt is the proof, the full subsystem is the EchoMQ course's territory.

A door, not a depth

This workshop cites one excerpt of each move as proof the patterns ship. The full scheduler subsystem — the cron parser, the delay / promote / retry coordination across a worker pool, the polyglot concurrency models — is the subject of the dedicated EchoMQ course, built next with this toolkit, which opens at E6 · Lifecycle controls. This workshop closes R4 with five patterns over one sorted set; that course teaches the scheduler that runs them. Return to the Time, Delay & Priority chapter to continue.

References

The Redis commands behind the five moves — the single write, the due-range read, the smallest-score pop, the one structure they all share — and where the scheduler that runs them is taught.

Sources

  • Redis — ZADD — the single write behind every move: the fire-time score, the repeat upsert, the priority score, the leaderboard score.
  • Redis — ZRANGEBYSCORE — the due-range read inside promoteDelayedJobs that promotes the reminder and the retry.
  • Redis — ZPOPMIN — pop the smallest composite score: highest tier, earliest arrival — how the reset email jumps the queue.
  • Redis — Sorted sets — the one structure the scenario reads five ways: members ordered by a numeric score.
  • BullMQ — the queue protocol — the :delayed / :repeat / :prioritized protocol EchoMQ ports, where the Lua scripts are the protocol.
  • DragonflyDB — BullMQ on Dragonfly — the BullMQ-on-Dragonfly direction EchoMQ 2.0 takes native, prefix renamed and keys fully declared.

Related in this course