Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add support for nested transaction rollbacks in SQL databases
Browse files Browse the repository at this point in the history
This change adds support for handling rollbacks in nested transactions
in SQL databases. Specifically, the inner transaction should be rolled
back if the outer transaction fails.

To do this we keep track of the transaction ID and transaction depth so we can
re-use an existing open transaction in the underlying engine. This change also
allows the use of the `$transaction` method on an interactive transaction client.

depends-on: prisma/prisma-engines#4375
LucianBuzzo committed Jan 8, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent e6ef63b commit df1c752
Showing 29 changed files with 175 additions and 74 deletions.
6 changes: 6 additions & 0 deletions packages/adapter-d1/src/d1.ts
Original file line number Diff line number Diff line change
@@ -117,6 +117,12 @@ class D1Transaction extends D1Queryable<StdClient> implements Transaction {
super(client)
}

async begin(): Promise<Result<void>> {
debug(`[js::begin]`)

return ok(undefined)
}

async commit(): Promise<Result<void>> {
debug(`[js::commit]`)

9 changes: 9 additions & 0 deletions packages/adapter-libsql/src/libsql.ts
Original file line number Diff line number Diff line change
@@ -100,6 +100,15 @@ class LibSqlTransaction extends LibSqlQueryable<TransactionClient> implements Tr
super(client)
}

// eslint-disable-next-line @typescript-eslint/require-await
async begin(): Promise<Result<void>> {
debug(`[js::commit]`)

throw new Error('Method not implemented.')

return ok(undefined)
}

async commit(): Promise<Result<void>> {
debug(`[js::commit]`)

7 changes: 7 additions & 0 deletions packages/adapter-neon/src/neon.ts
Original file line number Diff line number Diff line change
@@ -154,6 +154,13 @@ class NeonTransaction extends NeonWsQueryable<neon.PoolClient> implements Transa
super(client)
}

async begin(): Promise<Result<void>> {
debug(`[js::begin]`)

this.client.release()
return Promise.resolve(ok(undefined))
}

async commit(): Promise<Result<void>> {
debug(`[js::commit]`)

7 changes: 7 additions & 0 deletions packages/adapter-pg-worker/src/pg.ts
Original file line number Diff line number Diff line change
@@ -143,6 +143,13 @@ class PgTransaction extends PgQueryable<TransactionClient> implements Transactio
super(client)
}

async begin(): Promise<Result<void>> {
debug(`[js::begin]`)

this.client.release()
return ok(undefined)
}

async commit(): Promise<Result<void>> {
debug(`[js::commit]`)

7 changes: 7 additions & 0 deletions packages/adapter-pg/src/pg.ts
Original file line number Diff line number Diff line change
@@ -145,6 +145,13 @@ class PgTransaction extends PgQueryable<TransactionClient> implements Transactio
super(client)
}

async begin(): Promise<Result<void>> {
debug(`[js::begin]`)

this.client.release()
return ok(undefined)
}

async commit(): Promise<Result<void>> {
debug(`[js::commit]`)

7 changes: 7 additions & 0 deletions packages/adapter-planetscale/src/planetscale.ts
Original file line number Diff line number Diff line change
@@ -131,6 +131,13 @@ class PlanetScaleTransaction extends PlanetScaleQueryable<planetScale.Transactio
super(tx)
}

async begin(): Promise<Result<void>> {
debug(`[js::begin]`)

this.txDeferred.resolve()
return Promise.resolve(ok(await this.txResultPromise))
}

