-
Notifications
You must be signed in to change notification settings - Fork 2.1k
bug: @prisma/adapter-planetscale silently swallows transaction COMMIT errors, causing data loss #29138
Description
Bug description
When PlanetScale’s vttablet kills a (an upsert) transaction server-side (e.g. due to its 20-second transaction timeout), the @prisma/adapter-planetscale adapter silently swallows the COMMIT failure. Prisma resolves the operation as successful, returns data from the uncommitted transaction, but nothing is actually persisted to the database.
Severity
🚨 Critical: Data loss, app crash, security issue
Reproduction
This is a standalone mocked reproduction:
/**
* Minimal reproduction of the Promise handling bug in @prisma/adapter-planetscale.
*
* This extracts the exact pattern from `startTransactionInner` and `commit()` in:
* https://github.com/prisma/prisma/blob/b6fd1bd092e120097c591f6c25f6fb1b1179d0bd/packages/adapter-planetscale/src/planetscale.ts#L216-L236
*
* No dependencies required — run with: npx tsx repro-promise-pattern.ts
*/
// ── Extracted from @prisma/adapter-planetscale ──────────────────────────────
class RollbackError extends Error {
constructor() {
super("ROLLBACK");
this.name = "RollbackError";
}
}
type Deferred<T> = {
resolve(value: T | PromiseLike<T>): void;
reject(reason: unknown): void;
};
function createDeferred<T>(): [Deferred<T>, Promise<T>] {
const deferred = {} as Deferred<T>;
return [
deferred,
new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
}),
];
}
// ── Mock PlanetScale conn.transaction() ─────────────────────────────────────
/**
* Simulates PlanetScale's conn.transaction() behavior:
* 1. Sends BEGIN
* 2. Calls the callback with a transaction object
* 3. Awaits the callback's return value
* 4. Sends COMMIT (which fails if vttablet killed the transaction)
*/
function mockConnTransaction(
callback: (tx: object) => Promise<void>,
shouldCommitFail: boolean,
): Promise<void> {
return (async () => {
// Simulate BEGIN (succeeds) — this await is critical: it makes the callback
// run asynchronously, just like the real PlanetScale driver which does an
// HTTP request for BEGIN before calling the callback. This means
// `const txResultPromise = mockConnTransaction(...)` has already been
// assigned by the time the callback runs.
await Promise.resolve();
console.log(" [mock DB] BEGIN");
const tx = {}; // mock transaction object
await callback(tx);
// Simulate COMMIT
if (shouldCommitFail) {
console.log(" [mock DB] COMMIT → ERROR (transaction killed by vttablet)");
throw new Error(
"target: db.-.primary: vttablet: rpc error: code = Aborted " +
"desc = transaction 123: ended at 2025-01-01 (exceeded timeout: 20s)",
);
}
console.log(" [mock DB] COMMIT → OK");
})();
}
// ── Reproduce the adapter's startTransactionInner ───────────────────────────
interface MockTransaction {
txDeferred: Deferred<void>;
txResultPromise: Promise<void>;
commit(): Promise<void>;
}
function startTransactionInner(shouldCommitFail: boolean): Promise<MockTransaction> {
return new Promise<MockTransaction>((resolve, reject) => {
const txResultPromise = mockConnTransaction(
async (_tx) => {
const [txDeferred, deferredPromise] = createDeferred<void>();
// This is the exact pattern from the adapter:
// resolve() is called HERE, fulfilling the outer promise immediately
resolve({
txDeferred,
txResultPromise, // captured via closure (assigned after mockConnTransaction returns)
commit: async () => {
txDeferred.resolve();
return await txResultPromise;
},
});
// The transaction stays open until this promise resolves
return deferredPromise;
},
shouldCommitFail,
).catch((error) => {
// This is the buggy .catch() handler from the adapter:
if (!(error instanceof RollbackError)) {
return reject(error); // NO-OP: outer promise was already resolve()'d above
}
return undefined;
});
});
}
// ── Run the reproduction ────────────────────────────────────────────────────
async function main() {
console.log("═══════════════════════════════════════════════════════════════");
console.log("Test 1: COMMIT succeeds (baseline — should work fine)");
console.log("═══════════════════════════════════════════════════════════════");
const tx1 = await startTransactionInner(false);
try {
await tx1.commit();
console.log(" ✅ commit() resolved (correct — commit succeeded)\n");
} catch (e) {
console.log(` ❌ commit() threw: ${e}\n`);
}
console.log("═══════════════════════════════════════════════════════════════");
console.log("Test 2: COMMIT fails (vttablet killed the transaction)");
console.log("═══════════════════════════════════════════════════════════════");
const tx2 = await startTransactionInner(true);
try {
await tx2.commit();
console.log(" 🐛 commit() resolved (BUG — commit FAILED but no error thrown!)");
console.log("");
console.log(" ➜ Prisma thinks the transaction committed successfully.");
console.log(" ➜ It returns data from the uncommitted SELECT.");
console.log(" ➜ But nothing was actually persisted to the database.");
console.log(" ➜ This causes silent data loss.\n");
} catch (e) {
console.log(` ✅ commit() threw: ${e}`);
console.log(" (This is the EXPECTED behavior — errors should propagate)\n");
}
}
main().catch(console.error);Expected vs. Actual Behavior
Would have expected the upsert to throw in this case, it silently failed and broke further things.
Frequency
Consistently reproducible
Does this occur in development or production?
Only in production (e.g., query engine, generated client)
Is this a regression?
I think probably not a regression but likely applies to all versions.
Workaround
Could use the MariaDb adapter that seems that is not affected by this. Could also patch this adapter with something like this.
The .catch() handler in startTransactionInner (L226-L234) needs to propagate the error through txResultPromise instead of trying to reject() an already-resolved promise:
async startTransactionInner(conn: planetScale.Connection, options: TransactionOptions): Promise<Transaction> {
return new Promise<Transaction>((resolve, reject) => {
const txResultPromise = conn
.transaction(async (tx) => {
const [txDeferred, deferredPromise] = createDeferred<void>()
const txWrapper = new PlanetScaleTransaction(tx, options, txDeferred, txResultPromise)
resolve(txWrapper)
return deferredPromise
})
.catch((error) => {
if (error instanceof RollbackError) {
return undefined
}
throw error // re-throw so txResultPromise rejects and commit() propagates the error
})
})
}Prisma Schema & Queries
Does not matter for this.
Prisma Config
Using Using foreignKeys mode but also affects in prisma mode, using
Logs & Debug Info
Observable Impact
- Any Prisma operation that uses implicit transactions (e.g.
upsert()with nested creates,create()with nested relations) can return data as if the operation succeeded - The returned data comes from
SELECTstatements within the uncommitted transaction - The actual data is never committed to the database
- Downstream operations that reference the supposedly-created records fail with foreign key constraint violations, which is the only visible symptom
How We Discovered This
A prisma.model.upsert() with a nested model create (e.g. relatedModel: { create: { ... } }) returned successfully with the created record's ID. A subsequent operation referencing that ID failed with a foreign key constraint violation -- the record simply did not exist in the database. The PlanetScale logs showed:
target: <db>.-.primary: vttablet: rpc error: code = Aborted desc = transaction <id>: ended at <time> (exceeded timeout: 20s)
Environment & Setup
@prisma/adapter-planetscale: 7.3.0
prisma: 7.3.0
Database: PlanetScale (Vitess)
Runtime: Node.js on Vercel
Prisma Version
Actually the bug is in node.js 24 on vercel, but adding here just a similar local environment for completion.
prisma : 7.3.0
@prisma/client : 7.3.0
Operating System : darwin
Architecture : arm64
Node.js : v24.13.0
TypeScript : unknown
Query Compiler : enabled
PSL : @prisma/prisma-schema-wasm 7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735
Schema Engine : schema-engine-cli 9d6ad21cbbceab97458517b147a6a09ff43aa735 (at ../../../Library/Caches/pnpm/dlx/9cfd8469db2d9b0e65b830a6105326af5006cae7d362d0db3e0e6dbf1cbdbac2/19c32a24c4d-17a31/node_modules/.pnpm/@prisma+engines@7.3.0/node_modules/@prisma/engines/schema-engine-darwin-arm64)
Default Engines Hash : 9d6ad21cbbceab97458517b147a6a09ff43aa735
Studio : 0.13.1