Skip to content

Commit 01d0666

Browse files
committed
chore(run-engine): move batch item heartbeating to its own branch
The heartbeat fixes a separate pre-existing bug (items slower than the visibility timeout are redelivered and executed twice) and shares no files with the concurrency slot fixes, so it ships on its own.
1 parent 1b24915 commit 01d0666

3 files changed

Lines changed: 6 additions & 123 deletions

File tree

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

Lines changed: 6 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@ 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-
6562
export class BatchQueue {
6663
private fairQueue: FairQueue<typeof BatchItemPayloadSchema>;
6764
private workerQueueManager: WorkerQueueManager;
@@ -70,8 +67,6 @@ export class BatchQueue {
7067
private tracer?: Tracer;
7168
private concurrencyRedis: Redis;
7269
private defaultConcurrency: number;
73-
private heartbeatIntervalMs: number;
74-
private visibilityTimeoutMs: number;
7570
private maxAttempts: number;
7671

7772
private processItemCallback?: ProcessBatchItemCallback;
@@ -100,8 +95,6 @@ export class BatchQueue {
10095
this.logger = options.logger ?? new Logger("BatchQueue", options.logLevel ?? "info");
10196
this.tracer = options.tracer;
10297
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));
10598
this.maxAttempts = options.retry?.maxAttempts ?? 1;
10699
this.abortController = new AbortController();
107100
this.workerQueueBlockingTimeoutSeconds = options.workerQueueBlockingTimeoutSeconds ?? 10;
@@ -161,8 +154,7 @@ export class BatchQueue {
161154
shardCount: options.shardCount ?? 1,
162155
consumerCount: options.consumerCount,
163156
consumerIntervalMs: options.consumerIntervalMs,
164-
visibilityTimeoutMs: this.visibilityTimeoutMs,
165-
heartbeatIntervalMs: this.visibilityTimeoutMs,
157+
visibilityTimeoutMs: 60_000, // 1 minute for batch item processing
166158
startConsumers: false, // We control when to start
167159
cooloff: {
168160
enabled: false,
@@ -760,44 +752,6 @@ export class BatchQueue {
760752
// Private - Message Handling
761753
// ============================================================================
762754

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-
801755
async #handleMessage(consumerId: string, messageId: string, queueId: string): Promise<void> {
802756
// Get message data from FairQueue's in-flight storage
803757
const storedMessage = await this.fairQueue.getMessageData(messageId, queueId);
@@ -866,10 +820,9 @@ export class BatchQueue {
866820
let processedCount: number;
867821

868822
try {
869-
const heartbeat = this.#startHeartbeat(messageId, queueId);
870-
let result: Awaited<ReturnType<ProcessBatchItemCallback>>;
871-
try {
872-
result = await this.#startSpan("BatchQueue.processItemCallback", async (innerSpan) => {
823+
const result = await this.#startSpan(
824+
"BatchQueue.processItemCallback",
825+
async (innerSpan) => {
873826
innerSpan?.setAttributes({
874827
"batch.id": batchId,
875828
"batch.itemIndex": itemIndex,
@@ -884,20 +837,8 @@ export class BatchQueue {
884837
attempt,
885838
isFinalAttempt,
886839
});
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-
}
840+
}
841+
);
901842

902843
if (result.success) {
903844
span?.setAttribute("batch.result", "success");

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

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -953,56 +953,4 @@ 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-
});
1008956
});

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,6 @@ 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;
223217
/**
224218
* Optional global rate limiter to limit processing across all consumers.
225219
* When configured, limits the max items/second processed globally.

0 commit comments

Comments
 (0)