Skip to content

Commit 734287e

Browse files
committed
fix(trigger): complete runs whose workflow was deleted or undeployed before they started
Not-found, archived, undeployed, unattached and suspended rejections come from a successful read and never wait on an execution log (not-found and undeployed never write one), so they complete without the hasCompleted gate. Quota rejections (402/429) still complete only once their log is saved.
1 parent 99b6de7 commit 734287e

2 files changed

Lines changed: 63 additions & 26 deletions

File tree

‎apps/sim/background/async-preprocessing-correlation.test.ts‎

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -683,28 +683,51 @@ describe('async preprocessing correlation threading', () => {
683683

684684
it.each([
685685
{ statusCode: 404, message: 'Workflow not found' },
686-
{ statusCode: 403, message: 'Workflow is not deployed' },
687-
{ statusCode: 402, message: 'Usage limit exceeded. Please upgrade your plan to continue.' },
688-
{ statusCode: 429, message: 'Rate limit exceeded. Please try again later.' },
689-
])('completes the job for a non-retryable $statusCode', async ({ statusCode, message }) => {
690-
loggingSessionMockFns.mockHasCompleted.mockReturnValueOnce(true)
691-
await expect(runRejected({ statusCode, message })).resolves.toMatchObject({
686+
{ statusCode: 403, message: 'Workflow is not deployed', code: 'WORKFLOW_NOT_DEPLOYED' },
687+
{
688+
statusCode: 403,
689+
message:
690+
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot execute.',
691+
},
692+
{ statusCode: 403, message: 'Account suspended', code: 'ACCOUNT_SUSPENDED' },
693+
])('completes the job for a $statusCode $message that never started a log', async (error) => {
694+
loggingSessionMockFns.mockHasCompleted.mockReturnValueOnce(false)
695+
696+
await expect(runRejected(error)).resolves.toMatchObject({
692697
success: false,
693698
workflowId: 'workflow-1',
694699
executionId: 'execution-rejected',
695-
error: message,
700+
error: error.message,
696701
})
697-
expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledWith(
698-
expect.objectContaining({ error: expect.objectContaining({ message }) })
699-
)
700702
})
701703

702-
it('fails the job when the rejection could not be logged', async () => {
704+
it.each([
705+
{ statusCode: 402, message: 'Usage limit exceeded. Please upgrade your plan to continue.' },
706+
{ statusCode: 429, message: 'Rate limit exceeded. Please try again later.' },
707+
])(
708+
'completes the job for a logged $statusCode quota rejection',
709+
async ({ statusCode, message }) => {
710+
loggingSessionMockFns.mockHasCompleted.mockReturnValueOnce(true)
711+
712+
await expect(runRejected({ statusCode, message })).resolves.toMatchObject({
713+
success: false,
714+
workflowId: 'workflow-1',
715+
executionId: 'execution-rejected',
716+
error: message,
717+
})
718+
expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledWith(
719+
expect.objectContaining({ error: expect.objectContaining({ message }) })
720+
)
721+
}
722+
)
723+
724+
it.each([
725+
{ statusCode: 402, message: 'Usage limit exceeded. Please upgrade your plan to continue.' },
726+
{ statusCode: 429, message: 'Rate limit exceeded. Please try again later.' },
727+
])('fails the job when a $statusCode quota rejection could not be logged', async (error) => {
703728
loggingSessionMockFns.mockHasCompleted.mockReturnValueOnce(false)
704729

705-
await expect(
706-
runRejected({ statusCode: 403, message: 'Workflow is not deployed' })
707-
).rejects.toThrow('Workflow is not deployed')
730+
await expect(runRejected(error)).rejects.toThrow(error.message)
708731
})
709732

710733
it.each([

‎apps/sim/background/workflow-execution.ts‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,34 @@ type LegacyWorkflowExecutionPayload = Omit<WorkflowExecutionPayload, 'principal'
109109
type WorkflowExecutionJobPayload = WorkflowExecutionPayload | LegacyWorkflowExecutionPayload
110110

111111
/**
112-
* Preprocessing rejections that are an outcome of the workflow's or account's
113-
* state — deleted or undeployed workflow, suspended account, exhausted usage
114-
* or rate quota — rather than a fault. A retryable rejection (concurrency
112+
* Rejections read from the workflow's own state — deleted, archived, undeployed,
113+
* not attached to a workspace, or a suspended account. Preprocessing returns
114+
* these only after a successful read (a failed read is a 5xx), and not-found
115+
* and undeployed rejections never write an execution log, so the job completes
116+
* without waiting for one.
117+
*/
118+
const WORKFLOW_STATE_REJECTION_STATUS_CODES: ReadonlySet<number> = new Set([403, 404])
119+
120+
/**
121+
* Exhausted usage or rate quota. These rejections write an execution log, so
122+
* the job completes only once that log is saved; an unsaved one (an outage)
123+
* still fails the job and alerts.
124+
*/
125+
const QUOTA_REJECTION_STATUS_CODES: ReadonlySet<number> = new Set([402, 429])
126+
127+
/**
128+
* Whether a preprocessing rejection is an outcome of the workflow's or
129+
* account's state rather than a fault. A retryable rejection (concurrency
115130
* capacity, an unreadable usage ledger, reservation infrastructure) and every
116131
* 5xx still fail the job.
117132
*/
118-
const NON_FAULT_PREPROCESSING_STATUS_CODES: ReadonlySet<number> = new Set([402, 403, 404, 429])
119-
120-
function isNonFaultPreprocessingRejection(rejection: PreprocessExecutionError): boolean {
121-
return (
122-
NON_FAULT_PREPROCESSING_STATUS_CODES.has(rejection.statusCode) && rejection.retryable !== true
123-
)
133+
function isNonFaultPreprocessingRejection(
134+
rejection: PreprocessExecutionError,
135+
rejectionLogged: boolean
136+
): boolean {
137+
if (rejection.retryable === true) return false
138+
if (WORKFLOW_STATE_REJECTION_STATUS_CODES.has(rejection.statusCode)) return true
139+
return QUOTA_REJECTION_STATUS_CODES.has(rejection.statusCode) && rejectionLogged
124140
}
125141

126142
/** Completes the job with a failure already in the execution log, keeping it on the run's span. */
@@ -378,11 +394,9 @@ export async function executeWorkflowJob(
378394
const scopeDeletedOnCompletion = loggingSession.getDeletedScope()
379395
if (scopeDeletedOnCompletion) return completeWithDeletedScope(scopeDeletedOnCompletion)
380396

381-
// Complete only when the rejection is durably logged; an unsaved one must still alert.
382397
if (
383398
preprocessingRejection &&
384-
isNonFaultPreprocessingRejection(preprocessingRejection) &&
385-
loggingSession.hasCompleted()
399+
isNonFaultPreprocessingRejection(preprocessingRejection, loggingSession.hasCompleted())
386400
) {
387401
return completeWithRecordedFailure(workflowId, executionId, error)
388402
}

0 commit comments

Comments
 (0)