Skip to content

Commit e7c3c86

Browse files
committed
fix(redis-worker): heal leaked concurrency slots instead of blocking on release failures
A failed slot release previously aborted its caller: completeMessage left the message in flight to be re-delivered as a duplicate execution, the retry path lost the attempt increment, and the reclaim path held messages in flight indefinitely. Release is now best-effort on every path: the primary state transition proceeds and the failure is logged. The resulting leaks are recoverable in two ways. reserve re-admits a message that already holds its own slot, since re-admission does not increase concurrency. A reconcile loop removes any slot member with no in-flight record; the check-and-remove is atomic and sound because a message is always registered in flight before its slot is reserved.
1 parent 72947fe commit e7c3c86

7 files changed

Lines changed: 699 additions & 71 deletions

File tree

.changeset/fair-queue-concurrency-slot-leak.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,4 @@
22
"@trigger.dev/redis-worker": patch
33
---
44

5-
Fair queue consumers no longer leak the concurrency slots that gate a tenant's throughput. Slots were held by messages that had already finished, were never reclaimed, and once enough of them accumulated every queue belonging to that tenant stopped being served. Slots are now freed on the paths that previously skipped them, freed before the record needed to recover them is discarded, and released before a reclaimed message goes back on the queue. A failed release is now surfaced instead of being silently treated as success.
6-
7-
Concurrency groups keyed on queue metadata rather than the tenant can still resolve to the wrong group when a consumer completes a message it did not enqueue, so this does not yet cover that case.
5+
Fair queue consumers no longer leak the concurrency slots that gate a tenant's throughput, and leaked slots now heal themselves. Slots are freed on the completion, retry, dead-letter, and reclaim paths that previously skipped them, and a failed release never blocks the message's own state transition, so a Redis error can no longer turn into a duplicate execution or a lost retry. A periodic reconcile loop removes any slot whose message is no longer in flight, and a message that still holds its own slot from an earlier failed release is re-admitted instead of being blocked by it.

packages/redis-worker/src/fair-queue/concurrency.ts

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface ConcurrencyManagerOptions {
1111
redis: RedisOptions;
1212
keys: FairQueueKeyProducer;
1313
groups: ConcurrencyGroupConfig[];
14+
logger?: {
15+
debug: (message: string, context?: Record<string, unknown>) => void;
16+
error: (message: string, context?: Record<string, unknown>) => void;
17+
};
1418
}
1519

