Skip to content

Commit 4d8d5ab

Browse files
committed
perf(webapp,run-store): point-lookup batch idempotency keys
Batch triggers that pass per-item idempotency keys could take seconds when the target task had a large run history, because the bulk `idempotencyKey IN (...)` lookup degraded to scanning every run for the (environment, task) pair and filtering in memory. Look each key up individually via a chunked UNION ALL of point lookups so the planner always uses a per-key index probe.
1 parent 9f4d8d8 commit 4d8d5ab

5 files changed

Lines changed: 97 additions & 20 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: 32 additions & 20 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 = 10;
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,28 +408,29 @@ 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+
({ taskIdentifier, idempotencyKeys }) =>
427+
this.runStore.findRunsByIdempotencyKeys(
428+
{ runtimeEnvironmentId: environment.id, taskIdentifier, idempotencyKeys },
429+
this._prisma
430+
),
431+
{ concurrency: IDEMPOTENCY_KEY_LOOKUP_CONCURRENCY }
420432
)
421-
).then((results) => results.flat());
433+
).flat();
422434

423435
// Build the run IDs in order: reuse an unexpired cached id, else mint a new id (and record any
424436
// expired cached id so its idempotency key can be cleared below).

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: 26 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,31 @@ 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 = await this.#new.findRunsByIdempotencyKeys(
472+
args,
473+
RoutingRunStore.#ownPrimary(this.#new, client)
474+
);
475+
const legacyRows = await this.#legacy.findRunsByIdempotencyKeys(
476+
args,
477+
RoutingRunStore.#ownPrimary(this.#legacy, client)
478+
);
479+
const byKey = new Map<string, IdempotencyKeyRunMatch>();
480+
for (const row of legacyRows) {
481+
if (row.idempotencyKey != null) byKey.set(row.idempotencyKey, row);
482+
}
483+
for (const row of newRows) {
484+
if (row.idempotencyKey != null) byKey.set(row.idempotencyKey, row);
485+
}
486+
return [...byKey.values()];
487+
}
488+
463489
// ---------------------------------------------------------------------------
464490
// TaskRun-core: update-family — route by run id in params
465491
// ---------------------------------------------------------------------------

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)