Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
74f746f
initial API
AlessioGr Jun 12, 2025
fb81dc8
simplify
AlessioGr Jun 12, 2025
4f54278
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 13, 2025
ffed35b
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 13, 2025
a5916de
scheduler property
AlessioGr Jun 13, 2025
96fc231
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 15, 2025
35246cd
wip
AlessioGr Jun 15, 2025
36d2f90
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 17, 2025
b105425
typescript
AlessioGr Jun 17, 2025
b9924b0
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 17, 2025
ae2f0d9
done with the function
AlessioGr Jun 19, 2025
318bc04
add local api
AlessioGr Jun 19, 2025
3556edf
handleSchedules endpoint
AlessioGr Jun 19, 2025
5f63875
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 19, 2025
61725cb
bump croner
AlessioGr Jun 19, 2025
caca044
improve example
AlessioGr Jun 19, 2025
f38b580
fix incorrect croner usage
AlessioGr Jun 19, 2025
25228d9
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 19, 2025
0910627
fix: mongodb transform can error if db.updateGlobal is called without…
AlessioGr Jun 19, 2025
3a1c6e9
clarifies the jsdocs for that
AlessioGr Jun 19, 2025
9602a70
make it work
AlessioGr Jun 19, 2025
bbd914a
better ecxample logging, improve waitUntil field admin appearance
AlessioGr Jun 19, 2025
63d8734
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 19, 2025
33092e1
improve return types
AlessioGr Jun 19, 2025
0c90da8
fix: do not shut down jobs on HMR, as we never start them up again af…
AlessioGr Jun 19, 2025
e61ebab
feat: conditionally add meta field to jobs collection
AlessioGr Jun 19, 2025
74aa604
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 23, 2025
6a6fdcb
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 24, 2025
f65bb6c
auto schedule
AlessioGr Jun 24, 2025
d6ba911
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 24, 2025
a13743b
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 24, 2025
87832ab
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 24, 2025
0468ff7
new tests, restructure tests
AlessioGr Jun 24, 2025
a2fe741
add new tests
AlessioGr Jun 24, 2025
8ee0158
add new test
AlessioGr Jun 24, 2025
274bd70
more tests
AlessioGr Jun 25, 2025
5e00a83
fix typo
AlessioGr Jun 25, 2025
490aef8
adds onlyScheduled property by checking job meta json field
AlessioGr Jun 25, 2025
b717af2
add tests for onlyScheduled
AlessioGr Jun 25, 2025
b17326d
docs
AlessioGr Jun 25, 2025
f28d8a1
fix example
AlessioGr Jun 25, 2025
ad798df
link
AlessioGr Jun 25, 2025
06915e5
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 26, 2025
4d9b3ad
use POST
AlessioGr Jun 26, 2025
74a04c7
Revert "use POST"
AlessioGr Jun 26, 2025
b1b259c
handleSchedules => handle-schedules
AlessioGr Jun 26, 2025
a63baf9
add clarifying comments
AlessioGr Jun 26, 2025
2613c4d
reduce flakes
AlessioGr Jun 27, 2025
aabf21f
feat: add silent flag for running jobs (#12931)
AlessioGr Jun 27, 2025
8f16e2d
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jun 27, 2025
c787cca
feat: add job system globals for modifying job system behavior during…
AlessioGr Jun 27, 2025
cd9f60d
feat: add _internal_resetJobSystemGlobals
AlessioGr Jun 27, 2025
2375255
back to the future 5
AlessioGr Jun 27, 2025
e084013
fix naming
AlessioGr Jun 27, 2025
f539c78
more time traveling
AlessioGr Jun 27, 2025
53ce5ca
super duper safe job int shutdown
AlessioGr Jun 27, 2025
323542b
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jul 2, 2025
4442933
enabledStats => stats
AlessioGr Jul 2, 2025
ddf5586
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jul 11, 2025
862a890
use autorun cron and endpoint for job scheduling. No separate endpoint
AlessioGr Jul 11, 2025
1bd01fc
fix build
AlessioGr Jul 11, 2025
ca04873
fix tests
AlessioGr Jul 11, 2025
55aa937
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jul 14, 2025
b7a6b2b
unbreak beautiful new int tests
AlessioGr Jul 14, 2025
55897df
code quality
AlessioGr Jul 14, 2025
f169d94
cleanup, make easier to understand
AlessioGr Jul 14, 2025
318c896
split up test suites to reduce different tests and configurations aff…
AlessioGr Jul 14, 2025
a64b32c
Merge remote-tracking branch 'origin/main' into feat/schedule-jobs
AlessioGr Jul 15, 2025
ea6e63b
remove flake comment - flake does not happen anymore as we no longer …
AlessioGr Jul 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions docs/jobs-queue/schedules.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
title: Job Schedules
label: Schedules
order: 60
desc: Payload allows you to schedule jobs to run periodically
keywords: jobs queue, application framework, typescript, node, react, nextjs, scheduling, cron, schedule
---

Payload's `schedule` property lets you enqueue Jobs regularly according to a cron schedule - daily, weekly, hourly, or any custom interval. This is ideal for tasks or workflows that must repeat automatically and without manual intervention.

Scheduling Jobs differs significantly from running them:

- **Queueing**: Scheduling only creates (enqueues) the Job according to your cron expression. It does not immediately execute any business logic.
- **Running**: Execution happens separately through your Jobs runner - such as autorun, or manual invocation using `payload.jobs.run()` or the `payload-jobs/run` endpoint.

Use the `schedule` property specifically when you have recurring tasks or workflows. To enqueue a single Job to run once in the future, use the `waitUntil` property instead.

## Example use cases

**Regular emails or notifications**

Send nightly digests, weekly newsletters, or hourly updates.

**Batch processing during off-hours**

Process analytics data or rebuild static sites during low-traffic times.

**Periodic data synchronization**

Regularly push or pull updates to or from external APIs.

## Handling schedules

Something needs to actually trigger the scheduling of jobs (execute the scheduling lifecycle seen below). By default, the `jobs.autorun` configuration, as well as the `/api/payload-jobs/run` will also handle scheduling for the queue specified in the `autorun` configuration.

You can disable this behavior by setting `disableScheduling: true` in your `autorun` configuration, or by passing `disableScheduling=true` to the `/api/payload-jobs/run` endpoint. This is useful if you want to handle scheduling manually, for example, by using a cron job or a serverless function that calls the `/api/payload-jobs/handle-schedules` endpoint or the `payload.jobs.handleSchedules()` local API method.

## Defining schedules on Tasks or Workflows

Schedules are defined using the `schedule` property:

```ts
export type ScheduleConfig = {
cron: string // required, supports seconds precision
queue: string // required, the queue to push Jobs onto
hooks?: {
// Optional hooks to customize scheduling behavior
beforeSchedule?: BeforeScheduleFn
afterSchedule?: AfterScheduleFn
}
}
```

### Example schedule

The following example demonstrates scheduling a Job to enqueue every day at midnight:

```ts
import type { TaskConfig } from 'payload'

export const SendDigestEmail: TaskConfig<'SendDigestEmail'> = {
slug: 'SendDigestEmail',
schedule: [
{
cron: '0 0 * * *', // Every day at midnight
queue: 'nightly',
},
],
handler: async () => {
await sendDigestToAllUsers()
},
}
```

This configuration only queues the Job - it does not execute it immediately. To actually run the queued Job, you configure autorun in your Payload config (note that autorun should **not** be used on serverless platforms):

```ts
export default buildConfig({
jobs: {
scheduler: 'cron',
autoRun: [
{
cron: '* * * * *', // Runs every minute
queue: 'nightly',
},
],
tasks: [SendDigestEmail],
},
})
```

That way, Payload's scheduler will automatically enqueue the job into the `nightly` queue every day at midnight. The autorun configuration will check the `nightly` queue every minute and execute any Jobs that are due to run.

## Scheduling lifecycle

Here's how the scheduling process operates in detail:

1. **Cron evaluation**: Payload (or your external trigger in `manual` mode) identifies which schedules are due to run. To do that, it will
read the `payload-jobs-stats` global which contains information about the last time each scheduled task or workflow was run.
2. **BeforeSchedule hook**:
- The default beforeSchedule hook checks how many active or runnable jobs of the same type that have been queued by the scheduling system currently exist.
If such a job exists, it will skip scheduling a new one.
- You can provide your own `beforeSchedule` hook to customize this behavior. For example, you might want to allow multiple overlapping Jobs or dynamically set the Job input data.
3. **Enqueue Job**: Payload queues up a new job. This job will have `waitUntil` set to the next scheduled time based on the cron expression.
4. **AfterSchedule hook**:
- The default afterSchedule hook updates the `payload-jobs-stats` global metadata with the last scheduled time for the Job.
- You can provide your own afterSchedule hook to it for custom logging, metrics, or other post-scheduling actions.

## Customizing concurrency and input (Advanced)

You may want more control over concurrency or dynamically set Job inputs at scheduling time. For instance, allowing multiple overlapping Jobs to be scheduled, even if a previously scheduled job has not completed yet, or preparing dynamic data to pass to your Job handler:

```ts
import { countRunnableOrActiveJobsForQueue } from 'payload'

schedule: [
{
cron: '* * * * *', // every minute
queue: 'reports',
hooks: {
beforeSchedule: async ({ queueable, req }) => {
const runnableOrActiveJobsForQueue =
await countRunnableOrActiveJobsForQueue({
queue: queueable.scheduleConfig.queue,
req,
taskSlug: queueable.taskConfig?.slug,
workflowSlug: queueable.workflowConfig?.slug,
onlyScheduled: true,
})

// Allow up to 3 simultaneous scheduled jobs and set dynamic input
return {
shouldSchedule: runnableOrActiveJobsForQueue < 3,
input: { text: 'Hi there' },
}
},
},
},
]
```

This allows fine-grained control over how many Jobs can run simultaneously and provides dynamically computed input values each time a Job is scheduled.

## Scheduling in serverless environments

On serverless platforms, scheduling must be triggered externally since Payload does not automatically run cron schedules in ephemeral environments. You have two main ways to trigger scheduling manually:

- **Invoke via Payload's API:** `payload.jobs.handleSchedules()`
- **Use the REST API endpoint:** `/api/payload-jobs/handle-schedules`
- **Use the run endpoint, which also handles scheduling by default:** `GET /api/payload-jobs/run`

For example, on Vercel, you can set up a Vercel Cron to regularly trigger scheduling:

- **Vercel Cron Job:** Configure Vercel Cron to periodically call `GET /api/payload-jobs/handle-schedules`. If you would like to auto-run your scheduled jobs as well, you can use the `GET /api/payload-jobs/run` endpoint.

Once Jobs are queued, their execution depends entirely on your configured runner setup (e.g., autorun, or manual invocation).
4 changes: 4 additions & 0 deletions packages/db-mongodb/src/utilities/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,10 @@ export const transform = ({
parentIsLocalized = false,
validateRelationships = true,
}: Args) => {
if (!data) {
return null
}

if (Array.isArray(data)) {
for (const item of data) {
transform({ adapter, data: item, fields, globalSlug, operation, validateRelationships })
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"busboy": "^1.6.0",
"ci-info": "^4.1.0",
"console-table-printer": "2.12.1",
"croner": "9.0.0",
"croner": "9.1.0",
"dataloader": "2.2.3",
"deepmerge": "4.3.1",
"file-type": "19.3.0",
Expand Down
28 changes: 25 additions & 3 deletions packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import {
} from '../locked-documents/config.js'
import { getPreferencesCollection, preferencesCollectionSlug } from '../preferences/config.js'
import { getQueryPresetsConfig, queryPresetsCollectionSlug } from '../query-presets/config.js'
import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/index.js'
import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/collection.js'
import { getJobStatsGlobal } from '../queues/config/global.js'
import { flattenBlock } from '../utilities/flattenAllFields.js'
import { getSchedulePublishTask } from '../versions/schedule/job.js'
import { addDefaultsToConfig } from './defaults.js'
Expand Down Expand Up @@ -300,7 +301,28 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC

// Need to add default jobs collection before locked documents collections
if (config.jobs.enabled) {
let defaultJobsCollection = getDefaultJobsCollection(config as unknown as Config)
// Check for schedule property in both tasks and workflows
const hasScheduleProperty =
(config?.jobs?.tasks?.length && config.jobs.tasks.some((task) => task.schedule)) ||
(config?.jobs?.workflows?.length &&
config.jobs.workflows.some((workflow) => workflow.schedule))

if (hasScheduleProperty) {
config.jobs.scheduling = true
// Add payload-jobs-stats global for tracking when a job of a specific slug was last run
;(config.globals ??= []).push(
await sanitizeGlobal(
config as unknown as Config,
getJobStatsGlobal(config as unknown as Config),
richTextSanitizationPromises,
validRelationships,
),
)

config.jobs.stats = true
}

let defaultJobsCollection = getDefaultJobsCollection(config.jobs)

if (typeof config.jobs.jobsCollectionOverrides === 'function') {
defaultJobsCollection = config.jobs.jobsCollectionOverrides({
Expand Down Expand Up @@ -329,7 +351,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
validRelationships,
)

configWithDefaults.collections!.push(sanitizedJobsCollection)
;(config.collections ??= []).push(sanitizedJobsCollection)
}

configWithDefaults.collections!.push(
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/src/database/defaultUpdateJobs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DatabaseAdapter, Job } from '../index.js'
import type { UpdateJobs } from './types.js'

import { jobsCollectionSlug } from '../queues/config/index.js'
import { jobsCollectionSlug } from '../queues/config/collection.js'

export const defaultUpdateJobs: UpdateJobs = async function updateMany(
this: DatabaseAdapter,
Expand Down
3 changes: 3 additions & 0 deletions packages/payload/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ export interface BaseDatabaseAdapter {
}
}

/**
* Updates a global that exists. If the global doesn't exist yet, this will not work - you should use `createGlobal` instead.
*/
updateGlobal: UpdateGlobal

updateGlobalVersion: UpdateGlobalVersion
Expand Down
50 changes: 39 additions & 11 deletions packages/payload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import { countVersionsLocal } from './collections/operations/local/countVersions
import { consoleEmailAdapter } from './email/consoleEmailAdapter.js'
import { fieldAffectsData, type FlattenedBlock } from './fields/config/types.js'
import { getJobsLocalAPI } from './queues/localAPI.js'
import { _internal_jobSystemGlobals } from './queues/utilities/getCurrentDate.js'
import { isNextBuild } from './utilities/isNextBuild.js'
import { getLogger } from './utilities/logger.js'
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
Expand Down Expand Up @@ -847,24 +848,38 @@ export class BasePayload {

await Promise.all(
cronJobs.map((cronConfig) => {
const job = new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => {
const jobAutorunCron = new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => {
if (
_internal_jobSystemGlobals.shouldAutoSchedule &&
!cronConfig.disableScheduling &&
this.config.jobs.scheduling
) {
await this.jobs.handleSchedules({
queue: cronConfig.queue,
})
}

if (!_internal_jobSystemGlobals.shouldAutoRun) {
return
}

if (typeof this.config.jobs.shouldAutoRun === 'function') {
const shouldAutoRun = await this.config.jobs.shouldAutoRun(this)

if (!shouldAutoRun) {
job.stop()

return false
jobAutorunCron.stop()
return
}
}

await this.jobs.run({
limit: cronConfig.limit ?? DEFAULT_LIMIT,
queue: cronConfig.queue,
silent: cronConfig.silent,
})
})

this.crons.push(job)
this.crons.push(jobAutorunCron)
}),
)
}
Expand Down Expand Up @@ -913,8 +928,10 @@ export const reload = async (
payload: Payload,
skipImportMapGeneration?: boolean,
): Promise<void> => {
await payload.destroy()

if (typeof payload.db.destroy === 'function') {
// Only destroy db, as we then later only call payload.db.init and not payload.init
await payload.db.destroy()
}
payload.config = config

payload.collections = config.collections.reduce(
Expand Down Expand Up @@ -1158,6 +1175,7 @@ export type {

export type { CompoundIndex } from './collections/config/types.js'
export type { SanitizedCompoundIndex } from './collections/config/types.js'

export { createDataloaderCacheKey, getDataLoader } from './collections/dataloader.js'
export { countOperation } from './collections/operations/count.js'
export { createOperation } from './collections/operations/create.js'
Expand All @@ -1174,7 +1192,6 @@ export { updateOperation } from './collections/operations/update.js'
export { updateByIDOperation } from './collections/operations/updateByID.js'

export { buildConfig } from './config/build.js'

export {
type ClientConfig,
createClientConfig,
Expand All @@ -1183,6 +1200,7 @@ export {
type UnsanitizedClientConfig,
} from './config/client.js'
export { defaults } from './config/defaults.js'

export { type OrderableEndpointBody } from './config/orderable/index.js'
export { sanitizeConfig } from './config/sanitize.js'
export type * from './config/types.js'
Expand Down Expand Up @@ -1301,6 +1319,7 @@ export {

export type { ValidationFieldError } from './errors/index.js'
export { baseBlockFields } from './fields/baseFields/baseBlockFields.js'

export { baseIDField } from './fields/baseFields/baseIDField.js'

export {
Expand Down Expand Up @@ -1424,13 +1443,15 @@ export type {

export { getDefaultValue } from './fields/getDefaultValue.js'
export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js'

export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js'
export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js'
export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js'
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'

export { sortableFieldTypes } from './fields/sortableFieldTypes.js'
export { validations } from './fields/validations.js'

export type {
ArrayFieldValidation,
BlocksFieldValidation,
Expand Down Expand Up @@ -1485,6 +1506,7 @@ export type {

export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js'
export { findOneOperation } from './globals/operations/findOne.js'

export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js'
export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js'
export { restoreVersionOperation as restoreVersionOperationGlobal } from './globals/operations/restoreVersion.js'
Expand All @@ -1505,8 +1527,7 @@ export type {
TabsPreferences,
} from './preferences/types.js'
export type { QueryPreset } from './query-presets/types.js'
export { jobAfterRead } from './queues/config/index.js'

export { jobAfterRead } from './queues/config/collection.js'
export type { JobsConfig, RunJobAccess, RunJobAccessArgs } from './queues/config/types/index.js'
export type {
RunInlineTaskFunction,
Expand All @@ -1521,6 +1542,7 @@ export type {
TaskOutput,
TaskType,
} from './queues/config/types/taskTypes.js'

export type {
BaseJob,
JobLog,
Expand All @@ -1531,8 +1553,14 @@ export type {
WorkflowHandler,
WorkflowTypes,
} from './queues/config/types/workflowTypes.js'

export { countRunnableOrActiveJobsForQueue } from './queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.js'
export { importHandlerPath } from './queues/operations/runJobs/runJob/importHandlerPath.js'

export {
_internal_jobSystemGlobals,
_internal_resetJobSystemGlobals,
getCurrentDate,
} from './queues/utilities/getCurrentDate.js'
export { getLocalI18n } from './translations/getLocalI18n.js'
export * from './types/index.js'
export { getFileByPath } from './uploads/getFileByPath.js'
Expand Down
Loading
Loading