jonnifyredis
Redis Patterns / R4 · Time, Delay & Priority / Schedulers & repeatable jobs

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

0
emq:{queue}:repeat (ZSET) — one entry per scheduler sched:reports score = 09:00:00 (next run) ZADD emq:{queue}:repeat <nextMillis> sched:reports fire 0 · one entry, scored by the next run registry size: 1 entry

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 forAvoid 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.

the pattern

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.

in EchoMQ

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

Related in this course