Skip to content

Commit 1c625e1

Browse files
committed
fix(webapp,run-store): review follow-ups — split idempotency + realtime batch hardening
Production follow-ups from the CodeRabbit/Claude/Devin review. Caller-driven guard tests are in the stacked tests PR; the two test edits here are coupled to the production change (the publishClaim signature change invalidates a main assertion; the fan-out removal obsoletes a main test). - Claim TTL floor (TRIGGER_MOLLIFIER_CLAIM_MIN_TTL_SECONDS, default 5) independent of the customer key TTL, so a short key TTL can't expire the claim mid-pipeline and let a loser re-claim. - publishClaim returns the buffer CAS result; the trigger success path detects + logs a no-op'd publish. - reacquireClearedGlobalWinner fails closed with a retryable 503 (exhaustion / unfindable winner); the expired/failed clear-and-recreate path routes through it too (Devin), so that recreate is serialised. - Realtime batch route re-reads the owning primary on a replica miss (backend-agnostic; closes the Electric ShapeStream permanent-404 for self-hosters). - Remove the classifiable-id cross-store fan-out from findRun: a run's id-shape fixes its residency for life, so the single-store read is correct and the fan-out was dead code.
1 parent fb8fcfe commit 1c625e1

10 files changed

Lines changed: 128 additions & 356 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,9 @@ const EnvironmentSchema = z
13111311
// claim TTL), how long a waiter blocks before timing out, and the
13121312
// waiter poll interval.
13131313
TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS: z.coerce.number().int().positive().default(30),
1314+
// Pipeline floor: the claim never shrinks below this even for a short customer key TTL, so it
1315+
// can't expire mid-pipeline and let a loser re-claim (cross-DB duplicate under the split).
1316+
TRIGGER_MOLLIFIER_CLAIM_MIN_TTL_SECONDS: z.coerce.number().int().positive().default(5),
13141317
TRIGGER_MOLLIFIER_CLAIM_WAIT_MS: z.coerce.number().int().positive().default(5_000),
13151318
TRIGGER_MOLLIFIER_CLAIM_POLL_MS: z.coerce.number().int().positive().default(25),
13161319

apps/webapp/app/routes/realtime.v1.batches.$batchId.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from "zod";
22
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
33
import { resolveRealtimeStreamClient } from "~/services/realtime/resolveRealtimeStreamClient.server";
44
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5-
import { runStore } from "~/v3/runStore.server";
5+
import { resolveBatchTaskRunForRealtime } from "~/v3/realtime/resolveBatchForRealtime.server";
66

