Skip to content

Commit af500aa

Browse files
committed
fix(execution): compact loop state before serializing a pause snapshot
A loop compacts its accumulated iteration outputs when it exits, but a pause is by definition mid-flight and never reaches that point. The running total therefore arrived at the serializer uncompacted and tripped its size assertion, which throws rather than degrades — turning the pause into a failed run, so no paused_executions row was ever written. The approval notification goes out during block execution, well before the engine builds the paused result, so the approver was left holding a working looking resume link pointing at a row that never existed, and the run reported a generic failure with no hint that a byte budget caused it. Run the same compaction the loop performs on exit, and register the keys it mints: reads are gated on the context's key list, so a resumed run could not materialize the offloaded values otherwise. The assertion stays — it is a valid post-condition, and an unstorable snapshot means an unresumable pause.
1 parent 63faeb5 commit af500aa

3 files changed

Lines changed: 118 additions & 4 deletions

File tree

apps/sim/executor/execution/engine.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
import { BlockType, EDGE } from '@/executor/constants'
99
import type { DAG } from '@/executor/dag/builder'
1010
import type { EdgeManager } from '@/executor/execution/edge-manager'
11-
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
11+
import {
12+
compactPauseSnapshotScopes,
13+
serializePauseSnapshot,
14+
} from '@/executor/execution/snapshot-serializer'
1215
import type { SerializableExecutionState } from '@/executor/execution/types'
1316
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
1417
import type {
@@ -127,7 +130,7 @@ export class ExecutionEngine {
127130
}
128131

129132
if (this.pausedBlocks.size > 0) {
130-
return this.buildPausedResult(startTime)
133+
return await this.buildPausedResult(startTime)
131134
}
132135

133136
const endTime = performance.now()
@@ -491,12 +494,13 @@ export class ExecutionEngine {
491494
this.addMultipleToQueue(readyNodes)
492495
}
493496

494-
private buildPausedResult(startTime: number): ExecutionResult {
497+
private async buildPausedResult(startTime: number): Promise<ExecutionResult> {
495498
const endTime = performance.now()
496499
this.context.metadata.endTime = new Date().toISOString()
497500
this.context.metadata.duration = endTime - startTime
498501
this.context.metadata.status = 'paused'
499502

503+
await compactPauseSnapshotScopes(this.context)
500504
const snapshotSeed = serializePauseSnapshot(this.context, [], this.dag, this.edgeManager)
501505
const pausePoints: PausePoint[] = Array.from(this.pausedBlocks.values()).map((pause) => ({
502506
contextId: pause.contextId,

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import { describe, expect, it, vi } from 'vitest'
55
import type { DAG, DAGNode } from '@/executor/dag/builder'
66
import { EdgeManager } from '@/executor/execution/edge-manager'
7-
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
7+
import {
8+
compactPauseSnapshotScopes,
9+
serializePauseSnapshot,
10+
} from '@/executor/execution/snapshot-serializer'
811
import type { ExecutionContext } from '@/executor/types'
912
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
1013

@@ -256,3 +259,71 @@ describe('serializePauseSnapshot', () => {
256259
expect(serialized.metadata.includeToolCalls).toBeUndefined()
257260
})
258261
})
262+
263+
describe('compactPauseSnapshotScopes', () => {
264+
function createLoopContext(iterations: number, bytesPerIteration: number): ExecutionContext {
265+
const payload = 'x'.repeat(bytesPerIteration)
266+
return createContext({
267+
loopExecutions: new Map([
268+
[
269+
'loop-1',
270+
{
271+
loopId: 'loop-1',
272+
iteration: iterations,
273+
maxIterations: iterations,
274+
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 })),
278+
},
279+
],
280+
]),
281+
} as Partial<ExecutionContext>)
282+
}
283+
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+
*/
289+
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)
293+
294+
expect(() => serializePauseSnapshot(context, [], dag, edgeManager)).toThrow(
295+
'oversized loop execution state'
296+
)
297+
298+
await compactPauseSnapshotScopes(context)
299+
300+
const seed = serializePauseSnapshot(context, [], dag, edgeManager)
301+
expect(seed.snapshot).toBeTruthy()
302+
})
303+
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+
*/
308+
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)
312+
313+
await compactPauseSnapshotScopes(context)
314+
const parsed = JSON.parse(serializePauseSnapshot(context, [], dag, edgeManager).snapshot) as {
315+
state?: { trustedLargeValueAccess?: { largeValueKeys?: string[] } }
316+
}
317+
318+
expect(parsed.state?.trustedLargeValueAccess?.largeValueKeys?.length ?? 0).toBeGreaterThan(0)
319+
})
320+
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)
324+
325+
await compactPauseSnapshotScopes(context)
326+
327+
expect(context.loopExecutions?.get('loop-1')?.allIterationOutputs).toEqual(before)
328+
})
329+
})

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
12
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
3+
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
24
import type { DAG } from '@/executor/dag/builder'
35
import type { EdgeManager } from '@/executor/execution/edge-manager'
46
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
@@ -182,6 +184,43 @@ function serializeParallelExecutions(
182184
return result
183185
}
184186

187+
/**
188+
* Offload accumulated loop iteration outputs so a pause snapshot stays compact.
189+
*
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.
196+
*
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.
200+
*/
201+
export async function compactPauseSnapshotScopes(context: ExecutionContext): Promise<void> {
202+
if (!context.loopExecutions?.size) return
203+
204+
const options = {
205+
workspaceId: context.workspaceId,
206+
workflowId: context.workflowId,
207+
executionId: context.executionId,
208+
largeValueExecutionIds: context.largeValueExecutionIds,
209+
largeValueKeys: context.largeValueKeys,
210+
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
211+
userId: context.userId,
212+
requireDurable: true,
213+
}
214+
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)
221+
}
222+
}
223+
185224
export function serializePauseSnapshot(
186225
context: ExecutionContext,
187226
triggerBlockIds: string[],

0 commit comments

Comments
 (0)