E2.03.1 · Lock management · dive 1
One timer, not N
A worker keeps every held lock fresh with a single recurring timer that batch-renews in one Redis call.
EchoMQ.LockManager arms one
Process.send_after timer per worker, fired every
div(lock_renew_time, 2) = 7500 ms. When it fires it scans every
tracked job, selects those past their half-window threshold, and renews them all in one
extend_locks/5 call over extendLocks-1.lua. N jobs cost O(1)
timers and one round-trip, not N of each. Move the job count on the right to read the renewal-call delta.
Renewal calls per window · set the number of held jobs
The single timer
EchoMQ.LockManager is a GenServer. At
init it reads lock_duration (default
30000 ms — the lock TTL) and lock_renew_time (default
div(lock_duration, 2) = 15000 ms), then arms one timer for the
whole worker:
defp schedule_renewal(lock_renew_time) do interval = div(lock_renew_time, 2) -- 7500 ms by default Process.send_after(self(), :extend_locks, interval) end
Each worker has exactly one of these timers, regardless of how many jobs it holds. When the worker picks a job
up it calls track_job(manager, job_id, token) (arity 3), storing the job's token and a
millisecond timestamp; on complete or fail it calls untrack_job(manager, job_id)
(arity 2). The public surface is start_link/1, track_job/3,
untrack_job/2, get_active_job_count/1,
get_tracked_job_ids/1, is_tracked?/2, and
stop/1.
The threshold scan
When the timer fires, handle_info(:extend_locks, state) scans all
tracked_jobs and selects those whose info.ts + threshold < now,
where threshold = div(lock_renew_time, 2). Step the scan below over a fixed set of
tracked jobs, each with its own elapsed-since-renewal time, and read which jobs go into the one batched call.
handle_info(:extend_locks) · the half-window scan
The due jobs are renewed together via
EchoMQ.Scripts.extend_locks(connection, keys, job_ids, tokens, lock_duration) — arity 5,
the EchoMQ.LockManager.extend_locks/5 path. The script side packs all of it into one
call with exactly one key, so the script is extendLocks-1.lua:
def extend_locks(conn, ctx, job_ids, tokens, duration) do keys = [Keys.stalled(ctx)] -- exactly ONE key args = [ Keys.key(ctx), -- the baseKey Msgpax.pack!(tokens, iodata: false), -- all tokens in one ARGV Msgpax.pack!(job_ids, iodata: false), -- all ids in one ARGV duration ] execute(conn, :extend_locks, keys, args) end
Inside extendLocks-1.lua the script walks the packed ids and tokens. For each job it
reads the lock; if the stored token matches it re-sets the PX TTL and removes the job
from the stalled set; otherwise it records the id as failed:
-- extendLocks-1.lua · KEYS[1] stalled · ARGV baseKey, tokens, jobIds, lockDuration local currentToken = rcall("GET", baseKey .. jobIds[i] .. ':lock') if currentToken == token then rcall("SET", lockKey, token, "PX", lockDuration) -- re-set the TTL rcall("SREM", stalledKey, jobId) -- clear from stalled set else table.insert(failedJobs, jobId) -- record a lost lock end return failedJobs -- the FAILED ids; empty means all renewed
The script returns the list of failed job ids — an empty list means every lock was renewed. The
LockManager untracks any job whose lock was lost and fires its
on_lock_renewal_failed callback; that job has stalled, and the stalled checker
recovers it.
One scan, one batched call. The timer never grows a per-job structure to renew — it walks the tracked
set once and renews everyone due in a single extendLocks-1.lua round-trip.
The timing
Three numbers set the rhythm, with these defaults.
- lock_duration
- 30000 ms — the lock TTL set by
SET … PX. - lock_renew_time
- 15000 ms (= lock_duration/2) — the renew window.
- timer interval
- 7500 ms (= div(lock_renew_time, 2)) — how often the single timer fires.
A lock lives 30000 ms; the one timer fires every ~7500 ms and renews any tracked job past its half-window
threshold (div(lock_renew_time, 2) = 7500 ms elapsed). So every held lock is refreshed
well before its 30000 ms TTL — the timeline below lays the three windows against each other: the TTL, the renew
window inside it, and the timer ticks inside that.
The three windows · 30000 / 15000 / 7500 ms
The Golden Rule — the renew protocol is L1/L2
The lock key emq:{queue}:{jobId}:lock (a UUID token under a PX
TTL, L1) and the renew script extendLocks-1.lua (L2)
are frozen and shared. The single-timer batch design — the threshold scan in
handle_info, one batched extend_locks/5 — is the Elixir
runtime's own OTP GenServer choice (L3/L4). Go and Node.js renew the
same locks with the same extend script on their own schedule.
The lock key emq:{queue}:{jobId}:lock (UUID token + PX TTL)
and the renew script extendLocks-1.lua (one stalled key, the ids and tokens packed
into ARGV) — the same in every runtime.
Elixir renews with one EchoMQ.LockManager timer per worker — a
handle_info(:extend_locks) threshold scan into one
extend_locks/5 call; Go and Node.js run the same
extendLocks-1.lua their own way. The renew protocol does not move; the batching
strategy above it does.
One Process.send_after timer per worker, a half-window scan, and one batched
extendLocks-1.lua call — O(1) timers and one Redis round-trip, not O(N). The renew
protocol is L1/L2 — the same in every runtime.
Next: when a renewal fails, the job has stalled. E2.03.2 · stalled recovery finds such jobs and brings them back, or fails them.
References
The lock-renewal protocol and the Redis commands the batched extension issues.
Sources
- BullMQ — Documentation — the lock-renewal protocol and the batched lock-extension call EchoMQ implements.
- Redis — SET — the
SET key token PX msthat re-sets each lock's TTL insideextendLocks-1.lua. - Redis — SREM — the remove-from-stalled-set step a successful renewal performs.
- Redis — GET — the token read the script verifies before re-setting each lock.
Related in this course
- E2.03 · Lock management — the module hub.
- E2.03.2 · Stalled recovery — what happens to a job whose lock the renewal could not keep.
- E2.01.3 · The lock protocol — the single-lock acquire, heartbeat, and token check this dive scales up.
- redis-patterns R3 · Reliable queues — the reliable-queue pattern this lock renewal upholds.