jonnifyredis

R4.01.3 · dive 3

The next wake

Do not busy-poll an empty set. Read the head, compute the next due time, and wait until then — EchoMQ carries the next fire-time on a marker so a blocked worker wakes exactly when due.

A delayed set sits idle most of the time: its jobs are due in the future, not now. A worker that re-runs the sweep on a fixed tick spends round trips finding the same empty due head over and over. The set already holds the answer — its head's score is when the next job is due. Toggle busy-poll against the head-wait on the right to count the round trips each costs.

Busy-poll vs head-wait · round trips over an idle window

idle window — next job due at 60s due — —

The cost of busy-polling

Polling an empty set on a fixed tick wastes work. Every tick the worker runs the due-range, gets nothing back, and sleeps a fixed interval — then repeats, whether or not anything has changed. The shorter the tick the more round trips wasted on an empty set; the longer the tick the later a due job is noticed. A fixed poll cadence cannot be both responsive and cheap.

The waste is not a job that runs slowly; it is a worker spending round trips to learn nothing. On an idle set, every poll returns the same empty due head, and the interval between polls is latency added to whatever job lands next.

A fixed poll tick is a trade with no good setting: short ticks waste round trips on an empty set, long ticks add latency to the next due job.

The delay marker: wake exactly when due

EchoMQ does not sleep-and-recompute. It carries the next fire-time on a marker so a worker blocked on the marker wakes at exactly the right time. A worker parks on BZPOPMIN on the marker set (the blocking pop covered in R3.05). For a plain job the producer rings the marker with a score of zero — ZADD markerKey 0 "0" — which wakes the worker now, because a plain job is due now. For a delayed job the marker carries the fire-time instead. addDelayMarkerIfNeeded reads the head of the delayed set, recovers its real time, and writes that time as the marker's score.

-- addDelayMarkerIfNeeded.lua (included by addDelayedJob-6) — carry the next fire-time on the marker (real)
local nextTimestamp = getNextDelayedTimestamp(delayedKey)   -- reads the head, returns headScore / 0x1000
if nextTimestamp ~= nil then
  rcall("ZADD", markerKey, nextTimestamp, "1")              -- marker score = the next fire-time (ms)
end

getNextDelayedTimestamp reads the delayed head and returns nextTimestamp / 0x1000 — it divides out the twelve-bit shift the fire-time score carries, recovering the real millisecond. That millisecond is written as the marker's score with ZADD markerKey nextTimestamp "1". A worker blocked on the marker wakes when the marker's lowest score becomes available, which the runtime schedules for the next fire-time — so the worker wakes exactly when the next delayed job is due, not on a poll tick. The marker's member is "1" for a delayed wake, distinct from the "0" of the base marker that wakes a worker for a plain job now.

The marker's score is the next wake · ZADD markerKey (headScore / 0x1000) "1"

emq:{queue}:delayed — head (raw score) — emq:{queue}:marker (ZSET) — — recover: headScore / 0x1000 = fire-time ms

EchoMQ carries the next fire-time on the marker: ZADD markerKey (headScore / 0x1000) "1", so a worker blocked on BZPOPMIN wakes when the next delayed job is due, with no poll in between.

In EchoMQ — the delay marker, in real code

The whole wake is real code in echo/apps/echomq, in addDelayedJob-6.lua. getNextDelayedTimestamp reads the head and returns nextTimestamp / 0x1000, dividing out the shift to recover the millisecond. addDelayMarkerIfNeeded writes that millisecond as the marker score with rcall("ZADD", markerKey, nextTimestamp, "1"). The base marker for a plain due-now job is ZADD markerKey 0 "0" instead — the same marker, a different score, so the worker wakes now for a plain job and at the fire-time for a delayed one.

The recovery is the linchpin. Because the fire-time score is ms × 0x1000, the head's raw score is not a time a worker can sleep to; dividing by 0x1000 recovers the real millisecond, and that millisecond is what the marker carries. The shift that packs the within-millisecond discriminator is divided back out exactly, so the wake time is precise.

the pattern

Do not poll an empty delayed set; read the head, compute when the next job is due, and wait until then rather than re-polling on a tick.

in EchoMQ

getNextDelayedTimestamp reads the head and returns headScore / 0x1000, and addDelayMarkerIfNeeded writes it as the marker score with ZADD markerKey nextTimestamp "1", so a worker blocked on BZPOPMIN wakes exactly when the next delayed job is due — the base marker ZADD markerKey 0 "0" wakes it now for a plain job.

The head's score is the next wake; sleeping to it beats polling on a tick, and EchoMQ carries that fire-time on the marker so a blocked worker wakes the instant the next delayed job is due.

Door · not depth The full scheduler subsystem — how the delay marker coordinates with the worker fetch loop and the heartbeat across a worker pool, and the polyglot concurrency models that govern each runtime's block — is the subject of the dedicated EchoMQ course. The delay marker and scheduler coordination is E6 · Lifecycle controls. This dive teaches the next wake; that course teaches the scheduler that schedules it.

References

The blocking pop the worker parks on to wake at the next fire-time, the ZADD that writes that time as the marker's score, the sorted set behind both, and the queue protocol EchoMQ ports.

Sources

  • Redis — BZPOPMIN — block on a sorted set until a member is available; the marker pop the worker parks on to wake at the next fire-time.
  • Redis — ZADD — write the next fire-time as the marker's score; the ring that schedules the wake.
  • Redis — Sorted sets — the data type behind both the delayed set's head and the marker the worker blocks on.
  • BullMQ — the queue protocol — the delay-marker wake protocol EchoMQ ports, where the Lua scripts are the protocol.

Related in this course