diff --git a/packages/client-engine-runtime/src/transaction-manager/transaction-manager.ts b/packages/client-engine-runtime/src/transaction-manager/transaction-manager.ts index 5e4bddab6fcf..810b9c049ae5 100644 --- a/packages/client-engine-runtime/src/transaction-manager/transaction-manager.ts +++ b/packages/client-engine-runtime/src/transaction-manager/transaction-manager.ts @@ -300,28 +300,30 @@ export class TransactionManager { } async commitTransaction(transactionId: string): Promise { - return await this.tracingHelper.runInChildSpan('commit_transaction', async () => { - await this.#withActiveTransactionLock(transactionId, 'commit', async (txw) => { - if (txw.depth > 1) { - if (!txw.transaction) throw new TransactionNotFoundError() - const savepointName = txw.savepoints.at(-1) - if (!savepointName) { - throw new TransactionInternalConsistencyError( - `Missing savepoint for nested commit. Depth: ${txw.depth}, transactionId: ${txw.id}`, - ) - } - try { - await this.#releaseSavepoint(txw.transaction, savepointName) - } finally { - // Keep internal state consistent even if releasing the savepoint fails. - txw.savepoints.pop() - txw.depth -= 1 + return await this.tracingHelper + .runInChildSpan('commit_transaction', async () => { + await this.#withActiveTransactionLock(transactionId, 'commit', async (txw) => { + if (txw.depth > 1) { + if (!txw.transaction) throw new TransactionNotFoundError() + const savepointName = txw.savepoints.at(-1) + if (!savepointName) { + throw new TransactionInternalConsistencyError( + `Missing savepoint for nested commit. Depth: ${txw.depth}, transactionId: ${txw.id}`, + ) + } + try { + await this.#releaseSavepoint(txw.transaction, savepointName) + } finally { + // Keep internal state consistent even if releasing the savepoint fails. + txw.savepoints.pop() + txw.depth -= 1 + } + return } - return - } - await this.#closeTransaction(txw, 'committed') + await this.#closeTransaction(txw, 'committed') + }) }) - }) + .catch(rethrowAsUserFacing) } async rollbackTransaction(transactionId: string): Promise { diff --git a/packages/client/tests/functional/issues/30580/_matrix.ts b/packages/client/tests/functional/issues/30580/_matrix.ts new file mode 100644 index 000000000000..05f57793d4c0 --- /dev/null +++ b/packages/client/tests/functional/issues/30580/_matrix.ts @@ -0,0 +1,11 @@ +import { defineMatrix } from '../../_utils/defineMatrix' +import { Providers } from '../../_utils/providers' + +export default defineMatrix(() => [ + [ + { + driverAdapter: 'js_pg', + provider: Providers.POSTGRESQL, + }, + ], +]) diff --git a/packages/client/tests/functional/issues/30580/prisma/_schema.ts b/packages/client/tests/functional/issues/30580/prisma/_schema.ts new file mode 100644 index 000000000000..5d6733ec6428 --- /dev/null +++ b/packages/client/tests/functional/issues/30580/prisma/_schema.ts @@ -0,0 +1,20 @@ +import { idForProvider } from '../../../_utils/idForProvider' +import testMatrix from '../_matrix' + +export default testMatrix.setupSchema(({ provider }) => { + return /* Prisma */ ` + generator client { + provider = "prisma-client-js" + } + + datasource db { + provider = "${provider}" + } + + model AppSetting { + id ${idForProvider(provider)} + key String @unique + value String + } + ` +}) diff --git a/packages/client/tests/functional/issues/30580/tests.ts b/packages/client/tests/functional/issues/30580/tests.ts new file mode 100644 index 000000000000..0e40ef25cd16 --- /dev/null +++ b/packages/client/tests/functional/issues/30580/tests.ts @@ -0,0 +1,76 @@ +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite( + () => { + beforeEach(async () => { + await prisma.appSetting.deleteMany() + }) + + test('commit-time write conflicts are surfaced as P2034', async () => { + const prefix = 'probe-' + let reads = 0 + let releaseReads!: () => void + const readsCompleted = new Promise((resolve) => { + releaseReads = resolve + }) + let completedInserts = 0 + let successfulInserts = 0 + let releaseInserts!: () => void + const insertsCompleted = new Promise((resolve) => { + releaseInserts = resolve + }) + + const run = (suffix: number) => + prisma.$transaction( + async (tx) => { + await tx.appSetting.count({ where: { key: { startsWith: prefix } } }) + + reads += 1 + if (reads === 2) releaseReads() + await readsCompleted + + try { + await tx.appSetting.create({ + data: { key: `${prefix}${suffix}`, value: 'x' }, + }) + successfulInserts += 1 + } finally { + // Always release the post-insert barrier once both attempts have + // completed, even if one insert fails before COMMIT. + completedInserts += 1 + if (completedInserts === 2) releaseInserts() + } + + await insertsCompleted + }, + { isolationLevel: 'Serializable' }, + ) + + const results = await Promise.allSettled([run(1), run(2)]) + const rejected = results.filter((r) => r.status === 'rejected') + const fulfilled = results.filter((r) => r.status === 'fulfilled') + + expect(successfulInserts).toBe(2) + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect(rejected[0]).toMatchObject({ + reason: { + name: 'PrismaClientKnownRequestError', + code: 'P2034', + }, + }) + + await expect(prisma.appSetting.count()).resolves.toBe(1) + }) + }, + { + optOut: { + from: ['mongodb', 'mysql', 'sqlite', 'sqlserver', 'cockroachdb'], + reason: 'Serializable write skew surfaced as SQLSTATE 40001 at COMMIT is the PostgreSQL behavior from the issue', + }, + }, +)