From 7006673ab031c7480afd1278574df73844ee5624 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Fri, 22 Aug 2025 16:56:32 -0700 Subject: [PATCH] feat: crons for all bin scripts, new jobs:handle-schedules script --- docs/configuration/overview.mdx | 10 +++ docs/jobs-queue/queues.mdx | 14 ++-- docs/jobs-queue/schedules.mdx | 14 ++++ packages/payload/src/bin/index.ts | 105 ++++++++++++++++++++---------- packages/payload/src/index.ts | 4 +- 5 files changed, 107 insertions(+), 40 deletions(-) diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index cbbd35d068..dd697723cd 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -318,3 +318,13 @@ Now you can run the command using: ```sh pnpm payload seed ``` + +## Running bin scripts on a schedule + +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: + +```sh +pnpm payload run ./myScript.ts --cron "0 * * * *" +``` + +This will use the `run` bin script to execute the specified script on the defined schedule. diff --git a/docs/jobs-queue/queues.mdx b/docs/jobs-queue/queues.mdx index 6b0172df2c..063538267f 100644 --- a/docs/jobs-queue/queues.mdx +++ b/docs/jobs-queue/queues.mdx @@ -173,25 +173,31 @@ const results = await payload.jobs.runByID({ 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: ```sh -npx payload jobs:run +pnpm payload jobs:run ``` You can override the default queue and limit by passing the `--queue` and `--limit` flags: ```sh -npx payload jobs:run --queue myQueue --limit 15 +pnpm payload jobs:run --queue myQueue --limit 15 ``` If you want to run all jobs from all queues, you can pass the `--all-queues` flag: ```sh -npx payload jobs:run --all-queues +pnpm payload jobs:run --all-queues ``` 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: ```sh -npx payload jobs:run --cron "*/5 * * * *" +pnpm payload jobs:run --cron "*/5 * * * *" +``` + +You can also pass `--handle-schedules` flag to the `jobs:run` command to make it schedule jobs according to configured schedules: + +```sh +pnpm payload jobs:run --queue myQueue --handle-schedules # This will both schedule jobs according to the configuration and run them ``` ## Processing Order diff --git a/docs/jobs-queue/schedules.mdx b/docs/jobs-queue/schedules.mdx index 45869d8ca6..ed04c89a2c 100644 --- a/docs/jobs-queue/schedules.mdx +++ b/docs/jobs-queue/schedules.mdx @@ -35,6 +35,20 @@ Something needs to actually trigger the scheduling of jobs (execute the scheduli 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. +### Bin Scripts + +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: + +```sh +pnpm payload jobs:run --queue myQueue --handle-schedules # This will both schedule jobs according to the configuration and run them +``` + +If you only want to handle schedules, you can use the dedicated `jobs:handle-schedules` bin script: + +```sh +pnpm payload jobs:handle-schedules --queue myQueue # or --all-queues +``` + ## Defining schedules on Tasks or Workflows Schedules are defined using the `schedule` property: diff --git a/packages/payload/src/bin/index.ts b/packages/payload/src/bin/index.ts index 6bd604a373..c9a2867330 100755 --- a/packages/payload/src/bin/index.ts +++ b/packages/payload/src/bin/index.ts @@ -1,4 +1,3 @@ -// @ts-strict-ignore /* eslint-disable no-console */ import { Cron } from 'croner' import minimist from 'minimist' @@ -6,7 +5,7 @@ import { pathToFileURL } from 'node:url' import path from 'path' import { findConfig } from '../config/find.js' -import payload, { getPayload } from '../index.js' +import { getPayload, type Payload } from '../index.js' import { generateImportMap } from './generateImportMap/index.js' import { generateTypes } from './generateTypes.js' import { info } from './info.js' @@ -20,19 +19,54 @@ const availableScripts = [ 'generate:types', 'info', 'jobs:run', + 'jobs:handle-schedules', 'run', ...migrateCommands, ] as const export const bin = async () => { loadEnv() + process.env.DISABLE_PAYLOAD_HMR = 'true' const args = minimist(process.argv.slice(2)) const script = (typeof args._[0] === 'string' ? args._[0] : '').toLowerCase() + if (args.cron) { + new Cron(args.cron, async () => { + // If the bin script initializes payload (getPayload), this will only happen once, as getPayload + // caches the payload instance on the module scope => no need to manually cache and manage getPayload initialization + // outside the Cron here. + await runBinScript({ args, script }) + }) + + process.stdin.resume() // Keep the process alive + + return + } else { + const { payload } = await runBinScript({ args, script }) + if (payload) { + await payload.destroy() // close database connections after running jobs so process can exit cleanly + } + process.exit(0) + } +} + +async function runBinScript({ + args, + script, +}: { + args: minimist.ParsedArgs + script: string +}): Promise<{ + /** + * Scripts can return a payload instance if it exists. The bin script runner can then safely + * shut off the instance, depending on if it's running in a cron job or not. + */ + payload?: Payload +}> { if (script === 'info') { await info() - return + return {} } if (script === 'run') { @@ -58,7 +92,7 @@ export const bin = async () => { // Restore original process.argv process.argv = originalArgv } - return + return {} } const configPath = findConfig() @@ -91,19 +125,22 @@ export const bin = async () => { console.error(err) } - return + return {} } if (script.startsWith('migrate')) { - return migrate({ config, parsedArgs: args }).then(() => process.exit(0)) + await migrate({ config, parsedArgs: args }) + return {} } if (script === 'generate:types') { - return generateTypes(config) + await generateTypes(config) + return {} } if (script === 'generate:importmap') { - return generateImportMap(config) + await generateImportMap(config) + return {} } if (script === 'jobs:run') { @@ -111,45 +148,47 @@ export const bin = async () => { const limit = args.limit ? parseInt(args.limit, 10) : undefined const queue = args.queue ? args.queue : undefined const allQueues = !!args.allQueues + const handleSchedules = !!args.handleSchedules - if (args.cron) { - new Cron(args.cron, async () => { - await payload.jobs.run({ - allQueues, - limit, - queue, - }) - }) - - process.stdin.resume() // Keep the process alive - - return - } else { - await payload.jobs.run({ + if (handleSchedules) { + await payload.jobs.handleSchedules({ allQueues, - limit, queue, }) - - await payload.destroy() // close database connections after running jobs so process can exit cleanly - - process.exit(0) } + + await payload.jobs.run({ + allQueues, + limit, + queue, + }) + + return { payload } + } + + if (script === 'jobs:handle-schedules') { + const payload = await getPayload({ config }) // Do not setup crons here - this bin script can set up its own crons + const queue = args.queue ? args.queue : undefined + const allQueues = !!args.allQueues + + await payload.jobs.handleSchedules({ + allQueues, + queue, + }) + + return { payload } } if (script === 'generate:db-schema') { // Barebones instance to access database adapter, without connecting to the DB - await payload.init({ - config, - disableDBConnect: true, - disableOnInit: true, - }) + const payload = await getPayload({ config, disableDBConnect: true, disableOnInit: true }) // Do not setup crons here if (typeof payload.db.generateSchema !== 'function') { payload.logger.error({ msg: `${payload.db.packageName} does not support database schema generation`, }) + await payload.destroy() process.exit(1) } @@ -158,7 +197,7 @@ export const bin = async () => { prettify: args.prettify === 'false' ? false : true, }) - process.exit(0) + return { payload } } console.error(script ? `Unknown command: "${script}"` : 'Please provide a command to run') diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index d23a502f40..724ed891d0 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1010,9 +1010,7 @@ export const reload = async ( ;(global as any)._payload_doNotCacheClientSchemaMap = true } -export const getPayload = async ( - options: Pick, -): Promise => { +export const getPayload = async (options: InitOptions): Promise => { if (!options?.config) { throw new Error('Error: the payload config is required for getPayload to work.') }