@@ -39,26 +39,54 @@ export class UnresolvableWaitpointId extends Error {
3939
4040export 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