Skip to content

Commit 2b09d20

Browse files
d-csclaude
andcommitted
fix(webapp): parse idempotencyKeyOptions in canonical { key, scope } shape on buffer read-fallback
`readFallback.server.ts` was checking `Array.isArray(idempotencyKeyOptionsRaw)` and returning `undefined` otherwise. The SDK and Prisma both serialise this field as `{ key, scope }` (per `IdempotencyKeyOptionsSchema`); the array form never matches, so `SyntheticRun.idempotencyKeyOptions` was always `undefined` for buffered runs. Downstream effect: `getUserProvidedIdempotencyKey` falls back to `run.idempotencyKey` when options are absent — which is the *hash*, not the user-supplied key. PG-resident runs return the user's key (options parse succeeds); buffered runs were returning the hash. The API's `idempotencyKey` field silently flipped values at the drainer- materialisation boundary. Parse via `IdempotencyKeyOptionsSchema.safeParse` so the buffered response matches PG-resident behaviour from the moment the run is buffered. Type change is local to `SyntheticRun` — `synthesiseFoundRunFromBuffer` and `buildSyntheticSpanRun` both forward via `?? null` into Prisma `JsonValue | null` destinations, which accept the new object shape. Two regression tests on `mollifierReadFallback.test.ts`: 1. Canonical `{ key, scope }` object parses to the schema-shaped data. 2. Legacy array form (the previous bug) and other invalid shapes return undefined so downstream falls back to the hash, matching how PG-resident runs handle malformed/missing options. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c5e3ddb commit 2b09d20

2 files changed

Lines changed: 92 additions & 6 deletions

