Skip to content

feat!: prevent stale jobs queue jobs#17220

Open
AlessioGr wants to merge 34 commits into
mainfrom
leasing-agent
Open

feat!: prevent stale jobs queue jobs#17220
AlessioGr wants to merge 34 commits into
mainfrom
leasing-agent

Conversation

@AlessioGr

@AlessioGr AlessioGr commented Jul 7, 2026

Copy link
Copy Markdown
Member

This PR lets Payload recover Jobs Queue jobs after a worker crashes. It replaces the permanent processing flag with a renewable processingUntil lease and builds on #17441, which makes the initial job claim safe when multiple workers poll the same queue.

Why

Previously, a worker set processing: true when it claimed a job. If that process crashed, nothing reset the flag, so Payload treated the job as running forever.

For example, a schedule runs every 10 minutes but does not queue a new job while the previous one is processing. If its worker crashes, the old job stays at processing: true, cannot be picked up again, and can block every future scheduled run.

How it works

A job is claimable when processingUntil is missing or expired. Claiming it atomically writes:

  • processingUntil: how long Payload should consider the worker active
  • processingToken: which worker attempt currently owns the job

While the job runs, a heartbeat renews processingUntil every third of the lease duration. A healthy job can therefore run longer than one lease; this is not a task timeout.

Worker-owned updates - including heartbeats, task logs, completion, and handled errors - must still match the worker's token and have enough lease time remaining. If an update no longer matches, Payload throws an internal JobRunAbortedError and stops accepting results from that worker. This prevents a timed-out worker from overwriting a newer attempt.

When a worker disappears, its heartbeat stops. After the lease expires, the next normal payload.jobs.run() or runByID() call can claim the job directly with a new token and lease. No separate cleanup step is required.

A crash intentionally does not increment totalTried or consume the handler's retry configuration. Completed task logs are kept, so recovery continues from the remaining tasks.

Configuration

The default lease is 20 minutes and the default safety buffer is 30 seconds:

jobs: {
  processingLease: {
    duration: 20 * 60 * 1000,
    safetyBuffer: 30 * 1000,
  },
}

duration controls how long a job remains owned without a successful heartbeat, and will delay recovery. 20 minutes was chosen as the default, because recovery usually is rare and not time-sensitive.

safetyBuffer is the minimum time that must remain before a worker starts a job update. It gives db adapters that read and write separately time to finish before the lease expires. It should be longer than a normal database update and shorter than the lease duration.

Why the token and safety buffer are both needed

#17441 protects claim time: only one worker can change a pending job into a claimed job.

This PR must also protect later writes. After a lease expires, Worker B can recover a job while Worker A is still finishing old work. processingUntil alone only says that some worker has an active lease. The stable processingToken identifies whether the update belongs to Worker A or Worker B.

Some db adapters first find a matching job and then write it. Without a safety window, this race is possible:

  1. Worker A verifies its token just before its lease expires.
  2. The lease expires before A's job update write finishes.
  3. Worker B claims the job with a new token.
  4. A's delayed write could overwrite B's state.

The safety buffer prevents A from starting that update near lease expiry. This is a cross-db-adapter safety margin, not an atomic database guarantee. It can be increased for slower remote adapters, if you expect an update to take longer than 30 seconds.

MongoDB applies the ownership query and update atomically with findOneAndUpdate() or updateMany(), so it does not rely on the buffer for correctness. The buffer can be set to 0 for a db adapter like mongodb with the same guarantee.

Breaking change

The processing field on payload-jobs is replaced by the nullable, indexed processingUntil date. Custom collection overrides and direct database queries that use processing must be updated.

Existing SQL databases need a migration that replaces this column. Migration files will be added in a separate PR; MongoDB does not require a schema migration.


