mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
fix(workflows): reconcile replacement resume cancellation
This commit is contained in:
@@ -150,6 +150,12 @@ const ACTIVE_RESUME_TARGET = {
|
||||
resumeExecutionId: 'resume-ex-1',
|
||||
}
|
||||
|
||||
const REPLACEMENT_ACTIVE_RESUME_TARGET = {
|
||||
...ACTIVE_RESUME_TARGET,
|
||||
resumeEntryId: 'resume-entry-2',
|
||||
resumeExecutionId: 'resume-ex-2',
|
||||
}
|
||||
|
||||
describe('cancelWorkflowExecution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -785,6 +791,85 @@ describe('cancelWorkflowExecution', () => {
|
||||
expect(mockClearExecutionCancellation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finishes cancellation when a failed active-resume rollback detects a replacement', async () => {
|
||||
mockStagePausedCancellation
|
||||
.mockResolvedValueOnce({ kind: 'active_resume', target: ACTIVE_RESUME_TARGET })
|
||||
.mockResolvedValueOnce({
|
||||
kind: 'active_resume',
|
||||
target: REPLACEMENT_ACTIVE_RESUME_TARGET,
|
||||
})
|
||||
mockGetActiveResumeCancellationTarget.mockResolvedValueOnce(REPLACEMENT_ACTIVE_RESUME_TARGET)
|
||||
mockRollbackActiveResumeCancellation.mockResolvedValueOnce(false)
|
||||
mockMarkExecutionCancelled
|
||||
.mockResolvedValueOnce({ durablyRecorded: false, reason: 'redis_unavailable' })
|
||||
.mockResolvedValueOnce({ durablyRecorded: true, reason: 'recorded' })
|
||||
mockCompletePausedCancellation.mockResolvedValueOnce(true)
|
||||
|
||||
const response = await POST(makeRequest(), makeParams())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
durablyRecorded: true,
|
||||
pausedCancelled: true,
|
||||
reason: 'recorded',
|
||||
})
|
||||
expect(mockRollbackActiveResumeCancellation).toHaveBeenCalledWith(
|
||||
'ex-1',
|
||||
'wf-1',
|
||||
'resume-entry-1'
|
||||
)
|
||||
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(1, 'resume-ex-1', {
|
||||
executionDeadlineAt: null,
|
||||
})
|
||||
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(2, 'resume-ex-2', {
|
||||
executionDeadlineAt: null,
|
||||
})
|
||||
expect(mockCompletePausedCancellation).toHaveBeenCalledWith('ex-1', 'wf-1')
|
||||
})
|
||||
|
||||
it('finishes late pause cancellation when rollback detects a replacement resume', async () => {
|
||||
mockStagePausedCancellation
|
||||
.mockResolvedValueOnce({ kind: 'not_paused' })
|
||||
.mockResolvedValueOnce({ kind: 'active_resume', target: ACTIVE_RESUME_TARGET })
|
||||
.mockResolvedValueOnce({
|
||||
kind: 'active_resume',
|
||||
target: REPLACEMENT_ACTIVE_RESUME_TARGET,
|
||||
})
|
||||
mockGetActiveResumeCancellationTarget.mockResolvedValueOnce(REPLACEMENT_ACTIVE_RESUME_TARGET)
|
||||
mockRollbackActiveResumeCancellation.mockResolvedValueOnce(false)
|
||||
mockMarkExecutionCancelled
|
||||
.mockResolvedValueOnce({ durablyRecorded: false, reason: 'redis_unavailable' })
|
||||
.mockResolvedValueOnce({ durablyRecorded: false, reason: 'redis_unavailable' })
|
||||
.mockResolvedValueOnce({ durablyRecorded: true, reason: 'recorded' })
|
||||
mockCompletePausedCancellation.mockResolvedValueOnce(true)
|
||||
|
||||
const response = await POST(makeRequest(), makeParams())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
durablyRecorded: true,
|
||||
pausedCancelled: true,
|
||||
reason: 'recorded',
|
||||
})
|
||||
expect(mockRollbackActiveResumeCancellation).toHaveBeenCalledWith(
|
||||
'ex-1',
|
||||
'wf-1',
|
||||
'resume-entry-1'
|
||||
)
|
||||
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(1, 'ex-1', {
|
||||
executionDeadlineAt: null,
|
||||
})
|
||||
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(2, 'resume-ex-1', {
|
||||
executionDeadlineAt: null,
|
||||
})
|
||||
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(3, 'resume-ex-2', {
|
||||
executionDeadlineAt: null,
|
||||
})
|
||||
expect(mockCompletePausedCancellation).toHaveBeenCalledWith('ex-1', 'wf-1')
|
||||
})
|
||||
|
||||
it('returns success when a paused HITL execution is cancelled directly in the database', async () => {
|
||||
mockStagePausedCancellation.mockResolvedValue({ kind: 'idle' })
|
||||
mockCompletePausedCancellation.mockResolvedValue(true)
|
||||
|
||||
@@ -392,6 +392,49 @@ async function rollbackPausedCancellationAfterAbort(args: {
|
||||
return true
|
||||
}
|
||||
|
||||
async function rollbackActiveResumeAfterFailedSignal(args: {
|
||||
executionId: string
|
||||
workflowId: string
|
||||
resumeEntryId: string
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const rolledBack = await PauseResumeManager.rollbackActiveResumeCancellation(
|
||||
args.executionId,
|
||||
args.workflowId,
|
||||
args.resumeEntryId
|
||||
)
|
||||
if (!rolledBack) {
|
||||
logger.warn('Active resume cancellation could not be rolled back; completing cancellation', {
|
||||
executionId: args.executionId,
|
||||
activeResumeEntryId: args.resumeEntryId,
|
||||
})
|
||||
}
|
||||
return rolledBack
|
||||
} catch (error) {
|
||||
logger.warn('Failed to roll back active resume cancellation; completing cancellation', {
|
||||
executionId: args.executionId,
|
||||
activeResumeEntryId: args.resumeEntryId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function activeResumeSignalFailureResult(
|
||||
executionId: string,
|
||||
stopSummary: ExecutionStopSummary
|
||||
): CancelWorkflowExecutionResult {
|
||||
return {
|
||||
success: false,
|
||||
executionId,
|
||||
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
|
||||
durablyRecorded: stopSummary.cancellation.durablyRecorded,
|
||||
locallyAborted: stopSummary.locallyAborted,
|
||||
pausedCancelled: false,
|
||||
reason: 'active_resume_signal_failed',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCancellationReason(args: {
|
||||
activeResumeSignalFailed: boolean
|
||||
pauseReconciliationFailed: boolean
|
||||
@@ -676,26 +719,14 @@ export async function cancelWorkflowExecution({
|
||||
|
||||
if (!activeResumeSignalAccepted) {
|
||||
const failedResumeEntryId = activeResumeTarget.resumeEntryId
|
||||
await PauseResumeManager.rollbackActiveResumeCancellation(
|
||||
const rolledBack = await rollbackActiveResumeAfterFailedSignal({
|
||||
executionId,
|
||||
workflowId,
|
||||
failedResumeEntryId
|
||||
).catch((error) => {
|
||||
logger.warn('Failed to roll back active resume cancellation intent', {
|
||||
executionId,
|
||||
activeResumeEntryId: failedResumeEntryId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
resumeEntryId: failedResumeEntryId,
|
||||
})
|
||||
await clearStopSignalMarkers(stopSummary)
|
||||
return {
|
||||
success: false,
|
||||
executionId,
|
||||
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
|
||||
durablyRecorded: stopSummary.cancellation.durablyRecorded,
|
||||
locallyAborted: stopSummary.locallyAborted,
|
||||
pausedCancelled: false,
|
||||
reason: 'active_resume_signal_failed',
|
||||
if (rolledBack) {
|
||||
await clearStopSignalMarkers(stopSummary)
|
||||
return activeResumeSignalFailureResult(executionId, stopSummary)
|
||||
}
|
||||
}
|
||||
} else if (!effectivePausedCancellationPath && !isWorkflowGroupExecution) {
|
||||
@@ -738,26 +769,14 @@ export async function cancelWorkflowExecution({
|
||||
})
|
||||
if (!activeResumeSignalAccepted) {
|
||||
const failedResumeEntryId = activeResumeTarget.resumeEntryId
|
||||
await PauseResumeManager.rollbackActiveResumeCancellation(
|
||||
const rolledBack = await rollbackActiveResumeAfterFailedSignal({
|
||||
executionId,
|
||||
workflowId,
|
||||
failedResumeEntryId
|
||||
).catch((error) => {
|
||||
logger.warn('Failed to roll back late active resume cancellation intent', {
|
||||
executionId,
|
||||
activeResumeEntryId: failedResumeEntryId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
resumeEntryId: failedResumeEntryId,
|
||||
})
|
||||
await clearStopSignalMarkers(stopSummary)
|
||||
return {
|
||||
success: false,
|
||||
executionId,
|
||||
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
|
||||
durablyRecorded: stopSummary.cancellation.durablyRecorded,
|
||||
locallyAborted: stopSummary.locallyAborted,
|
||||
pausedCancelled: false,
|
||||
reason: 'active_resume_signal_failed',
|
||||
if (rolledBack) {
|
||||
await clearStopSignalMarkers(stopSummary)
|
||||
return activeResumeSignalFailureResult(executionId, stopSummary)
|
||||
}
|
||||
}
|
||||
} else if (!effectivePausedCancellationPath) {
|
||||
|
||||
Reference in New Issue
Block a user