Repository navigation
db-mongodb: concurrent commands at the start of a transaction fail with NoSuchTransaction (251) #18679
Description
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.
beginTransactioncallssession.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: trueand the newtxnNumber. The driver tags only the first command it builds. Every later command carries thetxnNumberwithout 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
txnNumberand rejects it withNoSuchTransaction. 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:
- Paginated
findindb-mongodb.Model.paginate()(mongoose-paginate-v2) starts the count and the docs query together and waits on both withPromise.all. If thatfindis the first command in the transaction, they race. This is our production case: a collectionbeforeChangehook runsreq.payload.find({ where: …, limit: 1, req }), and the stack trace is the docsfindlosing to the count. - Field validation with
filterOptions.beforeChangefield traversal validates sibling fields and array rows withPromise.all, and each relationship/upload field withfilterOptionsruns its own find invalidateFilterOptions. 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-tenantaddsfilterOptionsto every relationship that points at a tenant-enabled collection, so multi-tenant setups hit this without writing anyfilterOptionsthemselves ([plugin-multi-tenant] Race condition in auto-injected relationship field validation produces false "invalid relationship" errors #16462). - User code. Any hook that uses
Promise.allover Local API calls sharingreq.
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,
getSessionlets 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 afterstartTransaction(), 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
- [plugin-multi-tenant] Race condition in auto-injected relationship field validation produces false "invalid relationship" errors #16462: plugin-multi-tenant: race in auto-injected relationship validation gives false "invalid relationship"
errors (source 2) - fix(plugin-multi-tenant): serialize transaction dataloader finds #16463: fix(plugin-multi-tenant): serialize transaction dataloader finds (open, source 2)
- fix(db-mongodb): serialize paginated count and docs inside transactions #17053: fix(db-mongodb): serialize paginated count and docs inside transactions (open, source 1)
- MongoDB TransientTransactionError / WriteConflict is not retried for Payload Local API writes #16766: TransientTransactionError / WriteConflict is not retried for Local API writes (open)
- fix: prevent read operations from rolling back the caller's transaction #17748: prevent read operations from rolling back the caller's transaction (merged; another way hooks that read with
reqaffect the transaction) - fix(db-mongodb): fix pagination with collation in transactions #15990: fix pagination with collation in transactions (merged; earlier session bug in the same pagination code)
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: abeforeChangehook runs a paginated, filteredfindwithreq(source 1).orders-unpaginated: the same hook withpagination: false, as a control.posts: an array of relationships with staticfilterOptions(source 2).
- Set
DATABASE_URLin.envto a MongoDB Atlas cluster. pnpm installpnpm 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 |
ordersfails withNoSuchTransaction(251), as in production.postsfails 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 tagswith the 251 error.- Row 1 never fails. Over all
postsfailures, 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 carriesstartTransaction: 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
andershermansen commentedon Oct 13, 2026
I tested the same repro on v4.0.0-beta.0 on the
payload-v4branch. 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:orders(paginated find)orders-unpaginated(control)posts(filterOptionsrows)The error has changed too. On v4 every failure is:
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:
findstill uses
Model.paginate, andgetSessionis unchanged. The driver changed when a transaction counts as started(
lib/sessions.js):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 getNoSuchTransaction(251). That's a race.updateSessionFromResponse). So every command sent before that carriesstartTransaction: 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
postsresults. On v3, row 1 never failed, because its query was always the one carryingstartTransaction. On v4, row 1 fails as often as the others, because whichever row loses fails.What this means for the possible directions
TransientTransactionErrorno longer helps. The 117 errors carry no error labels, so a retry keyed on that label never fires.beginTransaction, still fixes it on both driver versions. Both directions make sure nothing else is sent until the server knows about the transaction.On v4 as it stands,
createon a collection withfilterOptionson 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.