Skip to content

Commit 14ca75d

Browse files
fix(cancel): report cancellation durability truthfully
Return explicit durability results for execution cancellation so success only reflects persisted cancellation state instead of best-effort Redis availability.
1 parent e9bdc57 commit 14ca75d

File tree

4 files changed

+182
-10
lines changed

4 files changed

+182
-10
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mockCheckHybridAuth = vi.fn()
9+
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
10+
const mockMarkExecutionCancelled = vi.fn()
11+
12+
vi.mock('@sim/logger', () => ({
13+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
14+
}))
15+
16+
vi.mock('@/lib/auth/hybrid', () => ({
17+
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
18+
}))
19+
20+
vi.mock('@/lib/execution/cancellation', () => ({
21+
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
22+
}))
23+
24+
vi.mock('@/lib/workflows/utils', () => ({
25+
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
26+
mockAuthorizeWorkflowByWorkspacePermission(params),
27+
}))
28+
29+
import { POST } from './route'
30+
31+
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
35+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
36+
})
37+
38+
it('returns success when cancellation was durably recorded', async () => {
39+
mockMarkExecutionCancelled.mockResolvedValue({
40+
durablyRecorded: true,
41+
reason: 'recorded',
42+
})
43+
44+
const response = await POST(
45+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
46+
method: 'POST',
47+
}),
48+
{
49+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
50+
}
51+
)
52+
53+
expect(response.status).toBe(200)
54+
await expect(response.json()).resolves.toEqual({
55+
success: true,
56+
executionId: 'ex-1',
57+
redisAvailable: true,
58+
durablyRecorded: true,
59+
reason: 'recorded',
60+
})
61+
})
62+
63+
it('returns unsuccessful response when Redis is unavailable', async () => {
64+
mockMarkExecutionCancelled.mockResolvedValue({
65+
durablyRecorded: false,
66+
reason: 'redis_unavailable',
67+
})
68+
69+
const response = await POST(
70+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
71+
method: 'POST',
72+
}),
73+
{
74+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
75+
}
76+
)
77+
78+
expect(response.status).toBe(200)
79+
await expect(response.json()).resolves.toEqual({
80+
success: false,
81+
executionId: 'ex-1',
82+
redisAvailable: false,
83+
durablyRecorded: false,
84+
reason: 'redis_unavailable',
85+
})
86+
})
87+
88+
it('returns unsuccessful response when Redis persistence fails', async () => {
89+
mockMarkExecutionCancelled.mockResolvedValue({
90+
durablyRecorded: false,
91+
reason: 'redis_write_failed',
92+
})
93+
94+
const response = await POST(
95+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
96+
method: 'POST',
97+
}),
98+
{
99+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
100+
}
101+
)
102+
103+
expect(response.status).toBe(200)
104+
await expect(response.json()).resolves.toEqual({
105+
success: false,
106+
executionId: 'ex-1',
107+
redisAvailable: false,
108+
durablyRecorded: false,
109+
reason: 'redis_write_failed',
110+
})
111+
})
112+
})

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,23 @@ export async function POST(
3535

3636
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
3737

38-
const marked = await markExecutionCancelled(executionId)
38+
const cancellation = await markExecutionCancelled(executionId)
3939

40-
if (marked) {
40+
if (cancellation.durablyRecorded) {
4141
logger.info('Execution marked as cancelled in Redis', { executionId })
4242
} else {
43-
logger.info('Redis not available, cancellation will rely on connection close', {
43+
logger.warn('Execution cancellation was not durably recorded', {
4444
executionId,
45+
reason: cancellation.reason,
4546
})
4647
}
4748

4849
return NextResponse.json({
49-
success: true,
50+
success: cancellation.durablyRecorded,
5051
executionId,
51-
redisAvailable: marked,
52+
redisAvailable: cancellation.durablyRecorded,
53+
durablyRecorded: cancellation.durablyRecorded,
54+
reason: cancellation.reason,
5255
})
5356
} catch (error: any) {
5457
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { loggerMock } from '@sim/testing'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mockGetRedisClient = vi.fn()
5+
const mockRedisSet = vi.fn()
6+
7+
vi.mock('@sim/logger', () => loggerMock)
8+
9+
vi.mock('@/lib/core/config/redis', () => ({
10+
getRedisClient: mockGetRedisClient,
11+
}))
12+
13+
import { markExecutionCancelled } from './cancellation'
14+
15+
describe('markExecutionCancelled', () => {
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
})
19+
20+
it('returns redis_unavailable when no Redis client exists', async () => {
21+
mockGetRedisClient.mockReturnValue(null)
22+
23+
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
24+
durablyRecorded: false,
25+
reason: 'redis_unavailable',
26+
})
27+
})
28+
29+
it('returns recorded when Redis write succeeds', async () => {
30+
mockRedisSet.mockResolvedValue('OK')
31+
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
32+
33+
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
34+
durablyRecorded: true,
35+
reason: 'recorded',
36+
})
37+
})
38+
39+
it('returns redis_write_failed when Redis write throws', async () => {
40+
mockRedisSet.mockRejectedValue(new Error('set failed'))
41+
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
42+
43+
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
44+
durablyRecorded: false,
45+
reason: 'redis_write_failed',
46+
})
47+
})
48+
})

apps/sim/lib/execution/cancellation.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,36 @@ const logger = createLogger('ExecutionCancellation')
66
const EXECUTION_CANCEL_PREFIX = 'execution:cancel:'
77
const EXECUTION_CANCEL_EXPIRY = 60 * 60
88

9+
export type ExecutionCancellationRecordResult =
10+
| { durablyRecorded: true; reason: 'recorded' }
11+
| {
12+
durablyRecorded: false
13+
reason: 'redis_unavailable' | 'redis_write_failed'
14+
}
15+
916
export function isRedisCancellationEnabled(): boolean {
1017
return getRedisClient() !== null
1118
}
1219

1320
/**
1421
* Mark an execution as cancelled in Redis.
15-
* Returns true if Redis is available and the flag was set, false otherwise.
22+
* Returns whether the cancellation was durably recorded.
1623
*/
17-
export async function markExecutionCancelled(executionId: string): Promise<boolean> {
24+
export async function markExecutionCancelled(
25+
executionId: string
26+
): Promise<ExecutionCancellationRecordResult> {
1827
const redis = getRedisClient()
1928
if (!redis) {
20-
return false
29+
return { durablyRecorded: false, reason: 'redis_unavailable' }
2130
}
2231

2332
try {
2433
await redis.set(`${EXECUTION_CANCEL_PREFIX}${executionId}`, '1', 'EX', EXECUTION_CANCEL_EXPIRY)
2534
logger.info('Marked execution as cancelled', { executionId })
26-
return true
35+
return { durablyRecorded: true, reason: 'recorded' }
2736
} catch (error) {
2837
logger.error('Failed to mark execution as cancelled', { executionId, error })
29-
return false
38+
return { durablyRecorded: false, reason: 'redis_write_failed' }
3039
}
3140
}
3241

0 commit comments

Comments
 (0)