[Bug]: getJobs Lua script (since 5.81.1) blocks Redis for seconds on unbounded ranges default 0-1 turned getDelayed() into a stop-the-world call
Version
v5.81.3
Platform
NodeJS
What happened?
Summary
The getJobs Lua script introduced in 5.81.1 (fix for #4300, "retrieve jobs in same transaction under getJobs") reads the id range and every job's hash (HGETALL) atomically inside a single script. With the unchanged default range start = 0, end = -1, any call like queue.getDelayed() / queue.getJobs(['completed']) on a large state set now hydrates the entire set inside one script execution, holding Redis' single execution thread for the whole duration.
In our production this blocked Redis/Valkey for >5s, which crossed busy-reply-threshold and caused a full outage: every other client (including Socket.IO pub/sub sharing the instance) received BUSY Valkey is busy running a script for ~6 minutes of degraded service.
Previously (≤5.81.0), the same call was ids-in-script + client-side pipelined HGETALLs slow for big sets, but interleaved with other traffic, so Redis stayed responsive.
Incident data
- Upgrade path: 5.79.3 → 5.81.3, deployed 2 days before the incident. No app code changed.
- Redis (Valkey) reported script SHA
2c21b4165511c6c23dffe537928638b4262e5752still running after 5.3s — this is exactly the SHA1 of thegetJobsscript content shipped in 5.81.3. - Trigger call:
queue.getDelayed()(no args →0..-1) on a queue with a large delayed backlog (delay-heavy workflow jobs; the delayed sets dominate our Redis memory). - Redis RSS spiked from ~1.6 GiB to ~4.4 GiB while the script ran (the full result set of job hashes materialized at once), then dropped back.
- All other clients got
BUSYreplies; 6,500+ HTTP 5xx across our APIs in ~75 seconds. - Rolling back to 5.81.0 (last version without the script) removes the behavior; we verified the script content is byte-identical from 5.81.1 through 6.2.0, so v6 has the same characteristics.
Why this is a problem
Redis Lua atomicity means monopolizing the server. The #4300 fix converted the worst case of an unbounded getJobs from "this caller is slow" (N pipelined round-trips, fair-scheduled against other clients) to "the entire datastore is unavailable for every client" (single O(N) script). The bug it fixed occasional undefined entries when a job is removed between the id read and hydration was recoverable by callers; the new failure mode is not.
The unbounded default end = -1 makes this easy to hit: every getter (getDelayed, getCompleted, getFailed, getJobs) still defaults to the full range, and code that was merely inefficient on ≤5.81.0 becomes an availability hazard after a patch upgrade, with a one-line changelog entry giving no signal to re-audit callers.
Suggested remedies (any of these would help)
- Cap or chunk unbounded ranges inside
getJobs: whenend = -1(or the window exceeds some N, e.g. 1000), fall back to the pre-5.81.1 two-step/pipelined path, or iterate the script over bounded chunks. Keeps the #4300 atomicity fix for the bounded/paginated case it was designed for. - Deprecate the unbounded default on the public getters, or emit a runtime warning when an unbounded
getJobsruns against a set larger than some threshold. - Document the change prominently (docs + a note in the v5 changelog):
getJobs-family calls are now atomic and must be bounded in production.
Environment
- BullMQ 5.81.3 (also verified against 6.2.0), Node.js, ioredis
- Valkey (Render Key Value), shared with Socket.IO pub/sub
Workaround for anyone hitting this
Pin bullmq to exact 5.81.0 and/or bound every getJobs/getDelayed/getCompleted/getFailed call to an explicit small range. If you were scanning a state set to find one job (our case), track the job id instead and use queue.getJob(id).
How to reproduce.
Reproduction sketch
- Add ~200k delayed jobs with non-trivial
data(a few KB each). - Call
queue.getDelayed()(or anygetJobswith default range). - Observe the script holding the Redis execution thread for multiple seconds (
SLOWLOG/busy-reply-thresholdBUSY replies to other clients), and server memory ballooning by roughly the serialized size of the whole set.
Relevant log output
Code of Conduct
- I agree to follow this project's Code of Conduct
Source: taskforcesh/bullmq