Skip to content

Commit b345750

Browse files
fix(custom-blocks): track the whole child run, not just its finalization
1 parent 264bc74 commit b345750

5 files changed

Lines changed: 149 additions & 92 deletions

File tree

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const {
1818
mockGetCustomBlockAuthority,
1919
mockGetUserEmailById,
2020
mockAdmitCustomBlockChildExecution,
21-
mockTrackChildFinalization,
21+
mockTrackChildRun,
2222
mockBuildTraceSpans,
2323
mockSafeStart,
2424
mockSafeComplete,
@@ -34,7 +34,7 @@ const {
3434
mockGetCustomBlockAuthority: vi.fn(),
3535
mockGetUserEmailById: vi.fn(),
3636
mockAdmitCustomBlockChildExecution: vi.fn(),
37-
mockTrackChildFinalization: vi.fn(),
37+
mockTrackChildRun: vi.fn(),
3838
mockBuildTraceSpans: vi.fn(),
3939
mockSafeStart: vi.fn(),
4040
mockSafeComplete: vi.fn(),
@@ -65,7 +65,7 @@ vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
6565

6666
vi.mock('@/lib/workflows/custom-blocks/child-execution', () => ({
6767
admitCustomBlockChildExecution: mockAdmitCustomBlockChildExecution,
68-
trackChildFinalization: mockTrackChildFinalization,
68+
trackChildRun: mockTrackChildRun,
6969
buildCustomBlockCorrelation: (params: Record<string, any>) =>
7070
params.invokerExecutionId
7171
? { source: 'custom_block', executionId: params.invokerExecutionId }
@@ -1250,22 +1250,32 @@ describe('WorkflowBlockHandler', () => {
12501250
expect(error.cause).toBeUndefined()
12511251
})
12521252

1253-
it('registers the finalization so a cancelled parent can still drain it', async () => {
1253+
it('registers the child run BEFORE executing it, and settles it when done', async () => {
1254+
// Ordering is the whole point: a cancelled parent drains while the child is
1255+
// still inside `execute`, so registering after it would find nothing.
1256+
let registeredBeforeExecute = false
1257+
mockExecutorExecute.mockImplementation(async () => {
1258+
registeredBeforeExecute = mockTrackChildRun.mock.calls.length === 1
1259+
return { success: true, output: { data: 'ok' } }
1260+
})
1261+
12541262
await handler.execute(customBlockContext(), customBlock(), {})
12551263

1256-
expect(mockTrackChildFinalization).toHaveBeenCalledTimes(1)
1257-
const [invokerId, promise] = mockTrackChildFinalization.mock.calls[0]
1264+
expect(registeredBeforeExecute).toBe(true)
1265+
const [invokerId, childRun] = mockTrackChildRun.mock.calls[0]
12581266
expect(invokerId).toBe('parent-execution-id')
1259-
expect(promise).toBeInstanceOf(Promise)
1267+
// Settled in `finally`, so the invoking run's drain can complete.
1268+
await expect(childRun).resolves.toBeUndefined()
12601269
})
12611270

1262-
it('registers the finalization on the failure path too', async () => {
1271+
it('settles the registered child run on the failure path too', async () => {
12631272
mockExecutorExecute.mockRejectedValue(new Error('boom'))
12641273

12651274
await handler.execute(customBlockContext(), customBlock(), {}).catch(() => {})
12661275

1267-
expect(mockTrackChildFinalization).toHaveBeenCalledTimes(1)
1268-
expect(mockTrackChildFinalization.mock.calls[0][0]).toBe('parent-execution-id')
1276+
expect(mockTrackChildRun).toHaveBeenCalledTimes(1)
1277+
expect(mockTrackChildRun.mock.calls[0][0]).toBe('parent-execution-id')
1278+
await expect(mockTrackChildRun.mock.calls[0][1]).resolves.toBeUndefined()
12691279
})
12701280

12711281
it('never leaks the source workflow name when the child returns success: false', async () => {

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
admitCustomBlockChildExecution,
1515
buildCustomBlockCorrelation,
1616
createChildCancellationSignal,
17-
trackChildFinalization,
17+
trackChildRun,
1818
} from '@/lib/workflows/custom-blocks/child-execution'
1919
import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations'
2020
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
@@ -275,6 +275,8 @@ export class WorkflowBlockHandler implements BlockHandler {
275275
/** Large-value id list shared with the child (and any nested custom blocks). */
276276
let sharedLargeValueIds: string[] | undefined
277277
let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined
278+
/** Settled in `finally` once the child is fully done — see `trackChildRun`. */
279+
let settleChildRun: (() => void) | undefined
278280
try {
279281
// A custom block runs the source's latest deployment; if the source has been
280282
// undeployed there's nothing to run. `BoundarySafeError` marks the message as
@@ -485,6 +487,17 @@ export class WorkflowBlockHandler implements BlockHandler {
485487
parentSignal: ctx.abortSignal,
486488
parentExecutionId: ctx.executionId,
487489
})
490+
// Registered BEFORE the child starts and settled in `finally`, so the
491+
// tracked promise spans execution AND the terminal log write. A cancelled
492+
// parent drains at a moment when the child is still inside `execute`, so
493+
// registering only the finalization step would find nothing to await.
494+
trackChildRun(
495+
ctx.executionId,
496+
new Promise<void>((resolve) => {
497+
settleChildRun = resolve
498+
})
499+
)
500+
488501
// Large values are scoped by execution id, so the parent must be able to
489502
// read a large exposed output the child produced. ONE array is shared down
490503
// the whole chain rather than copied per hop: a nested custom block pushes
@@ -618,10 +631,7 @@ export class WorkflowBlockHandler implements BlockHandler {
618631
const duration = performance.now() - startTime
619632

620633
if (childSession && childSessionStarted) {
621-
await this.trackFinalization(
622-
ctx,
623-
this.finalizeChildSession(childSession, executionResult, duration, childWorkflowInput)
624-
)
634+
await this.finalizeChildSession(childSession, executionResult, duration, childWorkflowInput)
625635
childSessionFinalized = true
626636
}
627637

@@ -672,7 +682,7 @@ export class WorkflowBlockHandler implements BlockHandler {
672682
// The child's own log row records the real failure in the source workspace,
673683
// so the publisher sees what the consumer deliberately cannot.
674684
if (childSession && childSessionStarted && !childSessionFinalized) {
675-
await this.trackFinalization(ctx, this.failChildSession(childSession, error))
685+
await this.failChildSession(childSession, error)
676686
}
677687

678688
// A custom block is checked FIRST and unconditionally: errors this invocation
@@ -730,25 +740,12 @@ export class WorkflowBlockHandler implements BlockHandler {
730740
// A custom block inside a loop would otherwise leak one abort listener and
731741
// one cancellation subscription per iteration.
732742
childCancellation?.dispose()
743+
// Releases the invoking run's drain: reached on every exit path, including
744+
// when this handler's promise was abandoned by a cancelled parent engine.
745+
settleChildRun?.()
733746
}
734747
}
735748

736-
/**
737-
* Awaits a child-session finalization while ALSO registering it against the
738-
* invoking run. A cancelled or timed-out parent engine returns without
739-
* draining its in-flight node promises, which would abandon this `await`
740-
* mid-write; the registration lets the invoking run's completion path finish
741-
* the durable write instead of leaving the child's row `running` for the
742-
* stale-execution reaper.
743-
*/
744-
private async trackFinalization(
745-
ctx: ExecutionContext,
746-
finalization: Promise<void>
747-
): Promise<void> {
748-
trackChildFinalization(ctx.executionId, finalization)
749-
await finalization
750-
}
751-
752749
/**
753750
* Completes a custom-block child's own logging session, so the publisher gets a
754751
* full run record — trace waterfall, duration, and ledger rows — in the source

apps/sim/lib/workflows/custom-blocks/child-execution.test.ts

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import {
3232
buildCustomBlockCorrelation,
3333
CustomBlockAdmissionError,
3434
createChildCancellationSignal,
35-
trackChildFinalization,
36-
waitForChildFinalizations,
35+
trackChildRun,
36+
waitForChildRuns,
3737
} from '@/lib/workflows/custom-blocks/child-execution'
3838
import { isBoundarySafeError } from '@/executor/errors/boundary'
3939

@@ -245,47 +245,60 @@ describe('createChildCancellationSignal durable backstop', () => {
245245
})
246246
})
247247

248-
describe('child finalization tracking', () => {
249-
it('lets an invoking run await a finalization the engine abandoned', async () => {
250-
let resolveWrite: () => void = () => {}
251-
const durableWrite = new Promise<void>((resolve) => {
252-
resolveWrite = resolve
253-
})
248+
describe('child run tracking', () => {
249+
it('waits for a child the engine abandoned mid-execution', async () => {
250+
// The scenario this exists for: the parent cancelled, so at drain time the
251+
// child has NOT finished. Registration must already have happened.
252+
let settle: () => void = () => {}
254253
let finished = false
255-
const finalization = durableWrite.then(() => {
254+
const childRun = new Promise<void>((resolve) => {
255+
settle = resolve
256+
}).then(() => {
256257
finished = true
257258
})
258259

259-
trackChildFinalization('invoker-1', finalization)
260+
trackChildRun('invoker-1', childRun)
260261

261-
// The engine returned without draining; the write is still in flight.
262+
const drained = waitForChildRuns('invoker-1')
262263
expect(finished).toBe(false)
263-
264-
const drained = waitForChildFinalizations('invoker-1')
265-
resolveWrite()
264+
settle()
266265
await drained
267266

268267
expect(finished).toBe(true)
269268
})
270269

271-
it('does not let a rejected finalization break the drain', async () => {
272-
trackChildFinalization('invoker-2', Promise.reject(new Error('db down')))
270+
it('gives up on a hung child rather than wedging the invoking run', async () => {
271+
vi.useFakeTimers()
272+
try {
273+
// Never settles — an unbounded drain would hang the parent forever.
274+
trackChildRun('invoker-hung', new Promise<void>(() => {}))
275+
276+
const drained = waitForChildRuns('invoker-hung')
277+
await vi.advanceTimersByTimeAsync(10_000)
278+
279+
await expect(drained).resolves.toBeUndefined()
280+
} finally {
281+
vi.useRealTimers()
282+
}
283+
})
284+
285+
it('does not let a rejected child break the drain', async () => {
286+
trackChildRun('invoker-2', Promise.reject(new Error('db down')))
273287

274-
await expect(waitForChildFinalizations('invoker-2')).resolves.toBeUndefined()
288+
await expect(waitForChildRuns('invoker-2')).resolves.toBeUndefined()
275289
})
276290

277291
it('is a no-op for a run that registered nothing', async () => {
278-
await expect(waitForChildFinalizations('invoker-never')).resolves.toBeUndefined()
279-
await expect(waitForChildFinalizations(undefined)).resolves.toBeUndefined()
292+
await expect(waitForChildRuns('invoker-never')).resolves.toBeUndefined()
293+
await expect(waitForChildRuns(undefined)).resolves.toBeUndefined()
280294
})
281295

282296
it('releases its entry so a run that never drains cannot leak', async () => {
283-
const finalization = Promise.resolve()
284-
trackChildFinalization('invoker-3', finalization)
285-
await finalization
297+
const childRun = Promise.resolve()
298+
trackChildRun('invoker-3', childRun)
299+
await childRun
286300
await Promise.resolve()
287301

288-
// Draining after self-cleanup still resolves rather than hanging.
289-
await expect(waitForChildFinalizations('invoker-3')).resolves.toBeUndefined()
302+
await expect(waitForChildRuns('invoker-3')).resolves.toBeUndefined()
290303
})
291304
})

apps/sim/lib/workflows/custom-blocks/child-execution.ts

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createLogger } from '@sim/logger'
12
import {
23
type BillingAttributionSnapshot,
34
checkAttributedUsageLimits,
@@ -10,6 +11,8 @@ import {
1011
} from '@/lib/execution/cancellation'
1112
import { BoundarySafeError } from '@/executor/errors/boundary'
1213

14+
const logger = createLogger('CustomBlockChildExecution')
15+
1316
/**
1417
* The payer has no headroom for this custom-block child run.
1518
*
@@ -144,58 +147,91 @@ export async function createChildCancellationSignal(params: {
144147
}
145148

146149
/**
147-
* Child-session finalizations still in flight, keyed by the INVOKING run's
148-
* execution id.
150+
* Custom-block child runs still in flight, keyed by the INVOKING run's execution id.
149151
*
150152
* A cancelled or timed-out parent engine returns without draining its in-flight
151-
* node promises, so the custom-block handler's finalization would otherwise be
152-
* abandoned mid-write: the invoking run finishes, the worker tears down, and the
153-
* child's log row is left `running` until the stale-execution reaper sweeps it
154-
* minutes later. Registering here lets the run's own completion path await the
155-
* durable write instead of racing it.
153+
* node promises, so the child's execution AND its terminal log write are both
154+
* abandoned mid-flight: the invoking run finishes, the worker tears down, and the
155+
* child's row is left `running` until the stale-execution reaper sweeps it.
156+
*
157+
* The tracked promise therefore covers the WHOLE child lifecycle and is registered
158+
* BEFORE the child starts. Registering only the finalization step would be useless
159+
* on exactly the path this exists for: at drain time the child is still inside
160+
* `execute`, so nothing would be registered yet and the drain would no-op.
156161
*
157-
* Only the immediate invoker is tracked. A finalization orphaned *below* another
158-
* abandoned child still falls back to the reaper.
162+
* Only the immediate invoker is tracked. A child orphaned *below* another abandoned
163+
* child still falls back to the reaper.
159164
*/
160-
const pendingChildFinalizations = new Map<string, Set<Promise<unknown>>>()
165+
const pendingChildRuns = new Map<string, Set<Promise<unknown>>>()
166+
167+
/** How long a run will wait on abandoned children before leaving them to the reaper. */
168+
const CHILD_RUN_DRAIN_TIMEOUT_MS = 10_000
161169

162-
/** Registers a child-session finalization against its invoking run. */
163-
export function trackChildFinalization(
170+
/**
171+
* Registers a child run against its invoking run. The promise must settle when the
172+
* child is fully done — execution finished AND its session written.
173+
*/
174+
export function trackChildRun(
164175
invokerExecutionId: string | undefined,
165-
promise: Promise<unknown>
176+
childRun: Promise<unknown>
166177
): void {
167178
if (!invokerExecutionId) return
168179

169-
let pending = pendingChildFinalizations.get(invokerExecutionId)
180+
let pending = pendingChildRuns.get(invokerExecutionId)
170181
if (!pending) {
171182
pending = new Set()
172-
pendingChildFinalizations.set(invokerExecutionId, pending)
183+
pendingChildRuns.set(invokerExecutionId, pending)
173184
}
174-
pending.add(promise)
185+
pending.add(childRun)
175186

176187
// Self-cleaning so an invoker that never drains cannot leak the entry. The
177-
// `catch` also marks the rejection handled — callers use `allSettled`.
178-
void promise
188+
// `catch` also marks the rejection handled — the drain uses `allSettled`.
189+
void childRun
179190
.catch(() => {})
180191
.finally(() => {
181-
pending.delete(promise)
182-
if (pending.size === 0) pendingChildFinalizations.delete(invokerExecutionId)
192+
pending.delete(childRun)
193+
if (pending.size === 0) pendingChildRuns.delete(invokerExecutionId)
183194
})
184195
}
185196

186-
/** Awaits every child-session finalization registered for this run. */
187-
export async function waitForChildFinalizations(
188-
invokerExecutionId: string | undefined
189-
): Promise<void> {
197+
/**
198+
* Awaits every child run registered for this invoking run, bounded.
199+
*
200+
* The bound matters: on the normal path the handler already awaited its child, so
201+
* this returns immediately. It only blocks when a child was abandoned, where the
202+
* bridged abort should end it in milliseconds. Waiting unbounded would trade a
203+
* stale `running` row — which the reaper fixes — for a wedged parent run, which
204+
* nothing fixes.
205+
*/
206+
export async function waitForChildRuns(invokerExecutionId: string | undefined): Promise<void> {
190207
if (!invokerExecutionId) return
191208

192-
const pending = pendingChildFinalizations.get(invokerExecutionId)
193-
if (!pending) return
209+
const pending = pendingChildRuns.get(invokerExecutionId)
210+
if (!pending || pending.size === 0) return
194211

195-
// Re-check: a settling finalization can register nothing further, but a
196-
// nested child can still be added while we await.
197-
while (pending.size > 0) {
198-
await Promise.allSettled([...pending])
212+
let timer: ReturnType<typeof setTimeout> | undefined
213+
const timedOut = new Promise<'timeout'>((resolve) => {
214+
timer = setTimeout(() => resolve('timeout'), CHILD_RUN_DRAIN_TIMEOUT_MS)
215+
})
216+
217+
const drained = (async () => {
218+
// Re-check: a nested child can still register while we await.
219+
while (pending.size > 0) {
220+
await Promise.allSettled([...pending])
221+
}
222+
return 'drained' as const
223+
})()
224+
225+
try {
226+
if ((await Promise.race([drained, timedOut])) === 'timeout') {
227+
logger.warn('Abandoned custom block child did not finish; leaving it to the reaper', {
228+
invokerExecutionId,
229+
outstanding: pending.size,
230+
})
231+
return
232+
}
233+
pendingChildRuns.delete(invokerExecutionId)
234+
} finally {
235+
if (timer) clearTimeout(timer)
199236
}
200-
pendingChildFinalizations.delete(invokerExecutionId)
201237
}

0 commit comments

Comments
 (0)