Skip to content

Commit 1114d9d

Browse files
authored
fix(redis-worker): stop fair queue leaking concurrency slots (#4540)
## Summary Fair queue consumers could leak the per-tenant concurrency slots that gate admission. Slots were freed on some paths and skipped on others, and once enough leaked slots accumulated for a tenant, every queue that tenant owned stopped being served until someone cleared the set by hand. This PR frees slots on every path and, more importantly, makes the remaining failure modes self-healing. ## Design The fix applies one rule uniformly: releasing a concurrency slot is best-effort cleanup and must never block the message's primary state transition. Blocking completion re-delivers the message, which duplicates customer work; blocking a retry loses the attempt increment, so the message can circle forever; blocking a reclaim strands the message in flight. A leaked slot is the better failure in every one of those trades because it is the only one that is recoverable. A failed release is therefore logged and the transition proceeds. Leaked slots then heal through two mechanisms: - `reserve` re-admits a message that is already a member of its own concurrency set, since re-admitting it does not increase concurrency. A message whose earlier release failed can no longer be blocked by its own leftover slot. - A reconcile loop periodically removes any set member with no in-flight record (interval configurable via `reconcileIntervalMs`, default 60s). The check-and-remove is atomic, and it is sound because a message is always registered in flight before its slot is reserved, so a member with no in-flight record can only be a leak. This also covers leaks this PR cannot prevent directly, such as a release that resolves the wrong concurrency group from queue metadata. Ordering hardening from earlier revisions stays: slots are released before the in-flight record needed to describe them is discarded, the release Lua scripts write the message back to the queue before removing it from in-flight (Lua does not roll back on error), and dangling in-flight entries with no payload are dropped instead of being rescanned forever. Every guard test was verified to fail without its specific fix, including the duplicate-execution case: completing a message while its slot release fails used to re-deliver and re-execute it.
1 parent 20a0ac5 commit 1114d9d

8 files changed

Lines changed: 1484 additions & 131 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/redis-worker": patch
3+
---
4+
5+
Fair queue tenants can no longer get permanently stuck behind leaked concurrency slots. Slots are now freed on every path that finishes a message, a failed release no longer causes a message to run twice or lose its retry, and a background sweep frees any slot that does leak, so a tenant's queues recover on their own instead of needing manual cleanup.

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

Lines changed: 188 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,22 @@ import type {
77
QueueDescriptor,
88
} from "./types.js";
99

10+
/**
11+
* Page size for iterating a concurrency set's members (SSCAN COUNT) and cap on how many
12+
* members one sweep script invocation checks, bounding both the snapshot reads and the
13+
* time the atomic Lua script can hold up Redis when a set has accumulated many leaked
14+
* members.
15+
*/
16+
const SWEEP_MEMBER_CHUNK_SIZE = 500;
17+
1018
export interface ConcurrencyManagerOptions {
1119
redis: RedisOptions;
1220
keys: FairQueueKeyProducer;
1321
groups: ConcurrencyGroupConfig[];
22+
logger?: {
23+
debug: (message: string, context?: Record<string, unknown>) => void;
24+
error: (message: string, context?: Record<string, unknown>) => void;
25+
};
1426
}
1527

1628
/**
@@ -26,12 +38,17 @@ export class ConcurrencyManager {
2638
private keys: FairQueueKeyProducer;
2739
private groups: ConcurrencyGroupConfig[];
2840
private groupsByName: Map<string, ConcurrencyGroupConfig>;
41+
private logger: NonNullable<ConcurrencyManagerOptions["logger"]>;
2942

3043
constructor(private options: ConcurrencyManagerOptions) {
3144
this.redis = createRedisClient(options.redis);
3245
this.keys = options.keys;
3346
this.groups = options.groups;
3447
this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
48+
this.logger = options.logger ?? {
49+
debug: () => {},
50+
error: () => {},
51+
};
3552

3653
this.#registerCommands();
3754
}
@@ -103,7 +120,37 @@ export class ConcurrencyManager {
103120
pipeline.srem(key, messageId);
104121
}
105122

106-
await pipeline.exec();
123+
this.#assertPipelineSucceeded(await pipeline.exec(), 1);
124+
}
125+
126+
/**
127+
* Throw if any command in a released pipeline failed. ioredis resolves `exec()` even when
128+
* individual commands error, so an unchecked pipeline reports success while leaving the
129+
* slot held, which strands it permanently once the caller drops the in-flight record.
130+
*/
131+
#assertPipelineSucceeded(
132+
results: Array<[Error | null, unknown]> | null,
133+
messageCount: number
134+
): void {
135+
if (results === null) {
136+
throw new Error(
137+
`Concurrency release pipeline for ${messageCount} message(s) was discarded without executing`
138+
);
139+
}
140+
141+
const errors = results
142+
.map(([error]) => error)
143+
.filter((error): error is Error => Boolean(error));
144+
145+
if (errors.length > 0) {
146+
throw new Error(
147+
`Failed to release ${errors.length} of ${
148+
results?.length ?? 0
149+
} concurrency slot commands across ${messageCount} message(s): ${errors
150+
.map((error) => error.message)
151+
.join("; ")}`
152+
);
153+
}
107154
}
108155

