Skip to content

Commit bd6a442

Browse files
committed
fix(webapp,run-store): match batch idempotency cache hits by task and key
Cached-run lookups from all task groups are flattened into one array, so matching by idempotency key alone could return a run from a different task that reused the same key string. Match on taskIdentifier and key. Adds a testcontainers test covering findRunsByIdempotencyKeys (multi-key UNION ALL, task scoping, idempotencyKeyExpiresAt deserialization, empty short-circuit).
1 parent 8771c07 commit bd6a442

2 files changed

Lines changed: 128 additions & 4 deletions

File tree

apps/webapp/app/v3/services/batchTriggerV3.server.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,13 @@ export class BatchTriggerV3Service extends BaseService {
423423
const cachedRuns = (
424424
await pMap(
425425
idempotencyKeyLookups,
426-
({ taskIdentifier, idempotencyKeys }) =>
427-
this.runStore.findRunsByIdempotencyKeys(
426+
async ({ taskIdentifier, idempotencyKeys }) => {
427+
const rows = await this.runStore.findRunsByIdempotencyKeys(
428428
{ runtimeEnvironmentId: environment.id, taskIdentifier, idempotencyKeys },
429429
this._prisma
430-
),
430+
);
431+
return rows.map((row) => ({ ...row, taskIdentifier }));
432+
},
431433
{ concurrency: IDEMPOTENCY_KEY_LOOKUP_CONCURRENCY }
432434
)
433435
).flat();
@@ -438,7 +440,9 @@ export class BatchTriggerV3Service extends BaseService {
438440

439441
const runs = await Promise.all(
440442
body.items.map(async (item) => {
441-
const cachedRun = cachedRuns.find((r) => r.idempotencyKey === item.options?.idempotencyKey);
443+
const cachedRun = cachedRuns.find(
444+
(r) => r.taskIdentifier === item.task && r.idempotencyKey === item.options?.idempotencyKey
445+
);
442446

443447
if (cachedRun) {
444448
if (cachedRun.idempotencyKeyExpiresAt && cachedRun.idempotencyKeyExpiresAt < new Date()) {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import type { PrismaClient } from "@trigger.dev/database";
3+
import { describe, expect } from "vitest";
4+
import { PostgresRunStore } from "./PostgresRunStore.js";
5+
6+
async function seedEnvironment(prisma: PrismaClient) {
7+
const organization = await prisma.organization.create({
8+
data: { title: "Test Organization", slug: "test-organization" },
9+
});
10+
const project = await prisma.project.create({
11+
data: {
12+
name: "Test Project",
13+
slug: "test-project",
14+
externalRef: "proj_1234",
15+
organizationId: organization.id,
16+
},
17+
});
18+
const environment = await prisma.runtimeEnvironment.create({
19+
data: {
20+
type: "DEVELOPMENT",
21+
slug: "dev",
22+
projectId: project.id,
23+
organizationId: organization.id,
24+
apiKey: "tr_dev_apikey",
25+
pkApiKey: "pk_dev_apikey",
26+
shortcode: "short_code",
27+
},
28+
});
29+
return { organization, project, environment };
30+
}
31+
32+
async function createRun(
33+
prisma: PrismaClient,
34+
params: {
35+
runtimeEnvironmentId: string;
36+
projectId: string;
37+
friendlyId: string;
38+
taskIdentifier: string;
39+
idempotencyKey: string;
40+
idempotencyKeyExpiresAt?: Date;
41+
}
42+
) {
43+
await prisma.taskRun.create({
44+
data: {
45+
friendlyId: params.friendlyId,
46+
taskIdentifier: params.taskIdentifier,
47+
idempotencyKey: params.idempotencyKey,
48+
idempotencyKeyExpiresAt: params.idempotencyKeyExpiresAt ?? null,
49+
payload: "{}",
50+
payloadType: "application/json",
51+
runtimeEnvironmentId: params.runtimeEnvironmentId,
52+
projectId: params.projectId,
53+
queue: `task/${params.taskIdentifier}`,
54+
traceId: `trace_${params.friendlyId}`,
55+
spanId: `span_${params.friendlyId}`,
56+
engine: "V2",
57+
},
58+
});
59+
}
60+
61+
describe("PostgresRunStore.findRunsByIdempotencyKeys", () => {
62+
postgresTest("resolves multiple keys, scoped to (env, task)", async ({ prisma }) => {
63+
const { project, environment } = await seedEnvironment(prisma);
64+
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
65+
66+
const expiresAt = new Date("2999-01-01T00:00:00.000Z");
67+
await createRun(prisma, {
68+
runtimeEnvironmentId: environment.id,
69+
projectId: project.id,
70+
friendlyId: "run_a1",
71+
taskIdentifier: "task-a",
72+
idempotencyKey: "idem-1",
73+
idempotencyKeyExpiresAt: expiresAt,
74+
});
75+
await createRun(prisma, {
76+
runtimeEnvironmentId: environment.id,
77+
projectId: project.id,
78+
friendlyId: "run_a2",
79+
taskIdentifier: "task-a",
80+
idempotencyKey: "idem-2",
81+
});
82+
await createRun(prisma, {
83+
runtimeEnvironmentId: environment.id,
84+
projectId: project.id,
85+
friendlyId: "run_b1",
86+
taskIdentifier: "task-b",
87+
idempotencyKey: "idem-1",
88+
});
89+
90+
const rows = await store.findRunsByIdempotencyKeys({
91+
runtimeEnvironmentId: environment.id,
92+
taskIdentifier: "task-a",
93+
idempotencyKeys: ["idem-1", "idem-2", "does-not-exist"],
94+
});
95+
96+
const byKey = new Map(rows.map((r) => [r.idempotencyKey, r]));
97+
expect(rows).toHaveLength(2);
98+
expect(byKey.get("idem-1")?.friendlyId).toBe("run_a1");
99+
expect(byKey.get("idem-2")?.friendlyId).toBe("run_a2");
100+
expect(rows.map((r) => r.friendlyId)).not.toContain("run_b1");
101+
expect(byKey.get("idem-1")?.idempotencyKeyExpiresAt).toBeInstanceOf(Date);
102+
expect(byKey.get("idem-1")?.idempotencyKeyExpiresAt?.toISOString()).toBe(
103+
expiresAt.toISOString()
104+
);
105+
expect(byKey.get("idem-2")?.idempotencyKeyExpiresAt).toBeNull();
106+
});
107+
108+
postgresTest("short-circuits on an empty key list without querying", async ({ prisma }) => {
109+
const { environment } = await seedEnvironment(prisma);
110+
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
111+
112+
const rows = await store.findRunsByIdempotencyKeys({
113+
runtimeEnvironmentId: environment.id,
114+
taskIdentifier: "task-a",
115+
idempotencyKeys: [],
116+
});
117+
118+
expect(rows).toEqual([]);
119+
});
120+
});

0 commit comments

Comments
 (0)