@@ -9,6 +9,7 @@ import { shouldIdempotencyKeyBeCleared } from "~/v3/taskStatus";
99import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server" ;
1010import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server" ;
1111import { claimOrAwait , resetResolvedClaim } from "~/v3/mollifier/idempotencyClaim.server" ;
12+ import { computeClaimTtlSeconds } from "~/v3/mollifier/claimTtl" ;
1213import { makeResolveMollifierFlag } from "~/v3/mollifier/mollifierGate.server" ;
1314import { runStore } from "~/v3/runStore.server" ;
1415import { runOpsLegacyPrisma , runOpsNewPrisma } from "~/db.server" ;
@@ -180,6 +181,14 @@ export class IdempotencyKeyConcern {
180181 }
181182 ) ;
182183
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+
183192 const existingRun = idempotencyKey
184193 ? await runStore . findRun (
185194 {
@@ -216,11 +225,37 @@ export class IdempotencyKeyConcern {
216225 }
217226
218227 if ( existingRun ) {
219- return await this . handleExistingRun ( request , parentStore , existingRun , {
228+ const handled = await this . handleExistingRun ( request , parentStore , existingRun , {
220229 idempotencyKey,
221230 idempotencyKeyExpiresAt,
222231 dedupClient,
223232 } ) ;
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 ;
224259 }
225260
226261 // Pre-gate claim — closes the PG+buffer race during gate transition.
@@ -247,11 +282,8 @@ export class IdempotencyKeyConcern {
247282 // older SDK) is treated conservatively as possibly-global — harmless for a
248283 // real run/attempt key, whose hash already embeds the parent id so two
249284 // parents mint DISTINCT keys that never share a claim slot.
250- const idempotencyKeyScope = request . body . options ?. idempotencyKeyOptions ?. scope ;
251- const globalUnderSplit =
252- ( idempotencyKeyScope === "global" || idempotencyKeyScope === undefined ) &&
253- ( await isSplitEnabled ( ) ) ;
254-
285+ // (idempotencyKeyScope / globalUnderSplit are computed above — they also gate the expired/failed
286+ // recreate serialisation.)
255287 const claimEligible =
256288 ! request . body . options ?. debounce &&
257289 ! request . options ?. oneTimeUseToken &&
@@ -268,13 +300,12 @@ export class IdempotencyKeyConcern {
268300 | undefined ) ?? null ,
269301 } ) ) ) ) ;
270302 if ( claimEligible ) {
271- const ttlSeconds = Math . max (
272- 1 ,
273- Math . min (
274- env . TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS ,
275- Math . ceil ( ( idempotencyKeyExpiresAt . getTime ( ) - Date . now ( ) ) / 1000 )
276- )
277- ) ;
303+ const ttlSeconds = computeClaimTtlSeconds ( {
304+ keyExpiresAt : idempotencyKeyExpiresAt ,
305+ now : Date . now ( ) ,
306+ minTtlSeconds : env . TRIGGER_MOLLIFIER_CLAIM_MIN_TTL_SECONDS ,
307+ maxTtlSeconds : env . TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS ,
308+ } ) ;
278309 const outcome = await claimOrAwait ( {
279310 envId : request . environment . id ,
280311 taskIdentifier : request . taskId ,
@@ -519,8 +550,8 @@ export class IdempotencyKeyConcern {
519550 // (returning its claim to publish); the rest resolve to the fresh run. If a
520551 // re-claim resolves to ANOTHER cleared winner we advance and loop, bounded
521552 // 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) .
553+ // resolution) we fail CLOSED with a retryable 503 so the SDK retry re-serialises,
554+ // rather than fall open to an unserialised cross-DB create .
524555 private async reacquireClearedGlobalWinner (
525556 request : TriggerTaskRequest ,
526557 parentStore : string | undefined ,
@@ -584,7 +615,14 @@ export class IdempotencyKeyConcern {
584615 }
585616 staleRunId = outcome . runId ;
586617 }
587- return { isCached : false , idempotencyKey, idempotencyKeyExpiresAt } ;
618+ // Exhausted the bounded reacquires (or the winner was unfindable). Rather than fall through to an
619+ // UNSERIALISED create — which under global-scope-split can dual-create across DBs (the per-DB unique
620+ // index can't dedup cross-residency) — fail closed with a retryable 503 so the SDK retry re-serialises
621+ // through a fresh claim (mirrors the timed_out branch above).
622+ throw new ServiceValidationError (
623+ "Idempotency claim could not be re-serialised after repeated cleared winners" ,
624+ 503
625+ ) ;
588626 }
589627
590628 // Resolve a claim winner (a run friendlyId) across both split DBs. Classify
0 commit comments