Skip to content

Commit 568b3bf

Browse files
committed
fix(webapp): serialise expired/failed global-scope recreate through the claim
Devin review follow-up. Only the claim-LOSER cleared path (reacquireClearedGlobalWinner) was claim-serialised; the INITIAL existingRun probe's expired/failed path cleared the key and fell through to an UNSERIALISED recreate. Under the split, two concurrent cross-residency triggers on an expired/failed global-scope key could each recreate on their own DB (the per-DB unique index can't dedup cross-residency). Route that initial-probe recreate through the same reacquireClearedGlobalWinner claim serialisation when global-under-split; non-split / non-global keeps the plain recreate.
1 parent 6ae9b8a commit 568b3bf

2 files changed

Lines changed: 130 additions & 6 deletions

File tree

apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ export class IdempotencyKeyConcern {
181181
}
182182
);
183183

184+
// `global`-scope (or scope-absent) keys under the split have no per-run salt, so the Redis claim is
185+
// their only cross-DB dedup mutex. Computed here (not just in the claim block below) because the
186+
// expired/failed clear-and-recreate path must serialise through it too.
187+
const idempotencyKeyScope = request.body.options?.idempotencyKeyOptions?.scope;
188+
const globalUnderSplit =
189+
(idempotencyKeyScope === "global" || idempotencyKeyScope === undefined) &&
190+
(await isSplitEnabled());
191+
184192
const existingRun = idempotencyKey
185193
? await runStore.findRun(
186194
{
@@ -217,11 +225,37 @@ export class IdempotencyKeyConcern {
217225
}
218226

219227
if (existingRun) {
220-
return await this.handleExistingRun(request, parentStore, existingRun, {
228+
const handled = await this.handleExistingRun(request, parentStore, existingRun, {
221229
idempotencyKey,
222230
idempotencyKeyExpiresAt,
223231
dedupClient,
224232
});
233+
// A LIVE cached hit (or andWait waitpoint wiring) is terminal.
234+
if (handled.isCached) {
235+
return handled;
236+
}
237+
// isCached === false → the existing run was EXPIRED/FAILED, so handleExistingRun cleared its key
238+
// and we must recreate. For a global-scope key under split that recreate has to be claim-
239+
// serialised too — otherwise two concurrent cross-residency recreates each create a run the
240+
// per-DB unique index can't dedup (the same hole reacquireClearedGlobalWinner closes on the
241+
// claim-loser path). Non-split / non-global: the plain unserialised recreate is safe.
242+
if (globalUnderSplit) {
243+
return await this.reacquireClearedGlobalWinner(request, parentStore, {
244+
idempotencyKey,
245+
idempotencyKeyExpiresAt,
246+
dedupClient,
247+
ttlSeconds: computeClaimTtlSeconds({
248+
keyExpiresAt: idempotencyKeyExpiresAt,
249+
now: Date.now(),
250+
minTtlSeconds: env.TRIGGER_MOLLIFIER_CLAIM_MIN_TTL_SECONDS,
251+
maxTtlSeconds: env.TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS,
252+
}),
253+
clearedRunId: existingRun.friendlyId,
254+
safetyNetMs: env.TRIGGER_MOLLIFIER_CLAIM_WAIT_MS,
255+
pollStepMs: env.TRIGGER_MOLLIFIER_CLAIM_POLL_MS,
256+
});
257+
}
258+
return handled;
225259
}
226260

227261
// Pre-gate claim — closes the PG+buffer race during gate transition.
@@ -248,11 +282,8 @@ export class IdempotencyKeyConcern {
248282
// older SDK) is treated conservatively as possibly-global — harmless for a
249283
// real run/attempt key, whose hash already embeds the parent id so two
250284
// parents mint DISTINCT keys that never share a claim slot.
251-
const idempotencyKeyScope = request.body.options?.idempotencyKeyOptions?.scope;
252-
const globalUnderSplit =
253-
(idempotencyKeyScope === "global" || idempotencyKeyScope === undefined) &&
254-
(await isSplitEnabled());
255-
285+
// (idempotencyKeyScope / globalUnderSplit are computed above — they also gate the expired/failed
286+
// recreate serialisation.)
256287
const claimEligible =
257288
!request.body.options?.debounce &&
258289
!request.options?.oneTimeUseToken &&
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
// Devin #2: a global-scope (or scope-absent) key whose existing run is EXPIRED/FAILED gets its key
4+
// cleared and recreated. Under the run-ops split that recreate must serialise through the claim (same
5+
// cross-DB dup risk as the claim-loser cleared path) rather than fall through to an unserialised create.
6+
vi.mock("~/db.server", () => ({
7+
prisma: {},
8+
$replica: {},
9+
runOpsNewPrisma: {},
10+
runOpsLegacyPrisma: {},
11+
runOpsNewReplica: {},
12+
runOpsLegacyReplica: {},
13+
}));
14+
15+
const h = vi.hoisted(() => ({ existingRun: null as unknown, splitEnabled: true }));
16+
17+
vi.mock("~/v3/runStore.server", () => ({
18+
runStore: { findRun: vi.fn(async () => h.existingRun) },
19+
}));
20+
vi.mock("~/v3/mollifier/mollifierBuffer.server", () => ({ getMollifierBuffer: () => null }));
21+
vi.mock("~/v3/mollifier/mollifierGate.server", () => ({
22+
makeResolveMollifierFlag: () => async () => false,
23+
}));
24+
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({
25+
isSplitEnabled: async () => h.splitEnabled,
26+
}));
27+
vi.mock("~/runEngine/concerns/idempotencyResidency.server", () => ({
28+
resolveIdempotencyDedupClient: async () => ({}),
29+
}));
30+
31+
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
32+
import type { TriggerTaskRequest } from "~/runEngine/types";
33+
34+
function makeRequest(): TriggerTaskRequest {
35+
return {
36+
taskId: "my-task",
37+
environment: { id: "env_a", organizationId: "org_1", organization: { featureFlags: {} } },
38+
options: {},
39+
body: { options: { idempotencyKey: "k-1" } }, // scope absent → treated as global
40+
} as unknown as TriggerTaskRequest;
41+
}
42+
43+
// handleExistingRun's documented contract for an expired/failed run: clear the key, return isCached:false.
44+
const CLEARED = {
45+
isCached: false as const,
46+
idempotencyKey: "k-1",
47+
idempotencyKeyExpiresAt: new Date(Date.now() + 60_000),
48+
};
49+
50+
describe("IdempotencyKeyConcern · expired/failed recreate re-serialisation", () => {
51+
it("routes the cleared recreate through the claim under global-scope split (no unserialised create)", async () => {
52+
h.existingRun = { id: "run_internal", friendlyId: "run_friendly" };
53+
h.splitEnabled = true;
54+
const concern = new IdempotencyKeyConcern({} as never, {} as never, {} as never);
55+
vi.spyOn(
56+
concern as never as { handleExistingRun: () => unknown },
57+
"handleExistingRun"
58+
).mockResolvedValue(CLEARED);
59+
const SENTINEL = { ...CLEARED, claim: { token: "t" } };
60+
const reacquire = vi
61+
.spyOn(
62+
concern as never as { reacquireClearedGlobalWinner: () => unknown },
63+
"reacquireClearedGlobalWinner"
64+
)
65+
.mockResolvedValue(SENTINEL);
66+
67+
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
68+
69+
expect(reacquire).toHaveBeenCalledOnce();
70+
expect(result).toBe(SENTINEL);
71+
});
72+
73+
it("does NOT re-serialise when the split is off — plain recreate", async () => {
74+
h.existingRun = { id: "run_internal", friendlyId: "run_friendly" };
75+
h.splitEnabled = false;
76+
const concern = new IdempotencyKeyConcern({} as never, {} as never, {} as never);
77+
vi.spyOn(
78+
concern as never as { handleExistingRun: () => unknown },
79+
"handleExistingRun"
80+
).mockResolvedValue(CLEARED);
81+
const reacquire = vi
82+
.spyOn(
83+
concern as never as { reacquireClearedGlobalWinner: () => unknown },
84+
"reacquireClearedGlobalWinner"
85+
)
86+
.mockResolvedValue({} as never);
87+
88+
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
89+
90+
expect(reacquire).not.toHaveBeenCalled();
91+
expect(result).toBe(CLEARED);
92+
});
93+
});

0 commit comments

Comments
 (0)