mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(terminal): use wall-clock duration for loop iterations with concurrent children (#4443)
This commit is contained in:
+306
@@ -482,6 +482,312 @@ describe('groupEntriesByExecution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('duration computation', () => {
|
||||
/**
|
||||
* Regression guard for the 18m → 20m → 22m bug.
|
||||
*
|
||||
* When a loop iteration contains a parallel block, the iteration's displayed
|
||||
* duration must be wall-clock (max(endedAt) − min(startedAt)), not the sum of
|
||||
* child durationMs. Summing over concurrent parallel branches over-counts time
|
||||
* and causes the displayed iteration duration to climb rapidly as each branch
|
||||
* resolves.
|
||||
*/
|
||||
it('loop iteration with concurrent parallel branches uses wall-clock duration', () => {
|
||||
const branches = 5
|
||||
const branchDurationMs = 110_000
|
||||
const loopIterStartMs = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const loopIterEndMs = loopIterStartMs + branchDurationMs
|
||||
|
||||
const entries: ConsoleEntry[] = []
|
||||
for (let branch = 0; branch < branches; branch++) {
|
||||
entries.push(
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
blockName: 'Function 1',
|
||||
executionOrder: branch + 1,
|
||||
startedAt: new Date(loopIterStartMs).toISOString(),
|
||||
endedAt: new Date(loopIterEndMs).toISOString(),
|
||||
durationMs: branchDurationMs,
|
||||
iterationType: 'parallel',
|
||||
iterationCurrent: branch,
|
||||
iterationTotal: branches,
|
||||
iterationContainerId: 'parallel-1',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 1,
|
||||
iterationContainerId: 'loop-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const loopSubflow = tree.find((n) => n.entry.blockType === 'loop')
|
||||
expect(loopSubflow).toBeDefined()
|
||||
|
||||
const iteration = loopSubflow!.children[0]
|
||||
expect(iteration.nodeType).toBe('iteration')
|
||||
expect(iteration.entry.durationMs).toBe(branchDurationMs)
|
||||
expect(iteration.entry.durationMs).toBeLessThan(branches * branchDurationMs)
|
||||
})
|
||||
|
||||
it('subflow container with concurrent children uses wall-clock duration', () => {
|
||||
const branches = 4
|
||||
const branchDurationMs = 60_000
|
||||
const startMs = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const endMs = startMs + branchDurationMs
|
||||
|
||||
const entries: ConsoleEntry[] = []
|
||||
for (let branch = 0; branch < branches; branch++) {
|
||||
entries.push(
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: branch + 1,
|
||||
startedAt: new Date(startMs).toISOString(),
|
||||
endedAt: new Date(endMs).toISOString(),
|
||||
durationMs: branchDurationMs,
|
||||
iterationType: 'parallel',
|
||||
iterationCurrent: branch,
|
||||
iterationTotal: branches,
|
||||
iterationContainerId: 'parallel-1',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const subflow = tree.find((n) => n.entry.blockType === 'parallel')
|
||||
expect(subflow).toBeDefined()
|
||||
expect(subflow!.entry.durationMs).toBe(branchDurationMs)
|
||||
expect(subflow!.entry.durationMs).toBeLessThan(branches * branchDurationMs)
|
||||
})
|
||||
|
||||
it('sequential loop iteration uses wall-clock duration', () => {
|
||||
const blockStart = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const blockEnd = blockStart + 5_000
|
||||
|
||||
const entries: ConsoleEntry[] = [
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: 1,
|
||||
startedAt: new Date(blockStart).toISOString(),
|
||||
endedAt: new Date(blockEnd).toISOString(),
|
||||
durationMs: 5_000,
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 1,
|
||||
iterationContainerId: 'loop-1',
|
||||
}),
|
||||
]
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const loop = tree.find((n) => n.entry.blockType === 'loop')
|
||||
expect(loop).toBeDefined()
|
||||
expect(loop!.children[0].entry.durationMs).toBe(5_000)
|
||||
})
|
||||
|
||||
it('parallel iteration uses wall-clock duration', () => {
|
||||
const start = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const end = start + 7_500
|
||||
|
||||
const entries: ConsoleEntry[] = [
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: 1,
|
||||
startedAt: new Date(start).toISOString(),
|
||||
endedAt: new Date(end).toISOString(),
|
||||
durationMs: 7_500,
|
||||
iterationType: 'parallel',
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 1,
|
||||
iterationContainerId: 'parallel-1',
|
||||
}),
|
||||
]
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const parallel = tree.find((n) => n.entry.blockType === 'parallel')
|
||||
expect(parallel).toBeDefined()
|
||||
expect(parallel!.children[0].entry.durationMs).toBe(7_500)
|
||||
})
|
||||
|
||||
it('sequential loop with gaps between iterations: each iteration is wall-clock of its own children', () => {
|
||||
const entries: ConsoleEntry[] = []
|
||||
const iterStarts = [0, 10_000, 30_000]
|
||||
const blockDuration = 1_000
|
||||
const base = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
|
||||
for (let i = 0; i < iterStarts.length; i++) {
|
||||
entries.push(
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: i + 1,
|
||||
startedAt: new Date(base + iterStarts[i]).toISOString(),
|
||||
endedAt: new Date(base + iterStarts[i] + blockDuration).toISOString(),
|
||||
durationMs: blockDuration,
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: i,
|
||||
iterationTotal: 3,
|
||||
iterationContainerId: 'loop-1',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const loop = tree.find((n) => n.entry.blockType === 'loop')!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(loop.children[i].entry.durationMs).toBe(blockDuration)
|
||||
}
|
||||
expect(loop.entry.durationMs).toBe(iterStarts[2] + blockDuration - iterStarts[0])
|
||||
})
|
||||
|
||||
it('loop-in-loop: outer iteration duration spans all inner iterations wall-clock', () => {
|
||||
const entries: ConsoleEntry[] = []
|
||||
const base = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const innerDuration = 2_000
|
||||
const innerCount = 3
|
||||
|
||||
for (let inner = 0; inner < innerCount; inner++) {
|
||||
const start = base + inner * innerDuration
|
||||
entries.push(
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: inner + 1,
|
||||
startedAt: new Date(start).toISOString(),
|
||||
endedAt: new Date(start + innerDuration).toISOString(),
|
||||
durationMs: innerDuration,
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: inner,
|
||||
iterationTotal: innerCount,
|
||||
iterationContainerId: 'inner-loop',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 1,
|
||||
iterationContainerId: 'outer-loop',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const outerLoop = tree.find((n) => n.entry.blockType === 'loop')!
|
||||
const outerIter = outerLoop.children[0]
|
||||
expect(outerIter.entry.durationMs).toBe(innerCount * innerDuration)
|
||||
})
|
||||
|
||||
it('loop-in-parallel: each branch duration reflects its own loop wall-clock', () => {
|
||||
const entries: ConsoleEntry[] = []
|
||||
const base = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const innerDuration = 1_500
|
||||
const innerCount = 2
|
||||
const branches = 3
|
||||
|
||||
for (let branch = 0; branch < branches; branch++) {
|
||||
for (let inner = 0; inner < innerCount; inner++) {
|
||||
const start = base + inner * innerDuration
|
||||
entries.push(
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: branch * innerCount + inner + 1,
|
||||
startedAt: new Date(start).toISOString(),
|
||||
endedAt: new Date(start + innerDuration).toISOString(),
|
||||
durationMs: innerDuration,
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: inner,
|
||||
iterationTotal: innerCount,
|
||||
iterationContainerId: 'inner-loop',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationType: 'parallel',
|
||||
iterationCurrent: branch,
|
||||
iterationTotal: branches,
|
||||
iterationContainerId: 'parallel-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const parallelSubflow = tree.find((n) => n.entry.blockType === 'parallel')!
|
||||
expect(parallelSubflow.children).toHaveLength(branches)
|
||||
for (let branch = 0; branch < branches; branch++) {
|
||||
const branchNode = parallelSubflow.children[branch]
|
||||
expect(branchNode.entry.durationMs).toBe(innerCount * innerDuration)
|
||||
}
|
||||
expect(parallelSubflow.entry.durationMs).toBe(innerCount * innerDuration)
|
||||
})
|
||||
|
||||
it('single-block iteration: duration equals the block durationMs', () => {
|
||||
const start = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
const blockDuration = 3_141
|
||||
|
||||
const entries: ConsoleEntry[] = [
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: 1,
|
||||
startedAt: new Date(start).toISOString(),
|
||||
endedAt: new Date(start + blockDuration).toISOString(),
|
||||
durationMs: blockDuration,
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 1,
|
||||
iterationContainerId: 'loop-1',
|
||||
}),
|
||||
]
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const loop = tree.find((n) => n.entry.blockType === 'loop')!
|
||||
expect(loop.children[0].entry.durationMs).toBe(blockDuration)
|
||||
expect(loop.entry.durationMs).toBe(blockDuration)
|
||||
})
|
||||
|
||||
it('does not sum concurrent branch durations into iteration duration', () => {
|
||||
const branches = 20
|
||||
const branchDurationMs = 100_000
|
||||
const start = Date.UTC(2025, 0, 1, 0, 0, 0)
|
||||
|
||||
const entries: ConsoleEntry[] = []
|
||||
for (let branch = 0; branch < branches; branch++) {
|
||||
const branchStart = start + branch * 5
|
||||
entries.push(
|
||||
makeEntry({
|
||||
blockId: 'function-1',
|
||||
executionOrder: branch + 1,
|
||||
startedAt: new Date(branchStart).toISOString(),
|
||||
endedAt: new Date(branchStart + branchDurationMs).toISOString(),
|
||||
durationMs: branchDurationMs,
|
||||
iterationType: 'parallel',
|
||||
iterationCurrent: branch,
|
||||
iterationTotal: branches,
|
||||
iterationContainerId: 'parallel-1',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationType: 'loop',
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 1,
|
||||
iterationContainerId: 'loop-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const tree = buildEntryTree(entries)
|
||||
const loopSubflow = tree.find((n) => n.entry.blockType === 'loop')!
|
||||
const iteration = loopSubflow.children[0]
|
||||
|
||||
const wallClock = branchDurationMs + (branches - 1) * 5
|
||||
expect(iteration.entry.durationMs).toBe(wallClock)
|
||||
expect(iteration.entry.durationMs).toBeLessThan(branches * branchDurationMs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenVisibleExecutionRows', () => {
|
||||
it('only includes children for expanded nodes', () => {
|
||||
const childBlock = makeEntry({
|
||||
|
||||
@@ -393,9 +393,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
|
||||
const subflowEndMs = Math.max(
|
||||
...allRelevantBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
|
||||
)
|
||||
const totalDuration = allRelevantBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
const subflowDuration =
|
||||
iterationType === 'parallel' ? subflowEndMs - subflowStartMs : totalDuration
|
||||
const subflowDuration = subflowEndMs - subflowStartMs
|
||||
|
||||
const subflowExecutionOrder = Math.min(...allRelevantBlocks.map((b) => b.executionOrder))
|
||||
const metadataSource = allRelevantBlocks[0]
|
||||
@@ -449,9 +447,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
|
||||
const iterEndMs = Math.max(
|
||||
...allIterEntries.map((b) => new Date(b.endedAt || b.timestamp).getTime())
|
||||
)
|
||||
const iterDuration = allIterEntries.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
const iterDisplayDuration =
|
||||
iterationType === 'parallel' ? iterEndMs - iterStartMs : iterDuration
|
||||
const iterDisplayDuration = iterEndMs - iterStartMs
|
||||
|
||||
const iterExecutionOrder = Math.min(...allIterEntries.map((b) => b.executionOrder))
|
||||
const iterMetadataSource = allIterEntries[0]
|
||||
|
||||
Reference in New Issue
Block a user