Skip to content

Commit 1ab5066

Browse files
authored
perf(webapp,run-store): point-lookup batch idempotency keys (#4255)
## Summary Batch triggers that use per-item idempotency keys could take seconds instead of milliseconds when the target task had a large run history. This keeps the idempotency lookup fast regardless of how many runs a task has accumulated. ## Root cause The batch path checks which items already have runs by looking up their idempotency keys with a single `WHERE runtimeEnvironmentId = ? AND taskIdentifier = ? AND idempotencyKey IN (...)` query. On a very large `TaskRun` table Postgres underestimates the row count of a specific `(environment, task)` pair, so once the `IN` list grows past a handful of keys it stops doing per-key index probes and instead scans every run for that `(environment, task)` and filters the keys in memory. The cost is then flat and large regardless of how many keys are being checked, and a routine `ANALYZE` does not correct the estimate at that table size. ## Fix Look each idempotency key up on its own, batched into a `UNION ALL` of point lookups (chunked, run with bounded concurrency). Each branch is an equality on all three columns of the unique index, so the planner can only do a per-key index probe and can never fall back to the range scan. Same results, same columns, confined to the batch trigger path.
1 parent 2aa6420 commit 1ab5066

6 files changed

Lines changed: 221 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Speed up idempotency checks on `batchTrigger` calls that use idempotency keys. Large batches against a task with a big run history no longer degrade to multi-second lookups.

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

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { packetRequiresOffloading, parsePacket } from "@trigger.dev/core/v3";
77
import type { BatchTaskRun, TaskRunAttempt } from "@trigger.dev/database";
88
import { isUniqueConstraintError, Prisma } from "@trigger.dev/database";
99
import type { RunStore } from "@internal/run-store";
10+
import pMap from "p-map";
1011
import { z } from "zod";
1112
import type { PrismaClientOrTransaction } from "~/db.server";
1213
import { prisma } from "~/db.server";
@@ -32,6 +33,16 @@ import { BaseService, ServiceValidationError } from "./baseService.server";
3233
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
3334

3435
const PROCESSING_BATCH_SIZE = 50;
36+
const IDEMPOTENCY_KEY_LOOKUP_CHUNK_SIZE = 50;
37+
const IDEMPOTENCY_KEY_LOOKUP_CONCURRENCY = 5;
38+
39+
function chunkArray<T>(items: T[], size: number): T[][] {
40+
const chunks: T[][] = [];
41+
for (let i = 0; i < items.length; i += size) {
42+
chunks.push(items.slice(i, i + size));
43+
}
44+
return chunks;
45+
}
3546
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
3647
const MAX_ATTEMPTS = 10;
3748

@@ -397,36 +408,41 @@ export class BatchTriggerV3Service extends BaseService {
397408
itemsByTask,
398409
});
399410