@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 201.89 KB ✅ No change
packages/payload/meta_index.json esbuild/index.js 1.41 MB ⚠️ +3.77 KB (+0.3%)
packages/payload/meta_shared.json esbuild/exports/shared.js 213.15 KB ✅ -53 B (-0.0%)
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 285.56 KB ✅ No change
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 36.44 KB ✅ No change
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.95 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.9%, 197.86 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.4%, 718 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.3%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.3%, 526 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 409 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▏ }}}$ 68.6%, 964.38 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 44.37 KB
dist/collections/operations ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 42.74 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.99 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.68 KB
dist/queues/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 14.34 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.74 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.36 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.68 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.84 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.30 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.07 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.96 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.84 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.64 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.55 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.17 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.12 KB
dist/queues/config ${{\color{Goldenrod}{ }}}$ 0.4%, 5.71 KB
dist/uploads/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 5.56 KB
(other) ${{\color{Goldenrod}{ ███████▊ }}}$ 31.4%, 441.14 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▉ }}}$ 71.9%, 150.13 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▎ }}}$ 5.1%, 10.68 KB
dist/fields/config ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 5.82 KB
dist/utilities/traverseFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 4.44 KB
dist/collections/config ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 3.32 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.69 KB
dist/config/client.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.68 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.41 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 1.04 KB
dist/globals/config ${{\color{Goldenrod}{ }}}$ 0.4%, 939 B
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.4%, 793 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.3%, 713 B
dist/auth/extractJWT.js ${{\color{Goldenrod}{ }}}$ 0.3%, 696 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/errors/ValidationError.js ${{\color{Goldenrod}{ }}}$ 0.3%, 577 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
(other) ${{\color{Goldenrod}{ ███████ }}}$ 28.1%, 58.54 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▎ }}}$ 13.2%, 37.20 KB
dist/lexical/ui ${{\color{Goldenrod}{ ███ }}}$ 12.1%, 34.16 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▉ }}}$ 11.8%, 33.18 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▍ }}}$ 9.6%, 27.22 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.7%, 18.82 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.9%, 16.58 KB
dist/features/upload ${{\color{Goldenrod}{ █▎ }}}$ 5.0%, 14.09 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.9%, 11.08 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▉ }}}$ 3.5%, 10.02 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.4%, 9.61 KB
dist/features/converters ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 8.36 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.14 KB
dist/features/lists ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 3.64 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.28 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 2.88 KB
dist/lexical/nodes ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.66 KB
(other) ${{\color{Goldenrod}{ █████████████████████▋ }}}$ 86.8%, 245.15 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/exports/client ${{\color{Goldenrod}{ █████████████████████████ }}}$ 100.0%, 26.82 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.5%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▌ }}}$ 14.5%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██ }}}$ 8.3%, 1.51 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.2%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.3%, 419 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/layouts/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 230 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 158 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
(other) ${{\color{Goldenrod}{ █████████████████▍ }}}$ 69.5%, 12.68 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

@AlessioGr AlessioGr changed the title feat: prevent stale jobs queue jobs feat!: prevent stale jobs queue jobs Jul 7, 2026
@AlessioGr
AlessioGr changed the base branch from main to fix/job-concurrency July 22, 2026 03:05
remainingJobsFromQueried: 0,
})
expect(consoleCount).toHaveBeenCalledTimes(14) // Should be 14 sql calls if the optimizations are used. If not, this would be 23 calls
expect(consoleCount).toHaveBeenCalledTimes(16)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+2 queries, because of the new where queries that prevent updated when processingUntil is expired. These cannot go through the useOptimizedUpsertRow drizzle fast path, because:

  1. when updating just the job log, we now need to check 2 tables in order to verify processingUntil instead of one
  2. even when not updating the job log, useOptimizedUpsertRow currently does not allow where queries.

We can optimize again in the future. Getting it down to 14 again would require complex new logic in upsertRow

Base automatically changed from fix/job-concurrency to main July 23, 2026 18:36
@AlessioGr
AlessioGr marked this pull request as ready for review July 23, 2026 18:43
@AlessioGr
AlessioGr enabled auto-merge (squash) July 23, 2026 18:43
Comment on lines -240 to -243
admin: {
hidden: true,
readOnly: true,
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional or you removed it temporary for testing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's intentional, added it by accident in my last PR.

The Jobs collection already is hidden by default as a whole. If you go ahead and unhide it, you usually do so for debugging purposes. And for debugging purposes, you'll want to see this field in the admin panel

Comment thread docs/jobs-queue/queues.mdx
Comment on lines +289 to +291
processingLease: {
duration: 5 * 60 * 1000,
safetyBuffer: 60 * 1000,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be able to configure duration on a task/workflow level? or having it as global-only is fine? In your project you might have very different jobs in terms of complexity

@AlessioGr AlessioGr Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd think about this for the future if there is a need for this, no need to bloat the config if not necessary right now.

Generally I would say global-only is enough. The complexity of a job usually doesnt really change what processingLease duration you'll want to use. The processing lease continuously renews itself, so it automatically scales with the duration of a job. It's not a job timeout.

What does affect this is things like lambda duration, if you're hosting on lambdas. E.g. if the worker process shuts down after 4 minutes, there is no point keeping a 20 minute processing lease. And that would be something that takes effect globally.

Where it does maybe make sense is configuration based on the queue name, if different queues use different workers and worker configurations. But yea, I'd worry about that in the future. And even then, different workers for different queues can control this configuration individually through environment variables

@AlessioGr
AlessioGr requested a review from r1tsuu July 24, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants