@@ -8,7 +8,7 @@ import type { RunEngine } from "~/v3/runEngine.server";
88import { shouldIdempotencyKeyBeCleared } from "~/v3/taskStatus" ;
99import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server" ;
1010import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server" ;
11- import { claimOrAwait } from "~/v3/mollifier/idempotencyClaim.server" ;
11+ import { claimOrAwait , resetResolvedClaim } from "~/v3/mollifier/idempotencyClaim.server" ;
1212import { makeResolveMollifierFlag } from "~/v3/mollifier/mollifierGate.server" ;
1313import { runStore } from "~/v3/runStore.server" ;
1414import { runOpsLegacyPrisma , runOpsNewPrisma } from "~/db.server" ;
@@ -24,6 +24,13 @@ import type { TraceEventConcern, TriggerTaskRequest } from "../types";
2424// handleTriggerRequest.
2525const 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
0 commit comments