Lifecycle controls · dive 01
Scheduling & recurrence
A schedule is a score, not a second queue. A backoff is a value, not a sleep. A recurrence is a registration, not a loop.
A queue's default is "as soon as a worker is free." Three controls move that in time.
Schedule admits a job now but fences it out of pending until a run-at instant. Backoff
prices the delay before a retry as a pure curve. Recurrence registers a cadence and mints a fresh
job for every occurrence. Each is real code in echo/apps/echo_mq — a sorted-set score,
a deterministic function, a registry the pump sweeps. Drag "now" on the right, then read the two beats below.
The schedule clock · drag now
The schedule set is a visibility fence, not a second queue
Both enqueue_at/5 and enqueue_in/5 run one script:
@schedule. It is the enqueue transition with one change — the row is written
state = scheduled and the id is added to the schedule set
(emq:{q}:schedule) at the run-at score, not the pending set. The same branded id is the
member, so byte order is still mint order: once a scheduled job is released, it sorts among the pending jobs by
its mint instant, not its release instant.
Beat one — the named handle. enqueue_in/5 and
enqueue_at/5 both delegate to one private schedule/6; the public
verbs differ only in the mode token ("in" / "at") and the guard
on the value (an absolute millisecond, or a non-negative delay).
EchoMQ.Jobs @schedule — the handle
# echo_mq — EchoMQ.Jobs # Two public verbs, one private body. enqueue_at takes an absolute run-at ms; # enqueue_in takes a relative delay measured on the SERVER clock (see @schedule). def enqueue_at(conn, queue, job_id, payload, run_at_ms) when is_binary(job_id) and is_binary(payload) and is_integer(run_at_ms) do schedule(conn, queue, job_id, payload, "at", run_at_ms) end def enqueue_in(conn, queue, job_id, payload, delay_ms) when is_binary(job_id) and is_binary(payload) and is_integer(delay_ms) and delay_ms >= 0 do schedule(conn, queue, job_id, payload, "in", delay_ms) end defp schedule(conn, queue, job_id, payload, mode, value) do # KEYS = [the job row, the schedule set]; the values ride ARGV. keys = [Keyspace.job_key(queue, job_id), Keyspace.queue_key(queue, "schedule")] case Connector.eval(conn, @schedule, keys, [job_id, payload, mode, Integer.to_string(value)]) do {:ok, 1} -> {:ok, :scheduled} # row written scheduled, parked on the schedule set {:ok, 0} -> {:ok, :duplicate} # the id already exists — idempotent no-op {:error, {:server, "EMQKIND" <> _}} -> {:error, :kind} other -> other end end
Beat two — the script body. @schedule shares the enqueue gate and
idempotency, then diverges on one decision: the score. If the mode is "in", the score is
the server TIME plus the literal delay; otherwise it is the literal
run-at value. KEYS[1] is the job row, KEYS[2] the schedule set.
@schedule — the body
-- the @schedule script — admit a job, fenced out of pending until a run-at score -- The same branded-id gate as enqueue: the id must be JOB-namespaced. if string.sub(ARGV[1], 1, 3) ~= 'JOB' then return redis.error_reply('EMQKIND job id must be JOB-namespaced') end -- The same idempotency as enqueue: a row that already exists is a no-op. if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end -- The one divergence: the score. 'in' reads the SERVER clock and adds the delay, -- so the delay is measured on the same clock promote and reap read — never the -- caller's. 'at' takes the literal run-at value. local score if ARGV[3] == 'in' then local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) score = now + tonumber(ARGV[4]) else score = tonumber(ARGV[4]) end -- The row carries state = scheduled (not pending); the id is parked on the -- schedule set at the run-at score. Same row, same slot — fenced, not duplicated. redis.call('HSET', KEYS[1], 'state', 'scheduled', 'attempts', '0', 'payload', ARGV[2]) redis.call('ZADD', KEYS[2], score, ARGV[1]) return 1
A scheduled job is not a different kind of job in a different queue. It is the same row, on the same slot, fenced out of pending by a score the promote pass reads. The fence is a number; there is nothing to "move back" because there was never a second structure.
A schedule is a score on the one schedule set. The run-at instant fences the job out of pending; the mint instant still orders it once it is released.
promote releases the due ones, on the server clock
promote/3 runs @promote: read the server
TIME, take the schedule members scored at or before now
(ZRANGEBYSCORE schedule -inf now LIMIT 0 N), and for each one move it to pending — or to
its lane if it is a grouped job — at score 0, with state = pending. It returns how many
it released. The consumer loop calls promote on its beat, so a scheduled job becomes claimable on the next pump
after its instant passes.
EchoMQ.Jobs @promote — the body
-- the @promote script — release every due scheduled job to pending -- KEYS[1] = the schedule set, KEYS[2] = pending; ARGV[1] = the queue base root, -- ARGV[2] = the batch limit. The clock is the server's, never the caller's. local p = ARGV[1] local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) -- The due read: every member scored at or before now, oldest-due first. local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, tonumber(ARGV[2])) for _, id in ipairs(due) do redis.call('ZREM', KEYS[1], id) -- leave the schedule set local jk = p .. 'job:' .. id local g = redis.call('HGET', jk, 'group') if g then redis.call('ZADD', p .. 'g:' .. g .. ':pending', 0, id) -- a grouped job rejoins its lane -- (the lane re-wakes the ring when its concurrency allows — the lanes branch) else redis.call('ZADD', KEYS[2], 0, id) -- a flat job rejoins pending at score 0 end redis.call('HSET', jk, 'state', 'pending') end return #due -- the count released this pass
Promote is bounded by a batch and idempotent: a member already moved is gone from the schedule set, so a second
pass does not move it twice. Because the score is read against the server clock, a job scheduled with
enqueue_in releases on the same clock that priced its delay — there is no caller-clock
skew between admission and release.
Promote and reap share the server-clock read: promote releases due scheduled jobs, the reaper returns expired leases. Both run on the consumer's beat; both price time on the wire's clock, never the caller's.
Backoff is a curve computed above the wire
When a job fails and will be retried, the delay before the next attempt is
Backoff.delay_ms(policy, attempts) — a pure function: no process, no clock, no I/O.
Three policies. {:fixed, ms} is the same delay every attempt.
{:exponential, base, cap} is base * 2^(attempts-1), clamped at
cap, so the curve climbs then holds. {:jitter, inner} is a
uniform random delay in 0..inner_delay — the full-jitter form that spreads a retry storm.
# echo_mq — EchoMQ.Backoff — a pure function of (policy, attempts) → a literal ms def delay_ms({:fixed, ms}, attempts) when is_integer(ms) and ms >= 0 and is_integer(attempts) and attempts >= 1, do: ms # the same delay on every attempt def delay_ms({:exponential, base, cap}, attempts) when is_integer(base) and base >= 1 and is_integer(cap) and cap >= 1 and is_integer(attempts) and attempts >= 1 do # base * 2^(attempts-1), clamped at cap; the shift is exact-integer and the # cap holds the curve from climbing past the ceiling. raw = base * Bitwise.bsl(1, attempts - 1) min(raw, cap) end def delay_ms({:jitter, inner}, attempts) when is_integer(attempts) and attempts >= 1 do bound = delay_ms(inner, attempts) # the inner policy bounds the random draw if bound <= 0, do: 0, else: :rand.uniform(bound + 1) - 1 end
The module's doctests are real — they run as tests and are the curve's contract:
iex> EchoMQ.Backoff.delay_ms({:exponential, 100, 10_000}, 1) 100 iex> EchoMQ.Backoff.delay_ms({:exponential, 100, 10_000}, 3) 400 iex> EchoMQ.Backoff.delay_ms({:exponential, 100, 10_000}, 20) 10000
The backoff curve · plot delay over attempts
The wire takes a literal delay — Jobs.retry/7 carries the computed
value as the schedule delay, and the schedule transition you read above parks the job at
now + delay. The curve is a value the consumer can compute, table, and test without a
server; the wire never computes a curve.
Backoff is a deterministic function, not a sleeping process. Compute the delay above the wire, hand the wire a number, and the retry cadence becomes testable.
Recurrence mints a fresh job per occurrence
Repeat.register/6 writes a registration under a name: a record hash
emq:{q}:repeat:<name> carrying every_ms and a payload
template, and a member on the emq:{q}:repeat sorted set scored by
its next-run millisecond. cancel/3 removes both. due/3 reads the
names scored at or before now, each with its record. advance/4 moves a name's next-run
score forward by its period. count/2 is the registry depth.
EchoMQ.Repeat @register — the body
-- the @register script — register a cadence under a name, idempotently -- KEYS[1] = the repeat ZSET, KEYS[2] = the repeat:<name> record hash. -- ARGV[1] = name, ARGV[2] = every_ms, ARGV[3] = template, ARGV[4] = first-run ms. if redis.call('EXISTS', KEYS[2]) == 1 then return 0 -- a live name already registered — no-op end -- The record carries the period and the payload template the pump stamps. redis.call('HSET', KEYS[2], 'every_ms', ARGV[2], 'template', ARGV[3]) -- The name is the registry member, scored by its first-run instant. redis.call('ZADD', KEYS[1], tonumber(ARGV[4]), ARGV[1]) return 1
The cadence is the pump's: it reads due registrations, mints and enqueues each occurrence, and advances the score. Each occurrence is a first-class, browsable, mint-ordered job, because the id mints fresh per occurrence — never a reused row. Reusing a row would break both the order theorem (the id is the sort key) and the dedup semantics (the same id enqueued twice is a no-op).
Worked example. Register a daily report at every_ms = 86_400_000. The
registry has one member. When it comes due, the pump mints a new JOB-namespaced id,
enqueues an occurrence carrying the template, and advances the score by one day. Tomorrow's run is a different id,
a different row, a different mint instant — the registration is unchanged.
A recurrence is one registration the pump sweeps, not a loop that holds state. Every run is its own branded job, so a recurring stream is browsable and ordered like any other.
Pattern & implementation
A delayed or scheduled job is a sorted set scored by run-at time, swept by a promote pass; a retry curve is computed client-side. Time, delay & priority teaches the delay/schedule family over Redis.
@schedule parks on the schedule set at the run-at score,
@promote releases the due ones on the server clock,
Backoff.delay_ms/2 is the curve, and EchoMQ.Repeat is the
cadence — all real code, all on the owned keyspace.
The pattern says: park a delayed job on a scored set, sweep it due, price the retry above the wire.
The implementation is @schedule + @promote +
Backoff + Repeat.
Recap — time is a control
A schedule is a score, not a second queue — @schedule parks the same row on the schedule
set and @promote releases it due. A backoff is a value, not a sleep —
Backoff.delay_ms/2 prices the retry as a pure curve and the wire takes the literal. A
recurrence is a registration, not a loop — Repeat mints a fresh job per occurrence. The
next dive moves from time to the worker in hand: cancelling it, checkpointing its lease, and
recovering the ones that stall.
Next in lifecycle controls: Cancellation & checkpoints — the cooperative
cancellation token, extend_lock as a lease checkpoint, and the count-thresholded stalled
sweep.
References
Where the schedule, promote, and recurrence transitions are grounded, and the commands their bodies use.
Sources
- Valkey — ZADD — the schedule-set and repeat-set insertion at the run-at score.
- Valkey — ZRANGEBYSCORE — the due read promote runs each beat against the schedule set.
- Valkey — HSET — the scheduled row write and the repeat record fields.
- Redis — EVALSHA — the load-once dispatch the schedule verbs run by SHA.
- Valkey — Documentation — the substrate of record EchoMQ is backed by.
Related in this course
- Lifecycle controls — the module this dive belongs to.
- The Queue — the chapter, the state machine the schedule fences against.
- The Lua layer — how a schedule transition is one atomic script.
- The Protocol — the keyspace the schedule and repeat sets live in.
- redis-patterns · Time, delay & priority — the delay/schedule/priority family.