Skip to content

Nested $transaction mutates the caller's options object (writes newTxId), so a reused options object makes later top-level transactions fail with P2028 or silently join another open transaction #30367

Open

Description

@charbodjc

Package and version

@prisma/client@7.10.0 (with @prisma/adapter-pg@7.10.0; also reproduced with prisma@7.10.0)

What happened?

Since nested interactive transactions landed (7.5.0, #21678), PrismaClient.prototype._transactionWithCallback mutates the caller's options object when the call is nested, and then reads newTxId from the caller's options unconditionally, including on top-level calls:

// @prisma/client/runtime/client.js (7.10.0), _transactionWithCallback
_transactionWithCallback({ callback: n, options: i = {} }) {
  let o = tu(this), s = o.kind === "nested", ...
  if (s) {
    if (u.at(-1) !== o.scopeId) throw new Error("Concurrent nested transactions are not supported");
    i.newTxId = o.txId            // <-- writes into the object the caller passed
  }
  ...
  T = { maxWait: ..., timeout: ..., isolationLevel: ..., newTxId: i.newTxId }   // <-- read on every call
  S = await this._engine.transaction("start", P, T)

So if an application keeps a shared options object — a module constant such as const TX_OPTIONS = { timeout: 30_000, maxWait: 10_000 }, which is a very common pattern — and passes it to a nested tx.$transaction(cb, TX_OPTIONS) once, that object permanently gains newTxId: <that outer transaction's id>. Every later top-level prisma.$transaction(cb, TX_OPTIONS) in the process is then sent to the engine as a nested start against that id, with one of two outcomes:

  1. The outer transaction has since committed → P2028: Transaction API error: Transaction already closed: A start cannot be executed on a committed transaction.
  2. The outer transaction is still open (a concurrent request on the same instance) → the new "top-level" transaction is silently opened as a savepoint inside another request's transaction, on that request's connection. Its writes commit or roll back with the other request's work; row/advisory locks it takes are held on the other connection; a caller that acts "after commit" acts after someone else's commit.

Nothing in the public API suggests the options object is written to; TransactionOptions-shaped objects are naturally declared once and reused. We hit this in production after upgrading: a helper that takes either the root client or a transaction client and calls db.$transaction(cb, SHARED_OPTIONS) (a fresh transaction on the root, a savepoint inside a caller's) worked on a cold instance and failed with P2028 on the next unrelated request once any nested call had run.

What did you expect to happen?

  • $transaction should not mutate the object passed as options. The nested transaction id should be carried internally (a local copy, or a separate argument to the engine call), not written into caller-owned state.
  • A top-level $transaction call should never start nested. newTxId should be derived from the client the method is called on (the itx scope context), never read from user-supplied options — so even a polluted or hand-crafted options object cannot make a root-client call join another transaction.

Minimal reproduction

Schema:

generator client { provider = "prisma-client-js" }
datasource db { provider = "postgresql" }
model Item {
  id   Int    @id @default(autoincrement())
  name String
}

Script (npx tsx repro.ts):

import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'

const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }) })

// A perfectly ordinary shared options object.
const OPTIONS = { timeout: 30_000, maxWait: 10_000 }

async function main() {
  console.log('before:', JSON.stringify(OPTIONS))

  // 1. One nested interactive transaction, passing the shared options to the inner call.
  await prisma.$transaction(async (tx) => {
    await tx.$transaction(async (itx) => itx.item.create({ data: { name: 'inner' } }), OPTIONS)
  }, OPTIONS)
  console.log('after one nested use:', JSON.stringify(OPTIONS))

  // 2. A later, unrelated TOP-LEVEL transaction with the same options object.
  try {
    await prisma.$transaction(async (tx) => tx.item.create({ data: { name: 'later' } }), OPTIONS)
    console.log('later top-level transaction: OK')
  } catch (e) {
    console.log('later top-level transaction FAILED:', (e as Error).message.trim().split('\n').at(-1))
  }

  // 3. If the stale id names a transaction that is still open, the "top-level" call silently joins it.
  const pid = (c: { $queryRaw: PrismaClient['$queryRaw'] }) =>
    c.$queryRaw<{ pid: number }[]>`SELECT pg_backend_pid()::int AS pid`.then((r) => r[0].pid)
  const OPTS2 = { timeout: 30_000 }
  await prisma.$transaction(async (outer) => {
    await outer.$transaction(async () => undefined, OPTS2) // stamps OPTS2.newTxId = outer's id
    const outerPid = await pid(outer)
    const joinedPid = await prisma.$transaction((t) => pid(t), OPTS2) // meant to be independent
    console.log(`outer pid ${outerPid}, "independent" top-level tx pid ${joinedPid} -> ${outerPid === joinedPid ? 'JOINED the outer transaction' : 'separate'}`)
  })

  await prisma.$disconnect()
}
main()

Output:

before: {"timeout":30000,"maxWait":10000}
after one nested use: {"timeout":30000,"maxWait":10000,"newTxId":"e5d24ae0-de0a-4eb6-8856-151913b3e15f"}
later top-level transaction FAILED: Transaction API error: Transaction already closed: A start cannot be executed on a committed transaction.
outer pid 12823, "independent" top-level tx pid 12823 -> JOINED the outer transaction

Passing a fresh object per call ({ ...OPTIONS }) avoids both symptoms, which confirms the mutation is the cause.

Environment

  • Node: 22.16.0
  • OS: macOS 25.6 (aarch64)
  • Package manager: npm 10
  • Database: PostgreSQL 16.11, via @prisma/adapter-pg@7.10.0
  • prisma@7.10.0, @prisma/client@7.10.0 (latest 7.x at time of filing)

Additional context

  • Introduced by feat: add support for nested transaction rollbacks via savepoints in sql #21678 ("feat: add support for nested transaction rollbacks via savepoints in sql", 7.5.0). The same PR removed $transaction from the itx client deny list, so this is reachable from any transaction client.
  • A readonly / as const options object in TypeScript does not protect against it (it is a compile-time annotation); Object.freeze would turn the silent write into a TypeError on every nested call in strict mode, which is arguably worse.
  • Suggested fix: copy before annotating (const opts = { ...i, newTxId: s ? o.txId : undefined }) and never read newTxId from the caller's object — take it from tu(this) only.

Activity

  1. prisma-asks commented on Oct 9, 2026

    @prisma-asks

    @charbodjc Thanks for the detailed report! The fix is merged into v7 in #30368: a nested $transaction no longer writes anything onto the options object you pass it, so reusing one options object is safe again. It ships in the next 7.x patch release. You can follow where this stands here: https://asks.prisma.cool/public/asks/01a10b2e-25ad-702a-abdc-78b2edefdc74

  2. added a commit that references this issue on Oct 11, 2026
    97ea359
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions