Skip to main content

jobs-queue

This skill should be used when the user asks about "Payload jobs queue", "Payload background tasks", "Payload workflows", "Payload cron scheduling", "Payload task retries", "Payload runJobs", "Payload autoRun", "queue a job in Payload", or needs to run any background or scheduled work in PayloadCMS.

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 4, 2026 at 20:44
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
jobs-queue
description
This skill should be used when the user asks about "Payload jobs queue", "Payload background tasks", "Payload workflows", "Payload cron scheduling", "Payload task retries", "Payload runJobs", "Payload autoRun", "queue a job in Payload", or needs to run any background or scheduled work in PayloadCMS.
# PayloadCMS — Jobs Queue Payload ships a built-in job queue: durable background work backed by your DB. Two primitives: - **Task** — a single unit of work with typed input/output, retries, and timeout. - **Workflow** — a sequence of inline tasks composed in code. Failures resume from the last successful step. Jobs are stored in the `payload-jobs` collection (auto-created). You don't run a separate worker process unless you want to — Payload can execute jobs in-process on a schedule, or you can trigger them with `payload.jobs.run()` from a cron or webhook. > **Breaking since 3.89.0 — `payload-jobs` is closed by default.** The collection ships with `create`, `read`, `update` and `delete` all returning `false` (and stays hidden in the Admin Panel), because job inputs, outputs, errors and logs can hold sensitive data. REST/GraphQL CRUD on `payload-jobs` and Local API calls with `overrideAccess: false` are rejected. `payload.jobs.queue/run/runByID/cancel/handleSchedules` keep working, and so do `GET /api/payload-jobs/run` and `GET /api/payload-jobs/handle-schedules` — the endpoints are gated by `jobs.access.run`, not by the collection's access. To open the collection deliberately, see "Access to `payload-jobs`" below. ## Defining a Task ```ts // src/tasks/sendOrderConfirmation.ts import type { TaskConfig } from 'payload' export const sendOrderConfirmation: TaskConfig<'sendOrderConfirmation'> = { slug: 'sendOrderConfirmation', inputSchema: [ { name: 'orderId', type: 'text', required: true }, { name: 'email', type: 'email', required: true }, ], outputSchema: [ { name: 'messageId', type: 'text' }, { name: 'sentAt', type: 'date' }, ], retries: 3, // Attempts after first failure handler: async ({ input, req }) => { const order = await req.payload.findByID({ collection: 'orders', id: input.orderId, req, }) const result = await req.payload.sendEmail({ to: input.email, subject: `Order ${order.id} confirmed`, html: `<p>Thanks for your order.</p>`, }) return { output: { messageId: (result as any).messageId, sentAt: new Date().toISOString(), }, } }, } ``` Register tasks under `jobs.tasks` in `payload.config.ts`: ```ts import { sendOrderConfirmation } from './tasks/sendOrderConfirmation' import { renderPDF } from './tasks/renderPDF' export default buildConfig({ // … jobs: { tasks: [sendOrderConfirmation, renderPDF], }, }) ``` ## Queueing a Task From a hook, endpoint, or anywhere with a Payload instance: ```ts await req.payload.jobs.queue({ task: 'sendOrderConfirmation', input: { orderId: order.id, email: order.customer.email, }, waitUntil: new Date(Date.now() + 60_000), // Delay 60s // queue: 'default', // Optional named queue }) ``` Returns a `Job` document — query `payload-jobs` to see status, retries, output. ## Workflows — Multi-Step Jobs A workflow runs a sequence of inline tasks. State is persisted between steps so retries pick up where they left off: ```ts import type { WorkflowConfig } from 'payload' export const onboardUser: WorkflowConfig<'onboardUser'> = { slug: 'onboardUser', inputSchema: [{ name: 'userId', type: 'text', required: true }], handler: async ({ job, req, tasks }) => { // Step 1: provision account const provisioned = await tasks.inline({ task: 'provisionAccount', input: { userId: job.input.userId }, retries: 2, }) // Step 2: send welcome email await tasks.inline({ task: 'sendWelcomeEmail', input: { userId: job.input.userId, plan: provisioned.output.plan }, }) // Step 3: notify Slack await tasks.inline({ task: 'notifySlack', input: { event: 'user.onboarded', userId: job.input.userId }, }) }, } ``` Register under `jobs.workflows`: ```ts jobs: { tasks: [provisionAccount, sendWelcomeEmail, notifySlack], workflows: [onboardUser], }, ``` Queue it: ```ts await payload.jobs.queue({ workflow: 'onboardUser', input: { userId: newUser.id }, }) ``` If `sendWelcomeEmail` fails, retrying the job re-runs from `sendWelcomeEmail` — `provisionAccount` is skipped because Payload remembers its output. ## Running Queued Jobs > **Scheduled publish depends on this.** `versions.drafts.schedulePublish` enqueues publish/unpublish jobs — without a running processor (autoRun, `payload jobs:run`, or the run endpoint) those events never fire. ### In-process autoRun For low-volume apps, let Payload poll and run jobs automatically: ```ts jobs: { tasks: [/* … */], autoRun: [ { cron: '*/5 * * * *', // Every 5 minutes limit: 10, // Up to 10 jobs per tick queue: 'default', // disableScheduling: true, // Skip enqueueing due `schedule` entries on this tick }, { cron: '0 * * * *', // Hourly queue: 'heavy', }, ], shouldAutoRun: (payload) => process.env.ENABLE_JOBS === 'true', // receives the payload instance // Processing order: FIFO by default ('createdAt'); flip per queue: processingOrder: { default: 'createdAt', queues: { nightly: '-createdAt' } }, // LIFO for 'nightly' }, ``` Set `ENABLE_JOBS=true` on exactly one server instance — otherwise multiple replicas race for the same jobs. ### External cron / webhook For higher throughput or dedicated workers, hit Payload's job endpoint from outside: ```bash # GET only (POST reaches it solely via the 'X-Payload-HTTP-Method-Override: GET' header); # query params: limit (default 10), queue (default 'default'), allQueues=true curl 'https://app.example.com/api/payload-jobs/run?limit=100&queue=nightly' \ -H "Authorization: Bearer $CRON_SECRET" ``` Gate the endpoint via `jobs.access.run` in config — check the Authorization header (e.g. Vercel Cron's `CRON_SECRET`): ```ts jobs: { access: { run: ({ req }): boolean => { const authHeader = req.headers.get('authorization') return authHeader === `Bearer ${process.env.CRON_SECRET}` }, }, }, ``` Vercel cron config: ```jsonc // vercel.json { "crons": [{ "path": "/api/payload-jobs/run", "schedule": "*/5 * * * *" }] } ``` Or call from a Node script / Trigger.dev / GitHub Actions workflow: ```ts const { results } = await payload.jobs.run({ queue: 'default', limit: 50, }) ``` ### Bin scripts (dedicated worker) Run jobs in a **separate process** — no impact on API response times: ```bash # Drain queues on a schedule (flags: --queue, --limit, --cron, --handle-schedules, --all-queues) pnpm payload jobs:run --queue default --limit 10 --cron "*/5 * * * *" # Enqueue due scheduled jobs only (no execution) pnpm payload jobs:handle-schedules --cron "*/5 * * * *" ``` This is the recommended runner on dedicated servers. See the `cli-recipes` skill for flag details. ### Cron-only tasks (scheduled work) Combine `cron` with `autoRun` to schedule recurring tasks: ```ts jobs: { tasks: [ { slug: 'cleanupExpiredSessions', retries: 0, handler: async ({ req }) => { await req.payload.delete({ collection: 'sessions', where: { expiresAt: { less_than: new Date().toISOString() } }, req, }) return { output: {} } }, }, ], autoRun: [{ cron: '0 3 * * *', queue: 'cron' }], }, ``` ### Declarative job schedules (`schedule`) Newer Payload versions let a task or workflow declare its own recurring schedule with a `schedule` array, instead of wiring every cron in the central `autoRun`. Scheduling only **enqueues** jobs — you still need a runner (`autoRun`, a bin script, or an API trigger) to execute them. ```ts // src/jobs/SendDigestEmail.ts import type { TaskConfig } from 'payload' export const SendDigestEmail: TaskConfig = { slug: 'SendDigestEmail', schedule: [ { cron: '0 0 * * *', // every day at midnight (5- or 6-field cron) queue: 'nightly', // queue the enqueued job lands in hooks: { // beforeSchedule: control concurrency / inject dynamic input // afterSchedule: log or emit metrics after queueing }, }, ], handler: async () => { /* … */ return { output: {} } }, } ``` Use `schedule` when the cadence belongs with the task definition; use `autoRun` when you want one place to govern which queues run in this process. They compose — `schedule` decides *when to enqueue*, `autoRun`/`payload.jobs.run()` decides *when to drain*. Details worth knowing: - `queue` is **required** in each `ScheduleConfig` entry; the cron expression supports **seconds precision** (6-field form). - Due-schedule tracking lives in the auto-created `payload-jobs-stats` global. - `beforeSchedule` can return `{ shouldSchedule, input }` — use the provided `countRunnableOrActiveJobsForQueue` helper to cap concurrency. - Scheduling is executed by autoRun ticks (unless the autoRun entry sets `disableScheduling: true`), by `payload jobs:run --handle-schedules`, by `GET /api/payload-jobs/handle-schedules`, or programmatically via `await payload.jobs.handleSchedules()`. ## Concurrency Control Prevent two jobs that touch the same record from running at once — or collapse a burst of identical jobs into the latest one. Opt in globally, then give a task or workflow a `concurrency` key: ```ts jobs: { enableConcurrencyControl: true, // adds an indexed `concurrencyKey` field to payload-jobs tasks: [ { slug: 'syncCustomer', inputSchema: [{ name: 'customerId', type: 'text', required: true }], // Shorthand: return the key; only one job per key runs at a time (exclusive: true) concurrency: ({ input }) => `customer:${input.customerId}`, handler: async ({ input }) => ({ output: {} }), }, { slug: 'rebuildSearchIndex', // Object form: also drop older *pending* jobs with the same key when a new one is queued concurrency: { key: ({ input }) => 'search-index', exclusive: true, // default true supersedes: true, // default false; running jobs are never cancelled }, handler: async () => ({ output: {} }), }, ], }, ``` `enableConcurrencyControl` changes the `payload-jobs` schema, so on SQL adapters run `pnpm payload generate:types`, `pnpm payload migrate:create` and `pnpm payload migrate` after enabling it. It defaults to `false`; the Payload source marks it to default to `true` in Payload 4. ## Access to `payload-jobs` Because the collection denies everything by default (3.89.0+), decide explicitly who may see jobs. Trusted server code needs nothing: the Local API defaults to `overrideAccess: true` in v3, so existing `payload.find({ collection: 'payload-jobs' })` calls keep working — but pass `overrideAccess: true` explicitly, because the 4.0 canary flips the Local API default to `false` (do not run the canary in production). To let administrators inspect jobs in the Admin Panel, override the collection with the `@experimental` `jobsCollectionOverrides` and open **read only**: ```ts // src/payload.config.ts export default buildConfig({ jobs: { tasks: [/* … */], jobsCollectionOverrides: ({ defaultJobsCollection }) => ({ ...defaultJobsCollection, access: { ...defaultJobsCollection.access, // create/update/delete stay () => false read: ({ req }) => Boolean(req.user?.roles?.includes('admin')), }, admin: { ...defaultJobsCollection.admin, hidden: false }, // show it in the nav }), }, }) ``` Opening `create`/`update`/`delete` exposes execution-control fields (retry state, queue, `processing`, `hasError`) to API clients — prefer read-only for trusted admins, and never `() => true`. The run endpoints are separate: keep gating them with `jobs.access.run` (see "External cron / webhook"). ## Retries, Timeouts, Failure Handling ```ts { slug: 'fetchExternalAPI', retries: { attempts: 5, backoff: { type: 'exponential', delay: 1000 }, // 1s, 2s, 4s, 8s, 16s }, timeout: 30_000, // 30s max handler: async ({ input, req }) => { // … }, onFail: async ({ job, req }) => { req.payload.logger.error({ msg: 'job permanently failed', jobId: job.id }) await req.payload.create({ collection: 'alerts',
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub