Blocking, timers and the bus · dive 01
Who moves time today
EMQ.PROMOTE and EMQ.STALLED.CHECK run only when a
client sends them, and each reads one clock: the command's cached server time.
Time-driven work in a queue is two sweeps over sorted sets scored in server milliseconds. A
scheduled job falls due when its score in schedule is at or below the clock; a lease
expires when its score in active is. Neither set changes when the clock passes a score.
A sweep moves the due members, and a sweep runs only when a client sends it. Natively the two sweeps are
commands; in the Elixir library four processes send them on a beat or an interval, as scripts on the default
wire: 4 connection and as the two commands on a wire: 6
one. Move the beat to see how long a due job waits, then assemble a deployment to see who sends
what.
Two sweeps, sent by a client
Each sweep keeps the key and argument tail of the script it ports. A wire: 6
connection sends that tail with the verb in place of EVALSHA and its sha, and a raw
client on a module-loaded node writes the same line itself:
# the two sweeps on a module-loaded node, from a wire 6 connection or a raw client: the EVALSHA tails, the sha replaced by the name EMQ.PROMOTE 2 emq:{orders}:schedule emq:{orders}:pending emq:{orders}: 100 # the reply: the number of due members selected, ghosts included EMQ.STALLED.CHECK 3 emq:{orders}:active emq:{orders}:pending emq:{orders}:dead emq:{orders}: 1 100 # the reply: [[recovered ids], [dead ids]]
The handler runs the grammar the earlier modules read: shape, layout, the bound,
the fence, then a read pass. The read pass of EMQ.PROMOTE takes one clock reading and
selects the due members of schedule by score, at most batch
of them, before anything is written:
// trafikey — EMQ.PROMOTE: one clock reading, the due members by score, then one write pass pub(crate) fn promote(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { let s = host::shape("EMQ.PROMOTE", &args, 2)?; s.expect_argc(2)?; let q = promote_queue(&s).ok_or_else(|| s.layout_error())?; let batch = parse_positive(s.arg(2)).ok_or_else(|| errors::positive(s.verb, "batch"))?; let caller = host::fence(ctx)?; let (schedule, pending) = (s.key(1), s.key(2)); // Read pass (rule 15): one `now`, then Lua's `ZRANGEBYSCORE KEYS[1] -inf now LIMIT 0 batch`. let (now, limit) = (host::now_ms()?.to_string(), batch.to_string()); let selected = match host::read( ctx, cmd::ZRANGE, &[ schedule, b"-inf", now.as_bytes(), b"BYSCORE", b"LIMIT", b"0", limit.as_bytes(), ], )? { Reply::Array(items) => items, _ => return Err(errors::emq(errors::HOST_REPLY)), }; // … each selected member: TYPE, the lane check, a ghost or a live row if members.is_empty() { return Ok(ValkeyValue::Integer(0)); } // … pending is typed and the members counted, then the write guard let w = host::begin_writes(ctx)?; apply(&w, &promote_plan(&q, schedule, pending, &members))?; Ok(ValkeyValue::Integer(count)) }
EMQ.STALLED.CHECK has the same shape over
active: one reading, the expired leases by score up to limit,
a verdict for every member, then one write pass. Neither command is sent by anything inside the server. Each
call moves what is due at its own reading, bounded by its argument, and replies; a job that falls due a
millisecond later waits for the next call.
A sweep is a command like any other: it runs when it arrives, moves what its one reading makes due, and is gone.
One clock reading per call
The native clock is not a TIME command. host::now_ms asks the
server for its cached time of the running command, in microseconds, and divides by a thousand:
// trafikey — host::now_ms: the command's cached server time, the value TIME reports, read once
/// The server's millisecond time (G0.8): `server.ustime / 1000`, the value TIME reports and
/// frozen for the command, so it equals Lua's `t[1]*1000 + floor(t[2]/1000)`. Read it once.
pub fn now_ms() -> Result<i64, ValkeyError> {
let micros = api(unsafe { raw::RedisModule_CachedMicroseconds })?;
Ok(unsafe { micros() } / 1000)
}
The scripted twin is EchoMQ.Jobs @promote, the script
EchoMQ.Jobs.promote/3 runs. It calls TIME once at the top and
folds seconds and microseconds into the same millisecond value:
-- EchoMQ.Jobs @promote — one TIME call, then the due members by score, up to the batch local p = ARGV[1] local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) 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) local jk = p .. 'job:' .. id -- … a lane row joins its lane (and the ring, with a wake, when the lane can be served); a flat row joins pending redis.call('HSET', jk, 'state', 'pending') end return #due
On both executors the reading belongs to the call: a member is due when its score
is at or below that one value, and the call moves at most its bound. The cadence of the calls therefore sets
how late a due job leaves schedule. The figure sends a promotion every beat over four
scheduled jobs and measures each one's wait.
Due work waits for a call · pick the beat and the batch
A due 300 → promoted by the call at 1 000, waited 700 ms
B due 1 100 → promoted by the call at 2 000, waited 900 ms
C due 1 250 → promoted by the call at 2 000, waited 750 ms
D due 2 600 → promoted by the call at 3 000, waited 400 ms
longest wait: 900 ms; between calls, nothing moves
The server supplies a reading, never a wake-up: how late a due job leaves
schedule is set by how often a client calls and how much each call may move.
The processes that send them
On the library's default wire: 4 connection no process sends
EMQ.PROMOTE or EMQ.STALLED.CHECK: the library evaluates the
scripts. Four of its processes send the sweeps, each on a timer of its own in the client. EchoMQ.Consumer, run standalone,
owns the rhythm: each beat runs the recovery leg, the promotion leg and the drain, then parks.
# echo_mq — EchoMQ.Consumer's beat: recover, promote, drain, then park until a wake or the beat defp loop(s) do check_control() exit_if_ttl_elapsed(s) with {:ok, s} <- recover(s), {:ok, s} <- promote(s), {:ok, s} <- drain(s) do # … a healthy beat resets the degraded streak s = reset_degraded(s) park(s) loop(s) else # … a wire error on any leg skips the rest of the beat and backs off {:degraded, s} -> Process.sleep(degraded_sleep(s)) loop(s) end end # … the recovery leg: @reap by default, @sweep_stalled with recovery :stalled, neither with :off defp promote(s) do case Jobs.promote(s.conn, s.queue, s.pump_batch) do {:ok, _} -> {:ok, s} {:error, reason} -> {:degraded, degrade(s, :promote, reason)} end end
The beat is one second by default and the promotion batch a hundred. In a pool,
EchoMQ.Metronome takes the beat over: one process per queue runs the reaper and the
promotion, holds the single park and pokes the idle consumers, which then run neither sweep.
EchoMQ.Pump promotes on its own tick, a second by default, and also fires repeatable
jobs and delivers cross-queue flow completions; it does not reap. EchoMQ.Stalled can
run the counted sweep from a process of its own, re-armed after every sweep with a BEAM timer:
# echo_mq — EchoMQ.Stalled, the opt-in sweep process: a client-side timer, re-armed after every sweep
@impl true
def handle_info(:sweep, s) do
_ = check(s.conn, s.queue, s.opts)
{:noreply, arm(s)}
end
defp arm(s) do
Process.send_after(self(), :sweep, s.interval_ms)
s
end
The interval is thirty seconds by default, with max_stalled
1 and limit 100. Every one of these timers lives in a client: stop the processes and
the sets stop moving. Assemble a deployment to see which process sends which sweep, and what is left with no
sender; the connection's wire decides what each sweep travels as.
Who sends the sweeps · assemble a deployment
uncounted reap: EchoMQ.Consumer, each beat
counted sweep: nobody
waiting: EchoMQ.Consumer parks on
BLPOP emq:{q}:wake, the beat as timeouttrafikey: on the default
wire: 4 connection EMQ.PROMOTE and EMQ.STALLED.CHECK have no sender; the library evaluates the scriptsOn a wire: 6 connection the two sweeps travel as
EMQ.PROMOTE and EMQ.STALLED.CHECK:
EchoMQ.Jobs.promote/3 and EchoMQ.Stalled.check/3 send the
verbs their scripts declare. The reaper's script and the lane claim's declare none, so each is refused before
anything is sent. A consumer therefore claims nothing and backs off each turn, from the beat up to
30 000 ms, and a Metronome stops at its reaper before it promotes. A Pump, a Stalled process, or a consumer
started with recovery :stalled or :off sends the native
sweeps. No production caller opens a wire: 6 connection today.
Today every clock that moves a job runs in a client: a beat, a tick or an interval in an Elixir process on either wire, a timer in the Go client’s worker, or a person at a raw client; the server only answers what each call reads.
Pattern & implementation
A sorted set scored by fire time is the clock, and a periodic sweep promotes every member whose score has passed. The sorted set as a clock and ZRANGEBYSCORE promotion teach it.
schedule and active are the clocks;
@promote and EMQ.PROMOTE are the sweep, each reading the
server's time once per call, sent by a client on a beat it keeps itself.
The next dive turns from time to waiting: how a parked worker learns that work arrived.
References
Sources
- Valkey — TIME — the server time the scripts read and the cached command time equals.
- Valkey — ZRANGEBYSCORE — the scripted selection of due members.
- Valkey — ZRANGE — the
BYSCOREform the native handler reads with. - Valkey — Modules API reference — the cached command time a module reads, and the module timers it could arm.
Related in this course
- Blocking, timers and the bus — the module hub.
- Failures and recovery — the counted sweep's verdicts and the reaper with no native twin.
- Scheduling and recurrence — the schedule set and the promotion script in the Queue.
- The consumer loop — the beat in the Queue's reading.
- The blocking claim — the next dive: the park at the end of every beat.
- redis-patterns · The next wake — when a sweeper should run next, the near side of the door.