E2.03.2 · Lock management · dive 2
Stalled recovery
A job whose worker died sits in active with no live lock; a two-phase sweep brings it back or fails it.
A job is stalled when it sits in active but its worker
failed to complete it or renew its lock before the PX TTL expired — a crash, lost power,
a network failure, or a blocked processor. EchoMQ.StalledChecker lists
active, probes each job's lock with a pipelined EXISTS, and
recovers the jobs whose lock is gone in two phases: mark to the stalled set, then on the next
sweep requeue or fail. Step the phases on the right over a fixed active set.
Two-phase recovery · mark, then recover
What stalls a job
A job is stalled when a worker took it — it sits in active — but failed to complete it
or renew its lock before the lock's PX TTL expired. That happens when the worker process
crashes, the machine loses power, a network failure blocks lock renewal, or the job processor blocks without
yielding. EchoMQ.StalledChecker finds such jobs and recovers them.
The checker is a GenServer driving one timer:
# default stalled_interval = 30000 ms timer_ref = Process.send_after(self(), :check_stalled, stalled_interval)
Its public surface is start_link/1,
check(connection, queue, opts \\ []) (arity 3, a manual trigger returning
{:ok, %{recovered: n, failed: n}}), and
job_stalled?(connection, queue, job_id, opts \\ []) (arity 4, a single-job probe).
The lock probe
On each :check_stalled, do_check lists the active jobs and
probes their locks. The stall signal is the absence of the lock key for a job still in
active. Pick an active job below to read the probe and its verdict.
defp do_check(connection, ctx, max_stalled_count) do case RedisConnection.command(connection, ["LRANGE", Keys.active(ctx), 0, -1]) do {:ok, []} -> {:ok, %{recovered: 0, failed: 0}} {:ok, job_ids} -> check_jobs_stalled(connection, ctx, job_ids, max_stalled_count) end end # one EXISTS per active id, run as a single pipeline; exists == 0 means no lock stalled_jobs = Enum.zip(job_ids, results) |> Enum.filter(fn {_id, exists} -> exists == 0 end) |> Enum.map(fn {id, _} -> id end)
EXISTS lock · pick an active job
The checker flags a job whose lock key is gone while the job still sits in active. A live lock means a live worker; a missing one means the job needs recovery.
The recovery script, and the two phases
EchoMQ uses two-phase detection so it never fails a job on a single bad reading:
Mark phase — a job with no valid lock is moved into the stalled SET.
Recover phase — on the next check, a job still in the stalled set is either requeued
(→ wait) or moved to failed, decided by
max_stalled_count. The two phases prevent a false positive from a transient timing
window: a job that briefly looked lockless but is in fact healthy clears itself on the next pass instead of being
failed.
The recovery itself is moveStalledJobsToWait-8.lua — the -8 is
the numkeys. The canonical wrapper
EchoMQ.Scripts.move_stalled_jobs_to_wait/4 passes eight keys:
def move_stalled_jobs_to_wait(conn, ctx, max_stalled_count, opts \\ []) do keys = [ Keys.stalled(ctx), Keys.wait(ctx), Keys.active(ctx), Keys.failed(ctx), Keys.stalled_check(ctx), Keys.meta(ctx), Keys.paused(ctx), Keys.marker(ctx) ] -- eight keys → moveStalledJobsToWait-8.lua ... end
The recovery result the checker returns is
{:ok, %{recovered: count, failed: count}} — recovered is how
many jobs were requeued to wait, failed how many had exhausted
their stall count and were moved to failed. The checker emits telemetry for each:
:stalled_recovered and :stalled_failed.
Defaults
- stalled_interval
- 30000 ms — the sweep period.
- max_stalled_count
- 1 — max stalls before a job is failed rather than requeued.
The default max_stalled_count is 1 because a job that stalls more than
once usually signals a real bug — repeated crashes on specific job data, resource exhaustion, an external-service
failure. So the default fails the job rather than looping forever; raising it is generally not recommended.
Investigate why the job stalls instead.
The Golden Rule — the stall signal and the recovery are L1/L2
The stall signal — the absence of the lock key for a job still in
active (L1) — and the recovery script
moveStalledJobsToWait-8.lua (L2) are frozen and shared, the same in
every runtime. The Elixir checker — LRANGE active, a pipelined
EXISTS-lock probe, the recovery script, all on one GenServer
timer — is the Elixir runtime's own L3/L4.
The stall signal — no emq:{queue}:{jobId}:lock for a job still in
active — and the recovery script
moveStalledJobsToWait-8.lua (eight keys), the same in every runtime.
Elixir runs EchoMQ.StalledChecker — LRANGE active, a
pipelined EXISTS probe, the recovery script, on one timer; Go and Node.js run the same
signal and script with their own checker. (Go's recovery is part of its non-atomic critical path — separate
commands rather than embedded Lua — the cross-runtime contrast E2.04 takes up.) The detection signal and the
recovery script do not move.
A job is stalled when it sits in active with no live lock; recovery is two-phase — mark to the
stalled set, then requeue or fail by max_stalled_count — over
moveStalledJobsToWait-8.lua. The signal and the recovery are L1/L2 — the same in every
runtime.
Next: the scheduler and the event stream around the lifecycle.
E2.03.3 · schedulers & events closes the
module, including the :stalled event this checker can emit.
References
The two-phase recovery sweep and the Redis commands the checker issues.
Sources
- BullMQ — Documentation — the two-phase stalled-job detection and recovery sweep EchoMQ implements.
- Redis — LRANGE — the read of the
activelist the checker scans each sweep. - Redis — EXISTS — the pipelined lock-presence probe that flags a stalled job.
- Redis — SREM — the remove-from-stalled-set step a successful renewal performs before a sweep.
Related in this course
- E2.03 · Lock management — the module hub.
- E2.03.1 · One timer, not N — the renewal that, when it fails, leaves a job for this checker to recover.
- E2.01.3 · The lock protocol — the lock whose absence is the stall signal.
- redis-patterns R3 · Reliable queues — the reliable-queue pattern this recovery upholds.