Skip to content

Commit cdeb828

Browse files
authored
feat: crons for all bin scripts, new jobs:handle-schedules script, more reliable job system crons (#13564)
## New jobs:handle-schedules bin script Similarly to `payload jobs:run`, this PR adds a new `jobs:handle-schedules` bin script which only handles scheduling. ## Allows jobs:run bin script to handle scheduling Similarly to how [payload autoRun](https://payloadcms.com/docs/jobs-queue/queues#cron-jobs) handles both running and scheduling jobs by default, you can now set the `payload jobs:run` bin script to also handle scheduling. This is opt-in: ```sh pnpm payload jobs:run --cron "*/5 * * * *" --queue myQueue --handle-schedules # This will both schedule jobs according to the configuration and run them ``` ## Cron schedules for all bin scripts Previously, only the `payload jobs:run` bin script accepted a cron flag. The `payload jobs:handle-schedules` would have required the same logic to also handle a cron flag. Instead of opting for this duplicative logic, I'm now handling cron logic before we determine which script to run. This means: it's simpler and requires less duplicative code. **This allows all other bin scripts (including custom ones) to use the `--cron` flag**, enabling cool use-cases like scheduling your own custom scripts - no additional config required! Example: ```sh pnpm payload run ./myScript.ts --cron "0 * * * *" ``` Video Example: https://github.com/user-attachments/assets/4ded738d-2ef9-43ea-8136-f47f913a7ba8 ## More reliable job system crons When using autorun or `--cron`, if one cron run takes longer than the cron interval, the second cron would run before the first one finishes. This can be especially dangerous when running jobs using a bin script, potentially causing race conditions, as the first cron run will take longer due to payload initialization overhead (only for first cron run, consecutive ones use cached payload). Now, consecutive cron runs will wait for the first one to finish by using the `{ protect: true }` property of Croner. This change will affect both autorun and bin scripts. ## Cleanup - Centralized payload instance cleanup (payload.destroy()) for all bin scripts - The `getPayload` function arguments were not properly typed. Arguments like `disableOnInit: true` are already supported, but the type did not reflect that. This simplifies the type and makes it more accurate. ## Fixes - `allQueues` argument for `payload jobs:run` was not respected --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211124797199077
1 parent b34e5ea commit cdeb828

File tree

5 files changed

+154
-68
lines changed

5 files changed

+154
-68
lines changed

docs/configuration/overview.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,13 @@ Now you can run the command using:
319319
```sh
320320
pnpm payload seed
321321
```
322+
323+
## Running bin scripts on a schedule
324+
325+
Every bin script supports being run on a schedule using cron syntax. Simply pass the `--cron` flag followed by the cron expression when running the script. Example:
326+
327+
```sh
328+
pnpm payload run ./myScript.ts --cron "0 * * * *"
329+
```
330+
331+
This will use the `run` bin script to execute the specified script on the defined schedule.

docs/jobs-queue/queues.mdx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,25 +173,31 @@ const results = await payload.jobs.runByID({
173173
Finally, you can process jobs via the bin script that comes with Payload out of the box. By default, this script will run jobs from the `default` queue, with a limit of 10 jobs per invocation:
174174

175175
```sh
176-
npx payload jobs:run
176+
pnpm payload jobs:run
177177
```
178178

179179
You can override the default queue and limit by passing the `--queue` and `--limit` flags:
180180

181181
```sh
182-
npx payload jobs:run --queue myQueue --limit 15
182+
pnpm payload jobs:run --queue myQueue --limit 15
183183
```
184184

185185
If you want to run all jobs from all queues, you can pass the `--all-queues` flag:
186186

187187
```sh
188-
npx payload jobs:run --all-queues
188+
pnpm payload jobs:run --all-queues
189189
```
190190

191191
In addition, the bin script allows you to pass a `--cron` flag to the `jobs:run` command to run the jobs on a scheduled, cron basis:
192192

193193
```sh
194-
npx payload jobs:run --cron "*/5 * * * *"
194+
pnpm payload jobs:run --cron "*/5 * * * *"
195+
```
196+
197+
You can also pass `--handle-schedules` flag to the `jobs:run` command to make it schedule jobs according to configured schedules:
198+
199+
```sh
200+
pnpm payload jobs:run --cron "*/5 * * * *" --queue myQueue --handle-schedules # This will both schedule jobs according to the configuration and run them
195201
```
196202

197203
## Processing Order

docs/jobs-queue/schedules.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ Something needs to actually trigger the scheduling of jobs (execute the scheduli
3535

3636
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.
3737

38+
### Bin Scripts
39+
40+
Payload provides a set of bin scripts that can be used to handle schedules. If you're already using the `jobs:run` bin script, you can set it to also handle schedules by passing the `--handle-schedules` flag:
41+
42+
```sh
43+
pnpm payload jobs:run --cron "*/5 * * * *" --queue myQueue --handle-schedules # This will both schedule jobs according to the configuration and run them
44+
```
45+
46+
If you only want to handle schedules, you can use the dedicated `jobs:handle-schedules` bin script:
47+
48+
```sh
49+
pnpm payload jobs:handle-schedules --cron "*/5 * * * *" --queue myQueue # or --all-queues
50+
```
51+
3852
## Defining schedules on Tasks or Workflows
3953

4054
Schedules are defined using the `schedule` property:

packages/payload/src/bin/index.ts

Lines changed: 79 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
// @ts-strict-ignore
21
/* eslint-disable no-console */
32
import { Cron } from 'croner'
43
import minimist from 'minimist'
54
import { pathToFileURL } from 'node:url'
65
import path from 'path'
76

87
import { findConfig } from '../config/find.js'
9-
import payload, { getPayload } from '../index.js'
8+
import { getPayload, type Payload } from '../index.js'
109
import { generateImportMap } from './generateImportMap/index.js'
1110
import { generateTypes } from './generateTypes.js'
1211
import { info } from './info.js'
@@ -20,19 +19,61 @@ const availableScripts = [
2019
'generate:types',
2120
'info',
2221
'jobs:run',
22+
'jobs:handle-schedules',
2323
'run',
2424
...migrateCommands,
2525
] as const
2626

2727
export const bin = async () => {
2828
loadEnv()
29+
process.env.DISABLE_PAYLOAD_HMR = 'true'
2930

3031
const args = minimist(process.argv.slice(2))
3132
const script = (typeof args._[0] === 'string' ? args._[0] : '').toLowerCase()
3233

34+
if (args.cron) {
35+
new Cron(
36+
args.cron,
37+
async () => {
38+
// If the bin script initializes payload (getPayload), this will only happen once, as getPayload
39+
// caches the payload instance on the module scope => no need to manually cache and manage getPayload initialization
40+
// outside the Cron here.
41+
await runBinScript({ args, script })
42+
},
43+
{
44+
// Do not run consecutive crons if previous crons still ongoing
45+
protect: true,
46+
},
47+
)
48+
49+
process.stdin.resume() // Keep the process alive
50+
51+
return
52+
} else {
53+
const { payload } = await runBinScript({ args, script })
54+
if (payload) {
55+
await payload.destroy() // close database connections after running jobs so process can exit cleanly
56+
}
57+
process.exit(0)
58+
}
59+
}
60+
61+
async function runBinScript({
62+
args,
63+
script,
64+
}: {
65+
args: minimist.ParsedArgs
66+
script: string
67+
}): Promise<{
68+
/**
69+
* Scripts can return a payload instance if it exists. The bin script runner can then safely
70+
* shut off the instance, depending on if it's running in a cron job or not.
71+
*/
72+
payload?: Payload
73+
}> {
3374
if (script === 'info') {
3475
await info()
35-
return
76+
return {}
3677
}
3778

3879
if (script === 'run') {
@@ -58,7 +99,7 @@ export const bin = async () => {
5899
// Restore original process.argv
59100
process.argv = originalArgv
60101
}
61-
return
102+
return {}
62103
}
63104

64105
const configPath = findConfig()
@@ -91,65 +132,70 @@ export const bin = async () => {
91132
console.error(err)
92133
}
93134

94-
return
135+
return {}
95136
}
96137

97138
if (script.startsWith('migrate')) {
98-
return migrate({ config, parsedArgs: args }).then(() => process.exit(0))
139+
await migrate({ config, parsedArgs: args })
140+
return {}
99141
}
100142

101143
if (script === 'generate:types') {
102-
return generateTypes(config)
144+
await generateTypes(config)
145+
return {}
103146
}
104147

105148
if (script === 'generate:importmap') {
106-
return generateImportMap(config)
149+
await generateImportMap(config)
150+
return {}
107151
}
108152

109153
if (script === 'jobs:run') {
110154
const payload = await getPayload({ config }) // Do not setup crons here - this bin script can set up its own crons
111155
const limit = args.limit ? parseInt(args.limit, 10) : undefined
112156
const queue = args.queue ? args.queue : undefined
113-
const allQueues = !!args.allQueues
114-
115-
if (args.cron) {
116-
new Cron(args.cron, async () => {
117-
await payload.jobs.run({
118-
allQueues,
119-
limit,
120-
queue,
121-
})
122-
})
123-
124-
process.stdin.resume() // Keep the process alive
157+
const allQueues = !!args['all-queues']
158+
const handleSchedules = !!args['handle-schedules']
125159

126-
return
127-
} else {
128-
await payload.jobs.run({
160+
if (handleSchedules) {
161+
await payload.jobs.handleSchedules({
129162
allQueues,
130-
limit,
131163
queue,
132164
})
165+
}
133166

134-
await payload.destroy() // close database connections after running jobs so process can exit cleanly
167+
await payload.jobs.run({
168+
allQueues,
169+
limit,
170+
queue,
171+
})
135172

136-
process.exit(0)
137-
}
173+
return { payload }
174+
}
175+
176+
if (script === 'jobs:handle-schedules') {
177+
const payload = await getPayload({ config }) // Do not setup crons here - this bin script can set up its own crons
178+
const queue = args.queue ? args.queue : undefined
179+
const allQueues = !!args['all-queues']
180+
181+
await payload.jobs.handleSchedules({
182+
allQueues,
183+
queue,
184+
})
185+
186+
return { payload }
138187
}
139188

140189
if (script === 'generate:db-schema') {
141190
// Barebones instance to access database adapter, without connecting to the DB
142-
await payload.init({
143-
config,
144-
disableDBConnect: true,
145-
disableOnInit: true,
146-
})
191+
const payload = await getPayload({ config, disableDBConnect: true, disableOnInit: true }) // Do not setup crons here
147192

148193
if (typeof payload.db.generateSchema !== 'function') {
149194
payload.logger.error({
150195
msg: `${payload.db.packageName} does not support database schema generation`,
151196
})
152197

198+
await payload.destroy()
153199
process.exit(1)
154200
}
155201

@@ -158,7 +204,7 @@ export const bin = async () => {
158204
prettify: args.prettify === 'false' ? false : true,
159205
})
160206

161-
process.exit(0)
207+
return { payload }
162208
}
163209

164210
console.error(script ? `Unknown command: "${script}"` : 'Please provide a command to run')

packages/payload/src/index.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -637,38 +637,45 @@ export class BasePayload {
637637

638638
await Promise.all(
639639
cronJobs.map((cronConfig) => {
640-
const jobAutorunCron = new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => {
641-
if (
642-
_internal_jobSystemGlobals.shouldAutoSchedule &&
643-
!cronConfig.disableScheduling &&
644-
this.config.jobs.scheduling
645-
) {
646-
await this.jobs.handleSchedules({
647-
allQueues: cronConfig.allQueues,
648-
queue: cronConfig.queue,
649-
})
650-
}
640+
const jobAutorunCron = new Cron(
641+
cronConfig.cron ?? DEFAULT_CRON,
642+
async () => {
643+
if (
644+
_internal_jobSystemGlobals.shouldAutoSchedule &&
645+
!cronConfig.disableScheduling &&
646+
this.config.jobs.scheduling
647+
) {
648+
await this.jobs.handleSchedules({
649+
allQueues: cronConfig.allQueues,
650+
queue: cronConfig.queue,
651+
})
652+
}
651653

652-
if (!_internal_jobSystemGlobals.shouldAutoRun) {
653-
return
654-
}
654+
if (!_internal_jobSystemGlobals.shouldAutoRun) {
655+
return
656+
}
655657

656-
if (typeof this.config.jobs.shouldAutoRun === 'function') {
657-
const shouldAutoRun = await this.config.jobs.shouldAutoRun(this)
658+
if (typeof this.config.jobs.shouldAutoRun === 'function') {
659+
const shouldAutoRun = await this.config.jobs.shouldAutoRun(this)
658660

659-
if (!shouldAutoRun) {
660-
jobAutorunCron.stop()
661-
return
661+
if (!shouldAutoRun) {
662+
jobAutorunCron.stop()
663+
return
664+
}
662665
}
663-
}
664666

665-
await this.jobs.run({
666-
allQueues: cronConfig.allQueues,
667-
limit: cronConfig.limit ?? DEFAULT_LIMIT,
668-
queue: cronConfig.queue,
669-
silent: cronConfig.silent,
670-
})
671-
})
667+
await this.jobs.run({
668+
allQueues: cronConfig.allQueues,
669+
limit: cronConfig.limit ?? DEFAULT_LIMIT,
670+
queue: cronConfig.queue,
671+
silent: cronConfig.silent,
672+
})
673+
},
674+
{
675+
// Do not run consecutive crons if previous crons still ongoing
676+
protect: true,
677+
},
678+
)
672679

673680
this.crons.push(jobAutorunCron)
674681
}),
@@ -942,6 +949,7 @@ export const reload = async (
942949
config: SanitizedConfig,
943950
payload: Payload,
944951
skipImportMapGeneration?: boolean,
952+
options?: InitOptions,
945953
): Promise<void> => {
946954
if (typeof payload.db.destroy === 'function') {
947955
// Only destroy db, as we then later only call payload.db.init and not payload.init
@@ -991,9 +999,11 @@ export const reload = async (
991999
})
9921000
}
9931001

994-
await payload.db.init?.()
1002+
if (payload.db?.init) {
1003+
await payload.db.init()
1004+
}
9951005

996-
if (payload.db.connect) {
1006+
if (!options?.disableDBConnect && payload.db.connect) {
9971007
await payload.db.connect({ hotReload: true })
9981008
}
9991009

@@ -1037,7 +1047,7 @@ export const getPayload = async (
10371047
* @default 'default'
10381048
*/
10391049
key?: string
1040-
} & Pick<InitOptions, 'config' | 'cron' | 'disableOnInit' | 'importMap'>,
1050+
} & InitOptions,
10411051
): Promise<Payload> => {
10421052
if (!options?.config) {
10431053
throw new Error('Error: the payload config is required for getPayload to work.')
@@ -1080,7 +1090,7 @@ export const getPayload = async (
10801090
// will reach `if (cached.reload instanceof Promise) {` which then waits for the first reload to finish.
10811091
cached.reload = new Promise((res) => (resolve = res))
10821092
const config = await options.config
1083-
await reload(config, cached.payload, !options.importMap)
1093+
await reload(config, cached.payload, !options.importMap, options)
10841094

10851095
resolve()
10861096
}

0 commit comments

Comments
 (0)