jonnifyechomq

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 promotion every
batch
0 1 000 2 000 3 000 4 000 ms A700 ms B900 ms C750 ms D400 ms
a promotion every 1 000 ms · batch 100 · 4 calls by 4 000 ms, each reading the clock once
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

connection
EchoMQ.Consumer
EchoMQ.Pump
EchoMQ.Stalled process
promotion@promoteConsumer uncounted reap@reapConsumer counted sweep@sweep_stallednobody waitingBLPOP wakeConsumer wire 4: EMQ.PROMOTE, EMQ.STALLED.CHECK have no sender
promotion: EchoMQ.Consumer, each beat (1 000 ms)
uncounted reap: EchoMQ.Consumer, each beat
counted sweep: nobody
waiting: EchoMQ.Consumer parks on BLPOP emq:{q}:wake, the beat as timeout
trafikey: on the default wire: 4 connection EMQ.PROMOTE and EMQ.STALLED.CHECK have no sender; the library evaluates the scripts

On 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

the pattern (Redis Patterns Applied)

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.

the implementation (EchoMQ and trafikey)

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

Related in this course