Skip to content

$transaction is not atomic on timeout: a statement can commit after the rollback #29762

Description

@wakfi

Bug description

When a $transaction reaches its timeout, it is not rolled back atomically. A statement whose dispatch is delayed past the timeout is written to the connection after the ROLLBACK the timeout triggers, so it autocommits while other statements in the same transaction are rolled back. The result is a partial commit of a transaction the client reports as failed (P2028): some writes persist and some are rolled back, leaving data in a state the transaction was meant to make impossible.

Severity

🚨 Critical: Data loss/integrity

Reproduction

Minimal, deterministic reproduction: https://github.com/wakfi/prisma-tx-atomicity-repro

git clone https://github.com/wakfi/prisma-tx-atomicity-repro
cd prisma-tx-atomicity-repro
./setup.sh    # starts a throwaway MySQL 8.0 in Docker, installs, generates, runs. Included for convenience, the steps can be run manually if preferred

The table starts empty and the transaction is rolled back, so an atomic implementation must leave it empty. One row survives.

The repro wraps the driver adapter (a supported public API) to delay one statement's dispatch, which makes the race deterministic. The same delay occurs naturally under load: in a separate no-injection test (many concurrent transactions on a small connection pool, no adapter delay), the identical corruption appears in ~10–15% of timed-out transactions, and it disappears entirely when the timeout is generous, confirming the timeout, not concurrency, is the cause.

Expected vs. Actual Behavior

Expected: a transaction that times out is rolled back entirely, all statements or none.

Actual: one statement commits while others in the same transaction are rolled back. The client throws P2028 (reporting the transaction as rolled back), yet a write persists.

Frequency

Consistently reproducible. The minimal repro is deterministic; in production, it is intermittent.

Does this occur in development or production?

Both development and production. The defect is in the runtime client / query engine, so it occurs anywhere the client executes queries.

Is this a regression?

Unverified. Reproduces on every version I tested: 7.8.0, 7.9.0 (latest stable), and 7.10.0-dev.23 (latest dev) — so it is not fixed in any current release or pre-release. I did not test the legacy Rust query-engine client; the affected code (the TypeScript transaction manager in @prisma/client-engine-runtime) is part of the driver-adapter / query-compiler architecture and may not exist in the same form there, so I can't say whether it's a regression. We recently upgraded to Prisma 7 and had not seen this before, so I speculate it's likely a regression introduced in Prisma 7.

Workaround

No reliable workaround. The likelihood is reduced by keeping transactions well under their timeout (fewer statements, lower per-statement latency, less event-loop contention) so no statement's dispatch is delayed past the deadline. Raising timeout reduces but does not eliminate it.

Prisma Schema & Queries

datasource db {
  provider     = "mysql"
  relationMode = "prisma"
}

model Item {
  id Int @id
}
// Empty table. This transaction times out (P2028) and reports itself rolled back,
// but one of the two inserts survives.
await prisma.$transaction(
  [
    prisma.$executeRawUnsafe('INSERT INTO Item VALUES (1)'),
    prisma.$executeRawUnsafe('INSERT INTO Item VALUES (2)'),
  ],
  { timeout: 10 },
)

Full harness (driver-adapter wrapper that induces the delay) is in the linked repo's repro.ts.

Prisma Config

import { defineConfig } from 'prisma/config'

export default defineConfig({
  schema: 'prisma/schema.prisma',
  datasource: { url: process.env.DATABASE_URL },
})

Logs & Debug Info

Server-side statement order on the transaction's connection, captured from the MySQL general log:

BEGIN
Execute   INSERT INTO Item VALUES (1)     -- inside the transaction
ROLLBACK                                   -- timeout fires -> row 1 undone
Execute   INSERT INTO Item VALUES (2)     -- dispatched after ROLLBACK -> autocommits

The timeout's ROLLBACK lands between the two statements. Statement 1 (dispatched before it) is rolled back; statement 2 (dispatched after it) runs with no active transaction and commits. The timeout-triggered rollback is not serialized against statement dispatch on the connection, so a delayed statement can reach the wire after the transaction has already been rolled back.

Environment & Setup

  • OS: macOS (darwin arm64)
  • Database: MySQL 8.0, via @prisma/adapter-mariadb
  • Node.js version: v24.14.1

Prisma Version

prisma               : 7.9.0
@prisma/client       : 7.9.0
Query Compiler       : enabled
Node.js              : v24.14.1
OS / Arch            : darwin / arm64

Also reproduces on 7.8.0 and 7.10.0-dev.23.

Activity

  1. changed the title [-]$transaction is not atomic on timeout: a statement can commit after the rollback (partial commit, P2028)[/-] [+]$transaction is not atomic on timeout: a statement can commit after the rollback[/+] on Jul 23, 2026
  2. added 2 commits that reference this issue on Oct 8, 2026
    4f86d68
    137600e
  3. kensac commented on Oct 9, 2026

    @kensac

    I opened #30650 to fix this.

    The cause: when the transaction times out, a query that's still running keeps sending its remaining statements on the transaction it was given. With adapters like @prisma/adapter-pg, the manager sends ROLLBACK and then releases the connection, so those statements run outside the transaction and each one commits on its own. A nested write is enough to hit it.

    The fix makes TransactionManager.getTransaction return a transaction that refuses statements once it starts closing, with the same error a new query on that transaction already gets (P2028 after a timeout). The guard has to live in the manager, not in rollback(): the manager sends ROLLBACK through executeRaw first, so a guard in rollback() comes too late. That's why #29769 on its own still fails the regression test.

    It includes unit tests and a PostgreSQL functional test that reproduces the leak. Happy to adjust the approach if you'd prefer it done differently.

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