R4.02 · Time, Delay & Priority
Schedulers & repeatable jobs
A repeatable job is a delayed job that re-schedules itself: a sorted set holds one entry per scheduler, scored by the next run time, and each fire rewrites the score — so recurrence survives restarts without ever duplicating.
The delayed-queue pattern scores one future run in a sorted set. A scheduler carries that move forward: it keeps a registry, one entry per recurring schedule, each scored by when it next runs. When the job fires, the next run time is computed and the same entry's score is rewritten — the registry never grows past one row per scheduler, and a restart re-registering the same cron or interval rewrites the row rather than appending a second. The three dives take the move apart — naming the cadence, the upsert that adds no duplicate, and the start-to-start rewrite that holds the cadence to the clock.
What a scheduler adds over a one-shot delay
A one-shot delayed job is a member in the :delayed sorted set scored by its fire-time.
It runs once and is gone. A scheduler adds three things on top: a cadence — a rule for
"when next," a cron expression or a fixed interval, evaluated to a timestamp; a registry —
a separate sorted set, scored by the next run time, holding one entry per recurring schedule; and an
upsert — registering a schedule that already exists rewrites its single entry rather than
appending a duplicate.
The score is still a future timestamp, exactly as in the delayed queue. The new idea is that the entry is rewritten each cycle instead of consumed once. The interactive below runs a fixed scheduler — an every-15-minute interval and a daily cron — and shows the single registry entry being rewritten across three fires, not appended.
The repeat registry · one entry per scheduler, rewritten each fire — not appended
A scheduler is a one-shot delay made recurring: one registry entry, scored by the next run, rewritten each fire — the size stays at one row per scheduler.
Naming the cadence (cron vs interval)
There are two ways to name when next. A cron expression (0 9 * * *)
names a wall-clock calendar position — every day at 09:00 — and the next run is the next calendar
slot at or after now. A fixed interval (every: 900000 ms) names a gap
— every fifteen minutes — and the next run is the previous run plus the gap.
Both reduce to a single millisecond timestamp, which is the score the registry stores. The registry does not record whether the next run came from a calendar match or a gap addition; it stores a sorted set of next-run times. The first dive computes the next run each way over a fixed clock.
Cron names a calendar slot; an interval names a gap. Both reduce to one future millisecond — the registry's score.
The repeat registry (a sorted set of next-run times)
The registry is a sorted set scored by the next run time. The member is the scheduler's own key; the score is
the millisecond at which it next fires. EchoMQ builds this key with EchoMQ.Keys.repeat/1,
which returns "#{base}:repeat". The behaviour is a sorted set:
ZADD repeatKey nextMillis customKey to record,
ZSCORE repeatKey customKey to read the prior next-run time.
Per-scheduler options — the name, the cron pattern, the interval, the timezone — live in a
separate hash at repeatKey .. ":" .. customKey, written with
HMSET; the registry sorted set carries only the next-run times.
A source note: the keys.ex doc-comment labels :repeat
a "hash." The code's ZADD / ZSCORE calls are the ground truth
— the registry is used as a sorted set. The hash the comment loosely points at is the companion
per-scheduler options hash, a separate key.
The registry is a sorted set: member is the scheduler key, score is the next-run millisecond; the cadence options live in a separate companion hash.
The upsert (no duplicate on boot)
A service that boots re-registers all its schedules. A naive add would push a second entry for a schedule that
already exists, and the recurring job would fire twice. The upsert prevents that. Before adding, the script
probes the registry: local prevMillis = rcall("ZSCORE", repeatKey, customKey). If a
prior entry exists, the script reconciles the stale delayed entry instead of adding a second scheduler.
The store itself is rcall("ZADD", repeatKey, nextMillis, customKey), and re-adding the
same member rewrites its score rather than appending a row. One scheduler key, one registry entry, across any
number of boots. The second dive runs two boots over the same scheduler key and counts the entries.
-- addRepeatableJob-2.lua — the upsert (real) -- probe: was this scheduler already registered? local prevMillis = ZSCORE repeatKey customKey if prevMillis then -- reconcile the stale delayed entry, do not add a second end -- store: ZADD the same member rewrites its score, never appends a row ZADD repeatKey nextMillis customKey
Probe with ZSCORE, store with ZADD; the same member
rewrites rather than appends, so a boot adds no duplicate.
Producing the next run (start-to-start)
The registry holds only the next run, never a queue of future runs. When a job is produced, the next run time is computed from the cadence and the registry's single entry is rewritten with the new score. The next run is measured start-to-start — from this run's scheduled time plus the interval — not start-to-finish. Measuring from the finish would let a slow job push every later run later and drift the schedule; measuring start-to-start keeps the cadence anchored to the clock.
When the next run is known, EchoMQ also writes the wake marker —
ZADD markerKey nextTimestamp "1" — so a blocked worker wakes at the next run
rather than on a poll tick. The third dive compares start-to-start against start-to-finish and shows the
drift.
The registry holds only the next run, rewritten each cycle; start-to-start keeps the cadence on the clock, and the marker rings the worker at the next run.
When to use & when to avoid
| Use it for | Avoid it for |
|---|---|
| Recurring work on a calendar or a fixed cadence — periodic reports, cleanup sweeps, polling an upstream. | Sub-second or hard-real-time cadences — a worker-polled registry is millisecond-grained, not a real-time scheduler. |
| Schedules that must survive a restart without re-firing or duplicating. | Enormous fan-outs of distinct one-off future runs — those are delayed jobs in :delayed, not schedules. |
| A small-to-moderate number of named schedules, each with its own cron or interval. | Full calendar correctness across timezones and daylight-saving transitions at the edge — that parsing depth is the EchoMQ course's territory. |
Reach for a scheduler for restart-safe recurring work on a calendar or interval; reach elsewhere for real-time ticks, millions of one-off runs, or hard timezone edge correctness.
The three dives
Each dive takes one layer of the scheduler, in source order: naming the cadence that produces the next run, the upsert that keeps the registry at one entry per scheduler key, and the start-to-start rewrite that holds the cadence to the clock. Read them in order — the cadence first, the upsert second, the rewrite third.
Cron vs interval
Two ways to name "when next": a cron expression (0 9 * * *, a wall-clock
slot) vs a fixed interval (every: 900000, a gap). Each produces the one timestamp the
registry is scored by.
The upsert, no duplicates
A reboot re-registers the same scheduler. The ZSCORE-then-ZADD
upsert keeps exactly one registry entry per scheduler key — no duplicate recurring job on boot.
Start-to-start cadence
The registry holds only the next run; when a job is produced the next timestamp is computed and the single score rewritten — start-to-start, anchored to the clock, not start-to-finish.
A textbook delayed queue schedules one future run; a scheduler makes the run re-schedule itself — one registry entry per schedule, scored by the next run, rewritten each cycle and upserted so a restart never spawns a duplicate.
storeRepeatableJob in addRepeatableJob-2.lua runs
ZADD repeatKey nextMillis customKey on the :repeat ZSET
(EchoMQ.Keys.repeat/1), idempotent through the
ZSCORE repeatKey customKey probe above it — one entry per scheduler key, no
duplicate on boot.
Redis offers one move that re-schedules without duplicating; EchoMQ's repeat registry chose it, and proves the pattern is real.
A door, not a depth
This module cites one excerpt of EchoMQ's protocol — the ZADD/ZSCORE
upsert inside addRepeatableJob-2.lua — as proof the registry ships. The full cron
parser, the timezone and daylight-saving handling, and the scheduler-vs-delayed reconciliation across a worker
pool are the subject of the dedicated EchoMQ course, built next with this toolkit. From here,
open onto E6 · Lifecycle controls. This module teaches the registry and
its upsert; that course teaches the scheduler subsystem that maintains it.
R4.01 · the one-shot delayed queue this recurs on precedes this module; the chapter then continues with composite-score priority and backoff-and-retry. Return to the Time, Delay & Priority chapter to continue.
References
The Redis commands behind the registry and its upsert — the store, the idempotence probe, the sorted set underneath — the cron cadence spec, and where the scheduler subsystem that runs them is taught.
Sources
- Redis — ZADD — the upsert: re-
ZADDthe same member to rewrite its next-run score. - Redis — ZSCORE — the probe that makes the add idempotent: read the prior next-run time before storing.
- Redis — Sorted sets — the registry as a sorted set of next-run times.
- BullMQ — the queue protocol — the repeatable-jobs / job-schedulers protocol EchoMQ ports.
- Wikipedia — Cron — the cron expression as a cadence spec.
Related in this course
- R4.02.1 · Cron vs interval — naming the cadence.
- R4.02.2 · The upsert, no duplicates — one entry per scheduler key.
- R4.02.3 · Start-to-start cadence — rewriting the single next-run score.
- R4.01 · The delayed queue — the one-shot delay a scheduler recurs on.
- R4 · Score as meaning — the orientation dive that names the
:repeatZSET. - R4 · Time, Delay & Priority — the chapter.
- E6 · Lifecycle controls — the dedicated EchoMQ course: the scheduler subsystem in depth.