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.
Bug description
When a
$transactionreaches itstimeout, it is not rolled back atomically. A statement whose dispatch is delayed past the timeout is written to the connection after theROLLBACKthe 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
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. Raisingtimeoutreduces but does not eliminate it.Prisma Schema & Queries
Full harness (driver-adapter wrapper that induces the delay) is in the linked repo's
repro.ts.Prisma Config
Logs & Debug Info
Server-side statement order on the transaction's connection, captured from the MySQL general log:
The timeout's
ROLLBACKlands 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
@prisma/adapter-mariadbPrisma Version
Also reproduces on 7.8.0 and 7.10.0-dev.23.