Skip to content

Commit 1f833ee

Browse files
committed
fix(execution): cover every subflow field that grows without a bound
The first pass only compacted a loop's completed iteration outputs, which left the same pause failure reachable by four other routes: a forEach collection, an in-flight iteration output, two loops each individually under the limit but oversized together, and a parallel's accumulated branch outputs. Parallel state was not even asserted, so it shipped an oversized snapshot silently rather than failing. Compact every field that accumulates, assert parallel state alongside loop state, and offload at a threshold far below the snapshot's own — the assertion measures the combined record, so compacting at its ceiling is a no-op in exactly the case that needs it. Skip the pass entirely when the state already fits, so a pause per iteration inside a modest loop pays one bounded measurement rather than a structural rebuild each time, and count the compaction against the recorded duration instead of stopping the clock before it runs. Add an engine-level test: the serializer tests all passed with the call removed, leaving the wiring itself undefended.
1 parent af500aa commit 1f833ee

4 files changed

Lines changed: 236 additions & 55 deletions

File tree

apps/sim/executor/execution/engine.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,52 @@ describe('ExecutionEngine', () => {
427427
)
428428
})
429429

430+
/**
431+
* The compaction pass is what keeps an oversized loop from failing the pause
432+
* outright. Asserting it from the engine keeps the wiring defended: without
433+
* this, removing the call leaves every serializer test still green.
434+
*/
435+
it('compacts oversized loop state before building the paused result', async () => {
436+
const node = createMockNode('hitl', 'function')
437+
const dag = createMockDAG([node])
438+
const payload = 'x'.repeat(300_000)
439+
const context = createMockContext({
440+
decisions: { router: new Map(), condition: new Map() },
441+
loopExecutions: new Map([
442+
[
443+
'loop-1',
444+
{
445+
iteration: 1,
446+
currentIterationOutputs: new Map(),
447+
allIterationOutputs: Array.from({ length: 40 }, () => [{ payload }]),
448+
},
449+
],
450+
]),
451+
} as Partial<ExecutionContext>)
452+
const edgeManager = createMockEdgeManager()
453+
const nodeOrchestrator = createMockNodeOrchestrator()
454+
vi.mocked(nodeOrchestrator.executeNode).mockResolvedValue({
455+
nodeId: 'hitl',
456+
output: {
457+
response: { status: 'paused' },
458+
_pauseMetadata: {
459+
contextId: 'pause-1',
460+
blockId: 'hitl',
461+
response: { status: 'paused' },
462+
timestamp: new Date().toISOString(),
463+
pauseKind: 'hitl',
464+
},
465+
},
466+
isFinalOutput: false,
467+
})
468+
469+
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
470+
const result = await engine.run('hitl')
471+
472+
expect(result.status).toBe('paused')
473+
expect(result.snapshotSeed?.snapshot).toBeTruthy()
474+
})
475+
430476
it('does not stop run-until execution on parallel batch continuation', async () => {
431477
const parallelEnd = createMockNode('parallel-end', 'parallel')
432478
const nextNode = createMockNode('next', 'function')

apps/sim/executor/execution/engine.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -495,12 +495,12 @@ export class ExecutionEngine {
495495
}
496496

497497
private async buildPausedResult(startTime: number): Promise<ExecutionResult> {
498-
const endTime = performance.now()
499-
this.context.metadata.endTime = new Date().toISOString()
500-
this.context.metadata.duration = endTime - startTime
501498
this.context.metadata.status = 'paused'
502499

503500
await compactPauseSnapshotScopes(this.context)
501+
const endTime = performance.now()
502+
this.context.metadata.endTime = new Date().toISOString()
503+
this.context.metadata.duration = endTime - startTime
504504
const snapshotSeed = serializePauseSnapshot(this.context, [], this.dag, this.edgeManager)
505505
const pausePoints: PausePoint[] = Array.from(this.pausedBlocks.values()).map((pause) => ({
506506
contextId: pause.contextId,

apps/sim/executor/execution/snapshot-serializer.test.ts

Lines changed: 96 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -261,69 +261,132 @@ describe('serializePauseSnapshot', () => {
261261
})
262262

263263
describe('compactPauseSnapshotScopes', () => {
264-
function createLoopContext(iterations: number, bytesPerIteration: number): ExecutionContext {
265-
const payload = 'x'.repeat(bytesPerIteration)
264+
const dag = { nodes: new Map<string, DAGNode>() } as unknown as DAG
265+
const edgeManager = new EdgeManager(dag)
266+
267+
function fatIterations(count: number, bytes: number): any[][] {
268+
const payload = 'x'.repeat(bytes)
269+
// Real shape is an array per iteration, which routes oversized entries
270+
// through the chunked-manifest path rather than a single ref.
271+
return Array.from({ length: count }, () => [{ payload }])
272+
}
273+
274+
function loopContext(overrides: Record<string, unknown>): ExecutionContext {
266275
return createContext({
267276
loopExecutions: new Map([
268277
[
269278
'loop-1',
270279
{
271-
loopId: 'loop-1',
272-
iteration: iterations,
273-
maxIterations: iterations,
280+
iteration: 1,
281+
loopType: 'forEach',
274282
currentIterationOutputs: new Map(),
275-
// Each entry is well under the per-value cap; only the running total is oversized,
276-
// which is exactly what per-iteration compaction cannot catch.
277-
allIterationOutputs: Array.from({ length: iterations }, () => ({ payload })),
283+
allIterationOutputs: [],
284+
...overrides,
278285
},
279286
],
280287
]),
281288
} as Partial<ExecutionContext>)
282289
}
283290

284-
/**
285-
* A loop compacts its accumulated outputs when it exits, but a pause is
286-
* mid-flight and never gets there — so without this pass the running total
287-
* trips the serializer's size assertion and the pause fails outright.
288-
*/
291+
const serialize = (context: ExecutionContext) =>
292+
serializePauseSnapshot(context, [], dag, edgeManager)
293+
289294
it('lets a pause inside a long-running loop serialize', async () => {
290-
const context = createLoopContext(40, 300_000)
291-
const dag = { nodes: new Map<string, DAGNode>() } as unknown as DAG
292-
const edgeManager = new EdgeManager(dag)
295+
const context = loopContext({ allIterationOutputs: fatIterations(40, 300_000) })
293296

294-
expect(() => serializePauseSnapshot(context, [], dag, edgeManager)).toThrow(
295-
'oversized loop execution state'
296-
)
297+
expect(() => serialize(context)).toThrow('oversized loop execution state')
298+
await compactPauseSnapshotScopes(context)
299+
expect(serialize(context).snapshot).toBeTruthy()
300+
})
301+
302+
/** A forEach collection is the most common way a loop's state gets large. */
303+
it('compacts the forEach collection, not just the iteration outputs', async () => {
304+
const context = loopContext({ items: fatIterations(40, 300_000) })
305+
306+
expect(() => serialize(context)).toThrow('oversized loop execution state')
307+
await compactPauseSnapshotScopes(context)
308+
expect(serialize(context).snapshot).toBeTruthy()
309+
})
310+
311+
/** A single fat mid-flight block output reaches the limit on its own. */
312+
it('compacts in-flight iteration outputs', async () => {
313+
const context = loopContext({
314+
currentIterationOutputs: new Map([['block-1', { payload: 'x'.repeat(9_000_000) }]]),
315+
})
316+
317+
expect(() => serialize(context)).toThrow('oversized loop execution state')
318+
await compactPauseSnapshotScopes(context)
319+
expect(serialize(context).snapshot).toBeTruthy()
320+
})
321+
322+
/** The assertion is on the whole record, so per-scope headroom is not enough. */
323+
it('compacts across multiple loops whose combined state is oversized', async () => {
324+
const context = createContext({
325+
loopExecutions: new Map([
326+
[
327+
'loop-1',
328+
{
329+
iteration: 1,
330+
currentIterationOutputs: new Map(),
331+
allIterationOutputs: fatIterations(15, 300_000),
332+
},
333+
],
334+
[
335+
'loop-2',
336+
{
337+
iteration: 1,
338+
currentIterationOutputs: new Map(),
339+
allIterationOutputs: fatIterations(15, 300_000),
340+
},
341+
],
342+
]),
343+
} as Partial<ExecutionContext>)
297344

345+
expect(() => serialize(context)).toThrow('oversized loop execution state')
298346
await compactPauseSnapshotScopes(context)
347+
expect(serialize(context).snapshot).toBeTruthy()
348+
})
299349

300-
const seed = serializePauseSnapshot(context, [], dag, edgeManager)
301-
expect(seed.snapshot).toBeTruthy()
350+
/** Parallels accumulate the same way and were previously not even asserted. */
351+
it('compacts accumulated parallel branch outputs', async () => {
352+
const context = createContext({
353+
parallelExecutions: new Map([
354+
[
355+
'parallel-1',
356+
{
357+
parallelId: 'parallel-1',
358+
totalBranches: 40,
359+
branchOutputs: new Map([[0, fatIterations(40, 300_000).flat()]]),
360+
accumulatedOutputs: new Map(),
361+
},
362+
],
363+
]),
364+
} as Partial<ExecutionContext>)
365+
366+
expect(() => serialize(context)).toThrow('oversized parallel execution state')
367+
await compactPauseSnapshotScopes(context)
368+
expect(serialize(context).snapshot).toBeTruthy()
302369
})
303370

304-
/**
305-
* The refs are only usable if the resumed run is authorized to read them, so
306-
* the keys compaction registers must reach the snapshot's trusted-access list.
307-
*/
371+
/** Refs are unusable unless the resumed run is authorized to read them. */
308372
it('authorizes the offloaded values for the resumed run', async () => {
309-
const context = createLoopContext(40, 300_000)
310-
const dag = { nodes: new Map<string, DAGNode>() } as unknown as DAG
311-
const edgeManager = new EdgeManager(dag)
373+
const context = loopContext({ allIterationOutputs: fatIterations(40, 300_000) })
312374

313375
await compactPauseSnapshotScopes(context)
314-
const parsed = JSON.parse(serializePauseSnapshot(context, [], dag, edgeManager).snapshot) as {
376+
const parsed = JSON.parse(serialize(context).snapshot) as {
315377
state?: { trustedLargeValueAccess?: { largeValueKeys?: string[] } }
316378
}
317379

318380
expect(parsed.state?.trustedLargeValueAccess?.largeValueKeys?.length ?? 0).toBeGreaterThan(0)
319381
})
320382

321-
it('leaves a loop whose accumulated output already fits untouched', async () => {
322-
const context = createLoopContext(2, 100)
323-
const before = structuredClone(context.loopExecutions?.get('loop-1')?.allIterationOutputs)
383+
/** Compaction is a structural rebuild, so a modest loop must not pay for it. */
384+
it('skips the rebuild entirely when the state already fits', async () => {
385+
const context = loopContext({ allIterationOutputs: fatIterations(2, 100) })
386+
const before = context.loopExecutions?.get('loop-1')?.allIterationOutputs
324387

325388
await compactPauseSnapshotScopes(context)
326389

327-
expect(context.loopExecutions?.get('loop-1')?.allIterationOutputs).toEqual(before)
390+
expect(context.loopExecutions?.get('loop-1')?.allIterationOutputs).toBe(before)
328391
})
329392
})

apps/sim/executor/execution/snapshot-serializer.ts

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
22
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
3-
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
3+
import { compactExecutionPayload, compactSubflowResults } from '@/lib/execution/payloads/serializer'
44
import type { DAG } from '@/executor/dag/builder'
55
import type { EdgeManager } from '@/executor/execution/edge-manager'
66
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
@@ -185,23 +185,59 @@ function serializeParallelExecutions(
185185
}
186186

187187
/**
188-
* Offload accumulated loop iteration outputs so a pause snapshot stays compact.
188+
* Per-value offload ceiling applied once the subflow state is already oversized.
189189
*
190-
* A loop compacts `allIterationOutputs` when it exits, but a pause is by
191-
* definition mid-flight and never reaches that point — so the running total
192-
* arrives at the serializer uncompacted and trips its size assertion, failing
193-
* the pause outright. The approval notification has already gone out by then,
194-
* leaving the approver holding a link to a paused execution that was never
195-
* recorded.
190+
* Deliberately far below the snapshot's own limit. The assertion measures the
191+
* *combined* record, so scopes that are each individually under it still fail
192+
* together — compacting at the snapshot ceiling would be a no-op in exactly the
193+
* case that needs it. Only reached when the state is already too large, so the
194+
* fidelity cost lands on runs that would otherwise fail outright.
195+
*/
196+
const PAUSE_SNAPSHOT_COMPACT_VALUE_BYTES = 64 * 1024
197+
198+
/**
199+
* Whether the serialized subflow state is already past what the snapshot allows.
200+
*
201+
* Measured on the serialized shape because that is what the assertions read,
202+
* and bounded so an oversized structure short-circuits instead of being walked
203+
* in full.
204+
*/
205+
function isSubflowStateOversized(loops?: Map<string, any>, parallels?: Map<string, any>): boolean {
206+
const limit = LARGE_VALUE_THRESHOLD_BYTES
207+
const loopBytes = getBoundedJsonByteLength(serializeLoopExecutions(loops), limit)
208+
if (loopBytes !== undefined && loopBytes > limit) return true
209+
const parallelBytes = getBoundedJsonByteLength(serializeParallelExecutions(parallels), limit)
210+
return parallelBytes !== undefined && parallelBytes > limit
211+
}
212+
213+
/**
214+
* Offload accumulated subflow state so a pause snapshot stays under the size
215+
* assertions below.
216+
*
217+
* A loop or parallel compacts its accumulated outputs when it *exits*, but a
218+
* pause is by definition mid-flight and never reaches that point. The running
219+
* total therefore arrives here uncompacted and trips the assertion, which
220+
* throws rather than degrades — turning the pause into a failed run, so no
221+
* paused-execution row is ever written. The approval notification has already
222+
* gone out by then, leaving the approver holding a link to something that was
223+
* never recorded.
196224
*
197-
* Mirrors the loop-exit pass: entries move to large-value storage and the
198-
* snapshot keeps refs. The keys they register are picked up by the snapshot's
199-
* `trustedLargeValueAccess`, so the resumed run can still read them.
225+
* Every field that grows without an aggregate bound is covered: a loop's
226+
* iteration outputs, its in-flight iteration outputs and its `forEach`
227+
* collection, and the parallel equivalents. Compacting only one of them would
228+
* leave the same failure reachable by a different route.
229+
*
230+
* Skipped entirely when the state already serializes small enough, so the
231+
* common case — a pause per iteration inside a modest loop — pays one bounded
232+
* measurement rather than a full structural rebuild each time.
200233
*/
201234
export async function compactPauseSnapshotScopes(context: ExecutionContext): Promise<void> {
202-
if (!context.loopExecutions?.size) return
235+
const loops = context.loopExecutions
236+
const parallels = context.parallelExecutions
237+
if (!loops?.size && !parallels?.size) return
238+
if (!isSubflowStateOversized(loops, parallels)) return
203239

204-
const options = {
240+
const buildOptions = () => ({
205241
workspaceId: context.workspaceId,
206242
workflowId: context.workflowId,
207243
executionId: context.executionId,
@@ -210,14 +246,49 @@ export async function compactPauseSnapshotScopes(context: ExecutionContext): Pro
210246
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
211247
userId: context.userId,
212248
requireDurable: true,
249+
thresholdBytes: PAUSE_SNAPSHOT_COMPACT_VALUE_BYTES,
250+
})
251+
252+
const compactList = async <T>(values: T[]): Promise<T[]> =>
253+
compactSubflowResults(values, buildOptions())
254+
255+
const compactMapValues = async (map: Map<unknown, unknown[]>): Promise<void> => {
256+
for (const [key, value] of map) {
257+
if (Array.isArray(value) && value.length > 0) {
258+
map.set(key, await compactList(value))
259+
}
260+
}
261+
}
262+
263+
for (const scope of loops?.values() ?? []) {
264+
if (scope.allIterationOutputs?.length) {
265+
scope.allIterationOutputs = await compactList(scope.allIterationOutputs)
266+
}
267+
if (scope.items?.length) {
268+
scope.items = await compactList(scope.items)
269+
}
270+
if (scope.currentIterationOutputs instanceof Map && scope.currentIterationOutputs.size > 0) {
271+
for (const [blockId, output] of scope.currentIterationOutputs) {
272+
scope.currentIterationOutputs.set(
273+
blockId,
274+
await compactExecutionPayload(output, { ...buildOptions(), preserveRoot: false })
275+
)
276+
}
277+
}
278+
recordMaterializedAccessKeys(context, scope)
213279
}
214280

215-
for (const scope of context.loopExecutions.values()) {
216-
if (!scope.allIterationOutputs?.length) continue
217-
scope.allIterationOutputs = await compactSubflowResults(scope.allIterationOutputs, options)
218-
// Authorize the refs this pass just minted. Reads are gated on the context's
219-
// key list, so a resumed run cannot materialize them otherwise.
220-
recordMaterializedAccessKeys(context, scope.allIterationOutputs)
281+
for (const scope of parallels?.values() ?? []) {
282+
if (scope.items?.length) {
283+
scope.items = await compactList(scope.items)
284+
}
285+
if (scope.branchOutputs instanceof Map) {
286+
await compactMapValues(scope.branchOutputs)
287+
}
288+
if (scope.accumulatedOutputs instanceof Map) {
289+
await compactMapValues(scope.accumulatedOutputs)
290+
}
291+
recordMaterializedAccessKeys(context, scope)
221292
}
222293
}
223294

@@ -280,6 +351,7 @@ export function serializePauseSnapshot(
280351

281352
assertSnapshotValueIsCompact(context.workflowVariables, 'workflow variables')
282353
assertSnapshotValueIsCompact(state.loopExecutions, 'loop execution state')
354+
assertSnapshotValueIsCompact(state.parallelExecutions, 'parallel execution state')
283355

284356
const workspaceId = metadataFromContext?.workspaceId ?? context.workspaceId
285357
if (!workspaceId) {

0 commit comments

Comments
 (0)