Skip to content

Commit bdbe7e1

Browse files
committed
fix(run-engine): reconcile prunes members whose runs are queued
A member whose message still exists but is sitting in a queue zset holds no legitimate slot: queued and in-flight are mutually exclusive on every path. The bounded reconcile now prunes those members too (the stored payload's queue field is the full variant key, so one ZSCORE answers it), which frees a saturated cap even when the self-holding run's variant lies beyond the dequeue candidate window and can never exempt itself. Covered by a test where the leaked member belongs to a different queue entirely.
1 parent 8ef9fb1 commit bdbe7e1

2 files changed

Lines changed: 78 additions & 27 deletions

File tree

internal-packages/run-engine/src/run-queue/index.ts

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,24 @@ local function __gateKeys(gatesKeyPrefix, msg, gate)
8585
return base, variant, gateKey
8686
end
8787
88-
local function __gateReconcile(setKey, msgKeyPrefix)
88+
local function __gateReconcile(setKey, msgKeyPrefix, reconcileKeyPrefix)
8989
if not msgKeyPrefix then return end
9090
if redis.call('SET', setKey .. ':reconcileLock', '1', 'NX', 'EX', '10') then
9191
local cursorKey = setKey .. ':reconcileCursor'
9292
local cursor = redis.call('GET', cursorKey) or '0'
93-
local scanResult = redis.call('SSCAN', setKey, cursor, 'COUNT', '500')
93+
local scanResult = redis.call('SSCAN', setKey, cursor, 'COUNT', '100')
9494
redis.call('SET', cursorKey, scanResult[1], 'EX', '3600')
9595
for _, memberId in ipairs(scanResult[2]) do
96-
if redis.call('EXISTS', msgKeyPrefix .. memberId) == 0 then
96+
local rawMemberPayload = redis.call('GET', msgKeyPrefix .. memberId)
97+
if not rawMemberPayload then
9798
redis.call('SREM', setKey, memberId)
99+
elseif reconcileKeyPrefix then
100+
local okMember, member = pcall(cjson.decode, rawMemberPayload)
101+
if okMember and type(member) == 'table' and type(member.queue) == 'string' then
102+
if redis.call('ZSCORE', reconcileKeyPrefix .. member.queue, memberId) then
103+
redis.call('SREM', setKey, memberId)
104+
end
105+
end
98106
end
99107
end
100108
end
@@ -107,7 +115,7 @@ local function __gatesHaveCapacity(gatesKeyPrefix, msg, messageId, envLimit, msg
107115
local occupancy = tonumber(redis.call('SCARD', variant .. ':currentConcurrency') or '0')
108116
local perKeyLimit = math.min(tonumber(redis.call('GET', base .. ':concurrency') or '1000000'), envLimit)
109117
if occupancy >= perKeyLimit and redis.call('SISMEMBER', variant .. ':currentConcurrency', messageId) == 0 then
110-
__gateReconcile(variant .. ':currentConcurrency', msgKeyPrefix)
118+
__gateReconcile(variant .. ':currentConcurrency', msgKeyPrefix, gatesKeyPrefix)
111119
return false
112120
end
113121
if gateKey and gateKey ~= '' then
@@ -116,7 +124,7 @@ local function __gatesHaveCapacity(gatesKeyPrefix, msg, messageId, envLimit, msg
116124
local totalLimit = math.min(tonumber(rawTotal), envLimit)
117125
local groupKey = base .. ':groupConcurrency'
118126
if tonumber(redis.call('SCARD', groupKey) or '0') >= totalLimit and redis.call('SISMEMBER', groupKey, messageId) == 0 then
119-
__gateReconcile(groupKey, msgKeyPrefix)
127+
__gateReconcile(groupKey, msgKeyPrefix, gatesKeyPrefix)
120128
return false
121129
end
122130
end
@@ -4921,29 +4929,14 @@ if totalConcurrencyEnabled then
49214929
local totalConcurrencyLimit = math.min(tonumber(rawTotalLimit), envConcurrencyLimit)
49224930
local groupCurrentConcurrency = tonumber(redis.call('SCARD', groupConcurrencyKey) or '0')
49234931
4924-
-- Self-heal before holding the queue at its limit. A terminal release path
4925-
-- that misses the group mirror (an older build, or a future script) leaves
4926-
-- a member behind, but every terminal path deletes the run's message key,
4927-
-- so a member with no message key is provably dead. Members of re-queued
4928-
-- runs keep their message key and clear through the mirrored ack when the
4929-
-- run completes. The short lock bounds a saturated queue to one pass per
4930-
-- interval, and SSCAN with a persisted cursor bounds each pass to one
4931-
-- batch so a large set never blocks Redis for a full traversal; successive
4932-
-- passes cover the whole set.
4932+
-- Self-heal before holding the queue at its limit: a member with no message
4933+
-- key was terminally released without the mirror and is dead, and a member
4934+
-- whose message is QUEUED (in its variant zset) holds no legitimate slot,
4935+
-- since queued and in-flight are mutually exclusive on every path. Both are
4936+
-- pruned by the shared bounded reconcile.
49334937
if groupCurrentConcurrency >= totalConcurrencyLimit then
4934-
local reconcileLockKey = groupConcurrencyKey .. ':reconcileLock'
4935-
if redis.call('SET', reconcileLockKey, '1', 'NX', 'EX', '10') then
4936-
local reconcileCursorKey = groupConcurrencyKey .. ':reconcileCursor'
4937-
local reconcileCursor = redis.call('GET', reconcileCursorKey) or '0'
4938-
local scanResult = redis.call('SSCAN', groupConcurrencyKey, reconcileCursor, 'COUNT', '500')
4939-
redis.call('SET', reconcileCursorKey, scanResult[1], 'EX', '3600')
4940-
for _, groupMemberId in ipairs(scanResult[2]) do
4941-
if redis.call('EXISTS', messageKeyPrefix .. groupMemberId) == 0 then
4942-
redis.call('SREM', groupConcurrencyKey, groupMemberId)
4943-
end
4944-
end
4945-
groupCurrentConcurrency = tonumber(redis.call('SCARD', groupConcurrencyKey) or '0')
4946-
end
4938+
__gateReconcile(groupConcurrencyKey, messageKeyPrefix, keyPrefix)
4939+
groupCurrentConcurrency = tonumber(redis.call('SCARD', groupConcurrencyKey) or '0')
49474940
end
49484941
49494942
totalHeadroom = totalConcurrencyLimit - groupCurrentConcurrency

internal-packages/run-engine/src/run-queue/tests/totalConcurrency.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,64 @@ describe("RunQueue total concurrency limit", () => {
365365
}
366366
);
367367

368+
redisTest(
369+
"reconciles a member whose run went back to waiting in a queue",
370+
async ({ redisContainer }) => {
371+
const queue = createQueue(redisContainer, true);
372+
try {
373+
const keys = testOptions.keys;
374+
await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, "task/my-task", 5);
375+
await queue.updateQueueTotalConcurrencyLimits(authenticatedEnvDev, "task/my-task", 1);
376+
377+
/**
378+
* A run on ANOTHER queue whose message exists and is queued there, leaked
379+
* into this queue's group set by an unmirrored release. It can never be a
380+
* dequeue candidate here, so only the reconcile can free the slot: queued
381+
* and in-flight are mutually exclusive, so a queued member holds nothing.
382+
*/
383+
await queue.enqueueMessage({
384+
env: authenticatedEnvDev,
385+
message: makeMessage({
386+
runId: "x0",
387+
queue: "other-queue",
388+
concurrencyKey: "ck-x",
389+
timestamp: Date.now() + 3_600_000,
390+
}),
391+
workerQueue: "main",
392+
});
393+
await queue.redis.sadd(
394+
keys.queueGroupConcurrencyKey(authenticatedEnvDev, "task/my-task"),
395+
"x0"
396+
);
397+
expect(await queue.totalConcurrencyOfQueue(authenticatedEnvDev, "task/my-task")).toBe(1);
398+
399+
await queue.enqueueMessage({
400+
env: authenticatedEnvDev,
401+
message: makeMessage({
402+
runId: "r0",
403+
concurrencyKey: "ck-a",
404+
timestamp: Date.now() - 1000,
405+
}),
406+
workerQueue: "main",
407+
});
408+
409+
const r0Admitted = await waitFor(async () => {
410+
await queue.redis.del(
411+
`${keys.queueGroupConcurrencyKey(authenticatedEnvDev, "task/my-task")}:reconcileLock`
412+
);
413+
const next = await queue.dequeueMessageFromWorkerQueue("consumer-1", "main", {
414+
blockingPop: false,
415+
});
416+
return next?.messageId === "r0";
417+
}, 30_000);
418+
expect(r0Admitted).toBe(true);
419+
expect(await queue.totalConcurrencyOfQueue(authenticatedEnvDev, "task/my-task")).toBe(1);
420+
} finally {
421+
await queue.quit();
422+
}
423+
}
424+
);
425+
368426
redisTest(
369427
"reconciles a large leaked backlog across bounded passes",
370428
async ({ redisContainer }) => {

0 commit comments

Comments
 (0)