jonnifyechomq

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

t0 +60s JOB · 10s JOB · 25s JOB · 45s 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

ms attempts 1 … 8

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

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq)

@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

Related in this course