109156
/**
@@ -127,7 +174,98 @@ export class ConcurrencyManager {
127174
}
128175
}
129176

130-
await pipeline.exec();
177+
this.#assertPipelineSucceeded(await pipeline.exec(), messages.length);
178+
}
179+
180+
/**
181+
* Remove concurrency set members that no longer correspond to an in-flight message,
182+
* healing slots leaked by failed releases. Scans every set of every group and, per
183+
* member, atomically removes it unless the message id appears in one of the given
184+
* in-flight data hashes. Sound because a message is registered in-flight before its
185+
* slot is reserved, so at the moment of the atomic check a member with no in-flight
186+
* record can only be a leak; if the message is about to be re-claimed, reserve simply
187+
* re-adds the member.
188+
*
189+
* @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep
190+
* refuses to run when this is empty, since with nowhere to look for running messages
191+
* every member would look orphaned and all concurrency accounting would be erased.
192+
* @returns The message ids that were removed, and how many sets were checked
193+
*/
194+
async sweepOrphanedSlots(
195+
inflightDataKeys: string[]
196+
): Promise<{ scannedSets: number; removed: string[] }> {
197+
if (inflightDataKeys.length === 0) {
198+
this.logger.error(
199+
"Refusing to sweep concurrency slots without any in-flight data keys: every member would look orphaned and all concurrency accounting would be erased"
200+
);
201+
return { scannedSets: 0, removed: [] };
202+
}
203+
204+
const keyPrefix = this.options.redis.keyPrefix ?? "";
205+
let scannedSets = 0;
206+
const removed: string[] = [];
207+
208+
for (const group of this.groups) {
209+
const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`;
210+
let cursor = "0";
211+
212+
do {
213+
const [nextCursor, foundKeys] = await this.redis.scan(
214+
cursor,
215+
"MATCH",
216+
pattern,
217+
"COUNT",
218+
1000
219+
);
220+
cursor = nextCursor;
221+
222+
for (const fullKey of foundKeys) {
223+
const key =
224+
keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
225+
226+
try {
227+
let sawMembers = false;
228+
let memberCursor = "0";
229+
230+
do {
231+
const [nextMemberCursor, page] = await this.redis.sscan(
232+
key,
233+
memberCursor,
234+
"COUNT",
235+
SWEEP_MEMBER_CHUNK_SIZE
236+
);
237+
memberCursor = nextMemberCursor;
238+
239+
if (page.length === 0) {
240+
continue;
241+
}
242+
sawMembers = true;
243+
244+
for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
245+
const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
246+
const removedIds = await this.redis.removeOrphanedConcurrencySlots(
247+
1 + inflightDataKeys.length,
248+
[key, ...inflightDataKeys],
249+
...chunk
250+
);
251+
removed.push(...removedIds);
252+
}
253+
} while (memberCursor !== "0");
254+
255+
if (sawMembers) {
256+
scannedSets++;
257+
}
258+
} catch (error) {
259+
this.logger.error("Failed to sweep concurrency set, skipping it", {
260+
key,
261+
error: error instanceof Error ? error.message : String(error),
262+
});
263+
}
264+
}
265+
} while (cursor !== "0");
266+
}
267+
268+
return { scannedSets, removed };
131269
}
132270

133271
/**
@@ -268,14 +406,20 @@ export class ConcurrencyManager {
268406
local numGroups = #KEYS
269407
local messageId = ARGV[1]
270408
271-
-- Check all groups first
409+
-- Check all groups first. A message that is already a member of a group's set passes
410+
-- that group's check: re-admitting it does not increase concurrency (SADD is a no-op),
411+
-- and counting its own leftover slot against it would let a message whose earlier
412+
-- release failed block its own retry forever.
272413
for i = 1, numGroups do
273414
local key = KEYS[i]
274415
local limit = tonumber(ARGV[1 + i]) -- Limits start at ARGV[2]
275-
local current = redis.call('SCARD', key)
276-
277-
if current >= limit then
278-
return 0 -- At capacity
416+
417+
if redis.call('SISMEMBER', key, messageId) == 0 then
418+
local current = redis.call('SCARD', key)
419+
420+
if current >= limit then
421+
return 0 -- At capacity
422+
end
279423
end
280424
end
281425
@@ -288,6 +432,37 @@ end
288432
return 1
289433
`,
290434
});
435+
436+
// Atomic orphan sweep for one concurrency set
437+
// KEYS[1]: concurrency set key
438+
// KEYS[2..n]: in-flight data hash keys for every shard
439+
// ARGV: candidate messageIds (a snapshot of the set's members)
440+
this.redis.defineCommand("removeOrphanedConcurrencySlots", {
441+
lua: `
442+
local concurrencyKey = KEYS[1]
443+
local removedIds = {}
444+
445+
for i = 1, #ARGV do
446+
local messageId = ARGV[i]
447+
local inflight = false
448+
449+
for j = 2, #KEYS do
450+
if redis.call('HEXISTS', KEYS[j], messageId) == 1 then
451+
inflight = true
452+
break
453+
end
454+
end
455+
456+
if not inflight then
457+
if redis.call('SREM', concurrencyKey, messageId) == 1 then
458+
table.insert(removedIds, messageId)
459+
end
460+
end
461+
end
462+
463+
return removedIds
464+
`,
465+
});
291466
}
292467
}
293468

@@ -300,5 +475,11 @@ declare module "@internal/redis" {
300475
messageId: string,
301476
...limits: string[]
302477
): Promise<number>;
478+
479+
removeOrphanedConcurrencySlots(
480+
numKeys: number,
481+
keys: string[],
482+
...messageIds: string[]
483+
): Promise<string[]>;
303484
}
304485
}

0 commit comments

Comments
 (0)