File tree

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
22
import { RunId } from "@trigger.dev/core/v3/isomorphic";
3+
import { IdempotencyKeyOptionsSchema } from "@trigger.dev/core/v3/schemas";
4+
import type { z } from "zod";
35
import { logger } from "~/services/logger.server";
46
import { deserialiseMollifierSnapshot } from "./mollifierSnapshot.server";
57
import { getMollifierBuffer } from "./mollifierBuffer.server";
@@ -50,7 +52,15 @@ export type SyntheticRun = {
5052
// expose it here so the cached-hit branch can apply the same check
5153
// rather than indefinitely returning the buffered run's id.
5254
idempotencyKeyExpiresAt: Date | undefined;
53-
idempotencyKeyOptions: string[] | undefined;
55+
// `{ key, scope }` object form, matching how the SDK serialises and PG
56+
// stores it. Previously typed as `string[]` (legacy/incorrect — Prisma
57+
// is `Json?` carrying the schema-shaped object). `getUserProvidedIdempotencyKey`
58+
// and `extractIdempotencyKeyScope` both parse via the same Zod schema;
59+
// they returned `undefined` for the array-shape, which silently
60+
// demoted the response to surface the hash instead of the user-
61+
// provided key for buffered runs — a contract divergence from
62+
// PG-resident runs. See the regression test in `mollifierReadFallback.test.ts`.
63+
idempotencyKeyOptions: z.infer<typeof IdempotencyKeyOptionsSchema> | undefined;
5464
isTest: boolean;
5565
depth: number;
5666
ttl: string | undefined;
@@ -145,9 +155,18 @@ export async function findRunByIdWithMollifierFallback(
145155
}
146156

147157
const snapshot = deserialiseMollifierSnapshot(entry.payload);
148-
const idempotencyKeyOptionsRaw = snapshot.idempotencyKeyOptions;
149-
const idempotencyKeyOptions = Array.isArray(idempotencyKeyOptionsRaw)
150-
? asStringArray(idempotencyKeyOptionsRaw)
158+
// Parse via the canonical schema (`{ key: string, scope: "run" |
159+
// "attempt" | "global" }`) rather than the legacy Array.isArray
160+
// check. The SDK and Prisma both store this as an object; the array
161+
// form never matches, so a buffered run's response previously fell
162+
// back to the server-side hash in `getUserProvidedIdempotencyKey`
163+
// instead of the customer-supplied key — diverging from how
164+
// materialised runs render the same field.
165+
const idempotencyKeyOptionsParsed = IdempotencyKeyOptionsSchema.safeParse(
166+
snapshot.idempotencyKeyOptions,
167+
);
168+
const idempotencyKeyOptions = idempotencyKeyOptionsParsed.success
169+
? idempotencyKeyOptionsParsed.data
151170
: undefined;
152171

153172
const tags = asStringArray(snapshot.tags);

apps/webapp/test/mollifierReadFallback.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ describe("findRunByIdWithMollifierFallback", () => {
137137
metadata: '{"customer":"acme"}',
138138
metadataType: "application/json",
139139
idempotencyKey: "client-abc",
140-
idempotencyKeyOptions: ["payload"],
140+
idempotencyKeyOptions: { key: "client-abc", scope: "run" },
141141
isTest: true,
142142
depth: 2,
143143
ttl: "1h",
@@ -161,7 +161,7 @@ describe("findRunByIdWithMollifierFallback", () => {
161161
expect(result!.metadata).toBe('{"customer":"acme"}');
162162
expect(result!.metadataType).toBe("application/json");
163163
expect(result!.idempotencyKey).toBe("client-abc");
164-
expect(result!.idempotencyKeyOptions).toEqual(["payload"]);
164+
expect(result!.idempotencyKeyOptions).toEqual({ key: "client-abc", scope: "run" });
165165
expect(result!.isTest).toBe(true);
166166
expect(result!.depth).toBe(2);
167167
expect(result!.ttl).toBe("1h");
@@ -195,6 +195,73 @@ describe("findRunByIdWithMollifierFallback", () => {
195195
expect(result!.parentSpanId).toBe("span_parent");
196196
});
197197

198+
it("parses idempotencyKeyOptions in the canonical { key, scope } object shape (regression for the buffered-vs-PG API contract divergence)", async () => {
199+
// Regression for the bug where `readFallback` parsed
200+
// `idempotencyKeyOptions` via Array.isArray and rejected the
201+
// canonical object shape. The SDK and Prisma both serialise this
202+
// as `{ key, scope }`; the legacy array check would reject it,
203+
// returning `undefined` here, which downstream demoted the API's
204+
// `idempotencyKey` field to surface the *hash* (server-side
205+
// generated) instead of the user-supplied key — diverging from
206+
// how materialised runs render the same field, and creating a
207+
// silent contract flip at the drainer-materialisation boundary.
208+
// Pin the schema-parse path so the buffered response matches
209+
// PG-resident behaviour from the moment the run is buffered.
210+
const entry: BufferEntry = {
211+
runId: "run_1",
212+
envId: "env_a",
213+
orgId: "org_1",
214+
payload: JSON.stringify({
215+
taskIdentifier: "t",
216+
idempotencyKey: "<hashed>",
217+
idempotencyKeyOptions: { key: "user-supplied-key", scope: "global" },
218+
}),
219+
status: "QUEUED",
220+
attempts: 0,
221+
createdAt: NOW,
222+
};
223+
const result = await findRunByIdWithMollifierFallback(
224+
{ runId: "run_1", environmentId: "env_a", organizationId: "org_1" },
225+
{ getBuffer: () => fakeBuffer(entry) },
226+
);
227+
expect(result).not.toBeNull();
228+
expect(result!.idempotencyKeyOptions).toEqual({
229+
key: "user-supplied-key",
230+
scope: "global",
231+
});
232+
});
233+
234+
it("returns undefined for idempotencyKeyOptions when the snapshot carries a legacy/invalid shape", async () => {
235+
// The Zod schema parse rejects:
236+
// - array shape (the legacy bug we just fixed)
237+
// - object without required fields
238+
// - missing field entirely
239+
// In all these cases the field is left `undefined`. Downstream
240+
// `getUserProvidedIdempotencyKey` then falls back to the
241+
// `idempotencyKey` field, matching how PG-resident runs handle
242+
// malformed/missing options.
243+
const entry: BufferEntry = {
244+
runId: "run_1",
245+
envId: "env_a",
246+
orgId: "org_1",
247+
payload: JSON.stringify({
248+
taskIdentifier: "t",
249+
idempotencyKey: "<hashed>",
250+
// Legacy array shape — must NOT be accepted.
251+
idempotencyKeyOptions: ["payload"],
252+
}),
253+
status: "QUEUED",
254+
attempts: 0,
255+
createdAt: NOW,
256+
};
257+
const result = await findRunByIdWithMollifierFallback(
258+
{ runId: "run_1", environmentId: "env_a", organizationId: "org_1" },
259+
{ getBuffer: () => fakeBuffer(entry) },
260+
);
261+
expect(result).not.toBeNull();
262+
expect(result!.idempotencyKeyOptions).toBeUndefined();
263+
});
264+
198265
it("defaults snapshot-derived fields to safe values when absent", async () => {
199266
const entry: BufferEntry = {
200267
runId: "run_1",

0 commit comments

Comments
 (0)