Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/sim/executor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ export const DEFAULTS = {
MAX_FOREACH_ITEMS: 1000,
MAX_PARALLEL_BRANCHES: 20,
MAX_NESTING_DEPTH: 10,
/**
* Maximum number of past iteration outputs retained in allIterationOutputs.
* Older entries are discarded (sliding window) to bound memory during long loops.
*/
MAX_LOOP_ITERATION_HISTORY: 100,
/**
* Maximum number of block logs retained during execution.
* When exceeded, the oldest logs are dropped to prevent OOM in long-running loops.
*/
MAX_BLOCK_LOGS: 10_000,
/** Maximum child workflow depth for propagating SSE callbacks (block:started, block:completed). */
MAX_SSE_CHILD_DEPTH: 3,
EXECUTION_TIME: 0,
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/executor/execution/block-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ export class BlockExecutor {
if (!isSentinel) {
blockLog = this.createBlockLog(ctx, node.id, block, node)
ctx.blockLogs.push(blockLog)

// Prune oldest block logs to bound memory in long-running loops (fixes #2525)
if (ctx.blockLogs.length > DEFAULTS.MAX_BLOCK_LOGS) {
const excess = ctx.blockLogs.length - DEFAULTS.MAX_BLOCK_LOGS
ctx.blockLogs.splice(0, excess)
}

this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
}

Expand Down Expand Up @@ -679,6 +686,7 @@ export class BlockExecutor {
}

const fullContent = chunks.join('')
chunks.length = 0 // Release chunk references to allow GC (fixes #2525)
if (!fullContent) {
return
}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/executor/execution/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface LoopScope {
skipFirstConditionCheck?: boolean
/** Error message if loop validation failed (e.g., exceeded max iterations) */
validationError?: string
/** Total number of iterations completed (may exceed allIterationOutputs.length when truncated) */
totalIterationCount?: number
}

export interface ParallelScope {
Expand Down
122 changes: 122 additions & 0 deletions apps/sim/executor/orchestrators/loop-memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
import { DEFAULTS } from '@/executor/constants'
import type { LoopScope } from '@/executor/execution/state'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'

vi.mock('@sim/logger', () => loggerMock)

/**
* Tests for memory bounds in loop execution (issue #2525).
*
* When loops run with many iterations (especially with agent blocks making
* tool calls), allIterationOutputs and blockLogs can grow unbounded,
* causing OOM on systems with limited memory.
*/

function createMinimalContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'test-workflow',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
completedLoops: new Set(),
activeExecutionPath: new Set(),
...overrides,
}
}

describe('Loop memory bounds', () => {
describe('allIterationOutputs sliding window', () => {
it('should keep at most MAX_LOOP_ITERATION_HISTORY entries', () => {
const scope: LoopScope = {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
}

const limit = DEFAULTS.MAX_LOOP_ITERATION_HISTORY

// Simulate more iterations than the limit
for (let i = 0; i < limit + 50; i++) {
const output: NormalizedBlockOutput = { content: `iteration-${i}` }
const iterationResults = [output]
scope.allIterationOutputs.push(iterationResults)

// Apply the same sliding window logic as loop.ts
if (scope.allIterationOutputs.length > limit) {
const excess = scope.allIterationOutputs.length - limit
scope.allIterationOutputs.splice(0, excess)
}
}

expect(scope.allIterationOutputs.length).toBe(limit)
// The oldest retained entry should be from iteration 50
expect(scope.allIterationOutputs[0][0].content).toBe('iteration-50')
// The newest entry should be the last one pushed
expect(scope.allIterationOutputs[limit - 1][0].content).toBe(
`iteration-${limit + 49}`
)
})

it('should not prune when under the limit', () => {
const scope: LoopScope = {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
}

for (let i = 0; i < 10; i++) {
scope.allIterationOutputs.push([{ content: `iter-${i}` }])
}

expect(scope.allIterationOutputs.length).toBe(10)
expect(scope.allIterationOutputs[0][0].content).toBe('iter-0')
})
})

describe('blockLogs pruning', () => {
it('should keep at most MAX_BLOCK_LOGS entries', () => {
const ctx = createMinimalContext()
const limit = DEFAULTS.MAX_BLOCK_LOGS

// Simulate pushing more logs than the limit
for (let i = 0; i < limit + 100; i++) {
ctx.blockLogs.push({
blockId: `block-${i}`,
blockType: 'function',
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
executionOrder: i + 1,
})

// Apply the same pruning logic as block-executor.ts
if (ctx.blockLogs.length > limit) {
const excess = ctx.blockLogs.length - limit
ctx.blockLogs.splice(0, excess)
}
}

expect(ctx.blockLogs.length).toBe(limit)
// The oldest retained log should be from index 100
expect(ctx.blockLogs[0].blockId).toBe('block-100')
})
})

describe('DEFAULTS constants', () => {
it('should define MAX_LOOP_ITERATION_HISTORY', () => {
expect(DEFAULTS.MAX_LOOP_ITERATION_HISTORY).toBeGreaterThan(0)
expect(typeof DEFAULTS.MAX_LOOP_ITERATION_HISTORY).toBe('number')
})

it('should define MAX_BLOCK_LOGS', () => {
expect(DEFAULTS.MAX_BLOCK_LOGS).toBeGreaterThan(0)
expect(typeof DEFAULTS.MAX_BLOCK_LOGS).toBe('number')
})
})
})
20 changes: 19 additions & 1 deletion apps/sim/executor/orchestrators/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,19 @@ export class LoopOrchestrator {

if (iterationResults.length > 0) {
scope.allIterationOutputs.push(iterationResults)
scope.totalIterationCount = (scope.totalIterationCount ?? 0) + 1

// Sliding window: discard oldest iteration outputs to bound memory (fixes #2525)
if (scope.allIterationOutputs.length > DEFAULTS.MAX_LOOP_ITERATION_HISTORY) {
const excess = scope.allIterationOutputs.length - DEFAULTS.MAX_LOOP_ITERATION_HISTORY
scope.allIterationOutputs.splice(0, excess)
logger.warn('Loop iteration history truncated to prevent OOM', {
loopId,
totalIterations: scope.totalIterationCount,
retained: DEFAULTS.MAX_LOOP_ITERATION_HISTORY,
discarded: scope.totalIterationCount - DEFAULTS.MAX_LOOP_ITERATION_HISTORY,
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iteration counter only tracks output-producing iterations

Low Severity

totalIterationCount is only incremented inside the if (iterationResults.length > 0) block, so iterations where currentIterationOutputs is empty (e.g., all blocks skipped or errored) are not counted. However, scope.iteration is incremented unconditionally later. This means totalIterationCount can undercount actual iterations, contradicting its documented purpose of tracking "total number of iterations completed." The discarded field in the log message and the truncated flag in the output could also be inaccurate as a result.

Additional Locations (1)

Fix in Cursor Fix in Web

}

scope.currentIterationOutputs.clear()
Expand Down Expand Up @@ -275,7 +288,12 @@ export class LoopOrchestrator {
scope: LoopScope
): LoopContinuationResult {
const results = scope.allIterationOutputs
const output = { results }
const totalIterations = scope.totalIterationCount ?? results.length
const output = {
results,
totalIterations,
...(totalIterations > results.length && { truncated: true }),
}
this.state.setBlockOutput(loopId, output, DEFAULTS.EXECUTION_TIME)

if (this.contextExtensions?.onBlockComplete) {
Expand Down