jonnify·redis /redis-patterns/time-delay-priority/workshop

R4.06 · Time, Delay & Priority — the chapter workshop

One sorted set, five readings.

The chapter capstone: fold the five R4 patterns into one scenario — EchoMQ schedules codemojex’s notification and digest jobs through Codemojex.NotificationWorker. Each move is one sorted set read a different way.

Grounding: echo/apps/echo_mq (EchoMQ.Jobs.enqueue_at/6, enqueue_in/6, promote/3 · EchoMQ.Repeat · EchoMQ.Backoff · EchoMQ.Metronome) · echo/apps/codemojex (Codemojex.NotificationWorker, Codemojex.RateLimiter, Codemojex.Scoring) — every surface verified on disk. The engine is Valkey.

The schedule board. One schedule set drives the worker; step the clock and the due head and the next wake recolour. Hover or select a clock to sweep.
emq:{cm.notify}:schedule — one sorted set, scored by run-at second members ordered by score → now due = score ≤ clock (ZRANGEBYSCORE -inf now) · next wake = earliest future
readout · Press a clock button to sweep the schedule set.

One schedule set, one worker: promote/3 reads the due head with ZRANGEBYSCORE -inf now on the server clock, and the reminder, the deferral, and the retry all ride the same set — three meanings, one structure.

§1

The consumer — codemojex’s notification worker

codemojex/notification_worker.ex

The five jobs in this scenario are all about codemojex entities, each carrying its own branded namespace. Codemojex.NotificationWorker mints a fresh NOT id with EchoData.BrandedId.generate!("NOT") for every re-enqueued occurrence.

  • USR — a player account.
  • RMM — a room, the chat where a game runs.
  • RND — a round, one secret to guess.
  • NOT — a notification job, minted fresh per occurrence.

Codemojex.NotificationWorker is the real worker. An EchoMQ.Consumer drains the cm.notify queue and handle/1 delivers each notice through the bot under three layers of control: fairness — a fair lane per chat via Codemojex.Notifier, so one chat’s burst cannot starve others; rate — a token from Codemojex.RateLimiter before each send, global about thirty a second, per-chat about one a second; and delivery — EchoBot.deliver/3 classifies the Telegram result, success acks, a transient failure re-enqueues with capped exponential backoff up to @max_attempts of six, a permanent failure acks-and-drops. The payload is JSON {chat, text, opts, id, attempt}. The five readings below are the chapter’s patterns assembled on top of that worker.

codemojex ships a live notification worker that schedules, defers, retries, and ranks — the chapter’s five patterns are already present in one real surface; the workshop is the assembly.

§2

The five moves, one structure

echo_mq/jobs.ex · repeat.ex · backoff.ex

Pick a move to read which EchoMQ surface it calls and which sorted set it writes. Each move is a different reading of the same ZADD / range / rank machinery; the readout names the call and the score the move pours in.

The five readings. Select a move to highlight it; the surface it calls and the score it writes appear in the readout.
1 · round-start reminderEchoMQ.Jobs.enqueue_at/6 → :schedule 2 · daily standings digestEchoMQ.Repeat.register/6 → :repeat 3 · over-budget deferralRateLimiter + EchoMQ.Jobs.enqueue_in/6 4 · backed-off retryEchoMQ.Backoff.delay_ms/2 → :schedule 5 · the ranking the digest carriesZADD / ZREVRANGE over Codemojex.Scoring L0 · Valkey — the engine under all five
readout · Select a move. The reminder, the deferral, and the retry all write the schedule set; the digest the repeat registry; the ranking a leaderboard set — each one a different reading.

Static verdicts — 1 reminder → enqueue_at/6 (run-at score) · 2 digest → Repeat.register/6 (EXISTS guard) · 3 deferral → enqueue_in/6 (now + wait) · 4 retry → Backoff.delay_ms/2 (backoff) · 5 ranking → ZREVRANGE over a percentage.

Move 1 — the round-start reminder (R4.01). EchoMQ schedules a reminder for an absolute due time with EchoMQ.Jobs.enqueue_at(conn, queue, job_id, payload, run_at_ms). The job’s row is written state = scheduled and the branded JOB id is parked on emq:{cm.notify}:schedule, scored by the run-at millisecond. The @schedule Lua declares both keys and guards the id:

