Skip to content

Commit e3671e8

Browse files
committed
fix(execution): stop offloading state the engine consumes structurally
Compacting a loop's `items` was a regression: the orchestrator indexes that collection to derive the current `item`, the resume path rebuilds the scope verbatim without materializing anything, and the loop resolver asserts no refs reach it — so an oversized forEach would have traded a failed pause for a broken resume. `currentIterationOutputs` is excluded for the same reason: the block executor has already compacted its entries, and they resolve through the reference path rather than being read raw. Offloading is now limited to exactly the accumulators the orchestrators themselves compact when a subflow exits. An oversized `items` collection therefore still fails the pause; that is the honest outcome until it can be handled without breaking iteration.
1 parent 1f833ee commit e3671e8

2 files changed

Lines changed: 18 additions & 33 deletions

File tree

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

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -299,24 +299,19 @@ describe('compactPauseSnapshotScopes', () => {
299299
expect(serialize(context).snapshot).toBeTruthy()
300300
})
301301

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 () => {
302+
/**
303+
* `items` is consumed structurally — the orchestrator indexes it to derive
304+
* `item`, and the loop resolver asserts no refs reach it — so it stays inline
305+
* even though that leaves an oversized collection unhandled. Offloading it
306+
* would trade a failed pause for a broken resume.
307+
*/
308+
it('leaves the forEach collection inline rather than breaking iteration', async () => {
304309
const context = loopContext({ items: fatIterations(40, 300_000) })
305310

306-
expect(() => serialize(context)).toThrow('oversized loop execution state')
307311
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-
})
316312

317-
expect(() => serialize(context)).toThrow('oversized loop execution state')
318-
await compactPauseSnapshotScopes(context)
319-
expect(serialize(context).snapshot).toBeTruthy()
313+
const items = context.loopExecutions?.get('loop-1')?.items as unknown[]
314+
expect(JSON.stringify(items)).not.toContain('__simLargeValueRef')
320315
})
321316

322317
/** The assertion is on the whole record, so per-scope headroom is not enough. */

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

Lines changed: 9 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 { compactExecutionPayload, compactSubflowResults } from '@/lib/execution/payloads/serializer'
3+
import { 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'
@@ -222,10 +222,14 @@ function isSubflowStateOversized(loops?: Map<string, any>, parallels?: Map<strin
222222
* gone out by then, leaving the approver holding a link to something that was
223223
* never recorded.
224224
*
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.
225+
* Covers exactly the accumulators the orchestrators themselves compact when a
226+
* subflow exits — a loop's iteration outputs and a parallel's branch and
227+
* accumulated outputs. Deliberately excluded: `items`, which the loop consumes
228+
* structurally (`orchestrators/loop.ts` indexes it to derive `item`, and the
229+
* loop resolver asserts no refs reach it), and `currentIterationOutputs`, whose
230+
* entries the block executor has already compacted and which resolve through
231+
* the reference path that materializes refs. Offloading either would trade this
232+
* failure for a broken resume.
229233
*
230234
* Skipped entirely when the state already serializes small enough, so the
231235
* common case — a pause per iteration inside a modest loop — pays one bounded
@@ -264,24 +268,10 @@ export async function compactPauseSnapshotScopes(context: ExecutionContext): Pro
264268
if (scope.allIterationOutputs?.length) {
265269
scope.allIterationOutputs = await compactList(scope.allIterationOutputs)
266270
}
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-
}
278271
recordMaterializedAccessKeys(context, scope)
279272
}
280273

281274
for (const scope of parallels?.values() ?? []) {
282-
if (scope.items?.length) {
283-
scope.items = await compactList(scope.items)
284-
}
285275
if (scope.branchOutputs instanceof Map) {
286276
await compactMapValues(scope.branchOutputs)
287277
}

0 commit comments

Comments
 (0)