Skip to content

Commit 2e6bcd5

Browse files
committed
fix(execution): offload buffered event values under budget pressure
An execution buffers EVENT_LIMIT events inside a per-execution byte budget, so a full ring only fits if events average under budget/EVENT_LIMIT. Values were only offloaded to object storage at the shared 8 MiB cap, far above that, so a run emitting large block outputs exhausted its budget within a few dozen events and stayed pinned at its ceiling for the rest of its life. Applying that ceiling to every run would be worse than the problem: the SSE stream carries the compacted event and the terminal renders a ref only as a preview, so ordinary block outputs would stop being readable live, and every value would cost an object-storage write on the hot path. Engage the tight ceiling only once a run has actually buffered past half its budget. A short run keeps full-fidelity output and pays nothing; a runaway one stops accumulating. Both bounds derive from the existing budget rather than being asserted, and preserved UserFile base64 is exempt — it is an explicit request for inline delivery, already bounded by its own cap and the strip-and-recompact fallback. Also stop a failed resume-path buffer write from failing the run: it was awaited bare, so the rejection propagated into the executor callback and failed work that had already completed. The buffer only backs reconnect replay, so degrade to live-only delivery the way the execute route does.
1 parent 41592df commit 2e6bcd5

3 files changed

Lines changed: 100 additions & 1 deletion

File tree

apps/sim/lib/execution/event-buffer.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing'
55
import { sleep } from '@sim/utils/helpers'
66
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
77
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
8+
import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref'
89
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
910

