R4.04.3 · dive 3
Reusing the delayed ZSET
A retry introduces no new structure. It is R4.01's delayed sorted set used a second time: re-added
at a backoff fire-time, swept back by the same promoteDelayedJobs.
The whole module reduces to one fact: R4.01 owns the delayed set, the score shift, and the
promote sweep, and R4.04 reuses all three. retryJob-11.lua re-adds the failed job to
emq:{queue}:delayed scored by the backoff fire-time, and the script includes
the same due-sweep R4.01 teaches. Move the clock slider on the right to watch a retry re-enter the set at its
backoff score and be swept back when the clock reaches it.
A retry in the delayed set · re-added, then swept when due
The retry re-enters the delayed set
When a job fails and has retries left, the worker calls EchoMQ.Scripts.retry_job/5,
which runs retryJob-11.lua. That script's KEYS[7] is the
delayed key — emq:{queue}:delayed, the exact set R4.01 schedules into. The failed
job is re-added there, scored by its fire-time the way every delayed job is, with the score coming from the same
getDelayedScore shift R4.01 walks: (timestamp + delay) × 0x1000,
the fire-time shifted twelve bits so jobs due in the same millisecond stay ordered.
The delay in that fire-time is the backoff delay — computed first by
EchoMQ.Backoff.calculate/4 in Elixir, then handed to the reschedule. So the retry sits
in the delayed set indistinguishable from a freshly-scheduled deferred job: same key, same score shape, same
ordering. The set does not separate one member as a retry and another as a first-time schedule. To the sorted
set they are all jobs scored by when they are due.
A retry re-enters emq:{queue}:delayed via
retryJob-11.lua's KEYS[7], scored by the same
(timestamp + delay) × 0x1000 shift — indistinguishable in the set from a
first-time schedule.
The sweep is the same promoteDelayedJobs
retryJob-11.lua does not only re-add the job — it includes the promote
sweep. The script carries [INCLUDED: includes/promoteDelayedJobs.lua], and that included
function is the same one R4.01's promotion dive teaches.
-- promoteDelayedJobs (included by retryJob-11.lua) — real local jobs = rcall("ZRANGEBYSCORE", delayedKey, 0, (timestamp + 1) * 0x1000 - 1, "LIMIT", 0, 1000) if (#jobs > 0) then rcall("ZREM", delayedKey, unpack(jobs)) -- then ZADD each onto the wait / prioritized list end
It ranges the delayed key from 0 up to
(timestamp + 1) × 0x1000 - 1 — every job whose shifted fire-time is at or below
the current millisecond — removes them with ZREM, and moves them onto the wait or
prioritized list. The retry re-enters at a future score and is swept back by exactly this function when the
clock reaches it. There is no retry-specific sweep; the retry is promoted by the same machinery that promotes
every delayed job, invoked as promoteDelayedJobs(KEYS[7], ...) inside the retry
script.
The retry is swept back by the same included promoteDelayedJobs —
ZRANGEBYSCORE delayedKey 0 (timestamp + 1) × 0x1000 - 1 then
ZREM — no retry-specific sweep exists.
One structure, two uses — what changes and what does not
The whole module reduces to this: R4.01 owns the delayed set, the score shift, and the promote sweep. R4.04
reuses all three. What R4.04 adds is upstream of the set — the computation of which delay
to put in the score, done by EchoMQ.Backoff (the exponential doubling, the jitter). What
R4.04 does not add is any new key, command, or sweep. The reschedule is a ZADD
to emq:{queue}:delayed; the recovery is promoteDelayedJobs;
both are R4.01's.
This is why the pattern is economical. A queue that already has a delayed queue has a retry mechanism for free — it needs only a formula to size the delay and an attempts counter to bound the loop. The hard part (a time-ordered set with an efficient due-range read) was built once, in R4.01, and serves both scheduled work and retries. The interactive below puts a first-time schedule and a retry in the same set and sweeps them with one clock.
Two uses, one set · a schedule and a retry, swept together
A retry adds no structure: it reuses R4.01's delayed set, score shift, and
promoteDelayedJobs sweep, contributing only the backoff delay that sets the
fire-time.
In EchoMQ — the reuse, in real code
The reuse is real code in echo/apps/echomq.
EchoMQ.Scripts.retry_job/5 runs retryJob-11.lua;
KEYS[7] is the delayed key from EchoMQ.Keys.delayed/1
(emq:{queue}:delayed). The script re-adds the failed job there at the backoff score and
then calls the included promoteDelayedJobs(KEYS[7], ...) — the same sweep
addDelayedJob-6.lua and the worker scripts include. The backoff delay comes from
EchoMQ.Backoff; the key, the score shift, and the sweep are R4.01's.
-- retryJob-11.lua — re-add at the backoff score, then sweep (real shape) -- KEYS[7] = emq:{queue}:delayed ZADD KEYS[7] backoffScore jobId -- backoffScore = (now + backoff) × 0x1000 promoteDelayedJobs(KEYS[7], markerKey, target, ...) -- the same included sweep R4.01 teaches
A retry is a delayed job: re-add the failed job to the delayed set at a backoff fire-time, and let the existing due-sweep bring it back when its time comes. No new structure.
retryJob-11.lua re-adds the job to emq:{queue}:delayed
(KEYS[7]) at the backoff score, and the included
promoteDelayedJobs sweeps it back with
ZRANGEBYSCORE delayedKey 0 (timestamp + 1) × 0x1000 - 1 then
ZREM — R4.01's own sweep, called from inside the retry script.
A retry adds no structure — it reuses R4.01's delayed set, score shift, and
promoteDelayedJobs sweep, contributing only the backoff delay that sets the
fire-time.
emq:{queue}:delayed and is swept by
promoteDelayedJobs today. EchoMQ 2.0 renames the key to
emq:{queue}:delayed, applies the {queue} hashtag transparently in
the core, and declares every Lua key in KEYS[] — so
retryJob-11.lua's KEYS[7] and the sweep's
delayedKey become fully-declared emq:-prefixed keys, and
meta.version reads echomq:2.0.0 behind the two-way boot fence.
The reuse this dive teaches — one set serving both schedule and retry, swept by one
promoteDelayedJobs — is unchanged by the break. The "one structure, two uses"
property is a fact about the sorted set, not the prefix.
retryJob-11.lua — is the subject of the dedicated
EchoMQ course. The retry and checkpoint lifecycle is
E6 · Lifecycle controls. This dive teaches the reuse; that course teaches
the lifecycle that runs it.
References
The re-add at the backoff fire-time, the due-sweep that brings the retry back, the one sorted set both uses share, the queue protocol EchoMQ ports, and the direction EchoMQ 2.0 takes native.
Sources
- Redis — ZADD — re-add the failed job to
emq:{queue}:delayedat the backoff fire-time score. - Redis — ZRANGEBYSCORE — the
promoteDelayedJobsrange that sweeps the due retry back to wait. - Redis — Sorted sets — the one timer-wheel that serves both schedule and retry.
- BullMQ — the queue protocol — the retry-reschedule protocol EchoMQ ports, where the Lua scripts are the protocol.
- DragonflyDB — BullMQ on Dragonfly — the BullMQ-on-Dragonfly direction the EchoMQ 2.0 protocol break takes native.
Related in this course
- R4.04 · Backoff & retry — the module hub.
- R4.04.1 · Exponential backoff — the formula that sizes the delay re-added here.
- R4.04.2 · Jitter & the thundering herd — the spread on the delay re-added here.
- R4.01 · The delayed queue — the
:delayedset, score shift, and sweep this reuses. - R4.02 · Scheduler registry — recurring jobs on the sibling
:repeatset. - E6 · Lifecycle controls — the dedicated EchoMQ course: the retry lifecycle in depth.