deduplication.keepLastIfActive performs an O(active) LRANGE + Lua scan on every deduplicated add
Type: Performance
Component: Deduplication / Lua scripts
Affected versions: v5.72.0 (2026-04-01) through current master (6.1.2), all backends that use the Redis scripts
File: src/commands/includes/storeDeduplicatedNextJob.lua
Summary
When deduplication.keepLastIfActive is enabled, every deduplicated add() triggers a full read of the queue's active list into the Lua VM, followed by a linear scan in Lua to test membership of a single job ID.
Because the whole point of the option is to collapse a high rate of repeated adds, the hit rate on this path is high by construction. The cost therefore scales as O(active_depth × deduplicated_add_rate), executed serially on the Redis/Valkey main thread inside an atomic script.
The codebase already has a version-gated LPOS-based membership check used for exactly this purpose elsewhere (getStateV2-8.lua, RedisQueueBackend#isJobInState). Applying the same pattern here appears to be a one-line change.
Details
src/commands/includes/storeDeduplicatedNextJob.lua, lines 12–15 at master:
if deduplicationOpts['keepLastIfActive'] and currentDeduplicatedJobId then
local activeKey = prefix .. "active"
local activeItems = rcall('LRANGE', activeKey, 0, -1)
if checkItemInList(activeItems, currentDeduplicatedJobId) then
with src/commands/includes/checkItemInList.lua:
local function checkItemInList(list, item)
for _, v in pairs(list) do
if v == item then
return 1
end
end
return nil
end
storeDeduplicatedNextJob is reached from both deduplicateJob (replace path) and deduplicateJobWithoutReplace, i.e. from addStandardJob, addDelayedJob, addPrioritizedJob, and addParentJob.
To be clear about scope: the keepLastIfActive guard is the first condition in the function, so users who do not enable the option pay nothing. This is strictly an opt-in cost — but it is a cost that grows precisely where the feature is most useful.
Why this is more expensive than the comparison count suggests
Each invocation forces Redis to:
- Materialize the entire
active list as a multibulk reply,
- Convert it into a Lua table in the shared interpreter,
- Walk it with
pairs() (non-array iteration over what is an array),
- Discard the table, leaving GC pressure in the Lua VM.
Only step 3 is the nominal O(N) scan; steps 1, 2 and 4 dominate the constant factor.
Rough magnitude
A moderately sized deployment — say 20 workers at concurrency 50, so ~1,000 jobs active — with a 1,000/sec deduplicated add rate performs on the order of 10⁶ element allocations and comparisons per second on the single-threaded event loop, entirely inside atomic scripts that block all other clients for their duration. Larger active depths scale this linearly.
Proposed fix
Replace the list materialization with a server-side membership test.
Preferred: LPOS
if deduplicationOpts['keepLastIfActive'] and currentDeduplicatedJobId then
local activeKey = prefix .. "active"
- local activeItems = rcall('LRANGE', activeKey, 0, -1)
- if checkItemInList(activeItems, currentDeduplicatedJobId) then
+ if rcall('LPOS', activeKey, currentDeduplicatedJobId) then
This keeps identical semantics (membership of active) while eliminating the reply serialization, the Lua table allocation, and the interpreted scan. The remaining traversal happens inside Redis's own C loop.
This is the same substitution already made in getStateV2-8.lua relative to the legacy getState-8.lua:
-- getState-8.lua (legacy)
local active_items = rcall("LRANGE", KEYS[4], 0, -1)
-- getStateV2-8.lua
if rcall("LPOS", KEYS[4], ARGV[1]) then return "active" end
and in RedisQueueBackend#isJobInState, which calls client.lpos() directly. lpos() is implemented across all four client adapters (ioredis, node-redis, bun-redis-client, valkey-glide-client).
Handling the version gate
LPOS requires Redis ≥ 6.0.6, and RedisConnection.minimumVersion is still 5.0.0, so a fallback is needed. getState and isJobInState gate at the TypeScript call site via isRedisVersionLowerThan(..., '6.0.6', ...), which isn't directly available here because storeDeduplicatedNextJob is an include shared by several add scripts rather than a script selected per call.
Two options, in rough order of preference:
(a) Capability flag through the packed options. The client already knows queue.redisVersion. Passing a boolean alongside the existing de options and branching in Lua keeps checkItemInList as the ≤6.0.5 path with no script duplication.
(b) redis.pcall probe, self-contained.
local pos = redis.pcall('LPOS', activeKey, currentDeduplicatedJobId)
if type(pos) == 'table' and pos.err then
-- Redis < 6.0.6: fall back to the existing scan
pos = checkItemInList(rcall('LRANGE', activeKey, 0, -1), currentDeduplicatedJobId)
end
if pos then
No plumbing required, at the cost of one failed dispatch per call on legacy servers only.
Alternative considered and rejected: lock-key existence
EXISTS <prefix><jobId>:lock would be true O(1) and version-independent, since prepareJobForProcessing creates that key on transition to active. We do not propose it, because a stalled job remains in the active list after its lock has expired but before moveStalledJobsToWait reclaims it. That window would change the option's observable semantics. LPOS preserves them exactly.
Minor, independent of the above
If checkItemInList is retained as a fallback, pairs() over an array-shaped table can become a numeric for loop — free, and marginally faster.
Suggested verification
A benchmark that holds the deduplicated add rate constant while sweeping active depth (e.g. 10 / 100 / 1,000 / 10,000) should show the current implementation scaling linearly in p99 add() latency and in INSTANTANEOUS_OPS-adjacent blocking, and the LPOS version scaling far more gently. Measuring latency on an unrelated command against the same instance during the sweep is the clearest demonstration of the head-of-line blocking, since it isolates main-thread occupancy from client-side effects.
Background / prior art in this repo
keepLastIfActive shipped in v5.72.0 via #3902. Subsequent changes to this file have been correctness-focused rather than performance-focused:
| Change |
Version |
Nature |
| #3902 — original feature |
5.72.0 (2026-04-01) |
feature |
#4190 — preserve custom jobId when requeuing proto-jobs (fixes #4030) |
5.77.4 (2026-05-26) |
correctness |
| v6 release commit |
6.0.0 (2026-07-30) |
mechanical |
I could not find any existing issue or PR raising the performance characteristics of this path. #3912 ("consider other non finished states in keepLastIfActive") was closed unmerged; note that widening the check to additional states would compound this cost unless the underlying membership test is addressed first.
Questions
- Is there a reason the
LPOS pattern used in getStateV2 was not applied here, e.g. a semantic difference I've missed?
- If a capability flag is the preferred gating approach, is there an existing convention for threading Redis-version capabilities into shared includes that we should follow?
- Is a fix along these lines something you'd accept as a community PR, or would you prefer to handle the version-gating plumbing in-house?
deduplication.keepLastIfActiveperforms an O(active)LRANGE+ Lua scan on every deduplicated addType: Performance
Component: Deduplication / Lua scripts
Affected versions: v5.72.0 (2026-04-01) through current
master(6.1.2), all backends that use the Redis scriptsFile:
src/commands/includes/storeDeduplicatedNextJob.luaSummary
When
deduplication.keepLastIfActiveis enabled, every deduplicatedadd()triggers a full read of the queue'sactivelist into the Lua VM, followed by a linear scan in Lua to test membership of a single job ID.Because the whole point of the option is to collapse a high rate of repeated adds, the hit rate on this path is high by construction. The cost therefore scales as
O(active_depth × deduplicated_add_rate), executed serially on the Redis/Valkey main thread inside an atomic script.The codebase already has a version-gated
LPOS-based membership check used for exactly this purpose elsewhere (getStateV2-8.lua,RedisQueueBackend#isJobInState). Applying the same pattern here appears to be a one-line change.Details
src/commands/includes/storeDeduplicatedNextJob.lua, lines 12–15 atmaster:with
src/commands/includes/checkItemInList.lua:storeDeduplicatedNextJobis reached from bothdeduplicateJob(replace path) anddeduplicateJobWithoutReplace, i.e. fromaddStandardJob,addDelayedJob,addPrioritizedJob, andaddParentJob.To be clear about scope: the
keepLastIfActiveguard is the first condition in the function, so users who do not enable the option pay nothing. This is strictly an opt-in cost — but it is a cost that grows precisely where the feature is most useful.Why this is more expensive than the comparison count suggests
Each invocation forces Redis to:
activelist as a multibulk reply,pairs()(non-array iteration over what is an array),Only step 3 is the nominal
O(N)scan; steps 1, 2 and 4 dominate the constant factor.Rough magnitude
A moderately sized deployment — say 20 workers at concurrency 50, so ~1,000 jobs active — with a 1,000/sec deduplicated add rate performs on the order of 10⁶ element allocations and comparisons per second on the single-threaded event loop, entirely inside atomic scripts that block all other clients for their duration. Larger active depths scale this linearly.
Proposed fix
Replace the list materialization with a server-side membership test.
Preferred:
LPOSif deduplicationOpts['keepLastIfActive'] and currentDeduplicatedJobId then local activeKey = prefix .. "active" - local activeItems = rcall('LRANGE', activeKey, 0, -1) - if checkItemInList(activeItems, currentDeduplicatedJobId) then + if rcall('LPOS', activeKey, currentDeduplicatedJobId) thenThis keeps identical semantics (membership of
active) while eliminating the reply serialization, the Lua table allocation, and the interpreted scan. The remaining traversal happens inside Redis's own C loop.This is the same substitution already made in
getStateV2-8.luarelative to the legacygetState-8.lua:and in
RedisQueueBackend#isJobInState, which callsclient.lpos()directly.lpos()is implemented across all four client adapters (ioredis,node-redis,bun-redis-client,valkey-glide-client).Handling the version gate
LPOSrequires Redis ≥ 6.0.6, andRedisConnection.minimumVersionis still5.0.0, so a fallback is needed.getStateandisJobInStategate at the TypeScript call site viaisRedisVersionLowerThan(..., '6.0.6', ...), which isn't directly available here becausestoreDeduplicatedNextJobis an include shared by several add scripts rather than a script selected per call.Two options, in rough order of preference:
(a) Capability flag through the packed options. The client already knows
queue.redisVersion. Passing a boolean alongside the existingdeoptions and branching in Lua keepscheckItemInListas the ≤6.0.5 path with no script duplication.(b)
redis.pcallprobe, self-contained.No plumbing required, at the cost of one failed dispatch per call on legacy servers only.
Alternative considered and rejected: lock-key existence
EXISTS <prefix><jobId>:lockwould be true O(1) and version-independent, sinceprepareJobForProcessingcreates that key on transition to active. We do not propose it, because a stalled job remains in theactivelist after its lock has expired but beforemoveStalledJobsToWaitreclaims it. That window would change the option's observable semantics.LPOSpreserves them exactly.Minor, independent of the above
If
checkItemInListis retained as a fallback,pairs()over an array-shaped table can become a numericforloop — free, and marginally faster.Suggested verification
A benchmark that holds the deduplicated add rate constant while sweeping
activedepth (e.g. 10 / 100 / 1,000 / 10,000) should show the current implementation scaling linearly in p99add()latency and inINSTANTANEOUS_OPS-adjacent blocking, and theLPOSversion scaling far more gently. Measuring latency on an unrelated command against the same instance during the sweep is the clearest demonstration of the head-of-line blocking, since it isolates main-thread occupancy from client-side effects.Background / prior art in this repo
keepLastIfActiveshipped in v5.72.0 via #3902. Subsequent changes to this file have been correctness-focused rather than performance-focused:jobIdwhen requeuing proto-jobs (fixes #4030)I could not find any existing issue or PR raising the performance characteristics of this path. #3912 ("consider other non finished states in
keepLastIfActive") was closed unmerged; note that widening the check to additional states would compound this cost unless the underlying membership test is addressed first.Questions
LPOSpattern used ingetStateV2was not applied here, e.g. a semantic difference I've missed?