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.
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.
The consumer — codemojex’s notification worker
codemojex/notification_worker.exThe 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.
The five moves, one structure
echo_mq/jobs.ex · repeat.ex · backoff.exPick 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.
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.
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.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.
Five patterns, one sorted set
the schedule set · the repeat registry · a ranking setRead 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.
| Move | Set | The score means | The read |
|---|---|---|---|
| Reminder (R4.01) | emq:{cm.notify}:schedule | the run-at ms | ZRANGEBYSCORE -inf now |
| Digest (R4.02) | emq:{cm.notify}:repeat | the next run | EXISTS guard + ZADD |
| Deferral (R4.03) | emq:{cm.notify}:schedule | now + the bucket’s wait | the same promote sweep |
| Retry (R4.04) | emq:{cm.notify}:schedule | a backoff delay | the same promote sweep |
| Ranking (R4.05) | a ranking set | a player’s percentage | ZREVRANGE / 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.
Where the durable floor takes over
echo_store/graft · the persistence floorEvery 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 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:
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
The schedule pump in depth
The per-pool metronome, the promote and reap coordination across a consumer pool, the repeat sweep — the queue protocol taught rung by rung in the Queue pillar.
/echo-persistenceThe durable floor
The archive a scheduled notice reaches past the bus: the durability dial, the CubDB page tier, the Tigris remote behind a create-only commit fence.
up · the chapterR4 · Time, Delay & Priority
The five patterns this workshop folds into one scenario — the delayed queue, the scheduler, the priority score, backoff-and-retry, the leaderboard.
References
sources · relatedSources
- 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
@promoteLua 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
- R4.01 · The delayed queuethe reminder and retry’s run-at schedule set
- R4.02 · Schedulers & repeatable jobsthe digest’s repeat registry
- R4.03 · Priority with composite scoresthe contrast: a packed priority score
- R4.04 · Backoff & retrythe failed send’s backoff curve
- R4.05 · Leaderboardsthe ranking the digest carries
- R4 · Time, Delay & Prioritythe chapter this workshop closes
- EchoMQ · the Queuethe schedule pump in depth