Skip to content

Commit 7174549

Browse files
committed
feat: use GenerationID to gate stale checkpointing
1 parent a802711 commit 7174549

7 files changed

Lines changed: 64 additions & 2 deletions

File tree

.changeset/tender-areas-type.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"inngest": minor
3+
---
4+
5+
More gracefully handle 'stale' checkpoints to prevent duplicated runs.

packages/inngest/src/api/api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ import {
2323

2424
type FetchT = typeof fetch;
2525

26+
/**
27+
* Thrown when the executor has already requeued the current run. Returning
28+
* buffered ops after this would let the executor memoize them as canonical and
29+
* chain the next dispatch off this dead invocation, producing duplicates.
30+
*/
31+
export class StaleDispatchError extends Error {
32+
constructor(message: string) {
33+
super(message);
34+
this.name = "StaleDispatchError";
35+
}
36+
}
37+
2638
const realtimeSubscriptionTokenSchema = z.object({
2739
jwt: z.string(),
2840
});
@@ -538,12 +550,14 @@ export class InngestApi {
538550
runId: string;
539551
fnId: string;
540552
queueItemId: string;
553+
generationId?: number;
541554
steps: OutgoingOp[];
542555
}): Promise<void> {
543556
const body = JSON.stringify({
544557
run_id: args.runId,
545558
fn_id: args.fnId,
546559
qi_id: args.queueItemId,
560+
...(args.generationId ? { generation_id: args.generationId } : {}),
547561
steps: args.steps,
548562
ts: new Date().valueOf(),
549563
});
@@ -563,6 +577,14 @@ export class InngestApi {
563577
}
564578

565579
const res = result.value;
580+
// 409 means the executor has already requeued. Halt rather than returning
581+
// buffered ops, which would let the executor memoize them as canonical and
582+
// chain the next dispatch off this dead invocation. See EXE-1552.
583+
if (res.status === 409) {
584+
throw new StaleDispatchError(
585+
`Stale dispatch: checkpoint returned 409 (run ${args.runId})`,
586+
);
587+
}
566588
if (!res.ok) {
567589
throw new Error(
568590
`Failed to checkpoint async: ${res.status} ${

packages/inngest/src/components/InngestCommHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2192,6 +2192,7 @@ export class InngestCommHandler<
21922192
},
21932193
internalFnId: ctx?.fn_id,
21942194
queueItemId: ctx?.qi_id,
2195+
generationId: ctx?.generation_id,
21952196
stepState,
21962197
requestedRunStep,
21972198
timer,

packages/inngest/src/components/execution/InngestExecution.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ export interface InngestExecutionOptions {
127127
*/
128128
queueItemId?: string;
129129

130+
/**
131+
* Echoed back on every async checkpoint POST so the executor can reject
132+
* stale dispatches with 409.
133+
*/
134+
generationId?: number;
135+
130136
/**
131137
* Headers to be sent with any request to Inngest during this execution.
132138
*/

packages/inngest/src/components/execution/engine.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import hashjs from "hash.js";
33
import ms, { type StringValue } from "ms";
44
import { z } from "zod/v3";
55

6+
import { StaleDispatchError } from "../../api/api.ts";
67
import {
78
defaultMaxRetries,
89
ExecutionVersion,
@@ -91,6 +92,12 @@ import {
9192

9293
const { sha1 } = hashjs;
9394

95+
// Defends against `instanceof` failing across bundle boundaries.
96+
const isStaleDispatchError = (err: unknown): boolean =>
97+
err instanceof StaleDispatchError ||
98+
// biome-ignore lint/suspicious/noExplicitAny: name check across boundaries
99+
(err as any)?.name === "StaleDispatchError";
100+
94101
/**
95102
* Retry configuration for checkpoint operations.
96103
*
@@ -466,9 +473,13 @@ class InngestExecutionEngine
466473
runId: this.fnArg.runId,
467474
fnId: internalFnId,
468475
queueItemId,
476+
generationId: this.options.generationId,
469477
steps,
470478
}),
471-
CHECKPOINT_RETRY_OPTIONS,
479+
{
480+
...CHECKPOINT_RETRY_OPTIONS,
481+
shouldRetry: (err) => !isStaleDispatchError(err),
482+
},
472483
);
473484
} else {
474485
throw new Error(
@@ -864,6 +875,21 @@ class InngestExecutionEngine
864875
this.state.checkpointingStepBuffer,
865876
));
866877
} catch (err) {
878+
// Stale dispatch: the executor has already requeued. Returning
879+
// buffered ops would let it memoize them as canonical and chain the
880+
// next dispatch off this dead invocation, producing duplicate step
881+
// executions.
882+
if (isStaleDispatchError(err)) {
883+
this.devDebug("stale dispatch detected; halting execution");
884+
return {
885+
type: "function-rejected" as const,
886+
ctx: this.fnArg,
887+
ops: {},
888+
error: serializeError(err),
889+
retriable: false,
890+
};
891+
}
892+
867893
// If checkpointing fails for any reason, fall back to returning
868894
// ALL buffered steps to the executor via the normal async flow.
869895
// The executor persists completed steps and rediscovers any

packages/inngest/src/helpers/functions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export const parseFnData = (
160160
disable_immediate_execution: z.boolean().default(false),
161161
use_api: z.boolean().default(false),
162162
qi_id: z.string().optional(),
163+
generation_id: z.number().optional(),
163164
stack: z
164165
.object({
165166
stack: z

packages/inngest/src/helpers/promises.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export const retryWithBackoff = async <T>(
236236
opts?: {
237237
maxAttempts?: number;
238238
baseDelay?: number;
239+
shouldRetry?: (err: unknown) => boolean;
239240
},
240241
): Promise<T> => {
241242
const maxAttempts = opts?.maxAttempts || 5;
@@ -245,7 +246,7 @@ export const retryWithBackoff = async <T>(
245246
try {
246247
return await fn();
247248
} catch (err) {
248-
if (attempt >= maxAttempts) {
249+
if (attempt >= maxAttempts || opts?.shouldRetry?.(err) === false) {
249250
throw err;
250251
}
251252

0 commit comments

Comments
 (0)