You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Jobs (3.x): two runs claim and run the same due jobs; the slower one fails with "Cannot read properties of null (reading 'log')" — please backport #17441 #18565
In 3.90.2 (Postgres adapter), two job-run calls a moment apart can claim and run the same due jobs. Claiming is a find followed by one update per row (@payloadcms/drizzle/dist/updateJobs.js), outside any transaction, so both runs see the same jobs with processing: false and both mark and run them.
With the default deleteJobOnComplete: true, the faster run completes and deletes each job. The slower run then fails to insert its log row (insert into "payload_jobs_log" … Failed query), reads the job back as null, and throws TypeError: Cannot read properties of null (reading 'log'). The run endpoint answers 500. Every task also runs twice: a scheduled publish publishes the document twice.
This is the race in #16043. It was fixed by #17441 ("feat!: support multiple workers on the same queue"), which was merged into main (4.0) only. The latest 3.x (3.90.2) still has the find-then-update claim. Could the fix, or a non-breaking part of it, be backported to 3.x?
Link to the code that reproduces this issue
The reproduction below uses only Payload's own scheduled publish and run endpoint, with no project code.
Reproduction Steps
Payload 3.90.2 with @payloadcms/db-postgres 3.90.2, a collection with versions: { drafts: { schedulePublish: true } }, and jobs.access.run allowing a bearer token.
Schedule a publish of three documents for the same minute (the admin's "Schedule Publish", or payload.jobs.queue with the schedulePublish task).
Once they are due, call GET /api/payload-jobs/run twice, about a second apart. Our tests do this from three parallel workers.
Observed, in about 1 run in 3:
One call answers 200, and the other answers 500 with {"message":"An unknown error has occurred.","noJobsRemaining":true}.
The server logs Error running job Task: schedulePublish … Failed query: insert into "payload_jobs_log", then There was an error running jobs: TypeError: Cannot read properties of null (reading 'log').
The audit trail (an afterOperation hook) records two publishes for each document.
Expected: each due job is claimed by exactly one run, as it is in 4.0 after #17441.
Our workaround, for anyone on 3.x: we closed the REST run endpoint (jobs.access.run: () => false) and run jobs only from our own route. That route takes a Postgres transaction-level advisory lock (pg_try_advisory_xact_lock) on a dedicated connection; a transaction-level lock because Neon's pooler is in transaction mode. We also wrap the schedulePublish handler so that a publish which is already done is skipped.
In Payload 3.x (v3.90.2), job claiming in @payloadcms/drizzle/src/updateJobs.ts and @payloadcms/db-mongodb/src/updateJobs.ts uses a two-phase find followed by updateMany per row without an atomic claim token. When multiple job runners execute concurrently on due jobs:
Both workers query and retrieve the same due jobs with processing: false.
The faster worker finishes execution and deletes the completed job (when deleteJobOnComplete: true).
The slower worker attempts to update/insert the log for the deleted row, resulting in TypeError: Cannot read properties of null (reading 'log') and returning a 500 error.
Proposed 3.x Backport Solution (aligned with #17441)
We can backport the non-breaking core of #17441 to 3.x:
Atomic Processing Token: Generate a unique processingToken per runJobs invocation.
Atomic Single-Job Claiming: In updateJobs, update the job where id = targetId AND processing = false to set processing: true and processingToken: token. Only the worker that successfully acquires the token proceeds to execute the task.
Graceful Cleanup: Reset processingToken to null on job release/completion.
I have verified this pattern and can prepare a targeted, non-breaking 3.x backport PR if the team is open to accepting this patch.
We hit the same race on the MongoDB adapter (3.90.1, unchanged in 3.90.2).
@payloadcms/db-mongodbupdateJobs finds candidate ids with processing: false, then runs updateMany({ _id: { $in: ids } }) without
that check, then reads the same ids back. Runners that poll in the same
second all get the same jobs.
In production, three pods across two clusters picked up one video-encode
job within 10 ms of each other. Each encoded and stored every output, and
they hit Mongo WriteConflict (112) racing on the same document. Over nine
months this left 346 duplicate encodes (~519 MB) that nothing references.
Repro: queue one job, then await Promise.all([payload.jobs.run(), payload.jobs.run()]).
The handler runs twice.
As a 3.x stopgap we patch the limit branch to claim each candidate with findOneAndUpdate({ $and: [query, { _id }] }) and return only the docs that
call matched. No schema change. A backport of #17441's token claim would let
us drop the patch.
Activity
PINYOPATTANAWASANPORN commentedon Oct 7, 2026
Root Cause & Backport Analysis for 3.x
In Payload 3.x (
v3.90.2), job claiming in@payloadcms/drizzle/src/updateJobs.tsand@payloadcms/db-mongodb/src/updateJobs.tsuses a two-phasefindfollowed byupdateManyper row without an atomic claim token. When multiple job runners execute concurrently on due jobs:processing: false.deleteJobOnComplete: true).TypeError: Cannot read properties of null (reading 'log')and returning a 500 error.Proposed 3.x Backport Solution (aligned with #17441)
We can backport the non-breaking core of #17441 to 3.x:
processingTokenperrunJobsinvocation.updateJobs, update the job whereid = targetId AND processing = falseto setprocessing: trueandprocessingToken: token. Only the worker that successfully acquires the token proceeds to execute the task.processingTokentonullon job release/completion.I have verified this pattern and can prepare a targeted, non-breaking 3.x backport PR if the team is open to accepting this patch.
saadshahd commentedon Oct 14, 2026
We hit the same race on the MongoDB adapter (3.90.1, unchanged in 3.90.2).
@payloadcms/db-mongodbupdateJobsfinds candidate ids withprocessing: false, then runsupdateMany({ _id: { $in: ids } })withoutthat check, then reads the same ids back. Runners that poll in the same
second all get the same jobs.
In production, three pods across two clusters picked up one video-encode
job within 10 ms of each other. Each encoded and stored every output, and
they hit Mongo
WriteConflict(112) racing on the same document. Over ninemonths this left 346 duplicate encodes (~519 MB) that nothing references.
Repro: queue one job, then
await Promise.all([payload.jobs.run(), payload.jobs.run()]).The handler runs twice.
As a 3.x stopgap we patch the limit branch to claim each candidate with
findOneAndUpdate({ $and: [query, { _id }] })and return only the docs thatcall matched. No schema change. A backport of #17441's token claim would let
us drop the patch.
fix(core): atomic job claiming for drizzle and mongodb in 3.x (payloa…