async commit(): Promise<Result<void>> {
debug(`[js::commit]`)

4 changes: 2 additions & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
@@ -199,7 +199,7 @@
"@prisma/debug": "workspace:*",
"@prisma/driver-adapter-utils": "workspace:*",
"@prisma/engines": "workspace:*",
"@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/engines-version": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"@prisma/fetch-engine": "workspace:*",
"@prisma/generator-helper": "workspace:*",
"@prisma/get-platform": "workspace:*",
@@ -208,7 +208,7 @@
"@prisma/migrate": "workspace:*",
"@prisma/mini-proxy": "0.9.5",
"@prisma/pg-worker": "workspace:*",
"@prisma/query-engine-wasm": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/query-engine-wasm": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"@snaplet/copycat": "0.17.3",
"@swc-node/register": "1.10.9",
"@swc/core": "1.10.1",
8 changes: 8 additions & 0 deletions packages/client/src/runtime/RequestHandler.ts
Original file line number Diff line number Diff line change
@@ -109,6 +109,14 @@ export class RequestHandler {
const interactiveTransaction =
request.transaction?.kind === 'itx' ? getItxTransactionOptions(request.transaction) : undefined

if (interactiveTransaction) {
interactiveTransaction.payload = {
// If the interactive transaction has a payload, we need to merge it with the new_tx_id
...(interactiveTransaction.payload as any),
new_tx_id: interactiveTransaction?.id,
}
}

const response = await this.client._engine.request(request.protocolQuery, {
traceparent: this.client._tracingHelper.getTraceParent(),
interactiveTransaction,
Original file line number Diff line number Diff line change
@@ -792,6 +792,7 @@ You very likely have the wrong "binaryTarget" defined in the schema.prisma file.
max_wait: arg.maxWait,
timeout: arg.timeout,
isolation_level: arg.isolationLevel,
new_tx_id: arg?.newTxId,
})

const result = await Connection.onHttpError(
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ export type Options = {
maxWait?: number
timeout?: number
isolationLevel?: IsolationLevel
newTxId?: string
}

export type InteractiveTransactionInfo<Payload = unknown> = {
Original file line number Diff line number Diff line change
@@ -443,6 +443,7 @@ export class DataProxyEngine implements Engine<DataProxyTxInfoPayload> {
max_wait: arg.maxWait,
timeout: arg.timeout,
isolation_level: arg.isolationLevel,
new_tx_id: arg?.newTxId,
})

const url = await this.url('transaction/start')
Original file line number Diff line number Diff line change
@@ -195,6 +195,7 @@ export class LibraryEngine implements Engine<undefined> {
max_wait: arg.maxWait,
timeout: arg.timeout,
isolation_level: arg.isolationLevel,
new_tx_id: arg?.newTxId,
})

result = await this.engine?.startTransaction(jsonOptions, headerStr)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const denylist = ['$connect', '$disconnect', '$on', '$transaction', '$use', '$extends'] as const
const denylist = ['$connect', '$disconnect', '$on', '$use', '$extends'] as const

export const itxClientDenyList = denylist as ReadonlyArray<string | symbol>

9 changes: 6 additions & 3 deletions packages/client/src/runtime/getPrismaClient.ts
Original file line number Diff line number Diff line change
@@ -782,17 +782,21 @@ Or read our docs at https://www.prisma.io/docs/concepts/components/prisma-client
*/
async _transactionWithCallback({
callback,
options,
options = {},
}: {
callback: (client: Client) => Promise<unknown>
options?: Options
options?: Options & { newTxId?: string }
}) {
if (this[TX_ID]) {
options.newTxId = this[TX_ID]
}
const headers = { traceparent: this._tracingHelper.getTraceParent() }

const optionsWithDefaults: Options = {
maxWait: options?.maxWait ?? this._engineConfig.transactionOptions.maxWait,
timeout: options?.timeout ?? this._engineConfig.transactionOptions.timeout,
isolationLevel: options?.isolationLevel ?? this._engineConfig.transactionOptions.isolationLevel,
newTxId: options.newTxId,
}
const info = await this._engine.transaction('start', headers, optionsWithDefaults)

@@ -803,7 +807,6 @@ Or read our docs at https://www.prisma.io/docs/concepts/components/prisma-client

result = await callback(this._createItxClient(transaction))

// it went well, then we commit the transaction
await this._engine.transaction('commit', headers, info)
} catch (e: any) {
// it went bad, then we rollback the transaction
3 changes: 2 additions & 1 deletion packages/client/src/runtime/utils/getRuntime.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,8 @@ const runtimesPrettyNames = {
workerd: 'Cloudflare Workers',
deno: 'Deno and Deno Deploy',
netlify: 'Netlify Edge Functions',
'edge-light': 'Edge Runtime (Vercel Edge Functions, Vercel Edge Middleware, Next.js (Pages Router) Edge API Routes, Next.js (App Router) Edge Route Handlers or Next.js Middleware)',
'edge-light':
'Edge Runtime (Vercel Edge Functions, Vercel Edge Middleware, Next.js (Pages Router) Edge API Routes, Next.js (App Router) Edge Route Handlers or Next.js Middleware)',
} as const

type GetRuntimeOutput = {
Original file line number Diff line number Diff line change
@@ -220,7 +220,6 @@ function itxWithinGenericExtension() {

void xclient.$transaction((tx) => {
expectTypeOf(tx).toHaveProperty('helperMethod')
expectTypeOf(tx).not.toHaveProperty('$transaction')
expectTypeOf(tx).not.toHaveProperty('$extends')
return Promise.resolve()
})
2 changes: 1 addition & 1 deletion packages/client/tests/functional/extensions/itx.ts
Original file line number Diff line number Diff line change
@@ -315,7 +315,7 @@ testMatrix.setupTestSuite(
if (isTransaction) {
expect(ctx.$connect).toBeUndefined()
expect(ctx.$disconnect).toBeUndefined()
expect(ctx.$transaction).toBeUndefined()
expect(ctx.$transaction).toBeDefined()
expect(ctx.$extends).toBeUndefined()
} else {
expect(ctx.$connect).toBeDefined()
2 changes: 1 addition & 1 deletion packages/client/tests/functional/extensions/query.ts
Original file line number Diff line number Diff line change
@@ -829,7 +829,7 @@ testMatrix.setupTestSuite(
expectTypeOf(args).not.toBeAny()
expectTypeOf(query).toBeFunction()

expectTypeOf(operation).toMatchTypeOf <
expectTypeOf(operation).toMatchTypeOf<
| 'findFirst'
| 'findFirstOrThrow'
| 'findUnique'
72 changes: 70 additions & 2 deletions packages/client/tests/functional/interactive-transactions/tests.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { faker } from '@faker-js/faker'
import { ClientEngineType } from '@prisma/internals'
import { copycat } from '@snaplet/copycat'

@@ -30,7 +31,8 @@ testMatrix.setupTestSuite(

await prisma
// @ts-expect-error: Type 'void' is not assignable to type 'Promise<unknown>'
.$transaction(/* note how there's no `async` here */ (tx) => {
.$transaction(
/* note how there's no `async` here */ (tx) => {
console.log('1')
console.log(tx)
console.log('2')
@@ -211,11 +213,77 @@ testMatrix.setupTestSuite(
await expect(result).resolves.toHaveLength(2)
})

/**
* If a parent transaction is rolled back, the child transaction should also rollback
* - This is only supported in SQL derived servers
*/
testIf(provider === Providers.POSTGRESQL)('sql: nested rollback', async () => {
const rand1 = Math.floor(Math.random() * 1000)
const rand2 = rand1 + 1
const email1 = 'user_' + rand1 + '@website.com'
const email2 = 'user_' + rand2 + '@website.com'
const client = prisma
await expect(
client.$transaction(async (tx) => {
await tx.user.create({
data: {
email: email1,
},
})

await tx.$transaction(async (tx2) => {
await tx2.user.create({
data: {
email: email2,
},
})
})

// Abort the outer transaction
throw new Error('Rollback')
}),
).rejects.toThrow(/Rollback/)

const result = await prisma.user.findMany({
where: {
email: {
in: [email1, email2],
},
},
})

// Both transactions should rollback
expect(result).toHaveLength(0)
})

testIf(provider === Providers.POSTGRESQL)('sql: multiple interactive transactions', async () => {
const existingEmail = faker.internet.email()

await prisma.$transaction(async (tx) => {
await tx.user.create({ data: { email: existingEmail } })
})

await prisma.$transaction(async (tx) => {
await tx.user.create({ data: { email: existingEmail + 1 } })
})

const result = await prisma.user.findMany({
where: {
email: {
in: [existingEmail, existingEmail + 1],
},
},
})

// Both transactions should succeed
expect(result).toHaveLength(2)
})

/**
* We don't allow certain methods to be called in a transaction
*/
test('forbidden', async () => {
const forbidden = ['$connect', '$disconnect', '$on', '$transaction', '$use']
const forbidden = ['$connect', '$disconnect', '$on', '$use']
expect.assertions(forbidden.length + 1)

const result = prisma.$transaction((prisma) => {
4 changes: 1 addition & 3 deletions packages/client/tests/functional/skip/_matrix.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { defineMatrix } from '../_utils/defineMatrix'
import { allProviders } from '../_utils/providers'

export default defineMatrix(() => [
allProviders,
])
export default defineMatrix(() => [allProviders])
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { defineMatrix } from '../_utils/defineMatrix'
import { allProviders } from '../_utils/providers'

export default defineMatrix(() => [
allProviders,
])
export default defineMatrix(() => [allProviders])
1 change: 1 addition & 0 deletions packages/driver-adapter-utils/src/binder.ts
Original file line number Diff line number Diff line change
@@ -77,6 +77,7 @@ const bindTransaction = (errorRegistry: ErrorRegistryInternal, transaction: Tran
options: transaction.options,
queryRaw: wrapAsync(errorRegistry, transaction.queryRaw.bind(transaction)),
executeRaw: wrapAsync(errorRegistry, transaction.executeRaw.bind(transaction)),
begin: wrapAsync(errorRegistry, transaction.begin.bind(transaction)),
commit: wrapAsync(errorRegistry, transaction.commit.bind(transaction)),
rollback: wrapAsync(errorRegistry, transaction.rollback.bind(transaction)),
}
4 changes: 4 additions & 0 deletions packages/driver-adapter-utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -177,6 +177,10 @@ export interface Transaction extends Queryable {
* Transaction options.
*/
readonly options: TransactionOptions
/**
* Begin the transaction.
*/
begin(): Promise<Result<void>>
/**
* Commit the transaction.
*/
2 changes: 1 addition & 1 deletion packages/engines/package.json
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@
},
"dependencies": {
"@prisma/debug": "workspace:*",
"@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/engines-version": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"@prisma/fetch-engine": "workspace:*",
"@prisma/get-platform": "workspace:*"
},
2 changes: 1 addition & 1 deletion packages/fetch-engine/package.json
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@
},
"dependencies": {
"@prisma/debug": "workspace:*",
"@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/engines-version": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"@prisma/get-platform": "workspace:*"
},
"scripts": {
2 changes: 1 addition & 1 deletion packages/internals/package.json
Original file line number Diff line number Diff line change
@@ -85,7 +85,7 @@
"@prisma/fetch-engine": "workspace:*",
"@prisma/generator-helper": "workspace:*",
"@prisma/get-platform": "workspace:*",
"@prisma/prisma-schema-wasm": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/prisma-schema-wasm": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"@prisma/schema-files-loader": "workspace:*",
"arg": "5.0.2",
"prompts": "2.4.2"
2 changes: 1 addition & 1 deletion packages/migrate/package.json
Original file line number Diff line number Diff line change
@@ -56,7 +56,7 @@
},
"dependencies": {
"@prisma/debug": "workspace:*",
"@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/engines-version": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"@prisma/generator-helper": "workspace:*",
"@prisma/get-platform": "workspace:*",
"@prisma/internals": "workspace:*",
2 changes: 1 addition & 1 deletion packages/schema-files-loader/package.json
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@
],
"sideEffects": false,
"dependencies": {
"@prisma/prisma-schema-wasm": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69",
"@prisma/prisma-schema-wasm": "6.2.0-13.integration-sql-nested-transactions4-fa6eebbccfd5b45ef639efcb0fe328c189735aa4",
"fs-extra": "11.1.1"
},
"devDependencies": {
76 changes: 25 additions & 51 deletions pnpm-lock.yaml

0 comments on commit df1c752

Please sign in to comment.