-- the @schedule script (echo_mq/jobs.ex) — both keys declared, the id gated
if string.sub(ARGV[1], 1, 3) ~= 'JOB' then return error 'job id must be JOB-namespaced' end
local score = tonumber(ARGV[4])              -- run-at ms for the 'at' mode
redis.call('HSET', KEYS[1], 'state', 'scheduled', 'attempts', '0', 'payload', ARGV[2])
redis.call('ZADD', KEYS[2], score, ARGV[1])   -- KEYS[2] = emq:{cm.notify}:schedule

When the clock reaches the score, EchoMQ.Jobs.promote(conn, queue, batch) sweeps the due head with ZRANGEBYSCORE -inf now LIMIT 0 batch — now read from the server clock (redis.call('TIME')) — then ZREMs each due id and moves it to emq:{cm.notify}:pending. The mint-ordered id stays the sort key once promoted.

Move 2 — the daily standings digest (R4.02). A recurring schedule is a registration the bus sweeps: EchoMQ.Repeat.register(conn, queue, name, every_ms, template, first_in_ms). The @register Lua declares two {q}-hashtagged keys — emq:{cm.notify}:repeat, scored by next-run millisecond, and emq:{cm.notify}:repeat:<name>, the record hash carrying every_ms and the payload template — and guards with if redis.call('EXISTS', KEYS[2]) == 1 then return 0, so a second register of a live name answers :exists and changes nothing. That guard is what makes a reboot safe: a service re-registers all its schedules on boot, and a live name is a no-op rather than a second row. The pump reads due names with EchoMQ.Repeat.due/3, mints a fresh JOB id per occurrence, and calls EchoMQ.Repeat.advance/4 to re-score the name to now plus every_ms. The EchoMQ.Metronome per-queue beat drives that sweep.

Move 3 — the over-budget deferral (R4.03). The chapter packed a priority tier and an arrival counter into one score so a high-precedence job pops ahead. codemojex makes the same do-not-let-a-flood-starve-the-rest guarantee, but with fairness plus rate, not a packed priority score — the honest applied move. Codemojex.Notifier enqueues each notice on a fair lane per chat; before each send handle/1 takes a token from Codemojex.RateLimiter. When the bucket is empty, RateLimiter.take(chat) returns {:wait, ms} and the worker does not block the consumer: it re-enqueues the exact notification after the reported wait with EchoMQ.Jobs.enqueue_in(conn, queue, job_id, payload, delay_ms), and acks. The notice stays durable on the bus, deferred, not dropped. enqueue_in/6 is enqueue_at/6’s relative twin: the run-at score is computed wire-side from the server clock, so the delay is measured on the same clock promote/3 and reap/2 read. The composite-priority score (priority × 232 + arrival) is the alternative reading of the same set; the codemojex design is built on the fair lane and the token bucket instead.

Move 4 — the backed-off retry (R4.04). A delivery fails: Telegram returns a transient 429 or 5xx. EchoBot.deliver/3 classifies it {:retry, reason}, and while attempt < @max_attempts the worker re-enqueues the same notice with a growing delay so a struggling endpoint is not hammered. The reschedule re-uses the schedule set — enqueue_in/6 parks the job on emq:{cm.notify}:schedule at the backoff fire-time, and the same promote/3 sweep brings it back. The curve is EchoMQ.Backoff.delay_ms(policy, attempts), a pure function above the wire:

-- EchoMQ.Backoff.delay_ms/2 (echo_mq/backoff.ex) — the curve, host-side
delay_ms({:exponential, base, cap}, attempts) -> min(base * (1 << (attempts - 1)), cap)
# doc vectors, exact:
delay_ms({:exponential, 100, 10_000}, 1)  -> 100
delay_ms({:exponential, 100, 10_000}, 3)  -> 400
delay_ms({:exponential, 100, 10_000}, 20) -> 10000   # clamped at cap

The {:jitter, inner} policy wraps any inner curve with a uniform draw in 0..inner_delay, the full-jitter form that spreads a retry storm so a batch that failed together does not re-fire in one synchronized spike. The host computes the literal delay; the wire takes a literal delay_ms and never computes a curve.

