Skip to content

Commit a5c10ba

Browse files
committed
commit
1 parent e98eafa commit a5c10ba

7 files changed

Lines changed: 139 additions & 5 deletions

File tree

packages/payload/src/queues/errors/handleWorkflowError.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,16 @@ export async function handleWorkflowError({
4444
stack: error.stack,
4545
}
4646

47-
const { hasFinalError, maxWorkflowRetries, waitUntil } = getWorkflowRetryBehavior({
48-
job,
49-
retriesConfig: workflowConfig.retries!,
50-
})
47+
// No retries configured => permanently fail.
48+
const hasNoRetriesConfigured =
49+
workflowConfig.retries === undefined || workflowConfig.retries === null
50+
51+
const { hasFinalError, maxWorkflowRetries, waitUntil } = hasNoRetriesConfigured
52+
? { hasFinalError: true as const, maxWorkflowRetries: undefined, waitUntil: undefined }
53+
: getWorkflowRetryBehavior({
54+
job,
55+
retriesConfig: workflowConfig.retries,
56+
})
5157

5258
if (!hasFinalError) {
5359
if (job.waitUntil) {

packages/payload/src/queues/operations/runJobs/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,8 @@ export const runJobs = async (args: RunJobsArgs): Promise<RunJobsResult> => {
349349
}
350350

351351
if (!workflowConfig) {
352-
// Permanently fail jobs that reference a workflow or task that is no longer registered in config, as they can never be completed successfully. No point in retrying them
352+
// Permanently fail jobs whose task/workflow slug is no longer registered in config — they can never complete.
353+
const errorMessage = `${job.taskSlug ? `Task '${job.taskSlug}'` : `Workflow '${job.workflowSlug}'`} is not registered in payload.config.jobs.`
353354

354355
if (!silent || (typeof silent === 'object' && !silent.error)) {
355356
payload.logger.error({

test/queues/getConfig.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { selfCancelWorkflow } from './workflows/selfCancel.js'
3838
import { subTaskWorkflow } from './workflows/subTask.js'
3939
import { subTaskFailsWorkflow } from './workflows/subTaskFails.js'
4040
import { supersedesConcurrencyWorkflow } from './workflows/supersedesConcurrency.js'
41+
import { throwsInHandlerNoRetriesWorkflow } from './workflows/throwsInHandlerNoRetries.js'
42+
import { throwsInHandlerRetries1Workflow } from './workflows/throwsInHandlerRetries1.js'
4143
import { updatePostWorkflow } from './workflows/updatePost.js'
4244
import { updatePostJSONWorkflow } from './workflows/updatePostJSON.js'
4345
import { workflowAndTasksRetriesUndefinedWorkflow } from './workflows/workflowAndTasksRetriesUndefined.js'
@@ -179,6 +181,8 @@ export const getConfig: () => Partial<Config> = () => ({
179181
noConcurrencyWorkflow,
180182
queueSpecificConcurrencyWorkflow,
181183
supersedesConcurrencyWorkflow,
184+
throwsInHandlerNoRetriesWorkflow,
185+
throwsInHandlerRetries1Workflow,
182186
],
183187
},
184188
editor: lexicalEditor(),

test/queues/int.spec.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,78 @@ describe('Queues - Payload', () => {
973973
expect(jobAfterRun.processing).toBe(false)
974974
expect(jobAfterRun.totalTried).toBe(1)
975975
})
976+
977+
it('should permanently fail when a workflow handler calls a task that has been removed', async () => {
978+
payload.config.jobs.deleteJobOnComplete = false
979+
980+
const job = await payload.jobs.queue({
981+
workflow: 'workflowNoRetriesSet',
982+
input: {
983+
message: 'queued before referenced task removal',
984+
},
985+
})
986+
987+
payload.config.jobs.tasks = originalTasks!.filter((t) => t.slug !== 'CreateSimple')
988+
989+
await payload.jobs.run({ silent: true })
990+
991+
const jobAfterRun = await payload.findByID({
992+
collection: 'payload-jobs',
993+
id: job.id,
994+
})
995+
996+
expect(jobAfterRun.hasError).toBe(true)
997+
expect(jobAfterRun.processing).toBe(false)
998+
expect(jobAfterRun.totalTried).toBe(1)
999+
})
1000+
})
1001+
1002+
it('should not retry a workflow with no retries configured when its handler throws', async () => {
1003+
payload.config.jobs.deleteJobOnComplete = false
1004+
1005+
const job = await payload.jobs.queue({
1006+
workflow: 'throwsInHandlerNoRetries',
1007+
input: {},
1008+
})
1009+
1010+
await payload.jobs.run({ silent: true })
1011+
1012+
const jobAfterRun = await payload.findByID({
1013+
collection: 'payload-jobs',
1014+
id: job.id,
1015+
})
1016+
1017+
expect(jobAfterRun.hasError).toBe(true)
1018+
expect(jobAfterRun.processing).toBe(false)
1019+
expect(jobAfterRun.totalTried).toBe(1)
1020+
})
1021+
1022+
it('should retry a workflow with retries=1 exactly once when its handler throws', async () => {
1023+
payload.config.jobs.deleteJobOnComplete = false
1024+
1025+
const job = await payload.jobs.queue({
1026+
workflow: 'throwsInHandlerRetries1',
1027+
input: {},
1028+
})
1029+
1030+
let hasJobsRemaining = true
1031+
while (hasJobsRemaining) {
1032+
const response = await payload.jobs.run({ silent: true })
1033+
if (response.noJobsRemaining) {
1034+
hasJobsRemaining = false
1035+
}
1036+
}
1037+
1038+
const jobAfterRun = await payload.findByID({
1039+
collection: 'payload-jobs',
1040+
id: job.id,
1041+
})
1042+
1043+
// Initial attempt + 1 retry = 2. Once hasError is true the queue stops picking it up,
1044+
// so the loop naturally bounds at exactly 2 attempts.
1045+
expect(jobAfterRun.totalTried).toBe(2)
1046+
expect(jobAfterRun.hasError).toBe(true)
1047+
expect(jobAfterRun.processing).toBe(false)
9761048
})
9771049

9781050
it('can queue and run via the endpoint single tasks without workflows', async () => {

test/queues/payload-types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export interface Config {
9494
globals: {};
9595
globalsSelect: {};
9696
locale: null;
97+
widgets: {
98+
collections: CollectionsWidget;
99+
};
97100
user: User;
98101
jobs: {
99102
tasks: {
@@ -140,6 +143,8 @@ export interface Config {
140143
noConcurrency: WorkflowNoConcurrency;
141144
queueSpecificConcurrency: WorkflowQueueSpecificConcurrency;
142145
supersedesConcurrency: WorkflowSupersedesConcurrency;
146+
throwsInHandlerNoRetries: WorkflowThrowsInHandlerNoRetries;
147+
throwsInHandlerRetries1: WorkflowThrowsInHandlerRetries1;
143148
};
144149
};
145150
}
@@ -365,6 +370,8 @@ export interface PayloadJob {
365370
| 'noConcurrency'
366371
| 'queueSpecificConcurrency'
367372
| 'supersedesConcurrency'
373+
| 'throwsInHandlerNoRetries'
374+
| 'throwsInHandlerRetries1'
368375
)
369376
| null;
370377
taskSlug?:
@@ -571,6 +578,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
571578
updatedAt?: T;
572579
createdAt?: T;
573580
}
581+
/**
582+
* This interface was referenced by `Config`'s JSON-Schema
583+
* via the `definition` "collections_widget".
584+
*/
585+
export interface CollectionsWidget {
586+
data?: {
587+
[k: string]: unknown;
588+
};
589+
width: 'full';
590+
}
574591
/**
575592
* This interface was referenced by `Config`'s JSON-Schema
576593
* via the `definition` "MyUpdatePostType".
@@ -932,6 +949,20 @@ export interface WorkflowSupersedesConcurrency {
932949
delayMs?: number | null;
933950
};
934951
}
952+
/**
953+
* This interface was referenced by `Config`'s JSON-Schema
954+
* via the `definition` "WorkflowThrowsInHandlerNoRetries".
955+
*/
956+
export interface WorkflowThrowsInHandlerNoRetries {
957+
input?: unknown;
958+
}
959+
/**
960+
* This interface was referenced by `Config`'s JSON-Schema
961+
* via the `definition` "WorkflowThrowsInHandlerRetries1".
962+
*/
963+
export interface WorkflowThrowsInHandlerRetries1 {
964+
input?: unknown;
965+
}
935966
/**
936967
* This interface was referenced by `Config`'s JSON-Schema
937968
* via the `definition` "auth".
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { WorkflowConfig } from 'payload'
2+
3+
export const throwsInHandlerNoRetriesWorkflow: WorkflowConfig<'throwsInHandlerNoRetries'> = {
4+
slug: 'throwsInHandlerNoRetries',
5+
inputSchema: [],
6+
// Intentionally no `retries` set — exercises the default no-retries-on-workflow-error behavior
7+
handler: () => {
8+
throw new Error('This workflow throws in its handler')
9+
},
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { WorkflowConfig } from 'payload'
2+
3+
export const throwsInHandlerRetries1Workflow: WorkflowConfig<'throwsInHandlerRetries1'> = {
4+
slug: 'throwsInHandlerRetries1',
5+
inputSchema: [],
6+
retries: 1,
7+
handler: () => {
8+
throw new Error('This workflow throws in its handler')
9+
},
10+
}

0 commit comments

Comments
 (0)