Skip to content

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

Open

Description

@stratlabsin-byte

Describe the Bug

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

  1. 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.
  2. Schedule a publish of three documents for the same minute (the admin's "Schedule Publish", or payload.jobs.queue with the schedulePublish task).
  3. Once they are due, call GET /api/payload-jobs/run twice, about a second apart. Our tests do this from three parallel workers.
  4. 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.

Which area(s) are affected?

area: core, db: postgres

Environment Info

payload: 3.90.2
@payloadcms/db-postgres: 3.90.2
@payloadcms/drizzle: 3.90.2
next: 16.3.8
node: 22 (CI), 24 (local)
Postgres: Neon (pooled connection, transaction mode)
OS: Linux (CI), Windows 11 (local)

Activity

  1. PINYOPATTANAWASANPORN commented on Oct 7, 2026

    @PINYOPATTANAWASANPORN
    Contributor

    Root Cause & Backport Analysis for 3.x

    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:

    1. Both workers query and retrieve the same due jobs with processing: false.
    2. The faster worker finishes execution and deletes the completed job (when deleteJobOnComplete: true).
    3. 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:

    1. Atomic Processing Token: Generate a unique processingToken per runJobs invocation.
    2. 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.
    3. 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.

  2. saadshahd commented on Oct 14, 2026

    @saadshahd

    We hit the same race on the MongoDB adapter (3.90.1, unchanged in 3.90.2).

    @payloadcms/db-mongodb updateJobs 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions