Skip to content

Commit cc1d8af

Browse files
committed
fix(webapp,redis-worker): reacquire the idempotency claim when a cleared global winner is recreated
The global-scope claim serialized the initial create but not the clear-and-recreate path: a claim loser resolving an expired/failed winner cleared the key and then created a new run unguarded, so two concurrent cross-residency losers could each recreate — a duplicate the per-DB unique index can't catch. Now, when a loser's resolved winner is cleared, the recreate is re-serialized through the claim: compare-and-delete the stale resolved slot (keyed on the observed runId — never an unconditional DEL, so a concurrent reacquirer's freshly published winner is never wiped) then re-enter claimOrAwait; capped at 5 attempts, then fall open to the create with the PG unique index as backstop. Adds MollifierBuffer.resetResolvedClaim reusing the existing compare-and-delete Lua. Guarded by two caller-driven tests (expired-key + failed-winner, two concurrent losers, real Redis) that create two runs when reverted.
1 parent 2cd61af commit cc1d8af

4 files changed

Lines changed: 379 additions & 6 deletions

File tree

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

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { RunEngine } from "~/v3/runEngine.server";
88
import { shouldIdempotencyKeyBeCleared } from "~/v3/taskStatus";
99
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
1010
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
11-
import { claimOrAwait } from "~/v3/mollifier/idempotencyClaim.server";
11+
import { claimOrAwait, resetResolvedClaim } from "~/v3/mollifier/idempotencyClaim.server";
1212
import { makeResolveMollifierFlag } from "~/v3/mollifier/mollifierGate.server";
1313
import { runStore } from "~/v3/runStore.server";
1414
import { runOpsLegacyPrisma, runOpsNewPrisma } from "~/db.server";
@@ -24,6 +24,13 @@ import type { TraceEventConcern, TriggerTaskRequest } from "../types";
2424
// handleTriggerRequest.
2525
const resolveOrgMollifierFlag = makeResolveMollifierFlag();
2626

27+
// Cap on the claim-loser recreate re-acquisition loop (see
28+
// reacquireClearedGlobalWinner). Each pass reopens a stale resolved slot and
29+
// re-enters the claim; bounded so a pathological stream of expired/failed
30+
// winners can't spin forever. On exhaustion we fall open to the create with
31+
// PG's unique index as the backstop.
32+
const MAX_CLEARED_WINNER_REACQUIRES = 5;
33+
2734
// Claim ownership context returned to the caller when the
2835
// IdempotencyKeyConcern won a pre-gate claim. Caller MUST publish the
2936
// winning runId on pipeline success (`publishClaim`) or release the
@@ -287,10 +294,31 @@ export class IdempotencyKeyConcern {
287294
if (globalUnderSplit) {
288295
const winner = await this.resolveWinnerAcrossDbs(outcome.runId, request.environment.id);
289296
if (winner) {
290-
return await this.handleExistingRun(request, parentStore, winner, {
297+
const resolved = await this.handleExistingRun(request, parentStore, winner, {
298+
idempotencyKey,
299+
idempotencyKeyExpiresAt,
300+
dedupClient,
301+
});
302+
// A LIVE winner (cached hit, or andWait waitpoint wired) is terminal.
303+
if (resolved.isCached) {
304+
return resolved;
305+
}
306+
// CRITICAL (CodeRabbit): the resolved winner was EXPIRED or FAILED, so
307+
// handleExistingRun cleared its key and would have us CREATE a new run.
308+
// The initial create is serialised by the claim, but this clear-and-
309+
// recreate is not — and under the split the per-DB unique index can't
310+
// dedup a cross-residency recreate, so two concurrent losers clearing
311+
// the SAME winner would each create → duplicate run. Re-serialise the
312+
// recreate through the claim so exactly one caller recreates (and
313+
// publishes) while the rest resolve to the fresh run.
314+
return await this.reacquireClearedGlobalWinner(request, parentStore, {
291315
idempotencyKey,
292316
idempotencyKeyExpiresAt,
293317
dedupClient,
318+
ttlSeconds,
319+
clearedRunId: outcome.runId,
320+
safetyNetMs: env.TRIGGER_MOLLIFIER_CLAIM_WAIT_MS,
321+
pollStepMs: env.TRIGGER_MOLLIFIER_CLAIM_POLL_MS,
294322
});
295323
}
296324
}
@@ -479,6 +507,86 @@ export class IdempotencyKeyConcern {
479507
return { isCached: true, run: existingRun };
480508
}
481509

510+
// Re-serialise a cross-DB recreate through the claim after a claim-loser's
511+
// resolved winner turned out to be EXPIRED / FAILED (its key was cleared by
512+
// handleExistingRun). Without this, concurrent losers clearing the same
513+
// winner each create a new run on their own DB — the per-DB unique index
514+
// can't dedup a cross-residency pair, so the initial-create's serialisation
515+
// is lost on the recreate. Each pass: compare-and-delete the stale resolved
516+
// slot (keyed on the cleared runId — never an unconditional DEL, so a
517+
// reacquirer that already re-published a NEW winner is not wiped), then
518+
// re-enter claimOrAwait. Exactly one caller wins the re-claim and recreates
519+
// (returning its claim to publish); the rest resolve to the fresh run. If a
520+
// re-claim resolves to ANOTHER cleared winner we advance and loop, bounded
521+
// by MAX_CLEARED_WINNER_REACQUIRES; on exhaustion (or an unfindable
522+
// resolution) we fall open to the create, matching the initial claim's
523+
// fail-open posture (PG unique index as backstop).
524+
private async reacquireClearedGlobalWinner(
525+
request: TriggerTaskRequest,
526+
parentStore: string | undefined,
527+
ctx: {
528+
idempotencyKey: string;
529+
idempotencyKeyExpiresAt: Date;
530+
dedupClient: PrismaClientOrTransaction;
531+
ttlSeconds: number;
532+
clearedRunId: string;
533+
safetyNetMs: number;
534+
pollStepMs: number;
535+
}
536+
): Promise<IdempotencyKeyConcernResult> {
537+
const { idempotencyKey, idempotencyKeyExpiresAt, dedupClient, ttlSeconds } = ctx;
538+
const claimInput = {
539+
envId: request.environment.id,
540+
taskIdentifier: request.taskId,
541+
idempotencyKey,
542+
};
543+
let staleRunId = ctx.clearedRunId;
544+
for (let attempt = 0; attempt < MAX_CLEARED_WINNER_REACQUIRES; attempt++) {
545+
await resetResolvedClaim({ ...claimInput, runId: staleRunId });
546+
547+
const outcome = await claimOrAwait({
548+
...claimInput,
549+
ttlSeconds,
550+
safetyNetMs: ctx.safetyNetMs,
551+
pollStepMs: ctx.pollStepMs,
552+
});
553+
554+
if (outcome.kind === "timed_out") {
555+
throw new ServiceValidationError("Idempotency claim resolution timed out", 503);
556+
}
557+
if (outcome.kind === "claimed") {
558+
// We own the recreate. Caller MUST publish the new runId / release on error.
559+
return {
560+
isCached: false,
561+
idempotencyKey,
562+
idempotencyKeyExpiresAt,
563+
claim: { ...claimInput, token: outcome.token },
564+
};
565+
}
566+
// resolved: another caller won the recreate. Honour it like any cached
567+
// hit (incl. andWait wiring). If it too was cleared, advance and loop.
568+
const winner = await this.resolveWinnerAcrossDbs(outcome.runId, request.environment.id);
569+
if (!winner) {
570+
logger.warn("idempotency reacquire resolved but runId not findable", {
571+
envId: request.environment.id,
572+
taskIdentifier: request.taskId,
573+
claimedRunId: outcome.runId,
574+
});
575+
break;
576+
}
577+
const resolved = await this.handleExistingRun(request, parentStore, winner, {
578+
idempotencyKey,
579+
idempotencyKeyExpiresAt,
580+
dedupClient,
581+
});
582+
if (resolved.isCached) {
583+
return resolved;
584+
}
585+
staleRunId = outcome.runId;
586+
}
587+
return { isCached: false, idempotencyKey, idempotencyKeyExpiresAt };
588+
}
589+
482590
// Resolve a claim winner (a run friendlyId) across both split DBs. Classify
483591
// the id-shape to pick the writer client for read-your-writes, then read by
484592
// id — the routing store routes to the owning store and falls back to the

apps/webapp/app/v3/mollifier/idempotencyClaim.server.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,39 @@ export async function releaseClaim(input: {
213213
}
214214
}
215215

216+
// Reopen a stale RESOLVED claim slot whose winner was cleared (expired /
217+
// failed), so the claim-loser reacquire path can re-serialise a cross-DB
218+
// recreate through a fresh claim. Compare-and-delete on the observed winner
219+
// runId (buffer-side) so a concurrent reacquirer's freshly published winner
220+
// is never wiped. Best-effort: on buffer-null / error we no-op and let the
221+
// reacquire's claimOrAwait proceed (fail-open — the caller caps attempts and
222+
// ultimately falls through to the create, PG unique index as backstop).
223+
export async function resetResolvedClaim(input: {
224+
envId: string;
225+
taskIdentifier: string;
226+
idempotencyKey: string;
227+
runId: string;
228+
buffer?: MollifierBuffer | null;
229+
}): Promise<boolean> {
230+
const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
231+
if (!buffer) return false;
232+
try {
233+
return await buffer.resetResolvedClaim({
234+
envId: input.envId,
235+
taskIdentifier: input.taskIdentifier,
236+
idempotencyKey: input.idempotencyKey,
237+
expectedRunId: input.runId,
238+
});
239+
} catch (err) {
240+
logger.warn("idempotency resolved-claim reset failed", {
241+
envId: input.envId,
242+
taskIdentifier: input.taskIdentifier,
243+
err: err instanceof Error ? err.message : String(err),
244+
});
245+
return false;
246+
}
247+
}
248+
216249
function defaultSleep(ms: number): Promise<void> {
217250
return new Promise((resolve) => setTimeout(resolve, ms));
218251
}

0 commit comments

Comments
 (0)