77
const ParamsSchema = z.object({
88
batchId: z.string(),
@@ -13,14 +13,13 @@ export const loader = createLoaderApiRoute(
1313
params: ParamsSchema,
1414
allowJWT: true,
1515
corsStrategy: "all",
16-
// A just-created batch may not yet have replicated to the read replica this client-less
17-
// findBatchTaskRunByFriendlyId lookup routes to; return a retryable 404 so the SDK retries through
18-
// replica lag rather than stranding a live batch on a permanent 404 (mirrors the run-get routes,
19-
// e.g. api.v3.runs.$runId).
16+
// A just-created batch may not yet have replicated to the read replica the client-less lookup uses.
17+
// shouldRetryNotFound stamps a retryable 404 for the zodfetch GET; the realtime resolver ALSO
18+
// re-reads the owning primary on a replica miss, so the Electric ShapeStream consumer (which ignores
19+
// x-should-retry) doesn't strand a live batch on a permanent 404. Mirrors the run-get routes.
2020
shouldRetryNotFound: true,
21-
findResource: (params, auth) => {
22-
return runStore.findBatchTaskRunByFriendlyId(params.batchId, auth.environment.id);
23-
},
21+
findResource: (params, auth) =>
22+
resolveBatchTaskRunForRealtime(params.batchId, auth.environment.id),
2423
authorization: {
2524
action: "read",
2625
// See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}`

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

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { shouldIdempotencyKeyBeCleared } from "~/v3/taskStatus";
99
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
1010
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
1111
import { claimOrAwait, resetResolvedClaim } from "~/v3/mollifier/idempotencyClaim.server";
12+
import { computeClaimTtlSeconds } from "~/v3/mollifier/claimTtl";
1213
import { makeResolveMollifierFlag } from "~/v3/mollifier/mollifierGate.server";
1314
import { runStore } from "~/v3/runStore.server";
1415
import { 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

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,14 +752,25 @@ export class RunEngineTriggerTaskService {
752752
// Pipeline returned successfully — publish the claim if we held
753753
// one. Waiters polling for our key resolve to this runId.
754754
if (idempotencyClaim && result?.run?.friendlyId) {
755-
await publishMollifierClaim({
755+
const published = await publishMollifierClaim({
756756
envId: idempotencyClaim.envId,
757757
taskIdentifier: idempotencyClaim.taskIdentifier,
758758
idempotencyKey: idempotencyClaim.idempotencyKey,
759759
token: idempotencyClaim.token,
760760
runId: result.run.friendlyId,
761761
ttlSeconds: env.TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS,
762762
});
763+
if (!published) {
764+
// Our claim expired mid-pipeline and another claimant took it, so this publish no-op'd: a
765+
// different run is now canonical for this key while we return ours (a cross-DB dup under the
766+
// split). Rare now the claim TTL is floored (C1); surfaced for monitoring pending auto-
767+
// convergence (re-resolve the current winner + cancel this orphan).
768+
logger.warn("mollifier claim publish no-op'd; winner lost the claim mid-pipeline", {
769+
envId: idempotencyClaim.envId,
770+
taskIdentifier: idempotencyClaim.taskIdentifier,
771+
runId: result.run.friendlyId,
772+
});
773+
}
763774
}
764775
return result;
765776
} catch (err) {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// The claim is a serialization lock that must outlive the winner's create-and-publish pipeline. A short
2+
// customer key TTL must NOT shrink it below a pipeline floor (else the claim expires mid-pipeline and a
3+
// polling loser re-claims → cross-DB duplicate). Floor at `minTtlSeconds` independent of key TTL; cap at max.
4+
export function computeClaimTtlSeconds(input: {
5+
keyExpiresAt: Date;
6+
now: number;
7+
minTtlSeconds: number;
8+
maxTtlSeconds: number;
9+
}): number {
10+
const keyTtlSeconds = Math.ceil((input.keyExpiresAt.getTime() - input.now) / 1000);
11+
return Math.min(input.maxTtlSeconds, Math.max(input.minTtlSeconds, keyTtlSeconds));
12+
}

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,14 @@ export async function publishClaim(input: {
163163
runId: string;
164164
ttlSeconds?: number;
165165
buffer?: MollifierBuffer | null;
166-
}): Promise<void> {
166+
}): Promise<boolean> {
167167
const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
168-
if (!buffer) return;
168+
if (!buffer) return true;
169169
const ttlSeconds = input.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
170170
try {
171-
await buffer.publishClaim({
171+
// false = compare-and-set no-op: a stale claimant (our TTL expired) already moved in, so our
172+
// publish did NOT set the winner. The caller decides how to converge.
173+
return await buffer.publishClaim({
172174
envId: input.envId,
173175
taskIdentifier: input.taskIdentifier,
174176
idempotencyKey: input.idempotencyKey,
@@ -182,6 +184,8 @@ export async function publishClaim(input: {
182184
taskIdentifier: input.taskIdentifier,
183185
err: err instanceof Error ? err.message : String(err),
184186
});
187+
// Unknown publish state (transient error) — the claim TTL is the safety net; don't signal a no-op.
188+
return true;
185189
}
186190
}
187191

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { prisma } from "~/db.server";
2+
import { runStore } from "~/v3/runStore.server";
3+
4+
type BatchStore = Pick<typeof runStore, "findBatchTaskRunByFriendlyId">;
5+
6+
// The realtime batch route reads the batch client-less (replica), which can miss a just-created batch
7+
// under replica lag. `shouldRetryNotFound` covers the zodfetch GET, but the Electric ShapeStream
8+
// consumer (self-hosters) ignores `x-should-retry`, so re-read the owning primary on a miss — passing a
9+
// non-replica writer flips each store leg to its own primary — to avoid a permanent 404.
10+
export function resolveBatchTaskRunForRealtime(
11+
friendlyId: string,
12+
environmentId: string,
13+
deps?: { store?: BatchStore; writer?: unknown }
14+
) {
15+
const store = deps?.store ?? runStore;
16+
const writer = deps?.writer ?? prisma;
17+
return store
18+
.findBatchTaskRunByFriendlyId(friendlyId, environmentId)
19+
.then(
20+
(onReplica) =>
21+
onReplica ??
22+
store.findBatchTaskRunByFriendlyId(friendlyId, environmentId, undefined, writer as never)
23+
);
24+
}

apps/webapp/test/mollifierIdempotencyClaim.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,21 +181,21 @@ describe("publishClaim", () => {
181181
expect(buffer.publishClaim).toHaveBeenCalledOnce();
182182
});
183183

184-
it("no-op when buffer is null", async () => {
184+
it("no buffer → true (nothing to converge, not a no-op'd publish)", async () => {
185185
await expect(
186186
publishClaim({ ...baseInput, token: "owner-token", runId: "run_X", buffer: null })
187-
).resolves.toBeUndefined();
187+
).resolves.toBe(true);
188188
});
189189

190-
it("swallows errors so trigger pipeline isn't broken by Redis hiccups", async () => {
190+
it("swallows errors so trigger pipeline isn't broken by Redis hiccups (returns true, unknown state)", async () => {
191191
const buffer = {
192192
publishClaim: vi.fn(async () => {
193193
throw new Error("ECONNREFUSED");
194194
}),
195195
} as unknown as MollifierBuffer;
196196
await expect(
197197
publishClaim({ ...baseInput, token: "owner-token", runId: "run_X", buffer })
198-
).resolves.toBeUndefined();
198+
).resolves.toBe(true);
199199
});
200200
});
201201

0 commit comments

Comments
 (0)