Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 65 additions & 6 deletions internal-packages/run-engine/src/batch-queue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ const ENV_CONCURRENCY_KEY_PREFIX = "batch:env_concurrency";
// then all messages are routed to this queue for BatchQueue's own consumer loop.
const BATCH_WORKER_QUEUE_ID = "batch-worker-queue";

/** How long a claimed batch item stays invisible before the reclaim loop takes it back. */
const BATCH_ITEM_VISIBILITY_TIMEOUT_MS = 60_000;
Comment on lines +62 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 No .server-changes entry for a user-visible batch fix

CONTRIBUTING.md / AGENTS.md ask for a .server-changes/ file for server-only PRs (apps/webapp/, apps/supervisor/, "etc."). This PR touches only internal-packages/run-engine, which the table does not name explicitly, and repo history is mixed for run-engine-only PRs (some include one, some do not). Given the fix is user-visible (duplicate runs / batches that never finalize), a .server-changes/ note with area: webapp, type: fix would likely be expected here.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


export class BatchQueue {
private fairQueue: FairQueue<typeof BatchItemPayloadSchema>;
private workerQueueManager: WorkerQueueManager;
Expand All @@ -67,6 +70,8 @@ export class BatchQueue {
private tracer?: Tracer;
private concurrencyRedis: Redis;
private defaultConcurrency: number;
private heartbeatIntervalMs: number;
private visibilityTimeoutMs: number;
private maxAttempts: number;

private processItemCallback?: ProcessBatchItemCallback;
Expand Down Expand Up @@ -95,6 +100,8 @@ export class BatchQueue {
this.logger = options.logger ?? new Logger("BatchQueue", options.logLevel ?? "info");
this.tracer = options.tracer;
this.defaultConcurrency = options.defaultConcurrency ?? 10;
this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? BATCH_ITEM_VISIBILITY_TIMEOUT_MS;
this.heartbeatIntervalMs = Math.max(50, Math.floor(this.visibilityTimeoutMs / 3));
Comment on lines +103 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the visibility-timeout contract.

For a timeout below 50 ms, the first heartbeat occurs after the lease expires. Values near 50 ms also have no scheduling margin. This can reclaim an active item and cause duplicate processing.

  • internal-packages/run-engine/src/batch-queue/index.ts#L103-L104: Reject non-finite or too-small values, or derive a heartbeat interval that always occurs safely before expiry.
  • internal-packages/run-engine/src/batch-queue/types.ts#L217-L222: Document the accepted minimum timeout.
📍 Affects 2 files
  • internal-packages/run-engine/src/batch-queue/index.ts#L103-L104 (this comment)
  • internal-packages/run-engine/src/batch-queue/types.ts#L217-L222

this.maxAttempts = options.retry?.maxAttempts ?? 1;
this.abortController = new AbortController();
this.workerQueueBlockingTimeoutSeconds = options.workerQueueBlockingTimeoutSeconds ?? 10;
Expand Down Expand Up @@ -154,7 +161,8 @@ export class BatchQueue {
shardCount: options.shardCount ?? 1,
consumerCount: options.consumerCount,
consumerIntervalMs: options.consumerIntervalMs,
visibilityTimeoutMs: 60_000, // 1 minute for batch item processing
visibilityTimeoutMs: this.visibilityTimeoutMs,
heartbeatIntervalMs: this.visibilityTimeoutMs,
Comment on lines +164 to +165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pass the derived heartbeat interval to FairQueue.

Line 165 passes this.visibilityTimeoutMs as heartbeatIntervalMs. This bypasses the one-third interval derived on Line 104. FairQueue can then heartbeat at the lease deadline instead of before it.

Proposed fix
-      heartbeatIntervalMs: this.visibilityTimeoutMs,
+      heartbeatIntervalMs: this.heartbeatIntervalMs,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
visibilityTimeoutMs: this.visibilityTimeoutMs,
heartbeatIntervalMs: this.visibilityTimeoutMs,
visibilityTimeoutMs: this.visibilityTimeoutMs,
heartbeatIntervalMs: this.heartbeatIntervalMs,

startConsumers: false, // We control when to start
cooloff: {
enabled: false,
Expand Down Expand Up @@ -752,6 +760,44 @@ export class BatchQueue {
// Private - Message Handling
// ============================================================================

/**
* Keep extending a message's visibility deadline while its callback runs, so an item
* slower than the visibility timeout is not redelivered and executed a second time.
*
* `lostLease` reports that an extend found no in-flight entry, which means the item was
* reclaimed and is now back on the queue. It is a best-effort signal, not a fence: the
* in-flight member is keyed only by message and queue id, so once another consumer
* re-claims the item the member exists again and an extend from this consumer succeeds.
* Distinguishing owners would need a per-claim token in the member.
*/
#startHeartbeat(
messageId: string,
queueId: string
): { stop: () => void; lostLease: () => boolean } {
let lostLease = false;

const interval = setInterval(() => {
this.fairQueue
.heartbeatMessage(messageId, queueId)
.then((stillOwned) => {
if (!stillOwned) {
lostLease = true;
}
})
.catch((error) => {
this.logger.debug("Batch item heartbeat failed", {
messageId,
queueId,
error: error instanceof Error ? error.message : String(error),
});
});
}, this.heartbeatIntervalMs);

interval.unref?.();

return { stop: () => clearInterval(interval), lostLease: () => lostLease };
Comment on lines +776 to +798

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Wait for active heartbeats and handle lease loss for callback errors.

stop() only clears the interval. A heartbeatMessage() call already in flight can set lostLease after Line 892. Also, a rejected callback skips the lease check and enters failMessage or failure recording. Both paths can mutate a message after another consumer reclaimed it.

  • internal-packages/run-engine/src/batch-queue/index.ts#L776-L798: Make stop() await or serialize active heartbeat requests before it returns.
  • internal-packages/run-engine/src/batch-queue/index.ts#L869-L900: Capture both callback results and callback errors. After awaiting heartbeat shutdown, discard either outcome when lostLease() is true.
📍 Affects 1 file
  • internal-packages/run-engine/src/batch-queue/index.ts#L776-L798 (this comment)
  • internal-packages/run-engine/src/batch-queue/index.ts#L869-L900

Comment on lines +779 to +798

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A batch item whose processing hangs forever now blocks its batch from ever finishing

The item is kept permanently invisible by an unbounded keep-alive timer (setInterval at internal-packages/run-engine/src/batch-queue/index.ts:779-794) that runs for as long as the callback runs, so a callback that never returns stops the item from ever being retried and the batch never completes.

Impact: If a single item's processing stalls indefinitely (e.g. a hung network/database call), the whole batch stays stuck forever instead of recovering by retrying that item.

Why the safety net disappeared: extension has no upper bound

Before this change, a claimed message had a fixed 60s lease; if the consumer stopped making progress the reclaim loop (packages/redis-worker/src/fair-queue/visibility.ts:393) put the message back on the queue and another consumer eventually processed it, letting the batch finish.

Now #startHeartbeat extends the deadline by a full visibilityTimeoutMs every visibilityTimeoutMs/3 (internal-packages/run-engine/src/batch-queue/index.ts:773-799) and only stops in the finally after the callback settles (index.ts:888-890). A callback that never settles (no timeout in processItemCallback) keeps beating forever, so the item is never reclaimed, never redelivered, processedCount never reaches meta.runCount, and #finalizeBatch is never called.

A common mitigation is capping the total heartbeat lifetime (e.g. stop extending after N × visibility timeout, or after a configured max processing duration) so a genuinely stuck consumer still yields the item.

Prompt for agents
In internal-packages/run-engine/src/batch-queue/index.ts, #startHeartbeat extends a batch item's visibility deadline indefinitely for as long as the process-item callback is pending. Previously a stuck consumer would simply let the lease lapse and the FairQueue reclaim loop would redeliver the item, so the batch could still finish. With unbounded heartbeating, a callback that never settles keeps the item invisible forever and the batch's processed count never reaches runCount, so the batch never finalizes. Consider bounding how long an item may be heartbeated (e.g. a maxProcessingDurationMs option, or stop after N extensions) and letting the item lapse back to the queue past that bound, so a hung consumer cannot wedge a batch permanently.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

async #handleMessage(consumerId: string, messageId: string, queueId: string): Promise<void> {
// Get message data from FairQueue's in-flight storage
const storedMessage = await this.fairQueue.getMessageData(messageId, queueId);
Expand Down Expand Up @@ -820,9 +866,10 @@ export class BatchQueue {
let processedCount: number;

try {
const result = await this.#startSpan(
"BatchQueue.processItemCallback",
async (innerSpan) => {
const heartbeat = this.#startHeartbeat(messageId, queueId);
let result: Awaited<ReturnType<ProcessBatchItemCallback>>;
try {
result = await this.#startSpan("BatchQueue.processItemCallback", async (innerSpan) => {
innerSpan?.setAttributes({
"batch.id": batchId,
"batch.itemIndex": itemIndex,
Expand All @@ -837,8 +884,20 @@ export class BatchQueue {
attempt,
isFinalAttempt,
});
}
);
});
} finally {
heartbeat.stop();
}

if (heartbeat.lostLease()) {
this.logger.warn("Discarding batch item result, another consumer now owns it", {
batchId,
itemIndex,
messageId,
attempt,
});
return;
}
Comment on lines +892 to +900

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 An item taken over by another worker can still be marked permanently failed by the old worker

When processing throws, the code records a permanent failure and re-queues the item (failMessage/recordFailure in the catch block at internal-packages/run-engine/src/batch-queue/index.ts:892-899 is only applied to the success path), so an item that another worker has already taken over and succeeded on can be reported as failed.

Impact: A batch can report an item as failed even though a run for it was created successfully, and the created run is missing from the batch's results.

Asymmetric ownership check between the result path and the throw path

After the callback resolves, the code checks heartbeat.lostLease() and discards the result (internal-packages/run-engine/src/batch-queue/index.ts:892-900). If the callback instead throws, control jumps straight to the outer catch (see internal-packages/run-engine/src/batch-queue/index.ts:996 onwards) with no lostLease() check, so this consumer calls fairQueue.failMessage(...) (re-queuing a message now owned by someone else) or completionTracker.recordFailure(...).

recordFailure and recordSuccess are idempotent on itemIndex (internal-packages/run-engine/src/batch-queue/completionTracker.ts:329-351), so whichever lands first wins. If the stale consumer's failure lands first, the new owner's recordSuccess becomes a no-op: the batch counts the item as failed and the successfully created run never appears in runIds.

Applying the same lostLease() guard at the top of the catch block (before scheduling retry/recording failure) would close this.

Prompt for agents
In internal-packages/run-engine/src/batch-queue/index.ts #handleMessage, the new heartbeat lost-lease guard is only applied when the process-item callback resolves. When the callback throws, the outer catch block calls fairQueue.failMessage (re-queueing a message another consumer now owns) or completionTracker.recordFailure without checking heartbeat.lostLease(). Because recordSuccess/recordFailure are idempotent per itemIndex, a stale consumer's failure record can win the race against the new owner's success record, causing the batch to report a failure for an item whose run was actually created. Consider hoisting the heartbeat handle so the catch path can consult lostLease() and bail out the same way the success path does.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if (result.success) {
span?.setAttribute("batch.result", "success");
Expand Down
52 changes: 52 additions & 0 deletions internal-packages/run-engine/src/batch-queue/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,4 +953,56 @@ describe("BatchQueue", () => {
}
);
});

describe("visibility heartbeat", () => {
redisTest(
"should not redeliver an item that takes longer than the visibility timeout",
{ timeout: 60_000 },
async ({ redisContainer }) => {
const queue = new BatchQueue({
redis: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
keyPrefix: "test:",
},
drr: { quantum: 5, maxDeficit: 50 },
consumerCount: 2,
consumerIntervalMs: 50,
visibilityTimeoutMs: 1_000,
startConsumers: false,
});

const invocations: number[] = [];

try {
queue.onProcessItem(async ({ itemIndex }) => {
const isFirst = invocations.length === 0;
invocations.push(itemIndex);
if (isFirst) {
await new Promise((resolve) => setTimeout(resolve, 9_000));
}
return { success: true, runId: `run-${itemIndex}` };
});

await queue.initializeBatch(createInitOptions("batch-hb", "env-hb", 1));
await enqueueItems(queue, "batch-hb", "env-hb", createBatchItems(1));

queue.start();

await vi.waitFor(
() => {
expect(invocations.length).toBeGreaterThanOrEqual(1);
},
{ timeout: 10_000 }
);

await new Promise((resolve) => setTimeout(resolve, 14_000));

expect(invocations).toEqual([0]);
} finally {
await queue.close();
}
}
);
});
});
6 changes: 6 additions & 0 deletions internal-packages/run-engine/src/batch-queue/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ export type BatchQueueOptions = {
* Items wait in queue until capacity frees up.
*/
defaultConcurrency?: number;
/**
* How long a claimed item stays invisible before the reclaim loop takes it back.
* The item is heartbeated for as long as its callback runs, so this only bites when
* a consumer stops making progress. Defaults to 60s.
*/
visibilityTimeoutMs?: number;
/**
* Optional global rate limiter to limit processing across all consumers.
* When configured, limits the max items/second processed globally.
Expand Down
Loading