#1990·kysely

ControlledTransaction leaks the pooled connection when beginTransaction, commitTransaction or rollbackTransaction throws

Author: YohoCXCreated Aug 19, 2026Updated Sep 16, 2026
Labelsinternal

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 819

After 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 -> ok

Expected

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.