Skip to content

Commit 2d5a654

Browse files
committed
fix(run-engine): reconcile group and gate members against the home concurrency set
A member left behind by a release path without the mirror (an older build during a rolling upgrade) was only pruned if its message key was deleted or the run was re-queued. Dead-lettered runs keep their message key and sit in no queue, so they held a combined-cap slot forever; suspended runs held one until their terminal ack. Group and gate sets are strict mirrors of the home queue's currentConcurrency set, so the reconcile now prunes any member absent from it, healing every mirror-less path within one pass.
1 parent bdbe7e1 commit 2d5a654

2 files changed

Lines changed: 90 additions & 10 deletions

File tree

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

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,13 @@ const SemanticAttributes = {
6868
* flag. __gatesRelease runs on every release path UNCONDITIONALLY and is payload-
6969
* driven (a cheap substring probe before decoding), so slots acquired while the flag
7070
* was on always drain, and gateless messages pay near zero. __gateReconcile is the
71-
* same bounded self-heal as the total-cap gate: a member whose message key is gone
72-
* was terminally released by a path that missed the mirror and is provably dead.
71+
* same bounded self-heal as the total-cap gate. Group and gate sets are strict
72+
* mirrors of the member's home-queue currentConcurrency set (admits populate both
73+
* in one script), so a member whose message key is gone, or who is absent from its
74+
* home currentConcurrency set, holds no legitimate slot and is pruned. The home-set
75+
* rule is what heals leaks from release paths without the mirror (older builds
76+
* during a rolling upgrade), including runs parked in the DLQ or suspended on
77+
* checkpoints, which older rules based on the queue zset could never prune.
7378
*/
7479
const QUEUE_GATES_LUA_HELPERS = `
7580
local function __gateKeys(gatesKeyPrefix, msg, gate)
@@ -99,7 +104,8 @@ local function __gateReconcile(setKey, msgKeyPrefix, reconcileKeyPrefix)
99104
elseif reconcileKeyPrefix then
100105
local okMember, member = pcall(cjson.decode, rawMemberPayload)
101106
if okMember and type(member) == 'table' and type(member.queue) == 'string' then
102-
if redis.call('ZSCORE', reconcileKeyPrefix .. member.queue, memberId) then
107+
local homeConcurrencyKey = reconcileKeyPrefix .. member.queue .. ':currentConcurrency'
108+
if homeConcurrencyKey ~= setKey and redis.call('SISMEMBER', homeConcurrencyKey, memberId) == 0 then
103109
redis.call('SREM', setKey, memberId)
104110
end
105111
end
@@ -294,9 +300,11 @@ export type RunQueueOptions = {
294300
* A release path that misses the group mirror (an instance on an older build during
295301
* rollout) leaves the member behind, briefly under-admitting. The dequeue gate
296302
* reconciles: when a queue sits at its total, members whose message key no longer
297-
* exists are pruned, so such leaks clear within seconds instead of blocking the
298-
* queue. Enabling only after every instance runs this build avoids the noise but is
299-
* no longer load-bearing for correctness.
303+
* exists or who are absent from their home currentConcurrency set are pruned, so
304+
* such leaks clear within seconds instead of blocking the queue, including runs
305+
* that dead-lettered or suspended through a mirror-less path. Enabling only after
306+
* every instance runs this build avoids the noise but is no longer load-bearing
307+
* for correctness.
300308
*/
301309
totalConcurrencyEnabled?: boolean;
302310
/**
@@ -4930,10 +4938,9 @@ if totalConcurrencyEnabled then
49304938
local groupCurrentConcurrency = tonumber(redis.call('SCARD', groupConcurrencyKey) or '0')
49314939
49324940
-- 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.
4941+
-- key is dead, and a member absent from its home currentConcurrency set is
4942+
-- not in flight (group membership is a strict mirror of it), so neither
4943+
-- holds a legitimate slot. Both are pruned by the shared bounded reconcile.
49374944
if groupCurrentConcurrency >= totalConcurrencyLimit then
49384945
__gateReconcile(groupConcurrencyKey, messageKeyPrefix, keyPrefix)
49394946
groupCurrentConcurrency = tonumber(redis.call('SCARD', groupConcurrencyKey) or '0')

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,79 @@ describe("RunQueue total concurrency limit", () => {
423423
}
424424
);
425425

426+
redisTest(
427+
"reconciles a member whose run dead-lettered or suspended without the mirror",
428+
async ({ redisContainer }) => {
429+
const queue = createQueue(redisContainer, true);
430+
try {
431+
const keys = testOptions.keys;
432+
await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, "task/my-task", 5);
433+
await queue.updateQueueTotalConcurrencyLimits(authenticatedEnvDev, "task/my-task", 1);
434+
435+
await queue.enqueueMessage({
436+
env: authenticatedEnvDev,
437+
message: makeMessage({
438+
runId: "r0",
439+
concurrencyKey: "ck-a",
440+
timestamp: Date.now() - 1000,
441+
}),
442+
workerQueue: "main",
443+
});
444+
445+
const admitted = await waitFor(
446+
async () =>
447+
(await queue.totalConcurrencyOfQueue(authenticatedEnvDev, "task/my-task")) === 1
448+
);
449+
expect(admitted).toBe(true);
450+
451+
const dequeued = await queue.dequeueMessageFromWorkerQueue("consumer-1", "main");
452+
assertNonNullable(dequeued);
453+
expect(dequeued.messageId).toBe("r0");
454+
455+
/**
456+
* Simulate a dead-letter or checkpoint release from a build without the
457+
* group mirror: the per-key and env sets are cleared but the MESSAGE KEY
458+
* SURVIVES (dead-letter keeps it for redrive; suspension keeps it until
459+
* the terminal ack) and the run sits in no queue zset. Only the
460+
* home-currentConcurrency reconcile rule can prune this member.
461+
*/
462+
await queue.redis.srem(
463+
keys.queueCurrentConcurrencyKey(authenticatedEnvDev, "task/my-task", "ck-a"),
464+
"r0"
465+
);
466+
await queue.redis.srem(keys.envCurrentConcurrencyKey(authenticatedEnvDev), "r0");
467+
expect(
468+
await queue.redis.exists(keys.messageKey(authenticatedEnvDev.organization.id, "r0"))
469+
).toBe(1);
470+
expect(await queue.totalConcurrencyOfQueue(authenticatedEnvDev, "task/my-task")).toBe(1);
471+
472+
await queue.enqueueMessage({
473+
env: authenticatedEnvDev,
474+
message: makeMessage({
475+
runId: "r1",
476+
concurrencyKey: "ck-b",
477+
timestamp: Date.now() - 500,
478+
}),
479+
workerQueue: "main",
480+
});
481+
482+
const r1Admitted = await waitFor(async () => {
483+
await queue.redis.del(
484+
`${keys.queueGroupConcurrencyKey(authenticatedEnvDev, "task/my-task")}:reconcileLock`
485+
);
486+
const next = await queue.dequeueMessageFromWorkerQueue("consumer-1", "main", {
487+
blockingPop: false,
488+
});
489+
return next?.messageId === "r1";
490+
}, 30_000);
491+
expect(r1Admitted).toBe(true);
492+
expect(await queue.totalConcurrencyOfQueue(authenticatedEnvDev, "task/my-task")).toBe(1);
493+
} finally {
494+
await queue.quit();
495+
}
496+
}
497+
);
498+
426499
redisTest(
427500
"reconciles a large leaked backlog across bounded passes",
428501
async ({ redisContainer }) => {

0 commit comments

Comments
 (0)