Plugins
Jobs

Interruptions

Pause queues from another plugin and decide what happens to jobs stopped mid-run.

Another plugin can stop jobs for a while: a maintenance window that freezes content, a feature flag, a quota. The jobs plugin gives it two extension points and gives you one decision per job: when a job is stopped mid-run, does it fail or go back to the queue (defer)?

What happens to an interrupted job

A job is interrupted when its handler throws an error a registered classifier recognises, or JobDeferredError. What happens next depends on its policy:

PolicyResult
fail (default)The job fails for good with Interrupted by <name>, without Payload's retries. Re-queue it by hand if it is still wanted.
deferThe job goes back to the queue until the interruption is expected to end. It spends no retry, and its deferredBy field names who deferred it. A workflow re-runs from the step it stopped at, skipping the steps it completed.

Opt a task or workflow into defer only when running it again from the start is safe:

payload.config.ts
import { deferOnInterrupt, jobs } from '@10x-media/jobs'

export default buildConfig({
  jobs: {
    tasks: [deferOnInterrupt({ slug: 'reindexSearch', handler: reindexSearch })],
    workflows: [deferOnInterrupt(importCatalog)],
  },
  plugins: [
    // for tasks another plugin registers, or handlers registered by path:
    jobs({ interrupt: { defer: ['syncThumbnails'] } }),
  ],
})

A workflow's policy covers every task it runs.

Checkpoints

A long handler can stop cleanly between batches instead of running into the interruption:

import { checkpoint } from '@10x-media/jobs'

handler: async ({ job, req }) => {
  for (const page of pages) {
    await checkpoint({ job, req }) // throws JobDeferredError when a gate pauses this queue
    await processPage(page)
  }
}

A checkpoint always defers, whatever the job's policy, so place it only where stopping and restarting is safe.

Where it applies

Run gates are honoured by createWorker and the queue-run endpoint. Handlers are wrapped when Payload initialises, so tasks added by any plugin are covered; handlers registered by path are not. If you drive payload.jobs.run yourself, call wrapJobHandlers(payload) before it and applyJobInterruptions(payload) after it.

Payload's own autoRun and /api/payload-jobs/run do not consult the gates, and nothing applies the policy after them.

For plugin authors

The registry lives at config.custom['@10x-media/jobs']. A plugin ordered after jobs appends to it at config time, with no runtime import of this package:

import type { JobsRegistry } from '@10x-media/jobs'

const registry = config.custom?.['@10x-media/jobs'] as JobsRegistry | undefined
registry?.extensions.runGates.push(async ({ payload }) =>
  (await isFrozen(payload)) ? { by: 'my-plugin', paused: 'all', until: frozenUntil } : null
)
registry?.extensions.interruptOn.push(({ error }) =>
  error instanceof FrozenError ? { by: 'my-plugin', until: error.until } : null
)
  • A run gate returns null or { paused: 'all' | string[], until?, by? }. It adds to the manual pause, and lifting it never clears a pause an operator set. A gate that throws pauses everything until it answers again.
  • An interrupt classifier returns null or { until, by } for an error a handler threw.
  • registry.api.resumeDeferred(payload, by) releases the jobs deferred by by right away, for when the interruption ends early.

On this page