Skip to content

Commit 4ffec75

Browse files
committed
fix(logs): split child spans off block output so oversized state still spills
1 parent 9ba2a17 commit 4ffec75

3 files changed

Lines changed: 133 additions & 51 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -379,14 +379,15 @@ export class BlockExecutor {
379379
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
380380
}
381381

382-
normalizedOutput = (await compactBlockOutput(normalizedOutput, {
382+
const compacted = await compactBlockOutput(normalizedOutput, {
383383
workspaceId: blockCtx.workspaceId,
384384
workflowId: blockCtx.workflowId,
385385
executionId: blockCtx.executionId,
386386
userId: blockCtx.userId,
387387
preserveUserFileBase64: blockCtx.includeFileBase64 === true,
388388
requireDurable: true,
389-
})) as NormalizedBlockOutput
389+
})
390+
normalizedOutput = compacted.output
390391

391392
const endedAt = new Date().toISOString()
392393
const duration = performance.now() - startTime
@@ -396,8 +397,8 @@ export class BlockExecutor {
396397
blockLog.durationMs = duration
397398
blockLog.success = true
398399
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
399-
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
400-
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
400+
if (Array.isArray(compacted.childTraceSpans)) {
401+
blockLog.childTraceSpans = compacted.childTraceSpans
401402
}
402403
const childExecutionId = normalizedOutput[CHILD_EXECUTION_ID_OUTPUT_KEY]
403404
if (typeof childExecutionId === 'string' && childExecutionId) {
@@ -409,7 +410,6 @@ export class BlockExecutor {
409410
}
410411

411412
const {
412-
childTraceSpans: _traces,
413413
[CHILD_EXECUTION_ID_OUTPUT_KEY]: _childExecutionId,
414414
[CHILD_TRACE_DISABLED_OUTPUT_KEY]: _childTraceDisabled,
415415
...outputForState

‎apps/sim/lib/execution/payloads/serializer.test.ts‎

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -360,12 +360,23 @@ describe('compacting span trees', () => {
360360
mockRegisterLargeValueOwner.mockResolvedValue(true)
361361
})
362362

363-
it('keeps a block output child span tree shaped as a tree', async () => {
363+
const childWorkflowLog = (overrides: Partial<BlockLog>): BlockLog => ({
364+
blockId: 'child-workflow',
365+
blockType: 'workflow',
366+
startedAt: '2026-09-29T00:00:00.000Z',
367+
endedAt: '2026-09-29T00:00:00.002Z',
368+
durationMs: 2,
369+
success: true,
370+
...overrides,
371+
})
372+
373+
it('splits a block output child span tree off shaped as a tree', async () => {
364374
const compacted = await compactBlockOutput(
365375
{ result: 'done', childTraceSpans: childWorkflowSpans() },
366376
options
367377
)
368378

379+
expect(compacted.output).toEqual({ result: 'done' })
369380
expectSpanTree(compacted.childTraceSpans)
370381
})
371382

@@ -380,34 +391,46 @@ describe('compacting span trees', () => {
380391
expect(isLargeValueRef(spilled?.output?.result)).toBe(true)
381392
})
382393

383-
it('keeps block log child span trees shaped as trees', async () => {
384-
const log = {
385-
blockId: 'child-workflow',
386-
blockType: 'workflow',
387-
startedAt: '2026-09-29T00:00:00.000Z',
388-
endedAt: '2026-09-29T00:00:00.002Z',
389-
durationMs: 2,
390-
success: true,
391-
childTraceSpans: childWorkflowSpans(),
392-
} as BlockLog
393-
394-
const [compacted] = (await compactBlockLogs([log], options)) ?? []
395-
396-
expectSpanTree(compacted?.childTraceSpans)
397-
})
398-
399-
it('keeps the output a record when its other fields together exceed the threshold', async () => {
394+
it('still spills a block output whose fields together exceed the threshold', async () => {
400395
const compacted = await compactBlockOutput(
401396
{ first: 'a'.repeat(600), second: 'b'.repeat(600), childTraceSpans: childWorkflowSpans() },
402397
options
403398
)
404399

405-
expect(isLargeValueRef(compacted)).toBe(false)
406-
expect(compacted.first).toBe('a'.repeat(600))
407-
expect(compacted.second).toBe('b'.repeat(600))
400+
expect(isLargeValueRef(compacted.output)).toBe(true)
408401
expectSpanTree(compacted.childTraceSpans)
409402
})
410403

404+
it('keeps block log child span trees shaped as trees', async () => {
405+
const [compacted] =
406+
(await compactBlockLogs(
407+
[childWorkflowLog({ childTraceSpans: childWorkflowSpans() })],
408+
options
409+
)) ?? []
410+
411+
expectSpanTree(compacted?.childTraceSpans)
412+
})
413+
414+
it('keeps a block log output carrying child spans a record', async () => {
415+
const [compacted] =
416+
(await compactBlockLogs(
417+
[
418+
childWorkflowLog({
419+
output: {
420+
first: 'a'.repeat(600),
421+
second: 'b'.repeat(600),
422+
childTraceSpans: childWorkflowSpans(),
423+
},
424+
}),
425+
],
426+
options
427+
)) ?? []
428+
429+
expect(isLargeValueRef(compacted?.output)).toBe(false)
430+
expect(compacted?.output?.first).toBe('a'.repeat(600))
431+
expectSpanTree(compacted?.output?.childTraceSpans)
432+
})
433+
411434
it('keeps a nested child workflow span tree shaped as a tree', async () => {
412435
const nestedWorkflowSpan: TraceSpan = {
413436
id: 'nested-workflow',
@@ -416,16 +439,33 @@ describe('compacting span trees', () => {
416439
duration: 2,
417440
startTime: '2026-09-29T00:00:00.000Z',
418441
endTime: '2026-09-29T00:00:00.002Z',
419-
output: { result: 'done', childTraceSpans: childWorkflowSpans() },
442+
output: {
443+
first: 'a'.repeat(600),
444+
second: 'b'.repeat(600),
445+
childTraceSpans: childWorkflowSpans(),
446+
},
420447
}
421448

422-
const compacted = await compactBlockOutput(
423-
{ result: 'done', childTraceSpans: [nestedWorkflowSpan] },
424-
options
425-
)
449+
const compacted = await compactBlockOutput({ childTraceSpans: [nestedWorkflowSpan] }, options)
426450

427451
const [nested] = compacted.childTraceSpans as TraceSpan[]
428-
expect(nested.output?.result).toBe('done')
452+
expect(nested.output?.first).toBe('a'.repeat(600))
429453
expectSpanTree(nested.output?.childTraceSpans)
430454
})
455+
456+
it('terminates on a cyclic span tree', async () => {
457+
const span: TraceSpan = {
458+
id: 'cyclic',
459+
name: 'Cyclic',
460+
type: 'function',
461+
duration: 1,
462+
startTime: '2026-09-29T00:00:00.000Z',
463+
endTime: '2026-09-29T00:00:00.001Z',
464+
}
465+
span.children = [span]
466+
467+
const compacted = await compactBlockOutput({ childTraceSpans: [span] }, options)
468+
469+
expect((compacted.childTraceSpans as TraceSpan[])[0].id).toBe('cyclic')
470+
})
431471
})

‎apps/sim/lib/execution/payloads/serializer.ts‎

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -258,59 +258,96 @@ export async function compactSubflowResults<T>(
258258
/**
259259
* Compacts a trace span tree without collapsing its structure. Readers walk
260260
* `children` and `output.childTraceSpans` as arrays, so those stay arrays and
261-
* only each span's payload fields are spilled when oversized. Size of the tree
262-
* as a whole is bounded where the log is stored, not here.
261+
* only each span's payload fields are spilled when oversized. Spans are log
262+
* data: the tree as a whole is bounded where the log is stored, not here.
263263
*/
264264
async function compactTraceSpanTree(
265265
spans: unknown,
266-
options: CompactExecutionPayloadOptions
266+
options: CompactExecutionPayloadOptions,
267+
seen: WeakSet<object>
267268
): Promise<unknown> {
268269
if (!Array.isArray(spans)) {
269270
return compactExecutionPayload(spans, options)
270271
}
271-
return Promise.all(spans.map((span) => compactTraceSpan(span, options)))
272+
return Promise.all(spans.map((span) => compactTraceSpan(span, options, seen)))
272273
}
273274

274275
async function compactTraceSpan(
275276
span: unknown,
276-
options: CompactExecutionPayloadOptions
277+
options: CompactExecutionPayloadOptions,
278+
seen: WeakSet<object>
277279
): Promise<unknown> {
278280
if (!isRecordLike(span)) {
279281
return compactExecutionPayload(span, options)
280282
}
283+
if (seen.has(span)) {
284+
return span
285+
}
286+
seen.add(span)
281287
return Object.fromEntries(
282288
await Promise.all(
283289
Object.entries(span).map(async ([key, value]) => [
284290
key,
285291
key === 'children'
286-
? await compactTraceSpanTree(value, options)
292+
? await compactTraceSpanTree(value, options, seen)
287293
: key === 'output'
288-
? await compactBlockOutput(value, options)
294+
? await compactLoggedOutput(value, options, seen)
289295
: await compactExecutionPayload(value, options),
290296
])
291297
)
292298
)
293299
}
294300

295301
/**
296-
* Compacts a block output, keeping any `childTraceSpans` shaped as a span tree
297-
* (see {@link compactTraceSpanTree}). Everything else compacts as a normal
298-
* execution payload, except that an output carrying spans keeps its root so
299-
* the spans stay attached to it; its fields still spill individually.
302+
* Compacts a span or block log output. One carrying `childTraceSpans` keeps its
303+
* root so the spans stay attached; its other fields spill individually.
304+
*/
305+
async function compactLoggedOutput(
306+
output: unknown,
307+
options: CompactExecutionPayloadOptions,
308+
seen: WeakSet<object>
309+
): Promise<unknown> {
310+
if (!isRecordLike(output) || !('childTraceSpans' in output)) {
311+
return compactExecutionPayload(output, options)
312+
}
313+
return Object.fromEntries(
314+
await Promise.all(
315+
Object.entries(output).map(async ([key, value]) => [
316+
key,
317+
key === 'childTraceSpans'
318+
? await compactTraceSpanTree(value, options, seen)
319+
: await compactExecutionPayload(value, options),
320+
])
321+
)
322+
)
323+
}
324+
325+
export interface CompactedBlockOutput<T> {
326+
/** The output without `childTraceSpans`, compacted as execution state. */
327+
output: T
328+
/** The output's child span tree, compacted as log data. */
329+
childTraceSpans?: unknown
330+
}
331+
332+
/**
333+
* Compacts a block output for execution state and splits off its
334+
* `childTraceSpans`, which belong to the block log rather than state. The
335+
* output compacts as any execution payload, so an oversized one still spills
336+
* whole; the spans compact as a tree (see {@link compactTraceSpanTree}).
300337
*/
301338
export async function compactBlockOutput<T>(
302339
output: T,
303340
options: CompactExecutionPayloadOptions = {}
304-
): Promise<T> {
341+
): Promise<CompactedBlockOutput<T>> {
305342
if (!isRecordLike(output) || !('childTraceSpans' in output)) {
306-
return compactExecutionPayload(output, options)
343+
return { output: await compactExecutionPayload(output, options) }
307344
}
308345
const { childTraceSpans, ...rest } = output
309-
const [compactedRest, compactedSpans] = await Promise.all([
310-
compactExecutionPayload(rest, { ...options, preserveRoot: true }),
311-
compactTraceSpanTree(childTraceSpans, options),
346+
const [compactedOutput, compactedSpans] = await Promise.all([
347+
compactExecutionPayload(rest, options),
348+
compactTraceSpanTree(childTraceSpans, options, new WeakSet<object>()),
312349
])
313-
return { ...compactedRest, childTraceSpans: compactedSpans } as T
350+
return { output: compactedOutput as T, childTraceSpans: compactedSpans }
314351
}
315352

316353
export async function compactBlockLogs(
@@ -335,12 +372,17 @@ export async function compactBlockLogs(
335372
compactedLog.input = await compactExecutionPayload(compactedLog.input, options)
336373
}
337374
if ('output' in compactedLog) {
338-
compactedLog.output = await compactBlockOutput(compactedLog.output, options)
375+
compactedLog.output = (await compactLoggedOutput(
376+
compactedLog.output,
377+
options,
378+
new WeakSet<object>()
379+
)) as BlockLog['output']
339380
}
340381
if ('childTraceSpans' in compactedLog) {
341382
compactedLog.childTraceSpans = (await compactTraceSpanTree(
342383
compactedLog.childTraceSpans,
343-
options
384+
options,
385+
new WeakSet<object>()
344386
)) as BlockLog['childTraceSpans']
345387
}
346388
compactedLogs[index] = compactedLog

0 commit comments

Comments
 (0)