Skip to content

Commit 35568a3

Browse files
committed
fix(redis-worker): keep metadata-derived concurrency groups on the release path
Concurrency groups can key on queue metadata, not just the tenant. The fallback descriptor dropped metadata and skipped the descriptor cache, so those groups released against the wrong Redis set and leaked exactly as before. Prefer the cached descriptor in both branches, and move the retry path's release ahead of the re-queue so a redelivery cannot have its fresh reservation deleted by the previous holder.
1 parent 4df6cda commit 35568a3

2 files changed

Lines changed: 115 additions & 41 deletions

File tree

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

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,13 +1245,11 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
12451245
}
12461246
}
12471247

1248-
const descriptor: QueueDescriptor = storedMessage
1249-
? (this.queueDescriptorCache.get(queueId) ?? {
1250-
id: queueId,
1251-
tenantId: storedMessage.tenantId,
1252-
metadata: storedMessage.metadata ?? {},
1253-
})
1254-
: { id: queueId, tenantId: this.keys.extractTenantId(queueId), metadata: {} };
1248+
const descriptor: QueueDescriptor = this.queueDescriptorCache.get(queueId) ?? {
1249+
id: queueId,
1250+
tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
1251+
metadata: storedMessage?.metadata ?? {},
1252+
};
12551253

12561254
// Release concurrency
12571255
if (this.concurrencyManager) {
@@ -1300,13 +1298,11 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
13001298
}
13011299
}
13021300

1303-
const descriptor: QueueDescriptor = storedMessage
1304-
? (this.queueDescriptorCache.get(queueId) ?? {
1305-
id: queueId,
1306-
tenantId: storedMessage.tenantId,
1307-
metadata: storedMessage.metadata ?? {},
1308-
})
1309-
: { id: queueId, tenantId: this.keys.extractTenantId(queueId), metadata: {} };
1301+
const descriptor: QueueDescriptor = this.queueDescriptorCache.get(queueId) ?? {
1302+
id: queueId,
1303+
tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
1304+
metadata: storedMessage?.metadata ?? {},
1305+
};
13101306

13111307
// Release concurrency
13121308
if (this.concurrencyManager) {
@@ -1411,6 +1407,11 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
14111407
attempt: storedMessage.attempt + 1,
14121408
};
14131409

1410+
// Release concurrency
1411+
if (this.concurrencyManager) {
1412+
await this.concurrencyManager.release(descriptor, storedMessage.id);
1413+
}
1414+
14141415
// Release with delay, passing the updated message data so the Lua script
14151416
// atomically writes the incremented attempt count when re-queuing.
14161417
const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
@@ -1427,11 +1428,6 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
14271428
JSON.stringify(updatedMessage)
14281429
);
14291430

1430-
// Release concurrency
1431-
if (this.concurrencyManager) {
1432-
await this.concurrencyManager.release(descriptor, storedMessage.id);
1433-
}
1434-
14351431
this.telemetry.recordRetry();
14361432

14371433
this.logger.debug("Message scheduled for retry", {

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

Lines changed: 100 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,36 +1406,114 @@ describe("FairQueue", () => {
14061406

14071407
const redis = createRedisClient(redisOptions);
14081408

1409-
queue.onMessage(async (ctx) => {
1410-
if (ctx.message.payload.value === "msg-0") {
1411-
await redis.hdel(keys.inflightDataKey(0), ctx.message.id);
1409+
try {
1410+
queue.onMessage(async (ctx) => {
1411+
if (ctx.message.payload.value === "msg-0") {
1412+
await redis.hdel(keys.inflightDataKey(0), ctx.message.id);
1413+
}
1414+
processed.push(ctx.message.payload.value);
1415+
await ctx.complete();
1416+
});
1417+
1418+
for (let i = 0; i < 2; i++) {
1419+
await queue.enqueue({
1420+
queueId: "tenant:t1:queue:q1",
1421+
tenantId: "t1",
1422+
payload: { value: `msg-${i}` },
1423+
});
14121424
}
1413-
processed.push(ctx.message.payload.value);
1414-
await ctx.complete();
1425+
1426+
queue.start();
1427+
1428+
await vi.waitFor(
1429+
() => {
1430+
expect(processed).toHaveLength(2);
1431+
},
1432+
{ timeout: 10000 }
1433+
);
1434+
1435+
const held = await redis.scard(keys.concurrencyKey("tenant", "t1"));
1436+
expect(held).toBe(0);
1437+
} finally {
1438+
await redis.quit();
1439+
await queue.close();
1440+
}
1441+
}
1442+
);
1443+
1444+
redisTest(
1445+
"should release metadata-derived concurrency groups when the in-flight record is gone",
1446+
{ timeout: 15000 },
1447+
async ({ redisOptions }) => {
1448+
const processed: string[] = [];
1449+
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
1450+
1451+
const scheduler = new DRRScheduler({
1452+
redis: redisOptions,
1453+
keys,
1454+
quantum: 10,
1455+
maxDeficit: 100,
14151456
});
14161457

1417-
for (let i = 0; i < 2; i++) {
1418-
await queue.enqueue({
1419-
queueId: "tenant:t1:queue:q1",
1420-
tenantId: "t1",
1421-
payload: { value: `msg-${i}` },
1458+
const queue = new TestFairQueueHelper(redisOptions, keys, {
1459+
scheduler,
1460+
payloadSchema: TestPayloadSchema,
1461+
shardCount: 1,
1462+
consumerCount: 1,
1463+
consumerIntervalMs: 20,
1464+
visibilityTimeoutMs: 60000,
1465+
concurrencyGroups: [
1466+
{
1467+
name: "tenant",
1468+
extractGroupId: (q) => q.tenantId,
1469+
getLimit: async () => 5,
1470+
defaultLimit: 5,
1471+
},
1472+
{
1473+
name: "organization",
1474+
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
1475+
getLimit: async () => 1,
1476+
defaultLimit: 1,
1477+
},
1478+
],
1479+
startConsumers: false,
1480+
});
1481+
1482+
const redis = createRedisClient(redisOptions);
1483+
1484+
try {
1485+
queue.onMessage(async (ctx) => {
1486+
if (ctx.message.payload.value === "msg-0") {
1487+
await redis.hdel(keys.inflightDataKey(0), ctx.message.id);
1488+
}
1489+
processed.push(ctx.message.payload.value);
1490+
await ctx.complete();
14221491
});
1423-
}
14241492

1425-
queue.start();
1493+
for (let i = 0; i < 2; i++) {
1494+
await queue.enqueue({
1495+
queueId: "tenant:t1:queue:q1",
1496+
tenantId: "t1",
1497+
metadata: { orgId: "org-1" },
1498+
payload: { value: `msg-${i}` },
1499+
});
1500+
}
14261501

1427-
await vi.waitFor(
1428-
() => {
1429-
expect(processed).toHaveLength(2);
1430-
},
1431-
{ timeout: 10000 }
1432-
);
1502+
queue.start();
14331503

1434-
const held = await redis.scard(keys.concurrencyKey("tenant", "t1"));
1435-
expect(held).toBe(0);
1504+
await vi.waitFor(
1505+
() => {
1506+
expect(processed).toHaveLength(2);
1507+
},
1508+
{ timeout: 10000 }
1509+
);
14361510

1437-
await redis.quit();
1438-
await queue.close();
1511+
expect(await redis.scard(keys.concurrencyKey("organization", "org-1"))).toBe(0);
1512+
expect(await redis.scard(keys.concurrencyKey("organization", "default"))).toBe(0);
1513+
} finally {
1514+
await redis.quit();
1515+
await queue.close();
1516+
}
14391517
}
14401518
);
14411519
});

0 commit comments

Comments
 (0)