Skip to content

Commit 1e6af43

Browse files
d-csclaude
andcommitted
fix(webapp): IdempotencyKeyConcern honours buffered-run TTL on idempotency expiry
PG-resident path enforces `idempotencyKeyExpiresAt`: when an existing PG row is found, the lookup compares its `expiresAt` against now, clears the key on expiry, and lets a new run go through. The buffered path was missing this — `findBufferedRunWithIdempotency` returned any buffered run whose snapshot carried the key, regardless of how long ago the customer's TTL had elapsed. `idempotencyKeyTTL: "2s"` plus a second trigger 4s later would return the original buffered runId. Two layers needed the fix: 1. **Read-side (this concern).** Surface `idempotencyKeyExpiresAt` on `SyntheticRun` (the snapshot already stores it; `findRunByIdWithMollifierFallback` just wasn't exposing it). In `findBufferedRunWithIdempotency`, apply the same `expiresAt < new Date()` check as the PG path and return null on expiry. 2. **Write-side (the buffer's accept dedupe).** Returning null from step 1 isn't enough: the trigger pipeline then proceeds to `mollifyTrigger`, whose `buffer.accept` Lua dedupes by `(envId, taskIdentifier, idempotencyKey)` via SETNX on the same `mollifier:idempotency:*` key and would still echo the stale runId as `duplicate_idempotency`. On expiry, clear the buffer-side idempotency binding via `buffer.resetIdempotency` — the same primitive `ResetIdempotencyKeyService` uses for the explicit reset-via-API path. The next accept then goes through as a fresh trigger. Verified end-to-end: with mollifier active and `idempotencyKeyTTL: "2s"`, a same-key retrigger after 4s returns a new runId; same-key retriggers within the TTL still dedupe to the original. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6458243 commit 1e6af43

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,45 @@ export class IdempotencyKeyConcern {
9292
organizationId,
9393
});
9494
if (!synthetic) return null;
95+
// PG-resident path enforces idempotency-key expiry below
96+
// (`existingRun.idempotencyKeyExpiresAt < new Date()` clears the key
97+
// and lets a new run go through). The buffer path needs the same
98+
// check — without it a customer who passes `idempotencyKeyTTL: "2s"`
99+
// gets the cached buffered runId returned indefinitely, because the
100+
// buffer entry persists for its own (hours-long) TTL independent of
101+
// the customer's key TTL.
102+
//
103+
// Returning null isn't enough on its own: the trigger pipeline then
104+
// proceeds to `mollifyTrigger`, whose `buffer.accept` Lua dedupes by
105+
// `(envId, taskIdentifier, idempotencyKey)` via SETNX on the same
106+
// `mollifier:idempotency:*` key and would echo the stale runId as
107+
// `duplicate_idempotency`. Clear the buffer-side idempotency
108+
// binding (both the lookup and any in-flight claim) so the next
109+
// accept goes through as a fresh trigger. Mirrors what
110+
// `ResetIdempotencyKeyService` does for the explicit
111+
// reset-via-API path.
112+
if (
113+
synthetic.idempotencyKeyExpiresAt &&
114+
synthetic.idempotencyKeyExpiresAt < new Date()
115+
) {
116+
const buffer = getMollifierBuffer();
117+
if (buffer) {
118+
try {
119+
await buffer.resetIdempotency({
120+
envId: environmentId,
121+
taskIdentifier,
122+
idempotencyKey,
123+
});
124+
} catch (err) {
125+
logger.warn("IdempotencyKeyConcern: failed to reset expired buffer idempotency", {
126+
envId: environmentId,
127+
taskIdentifier,
128+
err: err instanceof Error ? err.message : String(err),
129+
});
130+
}
131+
}
132+
return null;
133+
}
95134
return synthetic as unknown as TaskRun;
96135
}
97136

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export type SyntheticRun = {
4444
seedMetadataType: string | undefined;
4545

4646
idempotencyKey: string | undefined;
47+
// Surfaced for the cached-hit expiration check in IdempotencyKeyConcern.
48+
// The PG-resident path enforces this (clears key, allows new run when
49+
// expired). For buffered runs the snapshot carries the same field — we
50+
// expose it here so the cached-hit branch can apply the same check
51+
// rather than indefinitely returning the buffered run's id.
52+
idempotencyKeyExpiresAt: Date | undefined;
4753
idempotencyKeyOptions: string[] | undefined;
4854
isTest: boolean;
4955
depth: number;
@@ -178,6 +184,7 @@ export async function findRunByIdWithMollifierFallback(
178184
seedMetadataType: asString(snapshot.seedMetadataType),
179185

180186
idempotencyKey: asString(snapshot.idempotencyKey),
187+
idempotencyKeyExpiresAt: asDate(snapshot.idempotencyKeyExpiresAt),
181188
idempotencyKeyOptions,
182189
isTest: snapshot.isTest === true,
183190
depth: typeof snapshot.depth === "number" ? snapshot.depth : 0,

0 commit comments

Comments
 (0)