Skip to content

Commit 802a21a

Browse files
authored
fix(next): prevent transaction race condition in renderDocument parallel operations (#14652)
## Description Fixes `MongoExpiredSessionError` caused by transaction state mutation leaking between parallel operations in `renderDocument`. ## Problem When `renderDocument` runs `getDocumentPermissions` and `getIsLocked` in parallel via `Promise.all`, they share the same `req` object. When `getDocumentPermissions` calls `docAccessOperation` → `initTransaction()`, it **mutates** `req.transactionID`. This mutation is visible to `getIsLocked`, which then tries to use the same transaction. When the permission check completes and commits its transaction, `getIsLocked` fails with an expired session error. This is because `getIsLocked` may still be trying to use the transactionID it (involuntarily) received from `getDocumentPermissions`, even though `getDocumentPermissions` already closed it. ## Solution Use `isolateObjectProperty(req, 'transactionID')` to give each parallel operation its own isolated transaction state. This creates a Proxy where `transactionID` mutations go to a delegate object instead of the shared parent req. ```ts // Before - shared req, mutations leak await Promise.all([ getDocumentPermissions({ req }), // Mutates req.transactionID getIsLocked({ req }), // Sees the mutation → ERROR ]) ``` ```ts // After - isolated transactionID const reqForPermissions = isolateObjectProperty(req, 'transactionID') const reqForLockCheck = isolateObjectProperty(req, 'transactionID') await Promise.all([ getDocumentPermissions({ req: reqForPermissions }), // Isolated getIsLocked({ req: reqForLockCheck }), // Isolated ]) ``` If parent `req` already has a transaction, it's preserved (no isolation applied). ## Testing This PR does not include tests, as it's very difficult to reliably test for this race condition. I reproduced this issue using this PR: #14631 as it just happened to speed up `getDocumentPermissions` just enough to trigger this race condition more often.
1 parent 87137fe commit 802a21a

File tree

1 file changed

+27
-5
lines changed

1 file changed

+27
-5
lines changed

packages/next/src/views/Document/index.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { handleLivePreview, handlePreview } from '@payloadcms/ui/rsc'
2121
import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
2222
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
2323
import { notFound, redirect } from 'next/navigation.js'
24-
import { logError } from 'payload'
24+
import { isolateObjectProperty, logError } from 'payload'
2525
import { formatAdminURL } from 'payload/shared'
2626
import React from 'react'
2727

@@ -140,6 +140,28 @@ export const renderDocument = async ({
140140

141141
const isTrashedDoc = Boolean(doc && 'deletedAt' in doc && typeof doc?.deletedAt === 'string')
142142

143+
// CRITICAL FIX FOR TRANSACTION RACE CONDITION:
144+
// When running parallel operations with Promise.all, if they share the same req object
145+
// and one operation calls initTransaction() which MUTATES req.transactionID, that mutation
146+
// is visible to all parallel operations. This causes:
147+
// 1. Operation A (e.g., getDocumentPermissions → docAccessOperation) calls initTransaction()
148+
// which sets req.transactionID = Promise, then resolves it to a UUID
149+
// 2. Operation B (e.g., getIsLocked) running in parallel receives the SAME req with the mutated transactionID
150+
// 3. Operation A (does not even know that Operation B even exists and is stil using the transactionID) commits/ends its transaction
151+
// 4. Operation B tries to use the now-expired session → MongoExpiredSessionError!
152+
//
153+
// Solution: Use isolateObjectProperty to create a Proxy that isolates the 'transactionID' property.
154+
// This allows each operation to have its own transactionID without affecting the parent req.
155+
// If parent req already has a transaction, preserve it (don't isolate), since this
156+
// issue only arises when one of the operations calls initTransaction() themselves -
157+
// because then, that operation will also try to commit/end the transaction itself.
158+
159+
// If the transactionID is already set, the parallel operations will not try to
160+
// commit/end the transaction themselves, so we don't need to isolate the
161+
// transactionID property.
162+
const reqForPermissions = req.transactionID ? req : isolateObjectProperty(req, 'transactionID')
163+
const reqForLockCheck = req.transactionID ? req : isolateObjectProperty(req, 'transactionID')
164+
143165
const [
144166
docPreferences,
145167
{ docPermissions, hasPublishPermission, hasSavePermission },
@@ -155,22 +177,22 @@ export const renderDocument = async ({
155177
user,
156178
}),
157179

158-
// Get permissions
180+
// Get permissions - isolated transactionID prevents cross-contamination
159181
getDocumentPermissions({
160182
id: idFromArgs,
161183
collectionConfig,
162184
data: doc,
163185
globalConfig,
164-
req,
186+
req: reqForPermissions,
165187
}),
166188

167-
// Fetch document lock state
189+
// Fetch document lock state - isolated transactionID prevents cross-contamination
168190
getIsLocked({
169191
id: idFromArgs,
170192
collectionConfig,
171193
globalConfig,
172194
isEditing,
173-
req,
195+
req: reqForLockCheck,
174196
}),
175197

176198
// get entity preferences

0 commit comments

Comments
 (0)