Skip to content

Commit b2ebf18

Browse files
committed
fix(redis-worker): close the remaining concurrency slot leaks in fair queue
Four paths could strand a slot with nothing left to reclaim it. Reclaim released the slot only after requeuing, and only for messages that survived the requeue, so a message whose requeue threw kept its slot forever. It now releases before the message becomes claimable, which also stops a redelivery having its fresh reservation deleted by the previous holder. The dead-letter branch completed the message before releasing, so anything throwing in between stranded the slot. Release now happens first. failMessage returned early when the stored message was missing or unparseable without releasing, completing or requeuing, leaving the slot held. The requeue script returned without removing the in-flight entry when the payload was gone, so the member was rescanned on every reclaim tick forever. Enough of them fill the scan window and starve a whole shard's reclaim. reclaimTimedOut takes an optional pre-requeue hook; no existing caller changes.
1 parent 35568a3 commit b2ebf18

4 files changed

Lines changed: 294 additions & 44 deletions

File tree

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

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
FairScheduler,
2626
QueueCooloffState,
2727
QueueDescriptor,
28+
ReclaimedMessageInfo,
2829
SchedulerContext,
2930
StoredMessage,
3031
TenantQueues,
@@ -1331,6 +1332,26 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
13311332
});
13321333
}
13331334

1335+
/**
1336+
* Release a concurrency slot for a message we can no longer describe, deriving the
1337+
* group from the queue id alone. Used on paths that bail out before the stored
1338+
* message is available, where the slot would otherwise be held with nothing left
1339+
* to reclaim it.
1340+
*/
1341+
async #releaseOrphanedConcurrency(messageId: string, queueId: string): Promise<void> {
1342+
if (!this.concurrencyManager) {
1343+
return;
1344+
}
1345+
1346+
const descriptor: QueueDescriptor = this.queueDescriptorCache.get(queueId) ?? {
1347+
id: queueId,
1348+
tenantId: this.keys.extractTenantId(queueId),
1349+
metadata: {},
1350+
};
1351+
1352+
await this.concurrencyManager.release(descriptor, messageId);
1353+
}
1354+
13341355
/**
13351356
* Mark a message as failed. This will trigger retry logic if configured,
13361357
* or move the message to the dead letter queue.
@@ -1349,6 +1370,7 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
13491370
const dataJson = await this.redis.hget(inflightDataKey, messageId);
13501371
if (!dataJson) {
13511372
this.logger.error("Cannot fail message: not found in in-flight data", { messageId, queueId });
1373+
await this.#releaseOrphanedConcurrency(messageId, queueId);
13521374
return;
13531375
}
13541376

@@ -1360,6 +1382,7 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
13601382
messageId,
13611383
queueId,
13621384
});
1385+
await this.#releaseOrphanedConcurrency(messageId, queueId);
13631386
return;
13641387
}
13651388

@@ -1441,13 +1464,13 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
14411464
}
14421465
}
14431466

1444-
// Move to DLQ
1445-
await this.#moveToDeadLetterQueue(storedMessage, error?.message);
1446-
14471467
// Release concurrency
14481468
if (this.concurrencyManager) {
14491469
await this.concurrencyManager.release(descriptor, storedMessage.id);
14501470
}
1471+
1472+
// Move to DLQ
1473+
await this.#moveToDeadLetterQueue(storedMessage, error?.message);
14511474
}
14521475

14531476
async #moveToDeadLetterQueue(
@@ -1521,49 +1544,54 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
15211544
}
15221545
}
15231546

1524-
async #reclaimTimedOutMessages(): Promise<void> {
1525-
let totalReclaimed = 0;
1547+
/**
1548+
* Free a reclaimed message's concurrency slot. Runs before the message is put back
1549+
* on the queue, so another consumer cannot reserve the same messageId and have its
1550+
* fresh reservation deleted by this release. Failures are logged rather than thrown
1551+
* so one bad message cannot stop the rest of the shard being reclaimed.
1552+
*/
1553+
async #releaseReclaimedConcurrency(message: ReclaimedMessageInfo): Promise<void> {
1554+
if (!this.concurrencyManager) {
1555+
return;
1556+
}
15261557

