ControlledTransaction leaks the pooled connection when beginTransaction, commitTransaction or rollbackTransaction throws
I got this issue on production, having pool starvation. I analyzed with Fable 5 where maybe the issue, than patched:
// lines 716
const settings = { isolationLevel, accessMode };
validateTransactionSettings(settings);
const connection = await provideControlledConnection(this.#props.executor);
// await this.#props.driver.beginTransaction(connection.connection, settings);
try {
await this.#props.driver.beginTransaction(connection.connection, settings);
}
catch (error) {
connection.release();
throw error;
}
return new ControlledTransaction({
...props,
connection,
// lines 724
// lines 804
rollback() {
assertNotCommittedOrRolledBack(this.#state);
return new Command(async () => {
// await this.#props.driver.rollbackTransaction(this.#props.connection.connection);
// this.#state.isRolledBack = true;
// this.#props.connection.release();
try {
await this.#props.driver.rollbackTransaction(this.#props.connection.connection);
}
finally {
this.#state.isRolledBack = true;
this.#props.connection.release();
}
});
}
// lines 819After everything working stable.
Summary from Fable5:
ControlledTransaction acquires a connection via provideControlledConnection() and releases it only on the success path of commit() / rollback(). If the driver throws in beginTransaction, commitTransaction, or rollbackTransaction, release() is never reached and the connection is never returned to the pool.
Because provideControlledConnection holds the connection open inside a provideConnection callback that only resolves when release() is called, the leak is permanent for the lifetime of the process — not just until GC. Under a pool of size N, N such failures deadlock the pool.
All three failure modes are reachable in normal operation:
- BEGIN — server refuses the transaction, or the connection died while idle in the pool.
- COMMIT — a DEFERRABLE INITIALLY DEFERRED constraint fires at commit time; a serialization failure under SERIALIZABLE; the connection dies mid-transaction.
- ROLLBACK — rolling back after the connection is already gone, which is precisely when rollback is most likely to be called.
Affected versions
Reproduced on 0.29.5. The relevant code is byte-identical in 0.29.2, 0.29.3, 0.29.4, 0.29.5 and 0.30.0-beta.1.
Reproduction
Self-contained — no database required. The driver below models a pool of exactly one connection, so a single leak starves the next consumer.
import { Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely'
class PoolDriver {
constructor(fail = {}) {
this.fail = fail
this.free = [{ id: 'conn-1', executeQuery: async () => ({ rows: [] }), streamQuery: async function* () {} }]
this.waiters = []
this.acquired = 0
this.released = 0
}
async init() {}
async acquireConnection() {
this.acquired++
const c = this.free.pop()
return c ?? new Promise((resolve) => this.waiters.push(resolve))
}
async releaseConnection(conn) {
this.released++
const w = this.waiters.shift()
if (w) w(conn); else this.free.push(conn)
}
async beginTransaction() { if (this.fail.begin) throw new Error('BEGIN failed') }
async commitTransaction() { if (this.fail.commit) throw new Error('COMMIT failed') }
async rollbackTransaction() { if (this.fail.rollback) throw new Error('ROLLBACK failed') }
async destroy() {}
}
const dialect = (driver) => ({
createAdapter: () => new PostgresAdapter(),
createDriver: () => driver,
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
})
const withTimeout = (p, ms) =>
Promise.race([p.then(() => 'ok'), new Promise((r) => setTimeout(() => r('TIMED OUT - pool starved'), ms))])
async function scenario(label, fail, run) {
const driver = new PoolDriver(fail)
const db = new Kysely({ dialect: dialect(driver) })
try { await run(db) } catch (e) { console.log(` caught (expected): ${e.message}`) }
const next = await withTimeout(db.connection().execute(async () => {}), 700)
console.log(` ${label}: acquired=${driver.acquired} released=${driver.released} -> ${next}\n`)
}
await scenario('failed BEGIN', { begin: true }, async (db) => {
await db.startTransaction().execute()
})
await scenario('failed ROLLBACK', { rollback: true }, async (db) => {
const trx = await db.startTransaction().execute()
await trx.rollback().execute()
})
await scenario('failed COMMIT', { commit: true }, async (db) => {
const trx = await db.startTransaction().execute()
await trx.commit().execute()
})
await scenario('control (clean commit)', {}, async (db) => {
const trx = await db.startTransaction().execute()
await trx.commit().execute()
})Actual output
caught (expected): BEGIN failed
failed BEGIN: acquired=2 released=0 -> TIMED OUT - pool starved
caught (expected): ROLLBACK failed
failed ROLLBACK: acquired=2 released=0 -> TIMED OUT - pool starved
caught (expected): COMMIT failed
failed COMMIT: acquired=2 released=0 -> TIMED OUT - pool starved
control (clean commit): acquired=2 released=2 -> okExpected
released should reach 1 for the transaction in every case, and the next consumer should acquire a connection. A failed BEGIN/COMMIT/ROLLBACK should still surface its error to the caller, but must not retain the connection.
Root cause
Three sites in src/kysely.ts, all the same shape — release() sits after an await that can throw.
ControlledTransactionBuilder.execute() — if beginTransaction rejects, the connection acquired on the previous line is orphaned:
const connection = await provideControlledConnection(this.#props.executor)
await this.#props.driver.beginTransaction(connection.connection, settings)
ControlledTransaction.commit() and ControlledTransaction.rollback():
await this.#props.driver.commitTransaction(this.#props.connection.connection) this.#state.isCommitted = true this.#props.connection.release()
Suggested fix
Release on the failure path in execute(), and move the state flag + release() into a finally for commit() / rollback():
// execute()
const connection = await provideControlledConnection(this.#props.executor)
try {
await this.#props.driver.beginTransaction(connection.connection, settings)
} catch (error) {
connection.release()
throw error
}
// commit() — same shape for rollback()
return new Command(async (): Promise<void> => {
try {
await this.#props.driver.commitTransaction(this.#props.connection.connection)
} finally {
this.#state.isCommitted = true
this.#props.connection.release()
}
})Setting the state flag in finally keeps assertNotCommittedOrRolledBack correct — a transaction whose COMMIT threw must not be retried on the same connection.
Source: kysely-org/kysely