Skip to content

Commit ff348c4

Browse files
committed
fix(run-engine,redis-worker): drop a batch item result once its lease is lost
The heartbeat already learns when another consumer has taken the item over: the in-flight member is gone, so the extend returns false. That signal was discarded, leaving the original consumer free to finish and complete over the new owner's in-flight record. It now stops and discards its result instead, so the consumer that actually owns the item is the one that finishes it. Also treat a discarded release pipeline as a failure rather than a success, since a null result means none of the commands ran.
1 parent a2f12f2 commit ff348c4

2 files changed

Lines changed: 39 additions & 11 deletions

File tree

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

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -767,20 +767,32 @@ export class BatchQueue {
767767
* then destroys the redelivery's in-flight record, silently dropping the item and
768768
* leaving the batch short of its expected count forever.
769769
*/
770-
#startHeartbeat(messageId: string, queueId: string): () => void {
770+
#startHeartbeat(
771+
messageId: string,
772+
queueId: string
773+
): { stop: () => void; lostLease: () => boolean } {
774+
let lostLease = false;
775+
771776
const interval = setInterval(() => {
772-
this.fairQueue.heartbeatMessage(messageId, queueId).catch((error) => {
773-
this.logger.debug("Batch item heartbeat failed", {
774-
messageId,
775-
queueId,
776-
error: error instanceof Error ? error.message : String(error),
777+
this.fairQueue
778+
.heartbeatMessage(messageId, queueId)
779+
.then((stillOwned) => {
780+
if (!stillOwned) {
781+
lostLease = true;
782+
}
783+
})
784+
.catch((error) => {
785+
this.logger.debug("Batch item heartbeat failed", {
786+
messageId,
787+
queueId,
788+
error: error instanceof Error ? error.message : String(error),
789+
});
777790
});
778-
});
779791
}, this.heartbeatIntervalMs);
780792

781793
interval.unref?.();
782794

783-
return () => clearInterval(interval);
795+
return { stop: () => clearInterval(interval), lostLease: () => lostLease };
784796
}
785797

786798
async #handleMessage(consumerId: string, messageId: string, queueId: string): Promise<void> {
@@ -851,7 +863,7 @@ export class BatchQueue {
851863
let processedCount: number;
852864

853865
try {
854-
const stopHeartbeat = this.#startHeartbeat(messageId, queueId);
866+
const heartbeat = this.#startHeartbeat(messageId, queueId);
855867
let result: Awaited<ReturnType<ProcessBatchItemCallback>>;
856868
try {
857869
result = await this.#startSpan("BatchQueue.processItemCallback", async (innerSpan) => {
@@ -871,7 +883,17 @@ export class BatchQueue {
871883
});
872884
});
873885
} finally {
874-
stopHeartbeat();
886+
heartbeat.stop();
887+
}
888+
889+
if (heartbeat.lostLease()) {
890+
this.logger.warn("Discarding batch item result, another consumer now owns it", {
891+
batchId,
892+
itemIndex,
893+
messageId,
894+
attempt,
895+
});
896+
return;
875897
}
876898

877899
if (result.success) {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,13 @@ export class ConcurrencyManager {
115115
results: Array<[Error | null, unknown]> | null,
116116
messageCount: number
117117
): void {
118-
const errors = (results ?? [])
118+
if (results === null) {
119+
throw new Error(
120+
`Concurrency release pipeline for ${messageCount} message(s) was discarded without executing`
121+
);
122+
}
123+
124+
const errors = results
119125
.map(([error]) => error)
120126
.filter((error): error is Error => Boolean(error));
121127

0 commit comments

Comments
 (0)