1527-
for (let shardId = 0; shardId < this.shardCount; shardId++) {
1528-
const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => {
1529-
const tenantId = this.keys.extractTenantId(queueId);
1530-
const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
1531-
return {
1532-
queueKey: this.keys.queueKey(queueId),
1533-
queueItemsKey: this.keys.queueItemsKey(queueId),
1534-
tenantQueueIndexKey: this.keys.tenantQueueIndexKey(tenantId),
1535-
dispatchKey: this.keys.dispatchKey(dispatchShardId),
1536-
tenantId,
1537-
};
1558+
try {
1559+
await this.concurrencyManager.release(
1560+
{
1561+
id: message.queueId,
1562+
tenantId: message.tenantId,
1563+
metadata: message.metadata ?? {},
1564+
},
1565+
message.messageId
1566+
);
1567+
} catch (error) {
1568+
this.logger.error("Failed to release concurrency for reclaimed message", {
1569+
messageId: message.messageId,
1570+
queueId: message.queueId,
1571+
error: error instanceof Error ? error.message : String(error),
15381572
});
1573+
}
1574+
}
15391575

1540-
if (reclaimedMessages.length > 0) {
1541-
// Release concurrency for all reclaimed messages in a single batch
1542-
// This is critical: when a message times out, its concurrency slot must be freed
1543-
// so the message can be processed again when it's re-claimed from the queue
1544-
if (this.concurrencyManager) {
1545-
try {
1546-
await this.concurrencyManager.releaseBatch(
1547-
reclaimedMessages.map((msg) => ({
1548-
queue: {
1549-
id: msg.queueId,
1550-
tenantId: msg.tenantId,
1551-
metadata: msg.metadata ?? {},
1552-
},
1553-
messageId: msg.messageId,
1554-
}))
1555-
);
1556-
} catch (error) {
1557-
this.logger.error("Failed to release concurrency for reclaimed messages", {
1558-
count: reclaimedMessages.length,
1559-
error: error instanceof Error ? error.message : String(error),
1560-
});
1561-
}
1562-
}
1576+
async #reclaimTimedOutMessages(): Promise<void> {
1577+
let totalReclaimed = 0;
15631578

1564-
// Dispatch indexes are updated atomically by the releaseMessage Lua script
1565-
// inside reclaimTimedOut, so no separate index update needed here.
1566-
}
1579+
for (let shardId = 0; shardId < this.shardCount; shardId++) {
1580+
const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(
1581+
shardId,
1582+
(queueId) => {
1583+
const tenantId = this.keys.extractTenantId(queueId);
1584+
const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
1585+
return {
1586+
queueKey: this.keys.queueKey(queueId),
1587+
queueItemsKey: this.keys.queueItemsKey(queueId),
1588+
tenantQueueIndexKey: this.keys.tenantQueueIndexKey(tenantId),
1589+
dispatchKey: this.keys.dispatchKey(dispatchShardId),
1590+
tenantId,
1591+
};
1592+
},
1593+
this.#releaseReclaimedConcurrency.bind(this)
1594+
);
15671595

15681596
totalReclaimed += reclaimedMessages.length;
15691597
}

packages/redis-worker/src/fair-queue/tests/fairQueue.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,5 +1516,146 @@ describe("FairQueue", () => {
15161516
}
15171517
}
15181518
);
1519+
1520+
redisTest(
1521+
"should release the concurrency slot even when the reclaim requeue fails",
1522+
{ timeout: 15000 },
1523+
async ({ redisOptions }) => {
1524+
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
1525+
1526+
const scheduler = new DRRScheduler({
1527+
redis: redisOptions,
1528+
keys,
1529+
quantum: 10,
1530+
maxDeficit: 100,
1531+
});
1532+
1533+
const queue = new TestFairQueueHelper(redisOptions, keys, {
1534+
scheduler,
1535+
payloadSchema: TestPayloadSchema,
1536+
shardCount: 1,
1537+
consumerCount: 1,
1538+
consumerIntervalMs: 20,
1539+
visibilityTimeoutMs: 200,
1540+
reclaimIntervalMs: 100,
1541+
concurrencyGroups: [
1542+
{
1543+
name: "tenant",
1544+
extractGroupId: (q) => q.tenantId,
1545+
getLimit: async () => 1,
1546+
defaultLimit: 1,
1547+
},
1548+
],
1549+
startConsumers: false,
1550+
});
1551+
1552+
const redis = createRedisClient(redisOptions);
1553+
const queueId = "tenant:t1:queue:reclaim-fail";
1554+
1555+
try {
1556+
queue.onMessage(async () => {
1557+
await new Promise((resolve) => setTimeout(resolve, 10000));
1558+
});
1559+
1560+
await queue.enqueue({
1561+
queueId,
1562+
tenantId: "t1",
1563+
payload: { value: "msg-0" },
1564+
});
1565+
1566+
queue.start();
1567+
1568+
await vi.waitFor(
1569+
async () => {
1570+
expect(await redis.scard(keys.concurrencyKey("tenant", "t1"))).toBe(1);
1571+
},
1572+
{ timeout: 5000 }
1573+
);
1574+
1575+
await redis.del(keys.queueKey(queueId));
1576+
await redis.set(keys.queueKey(queueId), "not-a-zset");
1577+
1578+
await vi.waitFor(
1579+
async () => {
1580+
expect(await redis.scard(keys.concurrencyKey("tenant", "t1"))).toBe(0);
1581+
},
1582+
{ timeout: 8000 }
1583+
);
1584+
} finally {
1585+
await redis.del(keys.queueKey(queueId));
1586+
await redis.quit();
1587+
await queue.close();
1588+
}
1589+
}
1590+
);
1591+
1592+
redisTest(
1593+
"should release the concurrency slot when failMessage cannot read the message",
1594+
{ timeout: 15000 },
1595+
async ({ redisOptions }) => {
1596+
const started: string[] = [];
1597+
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
1598+
1599+
const scheduler = new DRRScheduler({
1600+
redis: redisOptions,
1601+
keys,
1602+
quantum: 10,
1603+
maxDeficit: 100,
1604+
});
1605+
1606+
const queue = new TestFairQueueHelper(redisOptions, keys, {
1607+
scheduler,
1608+
payloadSchema: TestPayloadSchema,
1609+
shardCount: 1,
1610+
consumerCount: 1,
1611+
consumerIntervalMs: 20,
1612+
visibilityTimeoutMs: 60000,
1613+
concurrencyGroups: [
1614+
{
1615+
name: "tenant",
1616+
extractGroupId: (q) => q.tenantId,
1617+
getLimit: async () => 5,
1618+
defaultLimit: 5,
1619+
},
1620+
],
1621+
startConsumers: false,
1622+
});
1623+
1624+
const redis = createRedisClient(redisOptions);
1625+
1626+
try {
1627+
queue.onMessage(async (ctx) => {
1628+
started.push(ctx.message.payload.value);
1629+
await redis.hdel(keys.inflightDataKey(0), ctx.message.id);
1630+
await ctx.fail(new Error("boom"));
1631+
});
1632+
1633+
await queue.enqueue({
1634+
queueId: "tenant:t1:queue:q1",
1635+
tenantId: "t1",
1636+
payload: { value: "msg-0" },
1637+
});
1638+
1639+
queue.start();
1640+
1641+
await vi.waitFor(
1642+
() => {
1643+
expect(started).toHaveLength(1);
1644+
},
1645+
{ timeout: 10000 }
1646+
);
1647+
1648+
await vi.waitFor(
1649+
async () => {
1650+
expect(await redis.scard(keys.concurrencyKey("tenant", "t1"))).toBe(0);
1651+
},
1652+
{ timeout: 5000 }
1653+
);
1654+
} finally {
1655+
await redis.quit();
1656+
await queue.close();
1657+
}
1658+
}
1659+
);
15191660
});
15201661
});