1620
/**
@@ -26,12 +30,17 @@ export class ConcurrencyManager {
2630
private keys: FairQueueKeyProducer;
2731
private groups: ConcurrencyGroupConfig[];
2832
private groupsByName: Map<string, ConcurrencyGroupConfig>;
33+
private logger: NonNullable<ConcurrencyManagerOptions["logger"]>;
2934

3035
constructor(private options: ConcurrencyManagerOptions) {
3136
this.redis = createRedisClient(options.redis);
3237
this.keys = options.keys;
3338
this.groups = options.groups;
3439
this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
40+
this.logger = options.logger ?? {
41+
debug: () => {},
42+
error: () => {},
43+
};
3544

3645
this.#registerCommands();
3746
}
@@ -160,6 +169,69 @@ export class ConcurrencyManager {
160169
this.#assertPipelineSucceeded(await pipeline.exec(), messages.length);
161170
}
162171

172+
/**
173+
* Remove concurrency set members that no longer correspond to an in-flight message,
174+
* healing slots leaked by failed releases. Scans every set of every group and, per
175+
* member, atomically removes it unless the message id appears in one of the given
176+
* in-flight data hashes. Sound because a message is registered in-flight before its
177+
* slot is reserved, so at the moment of the atomic check a member with no in-flight
178+
* record can only be a leak; if the message is about to be re-claimed, reserve simply
179+
* re-adds the member.
180+
*
181+
* @param inflightDataKeys - The in-flight data hash keys for every shard
182+
* @returns The message ids that were removed, and how many sets were checked
183+
*/
184+
async sweepOrphanedSlots(
185+
inflightDataKeys: string[]
186+
): Promise<{ scannedSets: number; removed: string[] }> {
187+
const keyPrefix = this.options.redis.keyPrefix ?? "";
188+
let scannedSets = 0;
189+
const removed: string[] = [];
190+
191+
for (const group of this.groups) {
192+
const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`;
193+
let cursor = "0";
194+
195+
do {
196+
const [nextCursor, foundKeys] = await this.redis.scan(
197+
cursor,
198+
"MATCH",
199+
pattern,
200+
"COUNT",
201+
100
202+
);
203+
cursor = nextCursor;
204+
205+
for (const fullKey of foundKeys) {
206+
const key =
207+
keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
208+
209+
try {
210+
const members = await this.redis.smembers(key);
211+
if (members.length === 0) {
212+
continue;
213+
}
214+
scannedSets++;
215+
216+
const removedIds = await this.redis.removeOrphanedConcurrencySlots(
217+
1 + inflightDataKeys.length,
218+
[key, ...inflightDataKeys],
219+
...members
220+
);
221+
removed.push(...removedIds);
222+
} catch (error) {
223+
this.logger.error("Failed to sweep concurrency set, skipping it", {
224+
key,
225+
error: error instanceof Error ? error.message : String(error),
226+
});
227+
}
228+
}
229+
} while (cursor !== "0");
230+
}
231+
232+
return { scannedSets, removed };
233+
}
234+
163235
/**
164236
* Get current concurrency for a specific group.
165237
*/
@@ -298,14 +370,20 @@ export class ConcurrencyManager {
298370
local numGroups = #KEYS
299371
local messageId = ARGV[1]
300372
301-
-- Check all groups first
373+
-- Check all groups first. A message that is already a member of a group's set passes
374+
-- that group's check: re-admitting it does not increase concurrency (SADD is a no-op),
375+
-- and counting its own leftover slot against it would let a message whose earlier
376+
-- release failed block its own retry forever.
302377
for i = 1, numGroups do
303378
local key = KEYS[i]
304379
local limit = tonumber(ARGV[1 + i]) -- Limits start at ARGV[2]
305-
local current = redis.call('SCARD', key)
306-
307-
if current >= limit then
308-
return 0 -- At capacity
380+
381+
if redis.call('SISMEMBER', key, messageId) == 0 then
382+
local current = redis.call('SCARD', key)
383+
384+
if current >= limit then
385+
return 0 -- At capacity
386+
end
309387
end
310388
end
311389
@@ -318,6 +396,37 @@ end
318396
return 1
319397
`,
320398
});
399+
400+
// Atomic orphan sweep for one concurrency set
401+
// KEYS[1]: concurrency set key
402+
// KEYS[2..n]: in-flight data hash keys for every shard
403+
// ARGV: candidate messageIds (a snapshot of the set's members)
404+
this.redis.defineCommand("removeOrphanedConcurrencySlots", {
405+
lua: `
406+
local concurrencyKey = KEYS[1]
407+
local removedIds = {}
408+
409+
for i = 1, #ARGV do
410+
local messageId = ARGV[i]
411+
local inflight = false
412+
413+
for j = 2, #KEYS do
414+
if redis.call('HEXISTS', KEYS[j], messageId) == 1 then
415+
inflight = true
416+
break
417+
end
418+
end
419+
420+
if not inflight then
421+
if redis.call('SREM', concurrencyKey, messageId) == 1 then
422+
table.insert(removedIds, messageId)
423+
end
424+
end
425+
end
426+
427+
return removedIds
428+
`,
429+
});
321430
}
322431
}
323432

@@ -330,5 +439,11 @@ declare module "@internal/redis" {
330439
messageId: string,
331440
...limits: string[]
332441
): Promise<number>;
442+
443+
removeOrphanedConcurrencySlots(
444+
numKeys: number,
445+
keys: string[],
446+
...messageIds: string[]
447+
): Promise<string[]>;
333448
}
334449
}

0 commit comments

Comments
 (0)