Skip to content

Commit 5ecb968

Browse files
committed
fix(run-engine): heartbeat batch items so slow ones are not run twice
A claimed item stayed invisible for a fixed 60s and was never heartbeated, so any item whose callback ran longer than that was reclaimed and handed to a second consumer while the first was still working on it. Both consumers created a run for the same item, and the redelivery could then be dropped, leaving the batch short of its expected count. Items are now heartbeated for as long as their callback runs. Each beat extends by a full visibility timeout while the tick stays at a third of it, so a slow beat has margin rather than lapsing the deadline. The timeout is configurable so the behaviour is testable. If a beat reports the in-flight entry is gone the item was reclaimed, and the consumer discards its result rather than completing over the new owner. That is a best-effort signal, not a fence: the in-flight member carries no per-claim token, so once another consumer re-claims the item this consumer's beats succeed again.
1 parent 337dda1 commit 5ecb968

3 files changed

Lines changed: 123 additions & 6 deletions

File tree

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

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ const ENV_CONCURRENCY_KEY_PREFIX = "batch:env_concurrency";
5959
// then all messages are routed to this queue for BatchQueue's own consumer loop.
6060
const BATCH_WORKER_QUEUE_ID = "batch-worker-queue";
6161

62+
/** How long a claimed batch item stays invisible before the reclaim loop takes it back. */
63+
const BATCH_ITEM_VISIBILITY_TIMEOUT_MS = 60_000;
64+
6265
export class BatchQueue {
6366
private fairQueue: FairQueue<typeof BatchItemPayloadSchema>;
6467
private workerQueueManager: WorkerQueueManager;
@@ -67,6 +70,8 @@ export class BatchQueue {
6770
private tracer?: Tracer;
6871
private concurrencyRedis: Redis;
6972
private defaultConcurrency: number;
73+
private heartbeatIntervalMs: number;
74+
private visibilityTimeoutMs: number;
7075
private maxAttempts: number;
7176

7277
private processItemCallback?: ProcessBatchItemCallback;
@@ -95,6 +100,8 @@ export class BatchQueue {
95100
this.logger = options.logger ?? new Logger("BatchQueue", options.logLevel ?? "info");
96101
this.tracer = options.tracer;
97102
this.defaultConcurrency = options.defaultConcurrency ?? 10;
103+
this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? BATCH_ITEM_VISIBILITY_TIMEOUT_MS;
104+
this.heartbeatIntervalMs = Math.max(50, Math.floor(this.visibilityTimeoutMs / 3));
98105
this.maxAttempts = options.retry?.maxAttempts ?? 1;
99106
this.abortController = new AbortController();
100107
this.workerQueueBlockingTimeoutSeconds = options.workerQueueBlockingTimeoutSeconds ?? 10;
@@ -154,7 +161,8 @@ export class BatchQueue {
154161
shardCount: options.shardCount ?? 1,
155162
consumerCount: options.consumerCount,
156163
consumerIntervalMs: options.consumerIntervalMs,
157-
visibilityTimeoutMs: 60_000, // 1 minute for batch item processing
164+
visibilityTimeoutMs: this.visibilityTimeoutMs,
165+
heartbeatIntervalMs: this.visibilityTimeoutMs,
158166
startConsumers: false, // We control when to start
159167
cooloff: {
160168
enabled: false,
@@ -752,6 +760,44 @@ export class BatchQueue {
752760
// Private - Message Handling
753761
// ============================================================================
754762

763+
/**
764+
* Keep extending a message's visibility deadline while its callback runs, so an item
765+
* slower than the visibility timeout is not redelivered and executed a second time.
766+
*
767+
* `lostLease` reports that an extend found no in-flight entry, which means the item was
768+
* reclaimed and is now back on the queue. It is a best-effort signal, not a fence: the
769+
* in-flight member is keyed only by message and queue id, so once another consumer
770+
* re-claims the item the member exists again and an extend from this consumer succeeds.
771+
* Distinguishing owners would need a per-claim token in the member.
772+
*/
773+
#startHeartbeat(
774+
messageId: string,
775+
queueId: string
776+
): { stop: () => void; lostLease: () => boolean } {
777+
let lostLease = false;
778+
779+
const interval = setInterval(() => {
780+
this.fairQueue
781+
.heartbeatMessage(messageId, queueId)
782+
.then((stillOwned) => {
783+
if (!stillOwned) {
784+
lostLease = true;
785+
}
786+
})
787+
.catch((error) => {
788+
this.logger.debug("Batch item heartbeat failed", {
789+
messageId,
790+
queueId,
791+
error: error instanceof Error ? error.message : String(error),
792+
});
793+
});
794+
}, this.heartbeatIntervalMs);
795+
796+
interval.unref?.();
797+
798+
return { stop: () => clearInterval(interval), lostLease: () => lostLease };
799+
}
800+
755801
async #handleMessage(consumerId: string, messageId: string, queueId: string): Promise<void> {
756802
// Get message data from FairQueue's in-flight storage
757803
const storedMessage = await this.fairQueue.getMessageData(messageId, queueId);
@@ -820,9 +866,10 @@ export class BatchQueue {
820866
let processedCount: number;
821867

822868
try {
823-
const result = await this.#startSpan(
824-
"BatchQueue.processItemCallback",
825-
async (innerSpan) => {
869+
const heartbeat = this.#startHeartbeat(messageId, queueId);
870+
let result: Awaited<ReturnType<ProcessBatchItemCallback>>;
871+
try {
872+
result = await this.#startSpan("BatchQueue.processItemCallback", async (innerSpan) => {
826873
innerSpan?.setAttributes({
827874
"batch.id": batchId,
828875
"batch.itemIndex": itemIndex,
@@ -837,8 +884,20 @@ export class BatchQueue {
837884
attempt,
838885
isFinalAttempt,
839886
});
840-
}
841-
);
887+
});
888+
} finally {
889+
heartbeat.stop();
890+
}
891+
892+
if (heartbeat.lostLease()) {
893+
this.logger.warn("Discarding batch item result, another consumer now owns it", {
894+
batchId,
895+
itemIndex,
896+
messageId,
897+
attempt,
898+
});
899+
return;
900+
}
842901

843902
if (result.success) {
844903
span?.setAttribute("batch.result", "success");

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,4 +953,56 @@ describe("BatchQueue", () => {
953953
}
954954
);
955955
});
956+
957+
describe("visibility heartbeat", () => {
958+
redisTest(
959+
"should not redeliver an item that takes longer than the visibility timeout",
960+
{ timeout: 60_000 },
961+
async ({ redisContainer }) => {
962+
const queue = new BatchQueue({
963+
redis: {
964+
host: redisContainer.getHost(),
965+
port: redisContainer.getPort(),
966+
keyPrefix: "test:",
967+
},
968+
drr: { quantum: 5, maxDeficit: 50 },
969+
consumerCount: 2,
970+
consumerIntervalMs: 50,
971+
visibilityTimeoutMs: 1_000,
972+
startConsumers: false,
973+
});
974+
975+
const invocations: number[] = [];
976+
977+
try {
978+
queue.onProcessItem(async ({ itemIndex }) => {
979+
const isFirst = invocations.length === 0;
980+
invocations.push(itemIndex);
981+
if (isFirst) {
982+
await new Promise((resolve) => setTimeout(resolve, 9_000));
983+
}
984+
return { success: true, runId: `run-${itemIndex}` };
985+
});
986+
987+
await queue.initializeBatch(createInitOptions("batch-hb", "env-hb", 1));
988+
await enqueueItems(queue, "batch-hb", "env-hb", createBatchItems(1));
989+
990+
queue.start();
991+
992+
await vi.waitFor(
993+
() => {
994+
expect(invocations.length).toBeGreaterThanOrEqual(1);
995+
},
996+
{ timeout: 10_000 }
997+
);
998+
999+
await new Promise((resolve) => setTimeout(resolve, 14_000));
1000+
1001+
expect(invocations).toEqual([0]);
1002+
} finally {
1003+
await queue.close();
1004+
}
1005+
}
1006+
);
1007+
});
9561008
});

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ export type BatchQueueOptions = {
214214
* Items wait in queue until capacity frees up.
215215
*/
216216
defaultConcurrency?: number;
217+
/**
218+
* How long a claimed item stays invisible before the reclaim loop takes it back.
219+
* The item is heartbeated for as long as its callback runs, so this only bites when
220+
* a consumer stops making progress. Defaults to 60s.
221+
*/
222+
visibilityTimeoutMs?: number;
217223
/**
218224
* Optional global rate limiter to limit processing across all consumers.
219225
* When configured, limits the max items/second processed globally.

0 commit comments

Comments
 (0)