Skip to content

Commit c5c5d3e

Browse files
fix(custom-blocks): add a durable cancel backstop to the child bridge
1 parent 57d4e2b commit c5c5d3e

3 files changed

Lines changed: 97 additions & 13 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ export class WorkflowBlockHandler implements BlockHandler {
464464
}
465465
// The child no longer shares the parent's execution id, so it no longer
466466
// hears the parent's cancellation event — bridge it explicitly.
467-
childCancellation = createChildCancellationSignal({
467+
childCancellation = await createChildCancellationSignal({
468468
parentSignal: ctx.abortSignal,
469469
parentExecutionId: ctx.executionId,
470470
})

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

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockCheckAttributedUsageLimits, mockSubscribe, mockUnsubscribe } = vi.hoisted(() => ({
6+
const {
7+
mockCheckAttributedUsageLimits,
8+
mockSubscribe,
9+
mockUnsubscribe,
10+
mockIsExecutionCancelled,
11+
mockIsRedisCancellationEnabled,
12+
} = vi.hoisted(() => ({
713
mockCheckAttributedUsageLimits: vi.fn(),
814
mockSubscribe: vi.fn(),
915
mockUnsubscribe: vi.fn(),
16+
mockIsExecutionCancelled: vi.fn(),
17+
mockIsRedisCancellationEnabled: vi.fn(),
1018
}))
1119

1220
vi.mock('@/lib/billing/core/billing-attribution', () => ({
@@ -15,6 +23,8 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
1523

1624
vi.mock('@/lib/execution/cancellation', () => ({
1725
getCancellationChannel: () => ({ subscribe: mockSubscribe }),
26+
isExecutionCancelled: mockIsExecutionCancelled,
27+
isRedisCancellationEnabled: mockIsRedisCancellationEnabled,
1828
}))
1929

2030
import {
@@ -31,6 +41,8 @@ describe('admitCustomBlockChildExecution', () => {
3141
beforeEach(() => {
3242
vi.clearAllMocks()
3343
mockSubscribe.mockReturnValue(mockUnsubscribe)
44+
mockIsRedisCancellationEnabled.mockReturnValue(true)
45+
mockIsExecutionCancelled.mockResolvedValue(false)
3446
})
3547

3648
it('passes when the source payer has headroom', async () => {
@@ -88,11 +100,13 @@ describe('createChildCancellationSignal', () => {
88100
beforeEach(() => {
89101
vi.clearAllMocks()
90102
mockSubscribe.mockReturnValue(mockUnsubscribe)
103+
mockIsRedisCancellationEnabled.mockReturnValue(true)
104+
mockIsExecutionCancelled.mockResolvedValue(false)
91105
})
92106

93-
it('aborts when the parent signal aborts', () => {
107+
it('aborts when the parent signal aborts', async () => {
94108
const parent = new AbortController()
95-
const { signal } = createChildCancellationSignal({
109+
const { signal } = await createChildCancellationSignal({
96110
parentSignal: parent.signal,
97111
parentExecutionId: 'parent-1',
98112
})
@@ -102,17 +116,17 @@ describe('createChildCancellationSignal', () => {
102116
expect(signal.aborted).toBe(true)
103117
})
104118

105-
it('starts aborted when the parent already aborted', () => {
119+
it('starts aborted when the parent already aborted', async () => {
106120
const parent = new AbortController()
107121
parent.abort()
108122

109-
const { signal } = createChildCancellationSignal({ parentSignal: parent.signal })
123+
const { signal } = await createChildCancellationSignal({ parentSignal: parent.signal })
110124

111125
expect(signal.aborted).toBe(true)
112126
})
113127

114-
it('aborts on the parent cancellation event, and ignores other runs', () => {
115-
const { signal } = createChildCancellationSignal({ parentExecutionId: 'parent-1' })
128+
it('aborts on the parent cancellation event, and ignores other runs', async () => {
129+
const { signal } = await createChildCancellationSignal({ parentExecutionId: 'parent-1' })
116130
const handler = mockSubscribe.mock.calls[0][0]
117131

118132
handler({ executionId: 'someone-else' })
@@ -122,9 +136,9 @@ describe('createChildCancellationSignal', () => {
122136
expect(signal.aborted).toBe(true)
123137
})
124138

125-
it('unsubscribes on dispose so a looped block leaks nothing', () => {
139+
it('unsubscribes on dispose so a looped block leaks nothing', async () => {
126140
const parent = new AbortController()
127-
const { dispose } = createChildCancellationSignal({
141+
const { dispose } = await createChildCancellationSignal({
128142
parentSignal: parent.signal,
129143
parentExecutionId: 'parent-1',
130144
})
@@ -144,3 +158,55 @@ describe('CustomBlockAdmissionError', () => {
144158
expect(error.message).toBe('Organization usage limit exceeded')
145159
})
146160
})
161+
162+
describe('createChildCancellationSignal durable backstop', () => {
163+
beforeEach(() => {
164+
vi.clearAllMocks()
165+
mockSubscribe.mockReturnValue(mockUnsubscribe)
166+
mockIsRedisCancellationEnabled.mockReturnValue(true)
167+
mockIsExecutionCancelled.mockResolvedValue(false)
168+
})
169+
170+
it('aborts on a cancel published before the bridge subscribed', async () => {
171+
// The pub/sub event is long gone; only the durable key remains.
172+
mockIsExecutionCancelled.mockResolvedValue(true)
173+
174+
const { signal } = await createChildCancellationSignal({ parentExecutionId: 'parent-1' })
175+
176+
expect(mockIsExecutionCancelled).toHaveBeenCalledWith('parent-1')
177+
expect(signal.aborted).toBe(true)
178+
})
179+
180+
it('subscribes before reading the durable key so no window is left open', async () => {
181+
const order: string[] = []
182+
mockSubscribe.mockImplementation(() => {
183+
order.push('subscribe')
184+
return mockUnsubscribe
185+
})
186+
mockIsExecutionCancelled.mockImplementation(async () => {
187+
order.push('durable-read')
188+
return false
189+
})
190+
191+
await createChildCancellationSignal({ parentExecutionId: 'parent-1' })
192+
193+
expect(order).toEqual(['subscribe', 'durable-read'])
194+
})
195+
196+
it('fails open when the durable read throws', async () => {
197+
mockIsExecutionCancelled.mockRejectedValue(new Error('redis down'))
198+
199+
const { signal } = await createChildCancellationSignal({ parentExecutionId: 'parent-1' })
200+
201+
expect(signal.aborted).toBe(false)
202+
})
203+
204+
it('skips the durable read when redis cancellation is unavailable', async () => {
205+
mockIsRedisCancellationEnabled.mockReturnValue(false)
206+
207+
const { signal } = await createChildCancellationSignal({ parentExecutionId: 'parent-1' })
208+
209+
expect(mockIsExecutionCancelled).not.toHaveBeenCalled()
210+
expect(signal.aborted).toBe(false)
211+
})
212+
})

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {
33
checkAttributedUsageLimits,
44
} from '@/lib/billing/core/billing-attribution'
55
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
6-
import { getCancellationChannel } from '@/lib/execution/cancellation'
6+
import {
7+
getCancellationChannel,
8+
isExecutionCancelled,
9+
isRedisCancellationEnabled,
10+
} from '@/lib/execution/cancellation'
711
import { BoundarySafeError } from '@/executor/errors/boundary'
812

913
/**
@@ -73,10 +77,10 @@ export function buildCustomBlockCorrelation(params: {
7377
* leak one abort listener and one channel subscription per iteration onto a
7478
* long-lived parent signal.
7579
*/
76-
export function createChildCancellationSignal(params: {
80+
export async function createChildCancellationSignal(params: {
7781
parentSignal?: AbortSignal
7882
parentExecutionId?: string
79-
}): { signal: AbortSignal; dispose: () => void } {
83+
}): Promise<{ signal: AbortSignal; dispose: () => void }> {
8084
const controller = new AbortController()
8185

8286
if (params.parentSignal?.aborted) {
@@ -90,9 +94,23 @@ export function createChildCancellationSignal(params: {
9094
let unsubscribe: (() => void) | undefined
9195
if (params.parentExecutionId) {
9296
const parentExecutionId = params.parentExecutionId
97+
// Subscribe BEFORE the durable read, mirroring `markExecutionCancelled`'s
98+
// write-durable-then-publish order: a cancel published during the read is
99+
// caught by the subscription, and one published earlier — while the child's
100+
// session and admission were still being set up — by the read itself. The
101+
// child's own engine backstop cannot cover this, since it checks the CHILD's
102+
// execution id, which is never the one marked cancelled.
93103
unsubscribe = getCancellationChannel().subscribe((event) => {
94104
if (event.executionId === parentExecutionId) controller.abort()
95105
})
106+
if (isRedisCancellationEnabled()) {
107+
try {
108+
if (await isExecutionCancelled(parentExecutionId)) controller.abort()
109+
} catch {
110+
// Fail open, matching the engine's own backstop: a failed read must not
111+
// stop a child whose parent was never actually cancelled.
112+
}
113+
}
96114
}
97115

98116
return {

0 commit comments

Comments
 (0)