From f0714a31cccd909e2f26a5e174b814b90a945635 Mon Sep 17 00:00:00 2001 From: Ian Duvall Date: Mon, 10 Aug 2026 13:03:46 -0700 Subject: [PATCH 1/3] fix(adapter-pg): release the pool client at most once per transaction When an interactive transaction timeout expires while COMMIT is in flight, the query engine settles the transaction twice: the abandoned commit chain and the compensating rollback both reach the adapter. The second client.release() lands on a client pg-pool has already re-lent, silently corrupting the pool accounting until the new owner releases and pg throws an uncatchable double-release error inside its socket-data handler, killing the process. PgTransaction now settles at most once: the second commit/rollback is a no-op, queries after settlement reject with TransactionAlreadyClosed without dispatching SQL, and a client with statements still in flight at settlement is released with an error so the pool destroys the connection instead of re-lending a busy one. Fixes #29952 Claude-Session: https://claude.ai/code/session_01U3QGpkQTcYEAGGn5PVy6WG Signed-off-by: Ian Duvall --- packages/adapter-pg/src/__tests__/pg.test.ts | 115 ++++++++++++++++++- packages/adapter-pg/src/pg.ts | 51 +++++++- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/packages/adapter-pg/src/__tests__/pg.test.ts b/packages/adapter-pg/src/__tests__/pg.test.ts index 451005039372..d504b41043eb 100644 --- a/packages/adapter-pg/src/__tests__/pg.test.ts +++ b/packages/adapter-pg/src/__tests__/pg.test.ts @@ -1,7 +1,7 @@ import { getLogs } from '@prisma/debug' import type { SqlQuery } from '@prisma/driver-adapter-utils' import pg, { DatabaseError } from 'pg' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { PrismaPgAdapterFactory } from '../pg' @@ -153,3 +153,116 @@ describe('PrismaPgAdapterFactory', () => { await adapter.dispose() }) }) + +describe('PgTransaction', () => { + // Regression tests for https://github.com/prisma/prisma/issues/29952: when an + // interactive transaction timeout expires while COMMIT is in flight, the query + // engine settles the transaction twice (the abandoned commit chain and the + // compensating rollback), which must not release the pooled client twice or + // dispatch SQL on a client the pool may have re-lent. + // + // No database is needed: pg.Client's connect/query/end are stubbed and the + // engine's settlement sequences are scripted directly against the adapter. + + const statements: string[] = [] + + afterEach(() => { + vi.restoreAllMocks() + }) + + async function setup(queryImpl?: (text: string) => Promise | undefined) { + statements.length = 0 + + vi.spyOn(pg.Client.prototype, 'connect').mockImplementation(function (cb?: (err?: Error) => void) { + process.nextTick(() => cb?.()) + } as never) + vi.spyOn(pg.Client.prototype, 'end').mockImplementation((() => Promise.resolve()) as never) + vi.spyOn(pg.Client.prototype, 'query').mockImplementation(((query: string | { text: string }) => { + const text = typeof query === 'string' ? query : query.text + statements.push(text) + return queryImpl?.(text) ?? Promise.resolve({ rowCount: 0, rows: [], fields: [] }) + }) as never) + + const pool = new pg.Pool({ + connectionString: 'postgresql://user:pass@localhost:5432/db', + max: 1, + }) + const releases: unknown[] = [] + pool.on('release', (err) => releases.push(err)) + const adapter = await new PrismaPgAdapterFactory(pool).connect() + + return { pool, adapter, releases } + } + + it('should release the client exactly once when COMMIT is sent before commit()', async () => { + const { pool, adapter, releases } = await setup() + const tx = await adapter.startTransaction() + + await tx.executeRaw({ sql: 'COMMIT', args: [], argTypes: [] }) + await tx.commit() + + expect(statements).toEqual(['BEGIN', 'COMMIT']) + expect(releases).toEqual([undefined]) + expect(pool.idleCount).toBe(1) + expect(pool.totalCount).toBe(1) + }) + + it('should treat the second settlement as a no-op instead of releasing twice', async () => { + const { pool, adapter, releases } = await setup() + const tx = await adapter.startTransaction() + + await tx.commit() + await expect(tx.rollback()).resolves.toBeUndefined() + + expect(releases).toEqual([undefined]) + expect(pool.idleCount).toBe(1) + expect(pool.totalCount).toBe(1) + }) + + it('should not release a client the pool has re-lent to a new owner', async () => { + const { pool, adapter } = await setup() + const tx = await adapter.startTransaction() + + // The engine's compensation sequence when the transaction deadline drops an + // in-flight commit: rollback wins first, the pool re-lends the client, then + // the orphaned commit chain lands late. + await tx.rollback() + const nextOwner = await pool.connect() + await tx.commit() + + expect(() => nextOwner.release()).not.toThrow() + expect(pool.idleCount).toBe(1) + expect(pool.totalCount).toBe(1) + }) + + it('should reject queries after settlement without dispatching SQL', async () => { + const { adapter } = await setup() + const tx = await adapter.startTransaction() + + await tx.rollback() + statements.length = 0 + + await expect(tx.executeRaw({ sql: 'ROLLBACK', args: [], argTypes: [] })).rejects.toMatchObject({ + name: 'DriverAdapterError', + cause: { kind: 'TransactionAlreadyClosed' }, + }) + await expect(tx.queryRaw({ sql: 'SELECT 1', args: [], argTypes: [] })).rejects.toMatchObject({ + name: 'DriverAdapterError', + cause: { kind: 'TransactionAlreadyClosed' }, + }) + expect(statements).toEqual([]) + }) + + it('should destroy the connection when settling with a query still in flight', async () => { + const { pool, adapter, releases } = await setup((text) => + text === 'SELECT pending' ? new Promise(() => {}) : undefined, + ) + const tx = await adapter.startTransaction() + + void tx.executeRaw({ sql: 'SELECT pending', args: [], argTypes: [] }) + await tx.rollback() + + expect(releases).toEqual([expect.any(Error)]) + expect(pool.totalCount).toBe(0) + }) +}) diff --git a/packages/adapter-pg/src/pg.ts b/packages/adapter-pg/src/pg.ts index 6a677bdad614..a47a6caac7fe 100644 --- a/packages/adapter-pg/src/pg.ts +++ b/packages/adapter-pg/src/pg.ts @@ -98,7 +98,7 @@ class PgQueryable implements SqlQ * Should the query fail due to a connection error, the connection is * marked as unhealthy. */ - private async performIO(query: SqlQuery): Promise> { + protected async performIO(query: SqlQuery): Promise> { const { sql, args } = query const values = args.map((arg, i) => mapArg(arg, query.argTypes[i])) @@ -132,6 +132,9 @@ class PgQueryable implements SqlQ } class PgTransaction extends PgQueryable implements Transaction { + private settled = false + private inFlightQueries = 0 + constructor( client: pg.PoolClient, readonly options: TransactionOptions, @@ -141,18 +144,56 @@ class PgTransaction extends PgQueryable implements Transactio super(client, pgOptions) } + protected override async performIO(query: SqlQuery): Promise> { + if (this.settled) { + throw new DriverAdapterError({ + kind: 'TransactionAlreadyClosed', + cause: 'The transaction has already been committed or rolled back', + }) + } + + this.inFlightQueries++ + try { + return await super.performIO(query) + } finally { + this.inFlightQueries-- + } + } + + /** + * Settles the transaction at most once. The query engine can settle twice when + * an interactive transaction timeout expires while COMMIT is in flight: the + * abandoned commit chain and the compensating rollback both reach the adapter + * (https://github.com/prisma/prisma/issues/29952). A second `release()` would + * corrupt pg-pool's accounting once the pool has re-lent the client, so it is + * skipped, and a client with statements still in flight is released with an + * error so the pool destroys the connection instead of re-lending a busy one. + */ + private settle(): void { + if (this.settled) { + return + } + this.settled = true + + this.cleanup?.() + + if (this.inFlightQueries > 0) { + this.client.release(new Error('Transaction settled with statements still in flight')) + } else { + this.client.release() + } + } + async commit(): Promise { debug(`[js::commit]`) - this.cleanup?.() - this.client.release() + this.settle() } async rollback(): Promise { debug(`[js::rollback]`) - this.cleanup?.() - this.client.release() + this.settle() } async createSavepoint(name: string): Promise { From ac4434a416913a893abf4646c8c7ff2ce534a08d Mon Sep 17 00:00:00 2001 From: Ian Duvall Date: Wed, 12 Aug 2026 14:21:59 -0700 Subject: [PATCH 2/3] test(adapter-pg): end every pool created by setup() in afterEach Each PgTransaction settlement test creates its own pg.Pool through setup() but never closed it, leaking live pools past the point where afterEach restores the mocked pg.Client methods. Track the pools setup() creates and end them in afterEach before restoring mocks. Claude-Session: https://claude.ai/code/session_013ZRubEFkLdS4givbjSKXBJ Signed-off-by: Ian Duvall --- packages/adapter-pg/src/__tests__/pg.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/adapter-pg/src/__tests__/pg.test.ts b/packages/adapter-pg/src/__tests__/pg.test.ts index d504b41043eb..793911b3039c 100644 --- a/packages/adapter-pg/src/__tests__/pg.test.ts +++ b/packages/adapter-pg/src/__tests__/pg.test.ts @@ -165,8 +165,11 @@ describe('PgTransaction', () => { // engine's settlement sequences are scripted directly against the adapter. const statements: string[] = [] + const pools: pg.Pool[] = [] - afterEach(() => { + afterEach(async () => { + await Promise.all(pools.map((pool) => pool.end())) + pools.length = 0 vi.restoreAllMocks() }) @@ -187,6 +190,7 @@ describe('PgTransaction', () => { connectionString: 'postgresql://user:pass@localhost:5432/db', max: 1, }) + pools.push(pool) const releases: unknown[] = [] pool.on('release', (err) => releases.push(err)) const adapter = await new PrismaPgAdapterFactory(pool).connect() From 6abb9de711990f7e30a9818d661cc97d94d8c514 Mon Sep 17 00:00:00 2001 From: Ian Duvall Date: Wed, 12 Aug 2026 14:23:00 -0700 Subject: [PATCH 3/3] fix(adapter-pg): release the client even when a cleanup callback throws settle() exists to guarantee an at-most-once release of the pooled client, but it invoked the cleanup callback before the release, so a throwing callback could skip the release entirely. Wrap the callback in try/finally so the release is unconditional. Claude-Session: https://claude.ai/code/session_013ZRubEFkLdS4givbjSKXBJ Signed-off-by: Ian Duvall --- packages/adapter-pg/src/pg.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/adapter-pg/src/pg.ts b/packages/adapter-pg/src/pg.ts index a47a6caac7fe..e0a9db68fe9c 100644 --- a/packages/adapter-pg/src/pg.ts +++ b/packages/adapter-pg/src/pg.ts @@ -175,12 +175,14 @@ class PgTransaction extends PgQueryable implements Transactio } this.settled = true - this.cleanup?.() - - if (this.inFlightQueries > 0) { - this.client.release(new Error('Transaction settled with statements still in flight')) - } else { - this.client.release() + try { + this.cleanup?.() + } finally { + if (this.inFlightQueries > 0) { + this.client.release(new Error('Transaction settled with statements still in flight')) + } else { + this.client.release() + } } }