jonnify·redis /redis-patterns/time-delay-priority/schedulers/start-to-start-cadence

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.

Trace the cadence on a 4-minute run. Hover or select a measure to light its run marks and read whether the schedule holds or drifts.
09:00 09:22 09:45 :00 :15 :30 :45 09:00 → 09:19 → 09:38 (creeps)
readout · Select a measure. Start-to-start advances by the period; start-to-finish adds the run duration each cycle and drifts.

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.

§1

One entry, rewritten each cycle

delayed-queue.md.txt · applied to recurrence

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

§2

Start-to-start vs start-to-finish

the measure that governs the cadence

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

§3

In EchoMQ — next score from now plus the period

repeat.ex · advance · pump.ex

EchoMQ.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:

echo/apps/echo_mq · repeat.ex · @advance (verbatim)frozen
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.

/echo-persistence · when a fired occurrence is archived

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 pattern

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.

its EchoMQ application

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.

§4

Recap — rewrite, anchored to the period

module complete

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

§5

References

sources · related

Sources

Related in this course