Skip to content

Commit 1365a5f

Browse files
MaxwellCalkinclaude
andcommitted
fix: bound memory growth in loop executions to prevent OOM (#2525)
When workflows with loops containing agent blocks run many iterations, three memory accumulation points cause unbounded growth leading to OOM: 1. allIterationOutputs grows without limit — every iteration's block outputs are retained forever. Fix: sliding window keeps only the last MAX_LOOP_ITERATION_HISTORY (100) iterations. 2. blockLogs accumulates every block execution with full input/output. With 1000 iterations × 5 blocks = 5000+ logs at 50KB+ each. Fix: prune oldest logs when exceeding MAX_BLOCK_LOGS (10,000). 3. Stream chunk buffers are never released after joining. Fix: clear the chunks array immediately after concatenation to allow GC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8c0a2e0 commit 1365a5f

File tree

4 files changed

+146
-0
lines changed

4 files changed

+146
-0
lines changed

apps/sim/executor/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ export const DEFAULTS = {
159159
MAX_FOREACH_ITEMS: 1000,
160160
MAX_PARALLEL_BRANCHES: 20,
161161
MAX_NESTING_DEPTH: 10,
162+
/**
163+
* Maximum number of past iteration outputs retained in allIterationOutputs.
164+
* Older entries are discarded (sliding window) to bound memory during long loops.
165+
*/
166+
MAX_LOOP_ITERATION_HISTORY: 100,
167+
/**
168+
* Maximum number of block logs retained during execution.
169+
* When exceeded, the oldest logs are dropped to prevent OOM in long-running loops.
170+
*/
171+
MAX_BLOCK_LOGS: 10_000,
162172
/** Maximum child workflow depth for propagating SSE callbacks (block:started, block:completed). */
163173
MAX_SSE_CHILD_DEPTH: 3,
164174
EXECUTION_TIME: 0,

apps/sim/executor/execution/block-executor.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ export class BlockExecutor {
7777
if (!isSentinel) {
7878
blockLog = this.createBlockLog(ctx, node.id, block, node)
7979
ctx.blockLogs.push(blockLog)
80+
81+
// Prune oldest block logs to bound memory in long-running loops (fixes #2525)
82+
if (ctx.blockLogs.length > DEFAULTS.MAX_BLOCK_LOGS) {
83+
const excess = ctx.blockLogs.length - DEFAULTS.MAX_BLOCK_LOGS
84+
ctx.blockLogs.splice(0, excess)
85+
}
86+
8087
this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
8188
}
8289

@@ -679,6 +686,7 @@ export class BlockExecutor {
679686
}
680687

681688
const fullContent = chunks.join('')
689+
chunks.length = 0 // Release chunk references to allow GC (fixes #2525)
682690
if (!fullContent) {
683691
return
684692
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { loggerMock } from '@sim/testing'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { DEFAULTS } from '@/executor/constants'
4+
import type { LoopScope } from '@/executor/execution/state'
5+
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
6+
7+
vi.mock('@sim/logger', () => loggerMock)
8+
9+
/**
10+
* Tests for memory bounds in loop execution (issue #2525).
11+
*
12+
* When loops run with many iterations (especially with agent blocks making
13+
* tool calls), allIterationOutputs and blockLogs can grow unbounded,
14+
* causing OOM on systems with limited memory.
15+
*/
16+
17+
function createMinimalContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
18+
return {
19+
workflowId: 'test-workflow',
20+
blockStates: new Map(),
21+
executedBlocks: new Set(),
22+
blockLogs: [],
23+
metadata: { duration: 0 },
24+
environmentVariables: {},
25+
decisions: { router: new Map(), condition: new Map() },
26+
completedLoops: new Set(),
27+
activeExecutionPath: new Set(),
28+
...overrides,
29+
}
30+
}
31+
32+
describe('Loop memory bounds', () => {
33+
describe('allIterationOutputs sliding window', () => {
34+
it('should keep at most MAX_LOOP_ITERATION_HISTORY entries', () => {
35+
const scope: LoopScope = {
36+
iteration: 0,
37+
currentIterationOutputs: new Map(),
38+
allIterationOutputs: [],
39+
}
40+
41+
const limit = DEFAULTS.MAX_LOOP_ITERATION_HISTORY
42+
43+
// Simulate more iterations than the limit
44+
for (let i = 0; i < limit + 50; i++) {
45+
const output: NormalizedBlockOutput = { content: `iteration-${i}` }
46+
const iterationResults = [output]
47+
scope.allIterationOutputs.push(iterationResults)
48+
49+
// Apply the same sliding window logic as loop.ts
50+
if (scope.allIterationOutputs.length > limit) {
51+
const excess = scope.allIterationOutputs.length - limit
52+
scope.allIterationOutputs.splice(0, excess)
53+
}
54+
}
55+
56+
expect(scope.allIterationOutputs.length).toBe(limit)
57+
// The oldest retained entry should be from iteration 50
58+
expect(scope.allIterationOutputs[0][0].content).toBe('iteration-50')
59+
// The newest entry should be the last one pushed
60+
expect(scope.allIterationOutputs[limit - 1][0].content).toBe(
61+
`iteration-${limit + 49}`
62+
)
63+
})
64+
65+
it('should not prune when under the limit', () => {
66+
const scope: LoopScope = {
67+
iteration: 0,
68+
currentIterationOutputs: new Map(),
69+
allIterationOutputs: [],
70+
}
71+
72+
for (let i = 0; i < 10; i++) {
73+
scope.allIterationOutputs.push([{ content: `iter-${i}` }])
74+
}
75+
76+
expect(scope.allIterationOutputs.length).toBe(10)
77+
expect(scope.allIterationOutputs[0][0].content).toBe('iter-0')
78+
})
79+
})
80+
81+
describe('blockLogs pruning', () => {
82+
it('should keep at most MAX_BLOCK_LOGS entries', () => {
83+
const ctx = createMinimalContext()
84+
const limit = DEFAULTS.MAX_BLOCK_LOGS
85+
86+
// Simulate pushing more logs than the limit
87+
for (let i = 0; i < limit + 100; i++) {
88+
ctx.blockLogs.push({
89+
blockId: `block-${i}`,
90+
blockType: 'function',
91+
startedAt: new Date().toISOString(),
92+
endedAt: new Date().toISOString(),
93+
durationMs: 1,
94+
success: true,
95+
executionOrder: i + 1,
96+
})
97+
98+
// Apply the same pruning logic as block-executor.ts
99+
if (ctx.blockLogs.length > limit) {
100+
const excess = ctx.blockLogs.length - limit
101+
ctx.blockLogs.splice(0, excess)
102+
}
103+
}
104+
105+
expect(ctx.blockLogs.length).toBe(limit)
106+
// The oldest retained log should be from index 100
107+
expect(ctx.blockLogs[0].blockId).toBe('block-100')
108+
})
109+
})
110+
111+
describe('DEFAULTS constants', () => {
112+
it('should define MAX_LOOP_ITERATION_HISTORY', () => {
113+
expect(DEFAULTS.MAX_LOOP_ITERATION_HISTORY).toBeGreaterThan(0)
114+
expect(typeof DEFAULTS.MAX_LOOP_ITERATION_HISTORY).toBe('number')
115+
})
116+
117+
it('should define MAX_BLOCK_LOGS', () => {
118+
expect(DEFAULTS.MAX_BLOCK_LOGS).toBeGreaterThan(0)
119+
expect(typeof DEFAULTS.MAX_BLOCK_LOGS).toBe('number')
120+
})
121+
})
122+
})

apps/sim/executor/orchestrators/loop.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ export class LoopOrchestrator {
248248

249249
if (iterationResults.length > 0) {
250250
scope.allIterationOutputs.push(iterationResults)
251+
252+
// Sliding window: discard oldest iteration outputs to bound memory (fixes #2525)
253+
if (scope.allIterationOutputs.length > DEFAULTS.MAX_LOOP_ITERATION_HISTORY) {
254+
const excess = scope.allIterationOutputs.length - DEFAULTS.MAX_LOOP_ITERATION_HISTORY
255+
scope.allIterationOutputs.splice(0, excess)
256+
}
251257
}
252258

253259
scope.currentIterationOutputs.clear()

0 commit comments

Comments
 (0)