packages/redis-worker/src/fair-queue/tests/visibility.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,5 +912,70 @@ describe("VisibilityManager", () => {
912912
await redis.quit();
913913
}
914914
);
915+
916+
redisTest(
917+
"should drop a dangling in-flight entry whose payload is gone",
918+
{ timeout: 10000 },
919+
async ({ redisOptions }) => {
920+
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
921+
922+
const manager = new VisibilityManager({
923+
redis: redisOptions,
924+
keys,
925+
shardCount: 1,
926+
defaultTimeoutMs: 100,
927+
});
928+
929+
const redis = createRedisClient(redisOptions);
930+
const queueId = "tenant:t1:queue:dangling";
931+
const queueKey = keys.queueKey(queueId);
932+
const queueItemsKey = keys.queueItemsKey(queueId);
933+
const dispatchKey = keys.dispatchKey(0);
934+
const inflightKey = keys.inflightKey(0);
935+
const inflightDataKey = keys.inflightDataKey(0);
936+
937+
try {
938+
const messageId = "dangling-msg";
939+
const storedMessage = {
940+
id: messageId,
941+
queueId,
942+
tenantId: "t1",
943+
payload: { id: 1, value: "test" },
944+
timestamp: Date.now() - 1000,
945+
attempt: 1,
946+
};
947+
948+
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
949+
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
950+
951+
const claimResult = await manager.claim(
952+
queueId,
953+
queueKey,
954+
queueItemsKey,
955+
"consumer-1",
956+
100
957+
);
958+
expect(claimResult.claimed).toBe(true);
959+
960+
await redis.hdel(inflightDataKey, messageId);
961+
expect(await redis.zcard(inflightKey)).toBe(1);
962+
963+
await new Promise((resolve) => setTimeout(resolve, 150));
964+
965+
await manager.reclaimTimedOut(0, (qId) => ({
966+
queueKey: keys.queueKey(qId),
967+
queueItemsKey: keys.queueItemsKey(qId),
968+
tenantQueueIndexKey: keys.tenantQueueIndexKey(keys.extractTenantId(qId)),
969+
dispatchKey,
970+
tenantId: keys.extractTenantId(qId),
971+
}));
972+
973+
expect(await redis.zcard(inflightKey)).toBe(0);
974+
} finally {
975+
await manager.close();
976+
await redis.quit();
977+
}
978+
}
979+
);
915980
});
916981
});

0 commit comments

Comments
 (0)