R4.02.3 · Schedulers — dive 3
Anchored to the period: start-to-start.
The registry holds only the next run. When an occurrence is produced the single score is rewritten — start-to-start, anchored to the period, not start-to-finish.
Grounding: echo/apps/echo_mq · repeat.ex (EchoMQ.Repeat.advance, verbatim) ·
pump.ex (EchoMQ.Pump, the per-tick due-read + advance) · now_ms() + every_ms, the
start-to-start step.
Static verdicts (every-15-min, 4-min runs) — start-to-start → :00, :15, :30, :45, the schedule holds · start-to-finish → 09:00, 09:19, 09:38, drifting later by 4 min each cycle.
One entry, rewritten each cycle
delayed-queue.md.txt · applied to recurrenceA scheduler does not pre-compute a queue of future runs. It keeps exactly one entry per registration, scored by the next run, and rewrites that score each time an occurrence is produced. The registry is a sorted set scored by next-run time; a registration occupies one member.
When the pump produces the next occurrence, it computes the next run from the period and ZADDs the
new score against the same member — rewriting the single entry, not appending a new one. The registry's
size is the number of distinct registrations, never the number of runs. A registration that has fired ten
thousand times still holds exactly one row, and each occurrence is enqueued under a fresh
branded JOB id, so the runs are first-class browsable jobs while the registry stays at one entry.
- one member
- The registration's name; its score is the next run.
- rewrite
- Each cycle
ZADDs a new score against the same member. - fresh JOB id
- Each occurrence mints a new branded id; never a reused row.
The registry holds only the next run. Producing an occurrence rewrites that one score; the question is what the next score is measured from.
Start-to-start vs start-to-finish
the measure that governs the cadenceConsider an every-15-minute registration where an occurrence takes 4 minutes to run. Start-to-start: the occurrence released at 09:00 advances the next score to 09:15, the one after to 09:30 — the cadence is anchored to the 15-minute period and the 4-minute run does not move it. Start-to-finish: the 09:00 occurrence finishes at 09:04, so the next is scheduled for 09:19, which finishes at 09:23, so the next is 09:38 — every run drifts later by the run duration, and after enough cycles the schedule has slid far off the period.
Start-to-start keeps fifteen-minute marks at :00, :15, :30, :45; start-to-finish lets them creep.
EchoMQ.Repeat advances start-to-start.
How the next score is computed governs whether the cadence holds. Anchoring to the period, not the finish, is what keeps a slow occurrence from dragging the whole schedule later.
In EchoMQ — next score from now plus the period
repeat.ex · advance · pump.exEchoMQ.Pump is the cadence (Chapter 3.7). Each tick it reads the due registrations off the
registry — ZRANGEBYSCORE emq:{q}:repeat -inf <now> — mints a fresh branded
JOB id and enqueues each occurrence, then advances the score. The advance is
EchoMQ.Repeat.advance/4, and the host computes the next score as now_ms() + every_ms:
the release instant plus the period, never a finish time. The @advance script first checks the
record still exists (a cancelled name is swept), then rewrites the single entry:
if redis.call('EXISTS', KEYS[2]) == 0 then redis.call('ZREM', KEYS[1], ARGV[1]) -- cancelled mid-tick → sweep, {:ok, :absent} return 0 end redis.call('ZADD', KEYS[1], tonumber(ARGV[2]), ARGV[1]) -- ARGV[2] = now_ms() + every_ms return 1 -- {:ok, :advanced}
KEYS[1] is emq:{q}:repeat, KEYS[2] the record hash; ARGV[1]
is the registration name and ARGV[2] the next score, computed host-side in advance/4
as now_ms() + every_ms — the start-to-start step. A return of 1 is
{:ok, :advanced}; a return of 0 sweeps a name cancelled mid-tick and answers
{:ok, :absent}. Because promotion and the repeat sweep are both idempotent over their sets, a pump
restart re-sweeps without loss or duplication.
Notes on ValkeyRe-ZADD'ing the same member rewrites its
score in place — one row, a new sort position — so the advance never appends; the registry's
cardinality is the count of registrations, not of runs —
valkey.io/commands/zadd.
The fired job reaches the floor
Each advance mints a fresh occurrence enqueued on the bus. A scheduled occurrence that is archived for replay reaches the durable floor — ETS head over Valkey over the page engine, the durability dial. The snap over a missed slot and the scheduler-vs-delayed reconciliation belong to the EchoMQ course's /echomq/queue; the durable path down is /echo-persistence.
The registry holds only the next run; rewrite the single score each cycle, and measure the next run start-to-start so a slow run does not drag the cadence.
EchoMQ.Pump reads due, mints a fresh JOB id, and advance/4 rewrites
the one entry to now_ms() + every_ms — anchored to the period.
A textbook delayed queue scores one run and is done. A scheduler rewrites its single score each cycle; start-to-start anchors to the period, and each occurrence mints a fresh job.
Recap — rewrite, anchored to the period
module completeThe registry holds one entry per registration. The pump reads it due, mints a fresh JOB id per
occurrence, and advance/4 rewrites the single score to now_ms() + every_ms —
start-to-start, so the cadence holds even on a slow run. That completes the module: name the cadence, keep one
entry per name, rewrite it anchored to the period.
References
sources · relatedSources
- Redis — ZADD — rewriting the single registry score with the start-to-start next-run time.
- Valkey — ZRANGEBYSCORE — the pump's due read: the names scored at or before now.
- Valkey — Sorted sets — the one-entry-per-registration registry rewritten each cycle.
- Redis — EXISTS — the advance guard: a missing record sweeps the dangling registry member.
Related in this course
- R4.02 · Schedulersthe module hub
- R4.02.2 · The upsertone entry per registration name
- R4 · Time, Delay & Prioritythe chapter
- /echo-persistencethe durable floor a fired occurrence reaches when archived
- /echomq/queuethe scheduling subsystem in depth