jonnifyechomq
EchoMQ / E2 · Core / Lock management / One timer, not N

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

timer-per-job: N calls — LockManager: 1 batched call 1 call

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

threshold = div(15000, 2) = 7500 ms
job A · 9200 ms elapsed job B · 3100 ms elapsed job C · 8800 ms elapsed job D · 2000 ms elapsed one extend_locks/5 call selected: — extendLocks-1.lua · 1 stalled key

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

0 30000 ms lock_duration = 30000 ms (the TTL) lock_renew_time = 15000 ms timer fires every 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 protocol (immutable L1/L2)

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.

its three runtimes (variable L3/L4)

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 ms that re-sets each lock's TTL inside extendLocks-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