jonnifyredis

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

20
emq:{queue}:delayed — a retry among scheduled jobs (s) now —

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

emq:{queue}:delayed — one set, both uses —

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
the pattern

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.

in EchoMQ

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.

Where this is heading · EchoMQ 2.0 The retry re-enters 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.
Door · not depth The full lifecycle subsystem — the failed-job path through reschedule, the attempts cap, and the worker pool that runs 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

Related in this course