1011
const { mockRedis, persistedEntries } = vi.hoisted(() => {
@@ -622,6 +623,54 @@ describe('execution event buffer', () => {
622623
await expect(writer.flush()).resolves.toBeUndefined()
623624
})
624625

626+
/**
627+
* A short run must keep full-fidelity output: the SSE stream carries the
628+
* compacted event, and the terminal renders a ref only as a preview, so
629+
* offloading ordinary block outputs would make them unreadable live.
630+
*/
631+
it('keeps values inline while the execution is below the offload pressure mark', async () => {
632+
mockRedis.incrby.mockResolvedValue(100)
633+
const payload = 'x'.repeat(512 * 1024)
634+
635+
const writer = createExecutionEventWriter('exec-1', {
636+
workspaceId: 'ws-1',
637+
workflowId: 'wf-1',
638+
})
639+
await writer.write(makeEvent(payload))
640+
await writer.flush()
641+
642+
const persisted = JSON.stringify(persistedEntries[0])
643+
expect(persisted).toContain(payload)
644+
expect(persisted).not.toContain(LARGE_VALUE_REF_MARKER)
645+
})
646+
647+
/**
648+
* Once a run has buffered its way into the danger zone the tight ceiling
649+
* engages, so it stops accumulating against its budget instead of pinning
650+
* itself at the ceiling for the rest of its life.
651+
*/
652+
it('offloads values once the execution crosses the offload pressure mark', async () => {
653+
mockRedis.incrby.mockResolvedValue(100000)
654+
const payload = 'x'.repeat(2 * 1024 * 1024)
655+
656+
const writer = createExecutionEventWriter('exec-1', {
657+
workspaceId: 'ws-1',
658+
workflowId: 'wf-1',
659+
})
660+
// Push past half the per-execution budget so the next write is under pressure.
661+
for (let i = 0; i < 17; i++) {
662+
await writer.write(makeEvent(payload))
663+
await writer.flush()
664+
}
665+
persistedEntries.length = 0
666+
await writer.write(makeEvent(payload))
667+
await writer.flush()
668+
669+
const persisted = JSON.stringify(persistedEntries[0])
670+
expect(persisted).toContain(LARGE_VALUE_REF_MARKER)
671+
expect(persisted).not.toContain(payload)
672+
})
673+
625674
it('preserves requested UserFile base64 when buffering terminal events', async () => {
626675
mockRedis.incrby.mockResolvedValue(100)
627676
const base64 = Buffer.from('hello').toString('base64')

apps/sim/lib/execution/event-buffer.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ const FLUSH_INTERVAL_MS = 15
2727
const FLUSH_MAX_RETRY_INTERVAL_MS = 1000
2828
const FLUSH_MAX_BATCH = 200
2929
const MAX_PENDING_EVENTS = 1000
30+
/**
31+
* Bytes a single execution may buffer before its events start offloading
32+
* aggressively, and the per-value threshold applied once it does.
33+
*
34+
* The buffer holds `EVENT_LIMIT` events inside the per-execution byte budget,
35+
* so a full ring only fits if events average under budget/EVENT_LIMIT. Applying
36+
* that ceiling to every run would offload ordinary block outputs into refs the
37+
* terminal cannot display — the SSE stream carries the compacted event, and a
38+
* ref renders only as a preview. Instead the tight ceiling engages only once a
39+
* run has actually buffered its way into the danger zone, so a short run keeps
40+
* full-fidelity output and a runaway one stops accumulating.
41+
*/
42+
const EXECUTION_EVENT_OFFLOAD_PRESSURE_BYTES = getExecutionRedisBudgetLimits().maxExecutionBytes / 2
43+
const EXECUTION_EVENT_PRESSURE_VALUE_BYTES =
44+
getExecutionRedisBudgetLimits().maxExecutionBytes / EVENT_LIMIT
3045
const ACTIVE_META_ATTEMPTS = 3
3146
const FINALIZE_FLUSH_ATTEMPTS = 2
3247
const FLUSH_EVENTS_SCRIPT = `
@@ -282,6 +297,8 @@ export interface ExecutionEventWriter {
282297
export interface ExecutionEventWriterContext extends LargeValueStoreContext {
283298
requireDurablePayloads?: boolean
284299
preserveUserFileBase64?: boolean
300+
/** Offload ceiling for individual values; defaults to the shared large-value cap. */
301+
valueThresholdBytes?: number
285302
}
286303

287304
async function compactEventForBuffer(
@@ -297,6 +314,7 @@ async function compactEventForBuffer(
297314
executionId: context.executionId ?? event.executionId,
298315
requireDurable: context.requireDurablePayloads,
299316
preserveRoot: true,
317+
thresholdBytes: context.valueThresholdBytes,
300318
}
301319

302320
let compactedData = await compactExecutionPayload(event.data, {
@@ -746,6 +764,24 @@ export function createExecutionEventWriter(
746764
let maxReservedId = 0
747765
let flushTimer: ReturnType<typeof setTimeout> | null = null
748766
let consecutiveFlushFailures = 0
767+
/**
768+
* Bytes this execution has successfully buffered. Counted gross rather than
769+
* net of ring-buffer pruning, so it reaches the pressure mark early — erring
770+
* toward offloading sooner is the safe direction.
771+
*/
772+
let bufferedBytes = 0
773+
774+
/**
775+
* Preserved base64 is an explicit request for inline delivery and is already
776+
* bounded by its own cap and the strip-and-recompact fallback, so pressure
777+
* never rewrites it into a ref the caller cannot read.
778+
*/
779+
const getValueThresholdBytes = () => {
780+
if (context.preserveUserFileBase64) return undefined
781+
return bufferedBytes >= EXECUTION_EVENT_OFFLOAD_PRESSURE_BYTES
782+
? EXECUTION_EVENT_PRESSURE_VALUE_BYTES
783+
: undefined
784+
}
749785

750786
const getFlushDelayMs = () => {
751787
if (consecutiveFlushFailures === 0) return FLUSH_INTERVAL_MS
@@ -911,6 +947,7 @@ export function createExecutionEventWriter(
911947
}
912948
consecutiveFlushFailures = 0
913949
lastResourceLimitError = null
950+
bufferedBytes += batchBytes
914951
if (chunkTerminalStatus) pendingTerminalStatus = undefined
915952
return true
916953
} catch (error) {
@@ -992,6 +1029,7 @@ export function createExecutionEventWriter(
9921029
...context,
9931030
executionId,
9941031
requireDurablePayloads: true,
1032+
valueThresholdBytes: getValueThresholdBytes(),
9951033
})
9961034
const entry: ExecutionEventEntry = { eventId, executionId, event: compactEvent }
9971035
pending.push(entry)
@@ -1038,6 +1076,7 @@ export function createExecutionEventWriter(
10381076
...context,
10391077
executionId,
10401078
requireDurablePayloads: true,
1079+
valueThresholdBytes: getValueThresholdBytes(),
10411080
})
10421081
const entry: ExecutionEventEntry = { eventId, executionId, event: compactEvent }
10431082
pending.push(entry)

apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1323,7 +1323,18 @@ export class PauseResumeManager {
13231323
await degradeTerminalPublish(terminalStatus, error)
13241324
return { eventId: 0, executionId: resumeExecutionId, event }
13251325
})
1326-
: await eventWriter.write(event)
1326+
: await eventWriter.write(event).catch((error) => {
1327+
// The buffer only backs reconnect replay; the live stream is the
1328+
// primary delivery path. Awaiting this bare let a failed write
1329+
// propagate into the executor callback and fail work that had
1330+
// already run, so degrade the same way the execute route does.
1331+
logger.warn('Resume event buffer write failed; delivering live only', {
1332+
resumeExecutionId,
1333+
eventType: event.type,
1334+
error: toError(error).message,
1335+
})
1336+
return { eventId: 0, executionId: resumeExecutionId, event }
1337+
})
13271338
event.eventId = entry.eventId
13281339
terminalEventPublished ||= Boolean(terminalStatus)
13291340
}

0 commit comments

Comments
 (0)