@@ -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 */
7479const QUEUE_GATES_LUA_HELPERS = `
7580local 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')
0 commit comments