Skip to content

Commit 0308288

Browse files
d-csclaude
andcommitted
perf(run-engine): batch the resolver's deferred run reads, index its positions
Two costs in the completed-waitpoint resolver, both scaling with fan-in width, which is the input the record set exists to make cheap. The output hydration read one run per record, awaited in series inside the emit loop. A batch parent resuming on 500 children therefore performed 500 sequential Postgres reads, where the hydration it replaces does one chunked findMany. The reader is now plural: the resolver collects the distinct set of runs its records defer to, reads them in one chunked batch before the loop, and hydrates from the resulting map. A cycle that defers nothing reads nothing. Positions came from a linear scan of the order per record, making the emit loop quadratic -- about a million string comparisons for a thousand-wide wait. The order is now indexed once into a map of id to positions. Absence still carries the index-less case, so a wait with no batch index emits one entry with an undefined index exactly as before. Neither path has a production caller yet, so nothing changes for any organisation today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 152f826 commit 0308288

4 files changed

Lines changed: 268 additions & 79 deletions

File tree

internal-packages/run-engine/src/engine/waitpointCoordinator/completedWaitpointEquivalence.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { enhanceExecutionSnapshotWithWaitpoints } from "../systems/executionSnap
1010
import { buildCompletedWaitpointRecords } from "./completedWaitpointRecords.js";
1111
import {
1212
createCompletedWaitpointResolver,
13-
createRunOutputReader,
13+
createRunOutputsReader,
1414
} from "./completedWaitpointResolver.js";
1515
import { envelopeSourceFromWaitpointRow } from "./completionEnvelopeSource.js";
1616
import type { CompletionEnvelopeSource } from "./types.js";
@@ -89,7 +89,7 @@ async function bothPaths(
8989
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
9090

9191
const actual = await createCompletedWaitpointResolver({
92-
readRunOutput: createRunOutputReader(runStore),
92+
readRunOutputs: createRunOutputsReader(runStore),
9393
})({
9494
runId: RUN_ID,
9595
...(batchId ? { batchId } : {}),

internal-packages/run-engine/src/engine/waitpointCoordinator/completedWaitpointResolver.runOutput.test.ts

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import type { PrismaClient } from "@trigger.dev/database";
1010
import { describe, expect } from "vitest";
1111
import {
1212
createCompletedWaitpointResolver,
13-
createRunOutputReader,
13+
createRunOutputsReader,
1414
UnresolvableWaitpointId,
1515
} from "./completedWaitpointResolver.js";
16-
import { seedChildRunWithOutput } from "./testFixtures/childRun.js";
16+
import { seedChildRunsWithOutputs, seedChildRunWithOutput } from "./testFixtures/childRun.js";
1717

1818
function deriveRecord(completedByTaskRunId: string): CompletedWaitpointRecord {
1919
return {
@@ -30,7 +30,30 @@ function deriveRecord(completedByTaskRunId: string): CompletedWaitpointRecord {
3030

3131
function resolverFor(prisma: PrismaClient) {
3232
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
33-
return createCompletedWaitpointResolver({ readRunOutput: createRunOutputReader(runStore) });
33+
return createCompletedWaitpointResolver({ readRunOutputs: createRunOutputsReader(runStore) });
34+
}
35+
36+
/**
37+
* A resolver that records the id set of every batched read and DELEGATES to the real reader, so
38+
* the Postgres read still happens.
39+
*
40+
* Wrapping the collaborator rather than replacing it is deliberate: the assertion is about how
41+
* many reads occur and what they ask for, and neither is observable from the resolved output.
42+
*/
43+
function countingResolver(prisma: PrismaClient) {
44+
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
45+
const read = createRunOutputsReader(runStore);
46+
const batches: string[][] = [];
47+
48+
return {
49+
batches,
50+
resolve: createCompletedWaitpointResolver({
51+
readRunOutputs: async (ids) => {
52+
batches.push(ids);
53+
return read(ids);
54+
},
55+
}),
56+
};
3457
}
3558

3659
describe("the deriveFromRun branch", () => {
@@ -102,19 +125,9 @@ describe("the deriveFromRun branch", () => {
102125
// query per index.
103126
postgresTest("reads the run once for a record at several indexes", async ({ prisma }) => {
104127
const runId = await seedChildRunWithOutput(prisma, '{"value":42}');
105-
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
106-
const reads: string[] = [];
107-
const reader = createRunOutputReader(runStore);
108-
109-
// Counts calls and DELEGATES to the real reader, so the Postgres read still happens. This
110-
// wraps the collaborator rather than replacing it: the assertion is about how many reads
111-
// occur, which is not observable from the resolved output alone.
112-
const result = await createCompletedWaitpointResolver({
113-
readRunOutput: async (id) => {
114-
reads.push(id);
115-
return reader(id);
116-
},
117-
})({
128+
const { resolve, batches } = countingResolver(prisma);
129+
130+
const result = await resolve({
118131
runId: "run_parent",
119132
pointer: { cycleSeq: 1, count: 2 },
120133
order: ["wp_run", "wp_run"],
@@ -123,7 +136,67 @@ describe("the deriveFromRun branch", () => {
123136
});
124137

125138
expect(result).toHaveLength(2);
126-
expect(reads).toEqual([runId]);
139+
expect(batches).toEqual([[runId]]);
140+
});
141+
142+
// The shape a batch fan-in produces. Every deferring record resolves in ONE read, not one
143+
// read each: a per-record read put a serial round trip per child on the resume path, which is
144+
// the cost the record set exists to remove.
145+
postgresTest("reads every deferred run in one batch", async ({ prisma }) => {
146+
const runIds = await seedChildRunsWithOutputs(
147+
prisma,
148+
Array.from({ length: 12 }, (_, i) => `{"value":${i}}`)
149+
);
150+
const { resolve, batches } = countingResolver(prisma);
151+
152+
const records = runIds.map((runId, i) => ({
153+
...deriveRecord(runId),
154+
id: `wp_run_${i}`,
155+
friendlyId: `waitpoint_wp_run_${i}`,
156+
}));
157+
158+
const result = await resolve({
159+
runId: "run_parent",
160+
pointer: { cycleSeq: 1, count: records.length },
161+
order: records.map((r) => r.id),
162+
distinctIds: records.map((r) => r.id),
163+
records,
164+
});
165+
166+
expect(result).toHaveLength(12);
167+
// One batch, holding every distinct run id.
168+
expect(batches).toHaveLength(1);
169+
expect(batches[0]?.slice().sort()).toEqual(runIds.slice().sort());
170+
// And each output landed on its own waitpoint.
171+
for (const [i, runId] of runIds.entries()) {
172+
const entry = result.find((w) => w.id === `wp_run_${i}`);
173+
expect(entry?.output).toBe(`{"value":${i}}`);
174+
expect(runId).toBeTruthy();
175+
}
176+
});
177+
178+
// A cycle that defers nothing reads nothing, so an all-inline resume pays no Postgres round
179+
// trip at all.
180+
postgresTest("reads nothing when no record defers", async ({ prisma }) => {
181+
const { resolve, batches } = countingResolver(prisma);
182+
183+
const result = await resolve({
184+
runId: "run_parent",
185+
pointer: { cycleSeq: 1, count: 0 },
186+
order: [],
187+
distinctIds: ["wp_inline"],
188+
records: [
189+
{
190+
...deriveRecord("run_unused"),
191+
id: "wp_inline",
192+
friendlyId: "waitpoint_wp_inline",
193+
output: { inline: '{"ok":true}' },
194+
},
195+
],
196+
});
197+
198+
expect(result[0]?.output).toBe('{"ok":true}');
199+
expect(batches).toEqual([]);
127200
});
128201

129202
postgresTest("throws when a derive record arrives with no reader wired", async ({ prisma }) => {

internal-packages/run-engine/src/engine/waitpointCoordinator/completedWaitpointResolver.ts

Lines changed: 124 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,54 @@ export class UnresolvableWaitpointId extends Error {
3939

4040
export type CompletedWaitpointResolverDeps = {
4141
/**
42-
* Reads TaskRun.output. Returns undefined when the row is gone.
42+
* Reads TaskRun.output for a SET of completing runs, keyed by run id.
43+
*
44+
* Plural on purpose. A batch parent resumes on every child at once, so a per-id reader made
45+
* the resolver do one round trip per child -- 500 of them, in series, for a 500-wide fan-in,
46+
* where the path this replaces did one chunked read. An id absent from the returned map is an
47+
* absent output, which the caller refuses rather than resolving empty.
4348
*
4449
* Optional, because most cycles carry no `deriveFromRun` record and therefore never need it.
4550
* A cycle that DOES carry one without a reader is a wiring error, not a data condition, so it
4651
* throws rather than resolving empty.
4752
*/
48-
readRunOutput?(taskRunId: string): Promise<string | undefined>;
53+
readRunOutputs?(taskRunIds: string[]): Promise<Map<string, string>>;
4954
};
5055

56+
// Bounds one read, for the reason the envelope and waitpoint reads share: a run output can be
57+
// 100KB+, so a wide fan-in read whole can exceed Node's string conversion limits.
58+
const RUN_OUTPUT_CHUNK_SIZE = 100;
59+
5160
/**
52-
* The production reader: TaskRun.output for the completing run, through the store so the read
61+
* The production reader: TaskRun.output for the completing runs, through the store so each read
5362
* routes to the run's owning database.
63+
*
64+
* `findRunsByIds` is the store's own grouped replacement for `Promise.all(ids.map(findRun))`,
65+
* and it forces `id` into the projection so the map keys correctly even though this select
66+
* names only `output`.
5467
*/
55-
export function createRunOutputReader(
56-
runStore: Pick<RunStore, "findRun">,
68+
export function createRunOutputsReader(
69+
runStore: Pick<RunStore, "findRunsByIds">,
5770
client?: ReadClient
58-
): (taskRunId: string) => Promise<string | undefined> {
59-
return async (taskRunId) => {
60-
const run = await runStore.findRun({ id: taskRunId }, { select: { output: true } }, client);
61-
return run?.output ?? undefined;
71+
): (taskRunIds: string[]) => Promise<Map<string, string>> {
72+
return async (taskRunIds) => {
73+
const outputs = new Map<string, string>();
74+
75+
for (let i = 0; i < taskRunIds.length; i += RUN_OUTPUT_CHUNK_SIZE) {
76+
const chunk = taskRunIds.slice(i, i + RUN_OUTPUT_CHUNK_SIZE);
77+
const rows = await runStore.findRunsByIds(chunk, { select: { output: true } }, client);
78+
79+
for (const [id, row] of rows) {
80+
// A row present with a null output is the same absence as a missing row: either way the
81+
// value the waitpoint deferred is gone. Omitting it here keeps one absence rule, so the
82+
// caller's refusal covers both.
83+
if (row.output !== null) {
84+
outputs.set(id, row.output);
85+
}
86+
}
87+
}
88+
89+
return outputs;
6290
};
6391
}
6492

@@ -100,13 +128,23 @@ export function createCompletedWaitpointResolver(deps: CompletedWaitpointResolve
100128
}
101129
}
102130

131+
// Every deferred output in ONE read, before the emit loop. Reading inside the loop meant a
132+
// round trip per record, in series, which is the shape a batch fan-in punishes hardest: the
133+
// wide wait this feature exists to make cheap is exactly the wide wait that paid most.
134+
const runOutputs = await readDeferredOutputs(args.records, deps);
135+
136+
// Positions once, not once per record. `positionsOf` scanned the whole order for every
137+
// record, so the emit loop was O(records x order) -- a million comparisons for a 1000-wide
138+
// wait, growing with the same input as above.
139+
const positions = positionsById(args.order);
140+
103141
const out: CompletedWaitpoint[] = [];
104142

105143
for (const record of args.records) {
106-
const indexes = positionsOf(record.id, args.order);
144+
const indexes = positions.get(record.id) ?? [undefined];
107145
// Hydrated once per record, not once per position, so a run at several batch indexes
108-
// costs one read rather than one per index.
109-
const output = await hydrateOutput(record, deps);
146+
// resolves from one map entry rather than one per index.
147+
const output = hydrateOutput(record, runOutputs);
110148

111149
for (const index of indexes) {
112150
out.push({
@@ -144,24 +182,85 @@ export function createCompletedWaitpointResolver(deps: CompletedWaitpointResolve
144182
};
145183
}
146184

147-
// An id with no position yields one entry with an undefined index, matching what the
148-
// existing hydration does for a wait that carried no batch index.
149-
function positionsOf(waitpointId: string, order: string[]): (number | undefined)[] {
150-
const indexes: (number | undefined)[] = [];
185+
// Every id's positions in the order, built in one pass.
186+
//
187+
// An id ABSENT from this map has no position, and the caller emits it once with an undefined
188+
// index -- matching what the existing hydration does for a wait that carried no batch index.
189+
// Absence is how that case is carried, so this never stores an [undefined] entry itself.
190+
function positionsById(order: string[]): Map<string, number[]> {
191+
const positions = new Map<string, number[]>();
151192

152193
for (let i = 0; i < order.length; i++) {
153-
if (order[i] === waitpointId) {
154-
indexes.push(i);
194+
const id = order[i];
195+
if (id === undefined) {
196+
continue;
197+
}
198+
199+
const existing = positions.get(id);
200+
if (existing) {
201+
existing.push(i);
202+
} else {
203+
positions.set(id, [i]);
155204
}
156205
}
157206

158-
return indexes.length === 0 ? [undefined] : indexes;
207+
return positions;
159208
}
160209

161-
async function hydrateOutput(
162-
record: CompletedWaitpointRecord,
210+
/**
211+
* The output of every run a record defers to, in one batched read.
212+
*
213+
* Returns an empty map when no record defers, which is the common case: a cycle carrying only
214+
* inline values, refs and BATCH records reads nothing at all.
215+
*/
216+
async function readDeferredOutputs(
217+
records: CompletedWaitpointRecord[],
163218
deps: CompletedWaitpointResolverDeps
164-
): Promise<string | undefined> {
219+
): Promise<Map<string, string>> {
220+
const runIds = new Set<string>();
221+
let deferring: CompletedWaitpointRecord | undefined;
222+
223+
for (const record of records) {
224+
const runId = deferredRunIdOf(record);
225+
if (runId !== undefined) {
226+
runIds.add(runId);
227+
deferring ??= record;
228+
}
229+
}
230+
231+
if (runIds.size === 0) {
232+
return new Map();
233+
}
234+
235+
if (!deps.readRunOutputs) {
236+
throw new Error(
237+
`Waitpoint ${deferring?.id} defers its output to run ${deferring?.completedByTaskRunId}, but the resolver was built with no run-output reader.`
238+
);
239+
}
240+
241+
return deps.readRunOutputs([...runIds]);
242+
}
243+
244+
// The run a record defers its output to, or undefined when it carries its own output or has
245+
// nothing to defer to. This is the single definition of "needs a run read", so the pre-pass and
246+
// the hydration cannot disagree about which records those are.
247+
function deferredRunIdOf(record: CompletedWaitpointRecord): string | undefined {
248+
if (record.output === null) {
249+
return undefined;
250+
}
251+
252+
if ("inline" in record.output || "ref" in record.output) {
253+
return undefined;
254+
}
255+
256+
return record.completedByTaskRunId ?? undefined;
257+
}
258+
259+
// Synchronous: every read this needs already happened in readDeferredOutputs.
260+
function hydrateOutput(
261+
record: CompletedWaitpointRecord,
262+
runOutputs: Map<string, string>
263+
): string | undefined {
165264
if (record.output === null) {
166265
return undefined;
167266
}
@@ -176,20 +275,15 @@ async function hydrateOutput(
176275
return record.output.ref;
177276
}
178277

179-
if (!record.completedByTaskRunId) {
278+
const runId = deferredRunIdOf(record);
279+
if (runId === undefined) {
180280
return undefined;
181281
}
182282

183-
if (!deps.readRunOutput) {
184-
throw new Error(
185-
`Waitpoint ${record.id} defers its output to run ${record.completedByTaskRunId}, but the resolver was built with no run-output reader.`
186-
);
187-
}
188-
189283
// Postgres does not lose this: the back-reference nulls on delete but Waitpoint.output stays,
190284
// so the legacy path still emits it. Returning undefined here instead would resolve the
191285
// parent's triggerAndWait successfully with no output, which is silent wrong data.
192-
const output = await deps.readRunOutput(record.completedByTaskRunId);
286+
const output = runOutputs.get(runId);
193287
if (output === undefined) {
194288
throw new UnresolvableWaitpointId(record.id, "lost-run-output");
195289
}

0 commit comments

Comments
 (0)