Skip to content

db-mongodb: concurrent commands at the start of a transaction fail with NoSuchTransaction (251) #18679

Open

Description

@andershermansen

Describe the Bug

This issue was written with help from AI (Claude). I've reviewed and reproduced the findings.

On MongoDB, a create sometimes fails with:

MongoServerError: Given transaction number 762 on session a71c02ef-… using txnRetryCounter 0 does not match any
in-progress transactions. The active transaction number is 761
  code: 251, codeName: 'NoSuchTransaction', errorLabels: ['TransientTransactionError']
  at FindCursor._initialize (mongodb/lib/cursor/find_cursor.js)

There are already open issues and PRs for individual symptoms of this (see Related). I think they share one root cause, and I'm opening this so it can be fixed in one place rather than at each call site.

Root cause

Payload can send several commands at the same time on a transaction that hasn't sent its first command to the server yet.

  • beginTransaction calls session.startTransaction(). In the Node driver this only changes client state; nothing is sent to the server.
  • The server learns about the transaction from the first command, which the driver tags with startTransaction: true and the new txnNumber. The driver tags only the first command it builds. Every later command carries the txnNumber without the tag.
  • When several commands are sent at the same time on the session, they can use different pooled connections, so they can arrive out of order. If a command without the tag arrives first, the server has never seen that txnNumber and rejects it with NoSuchTransaction. That's what the error says: 762 was given while the server still has 761 as the latest.

Once the first command has completed, running commands concurrently is safe, so this only happens at the start of a transaction. In practice that means create. update and delete read the document on the session first, so the transaction has already started before anything else runs. That also explains why the workarounds in the related issues help: save as draft and then publish, or run one read in beforeOperation.

It depends on timing and latency. In production on Atlas we see it occasionally; the repro below triggers it reliably against Atlas. We haven't tried a local replica set, where the lower latency may hide it.

Where the concurrency comes from

