Skip to content

Commit a2f12f2

Browse files
committed
fix(redis-worker,run-engine): give the item heartbeat real margin and stop one bad key stalling a shard
Each beat extended the deadline by the same amount as the gap between beats, so timer drift and round-trip latency left the message briefly past its deadline on every cycle, and a reclaim scan landing in that window still handed a running item to a second consumer. Beats now extend by a full visibility timeout while the tick stays at a third of it. Reclaim released the whole timed-out batch in one pipeline and aborted every requeue if any command failed, so a single unusable concurrency key stalled reclaim for every other tenant in that shard. A failed batch now falls back to releasing message by message and only the messages whose slot could not be freed are held back.
1 parent 3f8ed8b commit a2f12f2

4 files changed

Lines changed: 61 additions & 16 deletions

File tree

internal-packages/run-engine/src/batch-queue/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export class BatchQueue {
162162
consumerCount: options.consumerCount,
163163
consumerIntervalMs: options.consumerIntervalMs,
164164
visibilityTimeoutMs: this.visibilityTimeoutMs,
165+
heartbeatIntervalMs: this.visibilityTimeoutMs,
165166
startConsumers: false, // We control when to start
166167
cooloff: {
167168
enabled: false,

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,21 +1550,45 @@ export class FairQueue<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
15501550
* leaves the messages in-flight for the next reclaim tick. That is the safe direction:
15511551
* requeuing a message whose slot is still held is what strands the slot permanently.
15521552
*/
1553-
async #releaseReclaimedConcurrency(messages: ReclaimedMessageInfo[]): Promise<void> {
1553+
async #releaseReclaimedConcurrency(messages: ReclaimedMessageInfo[]): Promise<string[]> {
15541554
if (!this.concurrencyManager || messages.length === 0) {
1555-
return;
1555+
return [];
15561556
}
15571557

1558-
await this.concurrencyManager.releaseBatch(
1559-
messages.map((message) => ({
1560-
queue: {
1561-
id: message.queueId,
1562-
tenantId: message.tenantId,
1563-
metadata: message.metadata ?? {},
1564-
},
1565-
messageId: message.messageId,
1566-
}))
1567-
);
1558+
const descriptorFor = (message: ReclaimedMessageInfo) => ({
1559+
id: message.queueId,
1560+
tenantId: message.tenantId,
1561+
metadata: message.metadata ?? {},
1562+
});
1563+
1564+
try {
1565+
await this.concurrencyManager.releaseBatch(
1566+
messages.map((message) => ({ queue: descriptorFor(message), messageId: message.messageId }))
1567+
);
1568+
return [];
1569+
} catch (error) {
1570+
this.logger.error("Batch concurrency release failed, retrying message by message", {
1571+
count: messages.length,
1572+
error: error instanceof Error ? error.message : String(error),
1573+
});
1574+
}
1575+
1576+
const failed: string[] = [];
1577+
1578+
for (const message of messages) {
1579+
try {
1580+
await this.concurrencyManager.release(descriptorFor(message), message.messageId);
1581+
} catch (error) {
1582+
failed.push(message.messageId);
1583+
this.logger.error("Failed to release concurrency for reclaimed message", {
1584+
messageId: message.messageId,
1585+
queueId: message.queueId,
1586+
error: error instanceof Error ? error.message : String(error),
1587+
});
1588+
}
1589+
}
1590+
1591+
return failed;
15681592
}
15691593

15701594
async #reclaimTimedOutMessages(): Promise<void> {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,16 @@ describe("FairQueue", () => {
15801580

15811581
expect(await redis.zcard(keys.inflightKey(0))).toBe(1);
15821582
expect(await redis.zcard(keys.queueKey(queueId))).toBe(0);
1583+
1584+
await redis.del(concurrencyKey);
1585+
1586+
await vi.waitFor(
1587+
async () => {
1588+
expect(await redis.zcard(keys.queueKey(queueId))).toBe(1);
1589+
expect(await redis.zcard(keys.inflightKey(0))).toBe(0);
1590+
},
1591+
{ timeout: 8000 }
1592+
);
15831593
} finally {
15841594
await redis.del(concurrencyKey);
15851595
await redis.quit();

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ export class VisibilityManager {
399399
dispatchKey: string;
400400
tenantId: string;
401401
},
402-
onBeforeRequeue?: (messages: ReclaimedMessageInfo[]) => Promise<void>
402+
onBeforeRequeue?: (messages: ReclaimedMessageInfo[]) => Promise<string[] | void>
403403
): Promise<ReclaimedMessageInfo[]> {
404404
const inflightKey = this.keys.inflightKey(shardId);
405405
const inflightDataKey = this.keys.inflightDataKey(shardId);
@@ -465,14 +465,24 @@ export class VisibilityManager {
465465
return [];
466466
}
467467

468-
if (onBeforeRequeue) {
469-
await onBeforeRequeue(candidates.map((candidate) => candidate.info));
470-
}
468+
const notReleased = new Set(
469+
(onBeforeRequeue
470+
? await onBeforeRequeue(candidates.map((candidate) => candidate.info))
471+
: undefined) ?? []
472+
);
471473

472474
const reclaimedMessages: ReclaimedMessageInfo[] = [];
473475

474476
for (const { member, deadlineScore, storedMessage, info } of candidates) {
475477
const { messageId, queueId } = info;
478+
479+
if (notReleased.has(messageId)) {
480+
this.logger.error("Skipping requeue, concurrency slot was not released", {
481+
messageId,
482+
queueId,
483+
});
484+
continue;
485+
}
476486
const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } =
477487
getQueueKeys(queueId);
478488

0 commit comments

Comments
 (0)