400-
// Fetch cached runs for each task identifier separately to make use of the index
401-
const cachedRuns = await Promise.all(
402-
Object.entries(itemsByTask).map(([taskIdentifier, items]) =>
403-
this.runStore.findRuns(
404-
{
405-
where: {
406-
runtimeEnvironmentId: environment.id,
407-
taskIdentifier,
408-
idempotencyKey: {
409-
in: items.map((i) => i.options?.idempotencyKey).filter(Boolean),
410-
},
411-
},
412-
select: {
413-
friendlyId: true,
414-
idempotencyKey: true,
415-
idempotencyKeyExpiresAt: true,
416-
},
417-
},
418-
this._prisma
411+
const idempotencyKeyLookups = Object.entries(itemsByTask).flatMap(([taskIdentifier, items]) => {
412+
const idempotencyKeys = Array.from(
413+
new Set(
414+
items.map((i) => i.options?.idempotencyKey).filter((key): key is string => Boolean(key))
419415
)
416+
);
417+
return chunkArray(idempotencyKeys, IDEMPOTENCY_KEY_LOOKUP_CHUNK_SIZE).map((chunk) => ({
418+
taskIdentifier,
419+
idempotencyKeys: chunk,
420+
}));
421+
});
422+
423+
const cachedRuns = (
424+
await pMap(
425+
idempotencyKeyLookups,
426+
async ({ taskIdentifier, idempotencyKeys }) => {
427+
const rows = await this.runStore.findRunsByIdempotencyKeys(
428+
{ runtimeEnvironmentId: environment.id, taskIdentifier, idempotencyKeys },
429+
this._prisma
430+
);
431+
return rows.map((row) => ({ ...row, taskIdentifier }));
432+
},
433+
{ concurrency: IDEMPOTENCY_KEY_LOOKUP_CONCURRENCY }
420434
)
421-
).then((results) => results.flat());
435+
).flat();
422436

423437
// Build the run IDs in order: reuse an unexpired cached id, else mint a new id (and record any
424438
// expired cached id so its idempotency key can be cleared below).
425439
const expiredRunIds = new Set<string>();
426440

427441
const runs = await Promise.all(
428442
body.items.map(async (item) => {
429-
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+
);
430446

431447
if (cachedRun) {
432448
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+
});

internal-packages/run-store/src/PostgresRunStore.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
ExpireSnapshotInput,
2020
FinalizeRunData,
2121
ForWaitpointCompletionContext,
22+
IdempotencyKeyRunMatch,
2223
LockRunData,
2324
ReadClient,
2425
RescheduleSnapshotInput,
@@ -1682,6 +1683,21 @@ export class PostgresRunStore implements RunStore {
16821683
return byId;
16831684
}
16841685

1686+
async findRunsByIdempotencyKeys(
1687+
args: { runtimeEnvironmentId: string; taskIdentifier: string; idempotencyKeys: string[] },
1688+
client?: ReadClient
1689+
): Promise<IdempotencyKeyRunMatch[]> {
1690+
if (args.idempotencyKeys.length === 0) {
1691+
return [];
1692+
}
1693+
const prisma = (client ?? this.readOnlyPrisma) as RunOpsCapableClient;
1694+
const branches = args.idempotencyKeys.map(
1695+
(key) =>
1696+
Prisma.sql`SELECT "friendlyId", "idempotencyKey", "idempotencyKeyExpiresAt" FROM "TaskRun" WHERE "runtimeEnvironmentId" = ${args.runtimeEnvironmentId} AND "taskIdentifier" = ${args.taskIdentifier} AND "idempotencyKey" = ${key}`
1697+
);
1698+
return prisma.$queryRaw<IdempotencyKeyRunMatch[]>(Prisma.join(branches, " UNION ALL "));
1699+
}
1700+
16851701
// --- run-ops persistence ---
16861702

16871703
async findLatestExecutionSnapshot(

internal-packages/run-store/src/runOpsStore.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
ExpireSnapshotInput,
2121
FinalizeRunData,
2222
ForWaitpointCompletionContext,
23+
IdempotencyKeyRunMatch,
2324
LockRunData,
2425
ReadClient,
2526
RescheduleSnapshotInput,
@@ -460,6 +461,30 @@ export class RoutingRunStore implements RunStore {
460461
return byId;
461462
}
462463

464+
async findRunsByIdempotencyKeys(
465+
args: { runtimeEnvironmentId: string; taskIdentifier: string; idempotencyKeys: string[] },
466+
client?: ReadClient
467+
): Promise<IdempotencyKeyRunMatch[]> {
468+
if (args.idempotencyKeys.length === 0) {
469+
return [];
470+
}
471+
const [newRows, legacyRows] = await Promise.all([
472+
this.#new.findRunsByIdempotencyKeys(args, RoutingRunStore.#ownPrimary(this.#new, client)),
473+
this.#legacy.findRunsByIdempotencyKeys(
474+
args,
475+
RoutingRunStore.#ownPrimary(this.#legacy, client)
476+
),
477+
]);
478+
const byKey = new Map<string, IdempotencyKeyRunMatch>();
479+
for (const row of legacyRows) {
480+
if (row.idempotencyKey != null) byKey.set(row.idempotencyKey, row);
481+
}
482+
for (const row of newRows) {
483+
if (row.idempotencyKey != null) byKey.set(row.idempotencyKey, row);
484+
}
485+
return [...byKey.values()];
486+
}
487+
463488
// ---------------------------------------------------------------------------
464489
// TaskRun-core: update-family — route by run id in params
465490
// ---------------------------------------------------------------------------

internal-packages/run-store/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import type { Residency } from "@trigger.dev/core/v3/isomorphic";
2222
*/
2323
export type ReadClient = PrismaClientOrTransaction | PrismaReplicaClient;
2424

25+
export type IdempotencyKeyRunMatch = {
26+
friendlyId: string;
27+
idempotencyKey: string | null;
28+
idempotencyKeyExpiresAt: Date | null;
29+
};
30+
2531
export type CreateRunSnapshotInput = {
2632
engine: "V2";
2733
executionStatus: TaskRunExecutionStatus;
@@ -624,6 +630,17 @@ export interface RunStore {
624630
): Promise<Map<string, Prisma.TaskRunGetPayload<{ include: I }>>>;
625631
findRunsByIds(ids: string[], client?: ReadClient): Promise<Map<string, TaskRun>>;
626632

633+
/**
634+
* Point-lookup a set of idempotency keys within one (runtimeEnvironmentId, taskIdentifier).
635+
* Each key is matched by full unique-key equality so the planner always does a per-key index
636+
* probe and never falls back to scanning the whole (env, task) range and filtering in memory.
637+
* Callers chunk large key sets; this resolves one chunk.
638+
*/
639+
findRunsByIdempotencyKeys(
640+
args: { runtimeEnvironmentId: string; taskIdentifier: string; idempotencyKeys: string[] },
641+
client?: ReadClient
642+
): Promise<IdempotencyKeyRunMatch[]>;
643+
627644
// --- run-ops persistence ---
628645
// Snapshots, waitpoints, implicit M:N joins, dependents, attempts and checkpoints. The
629646
// generic model wrappers are thin generics over the Prisma `*Args` types so include/select

0 commit comments

Comments
 (0)