Skip to content

Commit c642427

Browse files
committed
perf(webapp,run-store): group per-item run reads into batched queries
Several run-ops read paths issued one database query per item where a single batched query returns the same data: batch results hydrated each member run separately, the run retrieve endpoint resolved each locked worker version separately, the dedicated-schema relation hydrator ran per row, and a few services read runs in per-id loops. These now group into batched reads. Adds a grouped findRunsByIds to the run store (one residency-partitioned read for a set of run ids) and routes the presenters and services through it. The dedicated-schema relation hydrator batches across all rows per relation. The waitpoint connected-runs read also regains its display limit at the fetch, so it no longer loads every connected run into memory before slicing to five.
1 parent a6bd370 commit c642427

20 files changed

Lines changed: 3651 additions & 292 deletions

apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
2+
import { ownerEngine } from "@trigger.dev/core/v3/isomorphic";
23
import {
34
$replica,
45
type PrismaClientOrTransaction,
@@ -8,7 +9,6 @@ import {
89
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
910
import { executionResultForTaskRun } from "~/models/taskRun.server";
1011
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
11-
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
1212
import { runStore as defaultRunStore } from "~/v3/runStore.server";
1313
import { BasePresenter } from "./basePresenter.server";
1414

@@ -43,11 +43,14 @@ const memberRunSelect = {
4343
} as const;
4444

4545
/**
46-
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
47-
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
48-
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
49-
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
50-
* read; the dangling-reference termination gate is a separate, adjacent unit).
46+
* Split on: the batch row + its members resolve new-run-ops first, then the LEGACY RUN-OPS READ
47+
* REPLICA ONLY (never the legacy primary — there is no such handle). Members hydrate via ONE
48+
* grouped read against `newClient` for the whole id set, then ONE grouped read against
49+
* `legacyReplica` for just the misses that could still be legacy-resident — the same
50+
* residency-partitioned shape as the old per-member read-through, but batched instead of fanned
51+
* out one query per member. A batch whose members span migrated + abandoned runs returns the
52+
* complete reachable set (the batch-spanning-the-line read; the dangling-reference termination
53+
* gate is a separate, adjacent unit).
5154
*
5255
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
5356
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
@@ -133,7 +136,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
133136
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
134137
// be cuid or run-ops id, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
135138
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
136-
// hydrate every member run independently via the per-run read-through primitive.
139+
// hydrate every member run in ONE grouped new-then-legacy read.
137140
async #callSplit(
138141
friendlyId: string,
139142
env: AuthenticatedEnvironment
@@ -175,41 +178,35 @@ export class ApiBatchResultsPresenter extends BasePresenter {
175178
};
176179
}
177180

178-
const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
179-
client.taskRun.findFirst({
180-
where: { id: taskRunId },
181-
select: memberRunSelect,
182-
}) as Promise<TaskRunWithAttempts | null>;
183-
184-
// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
185-
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
186-
const memberResults = await Promise.all(
187-
batchRun.items.map(async (item) => {
188-
const result = await readThroughRun<TaskRunWithAttempts>({
189-
runId: item.taskRunId,
190-
environmentId: env.id,
191-
readNew: (client) => readMemberRun(client, item.taskRunId),
192-
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
193-
deps: {
194-
splitEnabled: true,
195-
// Pass the SAME resolved handles the batch row used, so the batch row and its members
196-
// never resolve against different DBs. (Letting these fall through to readThroughRun's
197-
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
198-
newClient,
199-
legacyReplica,
200-
isPastRetention: this.readThrough?.isPastRetention,
201-
},
202-
});
181+
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
203182

204-
// not-found / past-retention members are omitted (matches today's drop-undefined behavior);
205-
// the dangling-reference termination gate (separate unit) governs whether that's permitted.
206-
if (result.source === "not-found" || result.source === "past-retention") {
207-
return undefined;
208-
}
183+
const newRows = (await newClient.taskRun.findMany({
184+
where: { id: { in: taskRunIds } },
185+
select: memberRunSelect,
186+
})) as TaskRunWithAttempts[];
187+
const runsById = new Map(newRows.map((run) => [run.id, run]));
209188

210-
return executionResultForTaskRun(result.value);
211-
})
189+
// A run-ops id can only live on NEW, so only misses that AREN'T run-ops-shaped are candidates
190+
// for the legacy probe — mirrors readThroughRun's per-id "NEW residency skips legacy" rule.
191+
const legacyCandidateIds = taskRunIds.filter(
192+
(id) => !runsById.has(id) && ownerEngine(id) !== "NEW"
212193
);
194+
if (legacyCandidateIds.length > 0) {
195+
const legacyRows = (await legacyReplica.taskRun.findMany({
196+
where: { id: { in: legacyCandidateIds } },
197+
select: memberRunSelect,
198+
})) as TaskRunWithAttempts[];
199+
for (const run of legacyRows) {
200+
runsById.set(run.id, run);
201+
}
202+
}
203+
204+
// not-found members are omitted (matches today's drop-undefined behavior); the
205+
// dangling-reference termination gate (separate unit) governs whether that's permitted.
206+
const memberResults = batchRun.items.map((item) => {
207+
const run = runsById.get(item.taskRunId);
208+
return run ? executionResultForTaskRun(run) : undefined;
209+
});
213210

214211
return {
215212
id: batchRun.friendlyId,

apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,13 @@ export class ApiRetrieveRunPresenter {
164164
collect(child.lockedToVersionId);
165165
}
166166

167+
const lockedWorkersByVersionId =
168+
await controlPlaneResolver.resolveRunLockedWorkersByVersionIds([...distinctVersionIds]);
167169
const versionById = new Map<string, string | null>(
168-
await Promise.all(
169-
[...distinctVersionIds].map(
170-
async (id) =>
171-
[
172-
id,
173-
(await controlPlaneResolver.resolveRunLockedWorker({ lockedToVersionId: id }))
174-
?.lockedToVersion?.version ?? null,
175-
] as const
176-
)
177-
)
170+
[...distinctVersionIds].map((id) => [
171+
id,
172+
lockedWorkersByVersionId.get(id)?.lockedToVersion?.version ?? null,
173+
])
178174
);
179175

180176
const resolveVersion = (id: string | null): { version: string } | null => {

apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
1111

1212
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
1313

14+
// Single-sourced bound for connected run friendlyIds: applied at the FETCH in #connectedRunIdsOn,
15+
// not just at display time.
16+
export const CONNECTED_RUNS_DISPLAY_LIMIT = 5;
17+
1418
export class WaitpointPresenter extends BasePresenter {
1519
constructor(
1620
prisma?: PrismaClientOrTransaction,
@@ -98,16 +102,16 @@ export class WaitpointPresenter extends BasePresenter {
98102
const runs = await client.taskRun.findMany({
99103
where: { id: { in: runIds } },
100104
select: { friendlyId: true },
101-
take: 5,
105+
take: CONNECTED_RUNS_DISPLAY_LIMIT,
102106
});
103107
for (const run of runs) {
104108
friendlyIds.add(run.friendlyId);
105109
}
106-
if (friendlyIds.size >= 5) {
110+
if (friendlyIds.size >= CONNECTED_RUNS_DISPLAY_LIMIT) {
107111
break;
108112
}
109113
}
110-
return Array.from(friendlyIds).slice(0, 5);
114+
return Array.from(friendlyIds).slice(0, CONNECTED_RUNS_DISPLAY_LIMIT);
111115
}
112116

113117
// Schema-aware read of the run ids linked to a waitpoint: the dedicated subset uses the explicit
@@ -125,11 +129,14 @@ export class WaitpointPresenter extends BasePresenter {
125129
const links = await joinDelegate.findMany({
126130
where: { waitpointId },
127131
select: { taskRunId: true },
132+
take: CONNECTED_RUNS_DISPLAY_LIMIT,
128133
});
129134
return links.map((link) => link.taskRunId);
130135
}
136+
// CONNECTED_RUNS_DISPLAY_LIMIT is a compile-time constant int, not an interpolated value.
131137
const rows = await client.$queryRaw<{ A: string }[]>`
132138
SELECT "A" FROM "_WaitpointRunConnections" WHERE "B" = ${waitpointId}
139+
LIMIT ${CONNECTED_RUNS_DISPLAY_LIMIT}
133140
`;
134141
return rows.map((row) => row.A);
135142
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Real control-plane (single Postgres) proof that resolving the locked-worker version for many
2+
// runs' distinct `lockedToVersionId`s is a GROUPED query, not one `backgroundWorker.findFirst`
3+
// per id. The DB is never mocked: the call-counting proxy below delegates every call to the
4+
// real Prisma client (the real query still runs against the real container) - it only tallies
5+
// how many times each model.method pair was invoked.
6+
import { postgresTest } from "@internal/testcontainers";
7+
import { describe, expect } from "vitest";
8+
import type { PrismaClient, PrismaReplicaClient } from "@trigger.dev/database";
9+
import { ControlPlaneCache } from "./controlPlaneCache.server";
10+
import { ControlPlaneResolver } from "./controlPlaneResolver.server";
11+
12+
// Wraps a real Prisma client so every `client.<model>.<method>(...)` call is tallied by
13+
// `"<model>.<method>"` before being forwarded, unmodified, to the real delegate. No behavior is
14+
// faked or short-circuited - this is instrumentation, not a mock.
15+
function createCallCountingProxy<T extends object>(
16+
client: T
17+
): { client: T; counts: Map<string, number> } {
18+
const counts = new Map<string, number>();
19+
const bump = (key: string) => counts.set(key, (counts.get(key) ?? 0) + 1);
20+
const wrappedModels = new Map<string, unknown>();
21+
22+
const wrapModel = (model: string, delegate: object) =>
23+
new Proxy(delegate, {
24+
get(target, prop, receiver) {
25+
const value = Reflect.get(target, prop, receiver);
26+
if (typeof prop === "string" && typeof value === "function") {
27+
return (...args: unknown[]) => {
28+
bump(`${model}.${prop}`);
29+
return (value as (...a: unknown[]) => unknown).apply(target, args);
30+
};
31+
}
32+
return value;
33+
},
34+
});
35+
36+
const proxy = new Proxy(client, {
37+
get(target, prop, receiver) {
38+
const value = Reflect.get(target, prop, receiver);
39+
if (
40+
typeof prop === "string" &&
41+
value &&
42+
typeof value === "object" &&
43+
typeof (value as { findMany?: unknown }).findMany === "function"
44+
) {
45+
if (!wrappedModels.has(prop)) {
46+
wrappedModels.set(prop, wrapModel(prop, value as object));
47+
}
48+
return wrappedModels.get(prop);
49+
}
50+
return value;
51+
},
52+
});
53+
54+
return { client: proxy as T, counts };
55+
}
56+
57+
let n = 0;
58+
59+
async function seedEnv(prisma: PrismaClient) {
60+
const s = n++;
61+
const organization = await prisma.organization.create({
62+
data: { title: `Org ${s}`, slug: `org-${s}` },
63+
});
64+
const project = await prisma.project.create({
65+
data: {
66+
name: `P ${s}`,
67+
slug: `p-${s}`,
68+
externalRef: `proj_${s}`,
69+
organizationId: organization.id,
70+
},
71+
});
72+
const environment = await prisma.runtimeEnvironment.create({
73+
data: {
74+
type: "PRODUCTION",
75+
slug: `env-${s}`,
76+
projectId: project.id,
77+
organizationId: organization.id,
78+
apiKey: `tr_${s}`,
79+
pkApiKey: `pk_${s}`,
80+
shortcode: `sc_${s}`,
81+
},
82+
});
83+
return { organization, project, environment };
84+
}
85+
86+
async function seedBackgroundWorker(
87+
prisma: PrismaClient,
88+
ctx: { projectId: string; runtimeEnvironmentId: string },
89+
version: string
90+
) {
91+
const s = n++;
92+
return prisma.backgroundWorker.create({
93+
data: {
94+
friendlyId: `worker_${s}`,
95+
version,
96+
contentHash: `hash_${s}`,
97+
projectId: ctx.projectId,
98+
runtimeEnvironmentId: ctx.runtimeEnvironmentId,
99+
metadata: {},
100+
},
101+
});
102+
}
103+
104+
describe("ControlPlaneResolver.resolveRunLockedWorkersByVersionIds", () => {
105+
postgresTest(
106+
"issues ONE grouped findMany and ZERO per-id findFirst for N distinct ids",
107+
async ({ prisma }) => {
108+
const { project, environment } = await seedEnv(prisma);
109+
const workers = await Promise.all(
110+
[0, 1, 2].map((i) =>
111+
seedBackgroundWorker(
112+
prisma,
113+
{ projectId: project.id, runtimeEnvironmentId: environment.id },
114+
`2024010${i}.0`
115+
)
116+
)
117+
);
118+
119+
const { client: countedReplica, counts } = createCallCountingProxy(prisma);
120+
121+
const resolver = new ControlPlaneResolver({
122+
controlPlanePrimary: prisma,
123+
controlPlaneReplica: countedReplica as unknown as PrismaReplicaClient,
124+
cache: new ControlPlaneCache(),
125+
splitEnabled: () => true,
126+
});
127+
128+
const ids = workers.map((w) => w.id);
129+
const result = await resolver.resolveRunLockedWorkersByVersionIds(ids);
130+
131+
expect(counts.get("backgroundWorker.findMany") ?? 0).toBe(1);
132+
expect(counts.get("backgroundWorker.findFirst") ?? 0).toBe(0);
133+
134+
for (const worker of workers) {
135+
expect(result.get(worker.id)?.lockedToVersion?.version).toBe(worker.version);
136+
}
137+
}
138+
);
139+
140+
postgresTest("cache-hit ids issue no query at all", async ({ prisma }) => {
141+
const { project, environment } = await seedEnv(prisma);
142+
const worker = await seedBackgroundWorker(
143+
prisma,
144+
{ projectId: project.id, runtimeEnvironmentId: environment.id },
145+
"20240101.0"
146+
);
147+
148+
const cache = new ControlPlaneCache();
149+
const { client: countedReplica, counts } = createCallCountingProxy(prisma);
150+
151+
const resolver = new ControlPlaneResolver({
152+
controlPlanePrimary: prisma,
153+
controlPlaneReplica: countedReplica as unknown as PrismaReplicaClient,
154+
cache,
155+
splitEnabled: () => true,
156+
});
157+
158+
// Warm the cache.
159+
await resolver.resolveRunLockedWorkersByVersionIds([worker.id]);
160+
expect(counts.get("backgroundWorker.findMany") ?? 0).toBe(1);
161+
162+
// Second call for the same id is served entirely from cache - no query at all.
163+
const result = await resolver.resolveRunLockedWorkersByVersionIds([worker.id]);
164+
expect(counts.get("backgroundWorker.findMany") ?? 0).toBe(1);
165+
expect(counts.get("backgroundWorker.findFirst") ?? 0).toBe(0);
166+
expect(result.get(worker.id)?.lockedToVersion?.version).toBe(worker.version);
167+
});
168+
});

0 commit comments

Comments
 (0)