Any code that sends more than one command at the same time on a new transaction triggers it. I know of these:

  1. Paginated find in db-mongodb. Model.paginate() (mongoose-paginate-v2) starts the count and the docs query together and waits on both with Promise.all. If that find is the first command in the transaction, they race. This is our production case: a collection beforeChange hook runs req.payload.find({ where: …, limit: 1, req }), and the stack trace is the docs find losing to the count.
  2. Field validation with filterOptions. beforeChange field traversal validates sibling fields and array rows with Promise.all, and each relationship/upload field with filterOptions runs its own find in validateFilterOptions. Here the error is caught and logged (Error validating filter options for collection …), so the user gets a false "invalid relationship" validation error instead of a 500. plugin-multi-tenant adds filterOptions to every relationship that points at a tenant-enabled collection, so multi-tenant setups hit this without writing any filterOptions themselves ([plugin-multi-tenant] Race condition in auto-injected relationship field validation produces false "invalid relationship" errors #16462).
  3. User code. Any hook that uses Promise.all over Local API calls sharing req.

The open PRs each fix one source: #17053 makes pagination run one query after the other inside transactions, and #16463 queues the plugin's dataloader finds. Each new source would need its own fix, and source 3 can't be fixed that way at all.

Possible directions

I'd like the maintainers' view on where this belongs. Some options we considered:

  • Wait for the first command in the adapter. While a session's transaction hasn't sent its first command, getSession lets one caller through and makes the others wait until that command completes. After that, everything runs concurrently as today. This covers every source, including user code, and doesn't change behaviour outside that short window.
  • Start the transaction on the server in beginTransaction. Send a cheap command right after startTransaction(), so the server knows about the transaction before any Payload code runs. That's simpler, but every transaction pays for an extra round trip.
  • Retry on TransientTransactionError. The label means the whole transaction is safe to retry, and it is MongoDB's documented way to handle this (session.withTransaction() does it). Payload doesn't retry today (MongoDB TransientTransactionError / WriteConflict is not retried for Payload Local API writes #16766). This fits alongside either option above and isn't a replacement for them: retrying hides the race rather than removing it, and source 2 never throws.
  • Fix each call site (fix(db-mongodb): serialize paginated count and docs inside transactions #17053, fix(plugin-multi-tenant): serialize transaction dataloader finds #16463). This works for the known sources, but the problem returns whenever new concurrent code is added.

As a workaround we pass pagination: false to the find in our hook. That makes the first command a single query, and the field validation that follows is safe because the transaction has already started.

Related

Link to the code that reproduces this issue

https://github.com/andershermansen/payload-mongo-txn-race

Reproduction Steps

The repro is the blank template with three collections (src/collections/Repro.ts) and an integration test
(tests/int/transaction-race.int.spec.ts) that runs 100 payload.create calls per collection, one at a time:

  • orders: a beforeChange hook runs a paginated, filtered find with req (source 1).
  • orders-unpaginated: the same hook with pagination: false, as a control.
  • posts: an array of relationships with static filterOptions (source 2).
  1. Set DATABASE_URL in .env to a MongoDB Atlas cluster.
  2. pnpm install
  3. pnpm test:int

Expected: every create succeeds. Actual, against Atlas:

Collection One at a time 10 at a time
orders 58/100 failed 25/100 failed
orders-unpaginated 0/100 failed 0/100 failed
posts 56/100 failed 66/100 failed
  • orders fails with NoSuchTransaction (251), as in production.
  • posts fails with false validation errors such as
    The following fields are invalid: Rows 2 > Tag, Rows 4 > Tag, and each one logs
    Error validating filter options for collection tags with the 251 error.
  • Row 1 never fails. Over all posts failures, the invalid rows were 2 (87 times), 3 (91), 4 (81) and 5 (74), but never 1. Row 1's query is built first, so it is the one that carries startTransaction: true.
  • Running one create at a time fails at least as often, so the race happens inside each create and doesn't need other creates running at the same time.

Which area(s) are affected?

db: mongodb

Environment Info

payload: 3.90.2 (repro), 3.90.1 (production)
@payloadcms/db-mongodb: 3.90.2
@payloadcms/plugin-multi-tenant: 3.90.1 (production only)
mongoose: 8.24.1
mongoose-paginate-v2: 1.9.4
mongodb (driver): 6.20.0
Node: v24.21.0
MongoDB: Atlas free tier (M0), 8.0.34

Activity

  1. added
    status: needs-triagePossible bug which hasn't been reproduced yet
    validate-reproductionAuto-added tag on create to tell bot to check recreation URL, removed after check.
    on Oct 13, 2026
  2. andershermansen commented on Oct 13, 2026

    @andershermansen
    ContributorAuthor

    I tested the same repro on v4.0.0-beta.0 on the payload-v4 branch. It's the v4 codemod (upgrade run --tag beta) plus Next 16.4.0, with no changes to the repro collections or test. The bug is still there, and now it fails almost every time:

    Collection v3.90.2, one at a time v4 beta, one at a time v4 beta, 10 at a time
    orders (paginated find) 58/100 100/100 80/100
    orders-unpaginated (control) 0/100 0/100 0/100
    posts (filterOptions rows) 56/100 100/100 100/100

    The error has changed too. On v4 every failure is:

    MongoServerError: Only servers in a sharded cluster can start a new transaction at the active transaction number
      code: 117, codeName: 'ConflictingOperationInProgress'
    

    Why it changed

    v4 moves to mongodb driver 7.5.0 (from 6.20.0) and mongoose 9.9.2. The adapter code involved is the same: find
    still uses Model.paginate, and getSession is unchanged. The driver changed when a transaction counts as started
    (lib/sessions.js):

    • Driver 6: the first command built gets startTransaction: true, and the session moves to "in progress" right away. Commands sent alongside it go out without the flag. If one of them reaches the server first, you get NoSuchTransaction (251). That's a race.
    • Driver 7: the session stays in the "starting" state until the first command's response comes back (updateSessionFromResponse). So every command sent before that carries startTransaction: true. The server accepts the first one to arrive and rejects the others with 117.

    So on v4 this no longer depends on timing: any concurrent commands at the start of a transaction fail. That also shows in the posts results. On v3, row 1 never failed, because its query was always the one carrying startTransaction. On v4, row 1 fails as often as the others, because whichever row loses fails.

    What this means for the possible directions

    On v4 as it stands, create on a collection with filterOptions on more than one relationship field or array row, or with a hook that runs a paginated find, fails almost every time against Atlas. I haven't tested a local replica set, but with driver 7 the failure shouldn't depend on latency anymore.

  3. self-assigned this
    on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions