mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
pg-pool removes its own `error` listener when it hands a client out (pg-pool@3.14.0 index.js:344) and only reattaches it in `_release` (index.js:385). Between acquire and release the client therefore has no `error` listener, so when Cloud SQL terminates that session mid-statement the emit becomes an unhandled 'error' event and the process exits. `absorbPostgresIdleClientErrors` cannot see it: pg-pool routes to `pool.on('error')` only from the idle listener. Attach a per-checkout `error` listener in the one seam every relay checkout passes through, log a single warn line, and release the client with the error so pg-pool destroys it instead of pooling a dead connection. The listener is removed on release so it cannot accumulate. The in-flight query still rejects, so existing failure reporting and the transaction retry ladder are unchanged. Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { PostgresDatabase } from './database.js'
|
|
|
|
// Stands in for a pg client between acquire and release. pg-pool assigns
|
|
// `release` per checkout, which is the property the guard wraps.
|
|
class FakePoolClient extends EventEmitter {
|
|
readonly released: Array<Error | boolean | undefined> = []
|
|
readonly statements: string[] = []
|
|
|
|
constructor(private readonly respond: (sql: string) => { rows: unknown[]; rowCount: number }) {
|
|
super()
|
|
}
|
|
|
|
query = vi.fn((sql: string) => {
|
|
this.statements.push(sql)
|
|
return Promise.resolve(this.respond(sql))
|
|
})
|
|
|
|
release = (error?: Error | boolean): void => {
|
|
this.released.push(error)
|
|
}
|
|
}
|
|
|
|
function poolOf(client: FakePoolClient) {
|
|
return { totalCount: 1, idleCount: 0, waitingCount: 0, connect: async () => client }
|
|
}
|
|
|
|
describe('checked-out PostgreSQL client failure handling', () => {
|
|
it('crashes the process when nothing listens, which is the bug being fixed', () => {
|
|
// Node's own contract: this is what killed cell c28 on 2026-09-20 20:18Z.
|
|
const unguarded = new EventEmitter()
|
|
expect(() => unguarded.emit('error', new Error('Connection terminated unexpectedly'))).toThrow(
|
|
'Connection terminated unexpectedly'
|
|
)
|
|
})
|
|
|
|
it('absorbs the error, rejects the transaction, and releases the client as failed', async () => {
|
|
const terminated = Object.assign(new Error('Connection terminated unexpectedly'), {
|
|
code: '57P01'
|
|
})
|
|
let listenersWhileCheckedOut = 0
|
|
const client: FakePoolClient = new FakePoolClient((sql) => {
|
|
if (sql !== 'SELECT 1') return { rows: [], rowCount: 0 }
|
|
listenersWhileCheckedOut = client.listenerCount('error')
|
|
// Cloud SQL terminating the session: the client emits `error` and the
|
|
// in-flight statement rejects with the same failure.
|
|
expect(() => client.emit('error', terminated)).not.toThrow()
|
|
throw terminated
|
|
})
|
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const database = new PostgresDatabase(poolOf(client) as never)
|
|
|
|
await expect(
|
|
database.transaction(async (transaction) => await transaction.query('SELECT 1'))
|
|
).rejects.toBe(terminated)
|
|
|
|
expect(listenersWhileCheckedOut).toBe(1)
|
|
expect(client.listenerCount('error')).toBe(0)
|
|
expect(client.released).toEqual([terminated])
|
|
expect(client.statements).toEqual(['BEGIN', 'SELECT 1', 'ROLLBACK'])
|
|
expect(warning).toHaveBeenCalledWith(
|
|
'[orca-relay] checked-out PostgreSQL client failed: 57P01 Connection terminated unexpectedly'
|
|
)
|
|
|
|
warning.mockRestore()
|
|
})
|
|
|
|
it('releases a healthy client back to the pool with no error', async () => {
|
|
const client = new FakePoolClient(() => ({ rows: [{ one: 1 }], rowCount: 1 }))
|
|
const database = new PostgresDatabase(poolOf(client) as never)
|
|
|
|
await expect(
|
|
database.transaction(async (transaction) => await transaction.query('SELECT 1'))
|
|
).resolves.toEqual([{ one: 1 }])
|
|
|
|
expect(client.released).toEqual([undefined])
|
|
expect(client.listenerCount('error')).toBe(0)
|
|
})
|
|
})
|