Skip to content
21 changes: 15 additions & 6 deletions packages/db-mongodb/src/transactions/commitTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@ import type { MongooseAdapter } from '../index.js'

export const commitTransaction: CommitTransaction = async function commitTransaction(
this: MongooseAdapter,
id,
incomingID = '',
) {
if (id instanceof Promise) {
const transactionID = incomingID instanceof Promise ? await incomingID : incomingID

if (!this.sessions[transactionID]) {
return
}

if (!this.sessions[id]?.inTransaction()) {
if (!this.sessions[transactionID]?.inTransaction()) {
// Clean up the orphaned session reference
delete this.sessions[transactionID]
return
}

await this.sessions[id].commitTransaction()
const session = this.sessions[transactionID]

// Delete from registry FIRST to prevent race conditions
// This ensures other operations can't retrieve this session while we're ending it
delete this.sessions[transactionID]

await session.commitTransaction()
try {
await this.sessions[id].endSession()
await session.endSession()
} catch (_) {
// ending sessions is only best effort and won't impact anything if it fails since the transaction was committed
}
delete this.sessions[id]
}
21 changes: 10 additions & 11 deletions packages/db-mongodb/src/transactions/rollbackTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT
this: MongooseAdapter,
incomingID = '',
) {
let transactionID: number | string

if (incomingID instanceof Promise) {
transactionID = await incomingID
} else {
transactionID = incomingID
}
const transactionID = incomingID instanceof Promise ? await incomingID : incomingID

// if multiple operations are using the same transaction, the first will flow through and delete the session.
// subsequent calls should be ignored.
Expand All @@ -27,12 +21,17 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT
return
}

const session = this.sessions[transactionID]

// Delete from registry FIRST to prevent race conditions
// This ensures other operations can't retrieve this session while we're aborting it
delete this.sessions[transactionID]

// the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail
try {
await this.sessions[transactionID]?.abortTransaction()
await this.sessions[transactionID]?.endSession()
} catch (error) {
await session.abortTransaction()
await session.endSession()
} catch (_error) {
// ignore the error as it is likely a race condition from multiple errors
}
delete this.sessions[transactionID]
}
17 changes: 16 additions & 1 deletion packages/db-mongodb/src/utilities/getSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ export async function getSession(
}

if (transactionID) {
return db.sessions[transactionID]
const session = db.sessions[transactionID]

// Defensive check for race conditions where:
// 1. Session was retrieved from db.sessions
// 2. Another operation committed/rolled back and ended the session
// 3. This operation tries to use the now-ended session
// Note: This shouldn't normally happen as sessions are deleted from db.sessions
// after commit/rollback, but can occur due to async timing where we hold
// a reference to a session object that gets ended before we use it.
if (session && !session.inTransaction()) {
// Clean up the orphaned session reference
delete db.sessions[transactionID]
return undefined
}

return session
}
}
22 changes: 13 additions & 9 deletions packages/drizzle/src/transactions/commitTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type { CommitTransaction } from 'payload'

export const commitTransaction: CommitTransaction = async function commitTransaction(id) {
if (id instanceof Promise) {
return
}
export const commitTransaction: CommitTransaction = async function commitTransaction(
incomingID = '',
) {
const transactionID = incomingID instanceof Promise ? await incomingID : incomingID

// if the session was deleted it has already been aborted
if (!this.sessions[id]) {
if (!this.sessions[transactionID]) {
return
}

const session = this.sessions[transactionID]

// Delete from registry FIRST to prevent race conditions
// This ensures other operations can't retrieve this session while we're ending it
delete this.sessions[transactionID]

try {
await this.sessions[id].resolve()
await session.resolve()
} catch (_) {
await this.sessions[id].reject()
await session.reject()
}

delete this.sessions[id]
}
9 changes: 6 additions & 3 deletions packages/drizzle/src/transactions/rollbackTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT
return
}

// end the session promise in failure by calling reject
await this.sessions[transactionID].reject()
const session = this.sessions[transactionID]

// delete the session causing any other operations with the same transaction to fail
// Delete from registry FIRST to prevent race conditions
// This ensures other operations can't retrieve this session while we're ending it
delete this.sessions[transactionID]

// end the session promise in failure by calling reject
await session.reject()
}
3 changes: 3 additions & 0 deletions packages/drizzle/src/utilities/getTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type { DrizzleAdapter } from '../types.js'

/**
* Returns current db transaction instance from req or adapter.drizzle itself
*
* If a transaction session doesn't exist (e.g., it was already committed/rolled back),
* falls back to the default adapter.drizzle instance to prevent errors.
*/
export const getTransaction = async <T extends DrizzleAdapter = DrizzleAdapter>(
adapter: T,
Expand Down
89 changes: 53 additions & 36 deletions packages/next/src/utilities/initReq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,43 +94,60 @@ export const initReq = async function ({
}
}, 'global')

return reqCache.get(async () => {
const { i18n, languageCode, payload, responseHeaders, user } = partialResult

const { req: reqOverrides, ...optionsOverrides } = overrides || {}

const req = await createLocalReq(
{
return reqCache
.get(async () => {
const { i18n, languageCode, payload, responseHeaders, user } = partialResult

const { req: reqOverrides, ...optionsOverrides } = overrides || {}

const req = await createLocalReq(
{
req: {
headers,
host: headers.get('host'),
i18n: i18n as I18n,
responseHeaders,
user,
...(reqOverrides || {}),
},
...(optionsOverrides || {}),
},
payload,
)

const locale = await getRequestLocale({
req,
})

req.locale = locale?.code

const permissions = await getAccessResults({
req,
})

return {
cookies,
headers,
languageCode,
locale,
permissions,
req,
}
}, key)
.then((result) => {
// CRITICAL: Create a shallow copy of req before returning to prevent
// mutations from propagating to the cached req object.
// This ensures parallel operations using the same cache key don't affect each other.
return {
...result,
req: {
headers,
host: headers.get('host'),
i18n: i18n as I18n,
responseHeaders,
user,
...(reqOverrides || {}),
...result.req,
...(result.req?.context
? {
context: { ...result.req.context },
}
: {}),
},
...(optionsOverrides || {}),
},
payload,
)

const locale = await getRequestLocale({
req,
})

req.locale = locale?.code

const permissions = await getAccessResults({
req,
}
})

return {
cookies,
headers,
languageCode,
locale,
permissions,
req,
}
}, key)
}