Files
orca/cloud/apps/relay/src/postgres-pool-pressure.ts
Jinwoo Hong a3046cd27b fix(relay): treat database pool connect failures as transient, not director faults (#21243)
* fix(relay): treat pool connect failures as transient, not director faults

pg-pool raises connection-acquire failures as a plain Error with no SQLSTATE,
so the transient classifier matched only one of the three messages it can
produce. The other two reached the routes unclassified and became HTTP 500s,
which is what the rollout safety gate counts.

The acquire boundary now marks the errors it produces, so "Connection
terminated unexpectedly" counts as transient when the socket died during the
handshake and stays a hard failure mid-statement, where a retry could repeat a
commit whose outcome is unknown.

/v1/regions and /v1/admin/evacuation-status gain the transient handling
/v1/assign and /v1/resolve already had.

* fix(relay): mirror the pool-connect verdict in failure diagnostics

The query-failure event's connectionTimeout boolean matched one of the two
messages connectionTimeoutMillis can produce, so the 210 dialling timeouts in
the last day logged as false and were invisible to the field meant to find them.

The pool-connect vocabulary now lives beside the acquire boundary that owns it,
and both the router's classifier and the diagnostics read it from there, so the
two cannot drift. The event also carries the routing verdict the caller already
computed, making "how much of this burst reached users as a 500" one field.

* fix(relay): null-safe transient classification and honest transient docs

The classifier now runs inside the query catch, where a thrown null or
undefined would have turned a database failure into a TypeError that buried it.

The diagnostics doc claimed transient maps to a 503 or a 500. Sweeps, startup
reconciliation, and admin routes that answer 409 all emit the same event, so
counting the false ones over-states user-facing hard failures.
2026-09-17 12:26:22 -04:00

145 lines
4.8 KiB
TypeScript

import type pg from 'pg'
export type PostgresPoolPressureCounts = {
databasePoolTotal: number
databasePoolIdle: number
databasePoolWaiting: number
databasePoolWaitersMax: number
databasePoolOldestWaitMs: number
databasePoolWaitMsMax: number
}
// A pool that cannot hand out a client throws a bare Error with no SQLSTATE, so
// the message is all node-postgres gives us. Both of these come only from
// pg-pool's connect path, so neither can be a statement that already ran.
const POOL_CONNECT_TIMEOUT_MESSAGES = [
// No pooled client came free within connectionTimeoutMillis.
'timeout exceeded when trying to connect',
// A new client's own handshake outran connectionTimeoutMillis.
'Connection terminated due to connection timeout'
]
// pg raises this whenever a socket ends early, during the handshake and mid
// statement alike, so only the acquire boundary can tell the two apart.
const CONNECTION_TERMINATED_MESSAGE = 'Connection terminated unexpectedly'
// Membership is tracked beside the error rather than on it: an error object may
// be frozen, and a mutated one would leak the marker into logs.
const poolAcquireFailures = new WeakSet<object>()
function errorMessage(error: unknown): string {
return String((error as { message?: unknown } | null)?.message)
}
function isPostgresPoolAcquireFailure(error: unknown): boolean {
return typeof error === 'object' && error !== null && poolAcquireFailures.has(error)
}
// connectionTimeoutMillis firing, either waiting in the queue or dialling.
export function isPostgresPoolConnectTimeout(error: unknown): boolean {
const message = errorMessage(error)
return POOL_CONNECT_TIMEOUT_MESSAGES.some((known) => message.includes(known))
}
// Every way the pool can fail to hand out a usable client. An early-ended
// socket counts only at the acquire boundary: retrying a statement whose commit
// outcome is unknown is not safe.
export function isPostgresPoolConnectFailure(error: unknown): boolean {
if (isPostgresPoolConnectTimeout(error)) return true
return (
errorMessage(error).includes(CONNECTION_TERMINATED_MESSAGE) &&
isPostgresPoolAcquireFailure(error)
)
}
const emptyCounts = (): PostgresPoolPressureCounts => ({
databasePoolTotal: 0,
databasePoolIdle: 0,
databasePoolWaiting: 0,
databasePoolWaitersMax: 0,
databasePoolOldestWaitMs: 0,
databasePoolWaitMsMax: 0
})
export class PostgresPoolPressure {
private readonly waiters = new Map<symbol, number>()
private waitersMax = 0
private waitMsMax = 0
private lastConsumed = emptyCounts()
constructor(
private readonly pool: pg.Pool,
private readonly now: () => number = Date.now
) {}
async connect(): Promise<pg.PoolClient> {
const waitingBefore = this.pool.waitingCount
const connection = this.pool.connect()
if (this.pool.waitingCount <= waitingBefore) return await markedAcquire(connection)
const waiter = Symbol()
const startedAt = this.now()
this.waiters.set(waiter, startedAt)
this.waitersMax = Math.max(this.waitersMax, this.waiters.size)
try {
return await markedAcquire(connection)
} finally {
this.waitMsMax = Math.max(this.waitMsMax, this.now() - startedAt)
this.waiters.delete(waiter)
}
}
consumeCounts(): PostgresPoolPressureCounts {
const counts = this.readCounts()
this.lastConsumed = counts
this.waitersMax = this.waiters.size
this.waitMsMax = counts.databasePoolOldestWaitMs
return counts
}
peekCounts(): PostgresPoolPressureCounts {
const current = this.readCounts()
return {
...current,
databasePoolWaitersMax: Math.max(
current.databasePoolWaitersMax,
this.lastConsumed.databasePoolWaitersMax
),
databasePoolOldestWaitMs: Math.max(
current.databasePoolOldestWaitMs,
this.lastConsumed.databasePoolOldestWaitMs
),
databasePoolWaitMsMax: Math.max(
current.databasePoolWaitMsMax,
this.lastConsumed.databasePoolWaitMsMax
)
}
}
private readCounts(): PostgresPoolPressureCounts {
const now = this.now()
const oldestWaitMs =
this.waiters.size === 0 ? 0 : Math.max(0, now - Math.min(...this.waiters.values()))
return {
databasePoolTotal: this.pool.totalCount,
databasePoolIdle: this.pool.idleCount,
databasePoolWaiting: this.waiters.size,
databasePoolWaitersMax: Math.max(this.waitersMax, this.waiters.size),
databasePoolOldestWaitMs: oldestWaitMs,
databasePoolWaitMsMax: Math.max(this.waitMsMax, oldestWaitMs)
}
}
}
async function markedAcquire(connection: Promise<pg.PoolClient>): Promise<pg.PoolClient> {
try {
return await connection
} catch (error) {
if (typeof error === 'object' && error !== null) poolAcquireFailures.add(error)
throw error
}
}
export function emptyPostgresPoolPressureCounts(): PostgresPoolPressureCounts {
return emptyCounts()
}