Move 5 — the ranking the digest carries (R4.05). The digest names the room’s top players. Codemojex.Scoring computes a player’s score from their guess — points = 100 - 20*d for each guessed emoji at distance d from its secret position, totalled over six positions out of @max 600, the percentage round(total / 600 * 100) computed never stored. A leaderboard that ranks players by that percentage is exactly this pattern applied to the score: ZADD board <percentage> <player>, and the top-N players are a ZREVRANGE board 0 N-1, a player’s standing a ZREVRANK. The rank is computed on read in O(log N), never stored.

the chapter

Five patterns over the sorted set: 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.

codemojex’s notification worker

Codemojex.NotificationWorker reads the schedule set three ways — enqueue_at/6, enqueue_in/6 for the deferral and the retry — the repeat registry once (EchoMQ.Repeat), and a leaderboard over Codemojex.Scoring, all the same ZADD / range / rank machinery.

The chapter’s five patterns are one sorted set read five times. The score is a run-at, a next-run, a deferral wait, a backoff delay, or a player’s percentage — the structure never changes, only the meaning poured in.

§3

Five patterns, one sorted set

the schedule set · the repeat registry · a ranking set

Read back through the five moves and one structure stands behind all of them. The reminder scores by a run-at, the digest by a next-run, the deferral by a wait, the retry by a backoff delay, and the ranking by a percentage — five different numbers, one sorted set. The ZADD that writes the score, the range read that takes the due head, and the rank read are the same operations every time.

MoveSetThe score meansThe read
Reminder (R4.01)emq:{cm.notify}:schedulethe run-at msZRANGEBYSCORE -inf now
Digest (R4.02)emq:{cm.notify}:repeatthe next runEXISTS guard + ZADD
Deferral (R4.03)emq:{cm.notify}:schedulenow + the bucket’s waitthe same promote sweep
Retry (R4.04)emq:{cm.notify}:schedulea backoff delaythe same promote sweep
Ranking (R4.05)a ranking seta player’s percentageZREVRANGE / ZREVRANK

Notes on ValkeyThe schedule sweep reads now from the server’s own clock inside the script (redis.call('TIME')), not the caller’s — so a deferral, a retry, and a reminder are all due on one clock no matter which node enqueued them; the same clock the reaper reads to return an expired lease — valkey.io/commands/time.

The pattern lands twice: the chapter’s five readings of one sorted set, and codemojex’s notification worker assembling them on the real emq:{q}: keyspace.

§4

Where the durable floor takes over

echo_store/graft · the persistence floor

Every job in this scenario is durable while it waits: a scheduled notice, a deferred send, a backed-off retry all sit on emq:{cm.notify}:schedule in Valkey, surviving a worker crash and returned by the promote/3 sweep on recovery. Past the bus, the persistence floor takes the load that must outlive the volatile tiers — a notification archive, or an audit of every notice sent. That is a fold from the bus into the durable page tier: EchoStore’s native engine on CubDB, replicated off-box to Tigris behind a create-only commit fence. Durability is a dial the system turns; the enqueue hot path touches only the bus, never a database on the path of every send.

the application · one move on the real keyspace

The schedule fence, as built

The reminder, the deferral, and the retry all park one branded JOB id on one sorted set; the promote sweep reads the due head on the server clock. The shape, verbatim from the as-built scheduler:

echo_mq/jobs.ex · the @schedule and @promote scriptsfrozen
enqueue_at/6  ZADD emq:{cm.notify}:schedule  <run-at-ms>  <JOB id>   ← HSET state=scheduled
enqueue_in/6  score = TIME() + delay_ms                          ← server clock, relative
promote/3     ZRANGEBYSCORE :schedule -inf now LIMIT 0 batch     ← the due head
              ZREM :schedule · ZADD :pending                     ← release when due
§5

References

sources · related

Sources

  • Valkey — ZADD — the single write behind every move: the run-at score, the next-run register, the deferral, the backoff fire-time, the ranking score.
  • Valkey — ZRANGEBYSCORE — the due-range read inside the @promote Lua that returns the schedule head at or below the server clock.
  • Valkey — TIME — the server clock the schedule sweep and the reaper both read, so a deferral, a retry, and a reminder are due on one clock.
  • Valkey — Data types — the sorted set, the one structure the scenario reads five ways, the engine under EchoMQ.
  • Redis — ZREVRANGE — the top-N read the ranking digest carries, highest score first.
  • Answer.AI — The /llms.txt convention — the machine-readable map convention this course follows for agent readers.

Related in this course