Skip to content

Commit 086f1bf

Browse files
committed
fix(workflows): restore rejected cancellation staging
1 parent b9dd090 commit 086f1bf

2 files changed

Lines changed: 133 additions & 44 deletions

File tree

apps/sim/lib/execution/cancel-workflow-execution.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,36 @@ describe('cancelWorkflowExecution', () => {
305305
expect(mockCancelByExecution).not.toHaveBeenCalled()
306306
})
307307

308+
it('clears a staged pause when a vanished group target prevents active-resume rollback', async () => {
309+
dbChainMockFns.limit.mockResolvedValueOnce([
310+
{
311+
executionDeadlineAt: null,
312+
executionOrigin: 'workflow_group',
313+
status: 'running',
314+
workspaceId: 'workspace-1',
315+
},
316+
])
317+
mockStagePausedCancellation.mockResolvedValue({
318+
kind: 'active_resume',
319+
target: ACTIVE_RESUME_TARGET,
320+
})
321+
mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' })
322+
mockRollbackActiveResumeCancellation.mockResolvedValue(false)
323+
324+
const response = await POST(makeRequest(), makeParams())
325+
326+
expect(response.status).toBe(409)
327+
await expect(response.json()).resolves.toEqual({
328+
error: 'Workflow group execution is no longer the active table execution',
329+
})
330+
expect(mockRollbackActiveResumeCancellation).toHaveBeenCalledWith(
331+
'ex-1',
332+
'wf-1',
333+
'resume-entry-1'
334+
)
335+
expect(mockClearPausedCancellationIntent).toHaveBeenCalledWith('ex-1', 'wf-1')
336+
})
337+
308338
it('accepts an exact in-process group abort without cancelling its carrier', async () => {
309339
dbChainMockFns.limit.mockResolvedValueOnce([
310340
{
@@ -1618,6 +1648,28 @@ describe('cancelWorkflowExecution', () => {
16181648
expect(mockClearPausedCancellationIntent).toHaveBeenCalledWith('ex-1', 'wf-1')
16191649
})
16201650

1651+
it('retries paused cancellation cleanup before returning a terminal-race conflict', async () => {
1652+
mockStagePausedCancellation.mockResolvedValue({ kind: 'idle' })
1653+
mockClearPausedCancellationIntent.mockRejectedValueOnce(new Error('database unavailable'))
1654+
const returning = vi.fn().mockResolvedValue([])
1655+
const where = vi.fn(() => ({ returning }))
1656+
databaseMock.db.update.mockReturnValueOnce({ set: vi.fn(() => ({ where })) })
1657+
dbChainMockFns.limit
1658+
.mockResolvedValueOnce([
1659+
{ executionDeadlineAt: null, status: 'running', workspaceId: 'workspace-1' },
1660+
])
1661+
.mockResolvedValueOnce([{ status: 'completed' }])
1662+
1663+
const response = await POST(makeRequest(), makeParams())
1664+
1665+
expect(response.status).toBe(409)
1666+
await expect(response.json()).resolves.toEqual({
1667+
error: 'Execution cannot be cancelled while completed',
1668+
})
1669+
expect(mockClearPausedCancellationIntent).toHaveBeenCalledTimes(2)
1670+
expect(mockWriteTerminalEvent).not.toHaveBeenCalled()
1671+
})
1672+
16211673
it('treats a concurrent cancellation as an idempotent success', async () => {
16221674
mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' })
16231675
dbChainMockFns.limit

apps/sim/lib/execution/cancel-workflow-execution.ts

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,67 @@ async function completePausedCancellationWithRetry(
292292
return false
293293
}
294294

295+
async function clearPausedCancellationIntentWithRetry(
296+
executionId: string,
297+
workflowId: string
298+
): Promise<boolean> {
299+
for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) {
300+
try {
301+
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId)
302+
return true
303+
} catch (error) {
304+
logger.warn('Failed to clear paused cancellation intent', {
305+
executionId,
306+
attempt,
307+
error: toError(error).message,
308+
})
309+
if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) {
310+
await sleep(PAUSED_CANCELLATION_DB_RETRY_MS)
311+
}
312+
}
313+
}
314+
return false
315+
}
316+
317+
async function restorePausedCancellationAfterRejectedCommit(args: {
318+
executionId: string
319+
workflowId: string
320+
effectivePausedCancellationPath: boolean
321+
activeResumeEntryId: string | null
322+
}): Promise<boolean> {
323+
if (!args.effectivePausedCancellationPath) return true
324+
325+
if (args.activeResumeEntryId) {
326+
try {
327+
const rolledBack = await PauseResumeManager.rollbackActiveResumeCancellation(
328+
args.executionId,
329+
args.workflowId,
330+
args.activeResumeEntryId
331+
)
332+
if (rolledBack) return true
333+
logger.warn('Active resume rollback was rejected; clearing paused cancellation intent', {
334+
executionId: args.executionId,
335+
activeResumeEntryId: args.activeResumeEntryId,
336+
})
337+
} catch (error) {
338+
logger.warn('Active resume rollback failed; clearing paused cancellation intent', {
339+
executionId: args.executionId,
340+
activeResumeEntryId: args.activeResumeEntryId,
341+
error: toError(error).message,
342+
})
343+
}
344+
}
345+
346+
return clearPausedCancellationIntentWithRetry(args.executionId, args.workflowId)
347+
}
348+
349+
function throwPausedCancellationRestoreFailed(): never {
350+
throw new OrchestrationError(
351+
'internal',
352+
'Failed to restore paused execution after cancellation was rejected'
353+
)
354+
}
355+
295356
async function ensureCancellationEventPublished(
296357
executionId: string,
297358
workflowId: string,
@@ -681,6 +742,12 @@ export async function cancelWorkflowExecution({
681742
}
682743

683744
if (execution.status !== 'running' && execution.status !== 'pending') {
745+
const pausedCancellationRestored = await clearPausedCancellationIntentWithRetry(
746+
executionId,
747+
workflowId
748+
)
749+
if (!pausedCancellationRestored) throwPausedCancellationRestoreFailed()
750+
684751
if (!isWorkflowGroupExecution && isWorkflowRunAlreadyTerminalStatus(execution.status)) {
685752
throw new WorkflowRunAlreadyTerminalError({
686753
executionId,
@@ -853,28 +920,13 @@ export async function cancelWorkflowExecution({
853920

854921
if (workflowGroupNoLongerActive) {
855922
await clearStopSignalMarkers(stopSummary)
856-
if (activeResumeEntryId) {
857-
await PauseResumeManager.rollbackActiveResumeCancellation(
858-
executionId,
859-
workflowId,
860-
activeResumeEntryId
861-
).catch((error) => {
862-
logger.warn('Failed to roll back active resume after group target disappeared', {
863-
executionId,
864-
activeResumeEntryId,
865-
error: toError(error).message,
866-
})
867-
})
868-
} else if (effectivePausedCancellationPath) {
869-
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
870-
(error) => {
871-
logger.warn('Failed to clear cancellation intent after group target disappeared', {
872-
executionId,
873-
error: toError(error).message,
874-
})
875-
}
876-
)
877-
}
923+
const pausedCancellationRestored = await restorePausedCancellationAfterRejectedCommit({
924+
executionId,
925+
workflowId,
926+
effectivePausedCancellationPath,
927+
activeResumeEntryId,
928+
})
929+
if (!pausedCancellationRestored) throwPausedCancellationRestoreFailed()
878930
throw new OrchestrationError(
879931
'conflict',
880932
'Workflow group execution is no longer the active table execution'
@@ -883,28 +935,13 @@ export async function cancelWorkflowExecution({
883935

884936
if (competingTerminalStatus) {
885937
await clearStopSignalMarkers(stopSummary)
886-
if (activeResumeEntryId) {
887-
await PauseResumeManager.rollbackActiveResumeCancellation(
888-
executionId,
889-
workflowId,
890-
activeResumeEntryId
891-
).catch((error) => {
892-
logger.warn('Failed to roll back active resume after terminal race', {
893-
executionId,
894-
activeResumeEntryId,
895-
error: toError(error).message,
896-
})
897-
})
898-
} else if (effectivePausedCancellationPath) {
899-
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
900-
(error) => {
901-
logger.warn('Failed to clear cancellation intent after terminal race', {
902-
executionId,
903-
error: toError(error).message,
904-
})
905-
}
906-
)
907-
}
938+
const pausedCancellationRestored = await restorePausedCancellationAfterRejectedCommit({
939+
executionId,
940+
workflowId,
941+
effectivePausedCancellationPath,
942+
activeResumeEntryId,
943+
})
944+
if (!pausedCancellationRestored) throwPausedCancellationRestoreFailed()
908945
if (
909946
!isWorkflowGroupExecution &&
910947
isWorkflowRunAlreadyTerminalStatus(competingTerminalStatus)

0 commit comments

Comments
 (0)