Files
orca/cloud/apps/relay/src/database.ts
T
Jinwoo Hong 2a15a50691 fix(relay): retire a pooled Postgres connection that turned slow or lossy (#33900)
Director only. When one pooled connection is far slower than its peers (median over 25 ms and 3x theirs, or third-slowest statement in 60 s over 200 ms and 5x every peer's slowest), it is destroyed on release and the pool dials a fresh one. At most one per pool per 10 s; never mid-transaction; no change in connection count. Fixes the 10-26 director stalls where one connection stalled the sticky lane.
2026-10-26 13:38:58 -04:00

1579 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdirSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import pg from 'pg'
import { RELAY_REGIONS } from '@orca-cloud/relay-contract'
import {
emptyPostgresPoolPressureCounts,
isPostgresPoolConnectFailure,
PostgresPoolPressure,
type PostgresPoolLane,
type PostgresPoolPressureCounts
} from './postgres-pool-pressure.js'
import { applyPostgresSchema } from './postgres-schema-startup.js'
import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js'
import { reportPostgresQueryFailure } from './postgres-query-failure.js'
import { noteStickySlotPoolWait, noteStickySlotStatement } from './sticky-slot-statements.js'
import { noteConnectionStatement, SlowConnectionRetirement } from './postgres-connection-latency.js'
import {
isPostgresReadTimeout,
isRelayDatabaseLayerError,
markRelayDatabaseError
} from './relay-database-rejection-fence.js'
import {
CellInventoryHoldSamples,
emptyCellInventoryHoldCounts,
type CellInventoryHoldCounts,
type CellLockHoldSite
} from './cell-inventory-hold-samples.js'
export const POSTGRES_LOCK_TIMEOUT_MS = 1_000
function setLocalLockTimeout(milliseconds: number): string {
if (!Number.isInteger(milliseconds) || milliseconds < 1) {
throw new Error('invalid_lock_timeout')
}
return `SET LOCAL lock_timeout = '${milliseconds}ms'`
}
// Region CHECK lists come from the contract so a new region cannot leave a
// column rejecting values the rest of the relay already accepts.
const REGION_LIST = RELAY_REGIONS.map((region) => `'${region}'`).join(', ')
// A host that was just moved is not a candidate again for this long, so a
// desktop whose region probe flips cannot walk itself back and forth.
export const REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS = 7 * 24 * 60 * 60_000
export type SqlRow = Record<string, unknown>
export type RelayLockOptions = {
failIfUnavailable?: boolean
// Only honoured inside a transaction: SET LOCAL is a no-op in autocommit.
lockTimeoutMs?: number
// Report how long this lock is held to COMMIT. The hold, not the wait, is what
// forms the queue, and nothing measured it before.
measureHoldMs?: boolean
// Labels the measured hold; unset reads as the full-inventory lock.
holdSite?: CellLockHoldSite
// The statement carries its own row-lock clause (a CTE locks inside the
// statement), so none is appended. failIfUnavailable must match that clause.
lockClauseInStatement?: boolean
}
type MeasuredHold = { holdMs: number; site: CellLockHoldSite }
// A transaction that can report how long it held a measured lock before COMMIT.
type HoldMeasuringTransaction = { consumeHold(): MeasuredHold | undefined }
function recordMeasuredHold(holds: CellInventoryHoldSamples, transaction: unknown): void {
const hold = (transaction as HoldMeasuringTransaction).consumeHold?.()
if (hold) holds.record(hold.holdMs, hold.site)
}
function lockedStatement(sql: string, options: RelayLockOptions): string {
if (options.lockClauseInStatement) return sql
return `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`
}
export type RelayTransactionOptions = { reportRetries?: boolean }
export interface RelayDatabase {
readonly dialect?: 'sqlite' | 'postgres'
query(sql: string, params?: unknown[]): Promise<SqlRow[]>
// Ahead of every queued query() for the next free connection; see
// PostgresPoolPressure. Absent means plain query().
queryPriority?(sql: string, params?: unknown[]): Promise<SqlRow[]>
queryLocked(
sql: string,
params?: unknown[],
options?: RelayLockOptions
): Promise<SqlRow[]>
transaction<T>(
operation: (transaction: RelayDatabase) => Promise<T>,
options?: RelayTransactionOptions
): Promise<T>
// Only on a PostgreSQL transaction handle; see commitWithFinalWrite.
commitWithFinal?(sql: string, params?: unknown[]): Promise<boolean>
close(): Promise<void>
}
// Final RETURNING write and COMMIT share one PostgreSQL message, avoiding a lock-held round trip; false means already rolled back.
export async function commitWithFinalWrite(
database: RelayDatabase,
sql: string,
params: unknown[] = []
): Promise<boolean> {
if (database.commitWithFinal) return await database.commitWithFinal(sql, params)
return (await database.query(sql, params)).length > 0
}
// RULE - no new index and no new column on `relay_control_connection_reservations`,
// `relay_confirm_results`, `relay_audit_events`, `relay_connection_bases`, or any other large
// table may be added to SCHEMA or to POSTGRES_SCHEMA_MIGRATIONS. The catalog pre-check skips a
// lock-taking statement only once the object exists, so a brand-new one reports missing on every
// director at once and each runs a non-concurrent build over the whole table. POSTGRES_LOCK_TIMEOUT_MS
// bounds how long that build waits for its lock, not how long it holds it. Build the index out of
// band with CREATE INDEX CONCURRENTLY first, then add it here, where the pre-check skips it forever
// after. relay-schema-lock-targets.test.ts pins the current list, so an addition fails CI.
// Constraint swaps are matched by NAME in pg_constraint, never by body, because the CHECK list is
// generated from REGION_LIST. Changing a constraint's definition under the same name therefore does
// nothing on boot: an operator drops it, and the next boot adds the current definition back.
// relay_confirmable_splices, relay_cell_drain_attempts and relay_migration_leases are no longer
// created; nothing ever wrote them. Databases that have them keep them empty until a drop is safe:
// an older image still creates them at boot, and a drop racing that CREATE can fail its schema step.
// Account erasure in orca-cloud must be deployed with retired-table support (orca-cloud#493) first.
const SCHEMA = `
CREATE TABLE IF NOT EXISTS relay_invites (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
relay_device_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
state TEXT NOT NULL,
attempt_count BIGINT NOT NULL,
max_attempts BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
reservation_id TEXT,
reservation_expires_at BIGINT,
cooldown_until BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, relay_device_id, token_hash)
);
CREATE INDEX IF NOT EXISTS relay_invites_device
ON relay_invites(user_id, relay_host_id, relay_device_id);
-- schema-deferrable: created out of band, so a boot that cannot take the lock must retry
-- Why: the credential sweep matches (state, expires_at) every cycle while invites in a terminal
-- state accumulate for the life of the database. Unindexed it seq-scans the whole table inside the
-- maintenance transaction. Partial, so the index holds only the states the sweep can act on.
CREATE INDEX IF NOT EXISTS relay_invites_sweep_expiry
ON relay_invites(expires_at) WHERE state IN ('available', 'reserved', 'cooldown');
-- schema-deferrable: created out of band, so a boot that cannot take the lock must retry
-- Why: the second sweep pass matches (state, reservation_expires_at) over the same table.
CREATE INDEX IF NOT EXISTS relay_invites_sweep_reservation
ON relay_invites(reservation_expires_at) WHERE state = 'reserved';
CREATE TABLE IF NOT EXISTS relay_devices (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
relay_device_id TEXT NOT NULL,
current_hash TEXT NOT NULL,
current_version BIGINT NOT NULL,
current_expires_at BIGINT NOT NULL,
grace_hash TEXT,
grace_version BIGINT,
grace_expires_at BIGINT,
revoked_at BIGINT,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, relay_device_id)
);
CREATE INDEX IF NOT EXISTS relay_devices_current_hash ON relay_devices(relay_host_id, current_hash);
CREATE INDEX IF NOT EXISTS relay_devices_grace_hash ON relay_devices(relay_host_id, grace_hash);
CREATE TABLE IF NOT EXISTS relay_install_results (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
relay_device_id TEXT NOT NULL,
req_id TEXT NOT NULL,
authorization_mode TEXT NOT NULL,
result_json TEXT NOT NULL,
committed_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, relay_device_id, req_id)
);
CREATE TABLE IF NOT EXISTS relay_connection_bases (
basis_conn_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
relay_device_id TEXT NOT NULL,
owning_control_generation BIGINT NOT NULL,
credential_kind TEXT NOT NULL,
invite_token_hash TEXT,
accepted_credential_version BIGINT,
accepted_as TEXT,
deadline BIGINT NOT NULL,
active BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
-- Why: the maintenance sweep matches (active, deadline) while inactive bases
-- accumulate unboundedly. Unindexed it seq-scans millions of rows every cycle
-- and holds the maintenance transaction open long enough to time out
-- assignment lock waits.
-- Why not a partial index on active = 1: a basis is inserted active and flipped to 0, so each
-- deactivation leaves a dead entry in that index too. Measured on production-shaped history it
-- carries the same dead entries as this one, the planner picks this one in every state, and it
-- costs ~65 bytes of WAL per insert. Bloat here is cured by reaping and vacuum, not by a narrower
-- index.
CREATE INDEX IF NOT EXISTS relay_connection_bases_active_deadline
ON relay_connection_bases(active, deadline);
CREATE TABLE IF NOT EXISTS relay_direct_authorizations (
direct_auth_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
relay_device_id TEXT NOT NULL,
owning_control_generation BIGINT NOT NULL,
deadline BIGINT NOT NULL,
consumed_at BIGINT
);
-- schema-deferrable: created out of band, so a boot that cannot take the lock must retry
-- Why: the sweep expires pending authorizations by (consumed_at IS NULL, deadline), and consumed
-- rows are never deleted. Partial, so the index stays the size of the pending set.
CREATE INDEX IF NOT EXISTS relay_direct_authorizations_pending_deadline
ON relay_direct_authorizations(deadline) WHERE consumed_at IS NULL;
CREATE TABLE IF NOT EXISTS relay_confirm_results (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
req_id TEXT NOT NULL,
basis_conn_id TEXT NOT NULL,
tuple_json TEXT NOT NULL,
result_json TEXT NOT NULL,
committed_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, req_id)
);
CREATE TABLE IF NOT EXISTS relay_assignments (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
cell_id TEXT NOT NULL,
assignment_epoch BIGINT NOT NULL,
lease_expires_at BIGINT NOT NULL,
last_activity_at BIGINT NOT NULL,
reserved_controls BIGINT NOT NULL,
reserved_splices BIGINT NOT NULL,
reserved_invites BIGINT NOT NULL,
pending_installs BIGINT NOT NULL,
pending_confirmations BIGINT NOT NULL,
migration_leases BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id)
);
CREATE TABLE IF NOT EXISTS relay_assignment_region_preferences (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
preferred_region TEXT NOT NULL
CHECK (preferred_region IN (${REGION_LIST})),
observed_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id)
);
CREATE INDEX IF NOT EXISTS relay_assignment_region_preferences_observed
ON relay_assignment_region_preferences(observed_at);
CREATE TABLE IF NOT EXISTS relay_region_decisions (
user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL,
generation BIGINT NOT NULL, expires_at BIGINT NOT NULL,
assignment_epoch BIGINT NOT NULL, incumbent_region TEXT NOT NULL,
policy_version BIGINT NOT NULL, outcome TEXT NOT NULL,
cohort_bucket BIGINT NOT NULL DEFAULT 0,
last_considered_at BIGINT NOT NULL DEFAULT 0,
preferred_region TEXT, observed_at BIGINT NOT NULL, report_json TEXT,
PRIMARY KEY (user_id, relay_host_id)
);
CREATE TABLE IF NOT EXISTS relay_control_capabilities (
user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, activity_id TEXT NOT NULL,
cell_id TEXT NOT NULL, cell_incarnation TEXT NOT NULL,
assignment_epoch BIGINT NOT NULL, generation BIGINT NOT NULL,
finish_existing BIGINT NOT NULL,
idle_regional_rehome BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, relay_host_id, activity_id)
);
CREATE TABLE IF NOT EXISTS relay_region_rehome_worker_state (
worker_id TEXT PRIMARY KEY,
next_dispatch_at BIGINT NOT NULL,
paused_until BIGINT NOT NULL,
consecutive_failures BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_region_rehome_control (
control_id TEXT PRIMARY KEY,
generation BIGINT NOT NULL,
enabled BIGINT NOT NULL,
observation_started_at BIGINT NOT NULL,
not_before BIGINT NOT NULL,
rate_per_minute BIGINT NOT NULL,
preference_max_age_ms BIGINT NOT NULL,
host_cooldown_ms BIGINT NOT NULL
DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS},
drain_grace_ms BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts (
attempt_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
preferred_region TEXT NOT NULL
CONSTRAINT relay_region_rehome_attempts_preferred_region_valid
CHECK (preferred_region IN (${REGION_LIST})),
source_cell_id TEXT NOT NULL,
source_cell_incarnation TEXT NOT NULL,
source_generation BIGINT NOT NULL DEFAULT 0,
target_cell_id TEXT NOT NULL,
target_cell_incarnation TEXT NOT NULL,
previous_epoch BIGINT NOT NULL,
assignment_epoch BIGINT NOT NULL,
drain_grace_ms BIGINT NOT NULL,
send_attempts BIGINT NOT NULL,
last_send_attempt_at BIGINT,
drain_receipt_at BIGINT,
drain_outcome TEXT CHECK (
drain_outcome IN ('accepted', 'already-accepted', 'host-not-connected')
),
completed_at BIGINT,
aborted_at BIGINT,
abort_reason TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE (user_id, relay_host_id, assignment_epoch)
);
CREATE INDEX IF NOT EXISTS relay_region_rehome_attempts_pending
ON relay_region_rehome_attempts(drain_receipt_at, last_send_attempt_at, completed_at, aborted_at);
CREATE INDEX IF NOT EXISTS relay_region_rehome_attempts_host_recency
ON relay_region_rehome_attempts(user_id, relay_host_id, created_at);
CREATE TABLE IF NOT EXISTS relay_cells (
cell_id TEXT PRIMARY KEY,
cell_url TEXT NOT NULL UNIQUE,
enabled BIGINT NOT NULL,
capacity_requests BIGINT NOT NULL,
reserved_requests BIGINT NOT NULL,
observed_requests BIGINT NOT NULL,
last_heartbeat_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_cell_regions (
cell_id TEXT PRIMARY KEY,
region TEXT NOT NULL CHECK (region IN (${REGION_LIST}))
);
CREATE TABLE IF NOT EXISTS relay_cell_admission (
cell_id TEXT PRIMARY KEY,
admission_state TEXT NOT NULL
CHECK (admission_state IN ('existing-only', 'migration-only', 'general')),
updated_at BIGINT NOT NULL,
roll_isolated_at BIGINT
);
-- Step 5: written by the flag workflow. A cell with no row is in database mode.
CREATE TABLE IF NOT EXISTS relay_cell_admit_modes (
cell_id TEXT PRIMARY KEY,
admit_mode TEXT NOT NULL CHECK (admit_mode IN ('db', 'reserve')),
updated_at BIGINT NOT NULL
);
-- Step 5: written by each cell, its raw mode (switch file and dead-man latch). The database
-- path places on a reserve cell only while this row, from its current process, says db.
CREATE TABLE IF NOT EXISTS relay_cell_admit_effective (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('db', 'reserve')),
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_admission_selectors (
selector_id TEXT PRIMARY KEY,
generation BIGINT NOT NULL,
attempt_id TEXT,
membership_json TEXT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_admission_selector_intents (
attempt_id TEXT PRIMARY KEY,
expected_generation BIGINT NOT NULL,
intended_generation BIGINT NOT NULL,
previous_membership_json TEXT NOT NULL,
membership_json TEXT NOT NULL,
created_at BIGINT NOT NULL,
committed_at BIGINT
);
CREATE TABLE IF NOT EXISTS relay_admission_selector_cell_additions (
attempt_id TEXT PRIMARY KEY,
cells_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_cell_runtime (
cell_id TEXT PRIMARY KEY,
cell_url TEXT NOT NULL,
cell_incarnation TEXT NOT NULL,
started_at BIGINT NOT NULL,
ready BIGINT NOT NULL,
observed_requests BIGINT NOT NULL,
last_heartbeat_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_cell_runtime_heartbeat
ON relay_cell_runtime(ready, last_heartbeat_at);
CREATE TABLE IF NOT EXISTS relay_cell_capabilities (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
regional_rehome_protocol BIGINT NOT NULL,
last_heartbeat_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_cell_rehome_safety (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
observed_at BIGINT NOT NULL,
sql_failures BIGINT NOT NULL,
reconnects BIGINT NOT NULL,
control_activity_recovery_failures BIGINT NOT NULL,
database_pool_waiting BIGINT NOT NULL,
database_pool_waiters_max BIGINT NOT NULL,
database_pool_wait_ms_max BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_cell_connection_limits (
cell_id TEXT PRIMARY KEY,
hard_cap BIGINT NOT NULL,
unobserved_bound BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
reported_hard_cap BIGINT
);
CREATE TABLE IF NOT EXISTS relay_cell_connection_runtime (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
total_connections BIGINT NOT NULL,
in_flight_connections BIGINT NOT NULL,
reserved_connection_units BIGINT NOT NULL,
enforced_connection_units BIGINT NOT NULL,
last_heartbeat_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_cell_connection_runtime_heartbeat
ON relay_cell_connection_runtime(last_heartbeat_at);
CREATE TABLE IF NOT EXISTS relay_cell_connection_snapshots (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
inclusion_watermark BIGINT NOT NULL,
total_connections BIGINT NOT NULL,
in_flight_connections BIGINT NOT NULL,
reserved_connection_units BIGINT NOT NULL,
enforced_connection_units BIGINT NOT NULL,
snapshot_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_cell_connection_snapshot_freshness
ON relay_cell_connection_snapshots(snapshot_at);
CREATE TABLE IF NOT EXISTS relay_cell_fences (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
attested_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_cell_fences_expiry
ON relay_cell_fences(expires_at);
CREATE TABLE IF NOT EXISTS relay_cell_committed_fences (
cell_id TEXT PRIMARY KEY,
attempt_id TEXT NOT NULL UNIQUE,
cell_incarnation TEXT NOT NULL,
attested_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_cell_committed_fences_expiry
ON relay_cell_committed_fences(expires_at);
CREATE TABLE IF NOT EXISTS relay_cell_legacy_fence_adoptions (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
attested_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_cell_legacy_fence_adoptions_expiry
ON relay_cell_legacy_fence_adoptions(expires_at);
CREATE TABLE IF NOT EXISTS relay_cell_fence_attempts (
attempt_id TEXT PRIMARY KEY,
environment TEXT NOT NULL,
cell_id TEXT NOT NULL,
cell_incarnation TEXT NOT NULL,
mig_name TEXT NOT NULL,
instance_group TEXT NOT NULL,
generation_identity TEXT NOT NULL,
fence_commit TEXT NOT NULL,
plan_sha256 TEXT NOT NULL,
gce_operation TEXT,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
apply_started_at BIGINT,
completed_at BIGINT,
aborted_at BIGINT
);
CREATE INDEX IF NOT EXISTS relay_cell_fence_attempts_expiry
ON relay_cell_fence_attempts(expires_at);
CREATE INDEX IF NOT EXISTS relay_cell_fence_attempts_cell
ON relay_cell_fence_attempts(cell_id, created_at);
CREATE TABLE IF NOT EXISTS relay_cell_fence_plan_bindings (
attempt_id TEXT PRIMARY KEY,
plan_object_name TEXT NOT NULL,
plan_object_generation TEXT,
var_file_sha256 TEXT NOT NULL,
terraform_state_lineage TEXT NOT NULL,
terraform_state_serial BIGINT NOT NULL,
terraform_state_object_generation TEXT NOT NULL,
terraform_state_object_sha256 TEXT NOT NULL,
request_reason TEXT NOT NULL,
FOREIGN KEY (attempt_id) REFERENCES relay_cell_fence_attempts(attempt_id)
);
CREATE TABLE IF NOT EXISTS relay_cell_fence_apply_invocations (
invocation_id TEXT PRIMARY KEY,
attempt_id TEXT NOT NULL,
request_reason TEXT NOT NULL UNIQUE,
started_at BIGINT NOT NULL,
gce_operation TEXT,
FOREIGN KEY (attempt_id) REFERENCES relay_cell_fence_attempts(attempt_id)
);
CREATE INDEX IF NOT EXISTS relay_cell_fence_apply_invocations_attempt
ON relay_cell_fence_apply_invocations(attempt_id, started_at);
CREATE TABLE IF NOT EXISTS relay_cell_drain_attempt_states (
attempt_id TEXT PRIMARY KEY,
cell_id TEXT NOT NULL,
cell_incarnation TEXT NOT NULL,
trace_value TEXT NOT NULL UNIQUE,
planned_grace_ms BIGINT NOT NULL,
state TEXT NOT NULL CHECK (
state IN (
'prepared',
'send-may-have-started',
'application-receipt',
'proven-not-delivered'
)
),
prepared_at BIGINT NOT NULL,
send_may_have_started_at BIGINT,
send_permit_expires_at BIGINT,
application_receipt_at BIGINT,
backend_success_status BIGINT,
backend_instance TEXT,
receipt_cell_incarnation TEXT,
retry_after BIGINT,
recover_forward_attempted_at BIGINT,
proven_not_delivered_at BIGINT
);
CREATE INDEX IF NOT EXISTS relay_cell_drain_attempt_states_cell
ON relay_cell_drain_attempt_states(cell_id, prepared_at);
CREATE TABLE IF NOT EXISTS relay_cell_drain_recovery_attempts (
drain_attempt_id TEXT NOT NULL,
cell_incarnation TEXT NOT NULL,
attempted_at BIGINT NOT NULL,
PRIMARY KEY (drain_attempt_id, cell_incarnation),
FOREIGN KEY (drain_attempt_id) REFERENCES relay_cell_drain_attempt_states(attempt_id)
);
CREATE TABLE IF NOT EXISTS relay_assignment_activity_leases (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
activity_id TEXT NOT NULL,
activity_kind TEXT NOT NULL,
cell_id TEXT NOT NULL,
request_units BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, activity_id)
);
-- expires_at is deliberately unindexed: every control renewal writes it (~471/s), so an index on
-- it makes each renewal a non-HOT update that rewrites index entries. Its only reader is the 30s
-- expiry sweep, which seq-scans 14.8k rows / 7MB in a few milliseconds.
CREATE TABLE IF NOT EXISTS relay_control_connection_reservations (
reservation_id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
assignment_epoch BIGINT NOT NULL,
cell_id TEXT NOT NULL,
state TEXT NOT NULL
CHECK (state IN ('reserved', 'late-arrival-debt', 'claimed', 'released')),
inclusion_watermark BIGINT,
claim_activity_id TEXT,
created_at BIGINT NOT NULL,
timeout_at BIGINT NOT NULL,
claimed_at BIGINT,
released_at BIGINT,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_control_connection_reservation_headroom
ON relay_control_connection_reservations(cell_id, state);
CREATE INDEX IF NOT EXISTS relay_control_connection_reservation_assignment
ON relay_control_connection_reservations(
user_id, relay_host_id, assignment_epoch, cell_id, created_at
);
CREATE TABLE IF NOT EXISTS relay_rate_windows (
scope_key TEXT NOT NULL,
window_kind TEXT NOT NULL,
window_started_at BIGINT NOT NULL,
count BIGINT NOT NULL,
PRIMARY KEY (scope_key, window_kind, window_started_at)
);
-- schema-deferrable: created out of band, so a boot that cannot take the lock must retry
-- Why: window_started_at is the PRIMARY KEY's last column, so the sweep's 24h retention delete
-- cannot use it and seq-scans instead.
CREATE INDEX IF NOT EXISTS relay_rate_windows_started
ON relay_rate_windows(window_started_at);
CREATE TABLE IF NOT EXISTS relay_assignment_migrations (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
source_cell_id TEXT NOT NULL,
target_cell_id TEXT NOT NULL,
previous_epoch BIGINT NOT NULL,
assignment_epoch BIGINT NOT NULL,
source_request_units BIGINT NOT NULL,
target_reserved_units BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
target_registered_at BIGINT,
completed_at BIGINT,
aborted_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, assignment_epoch)
);
CREATE INDEX IF NOT EXISTS relay_assignment_migrations_active
ON relay_assignment_migrations(expires_at, completed_at, aborted_at);
CREATE TABLE IF NOT EXISTS relay_assignment_migration_incarnations (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
assignment_epoch BIGINT NOT NULL,
source_cell_incarnation TEXT NOT NULL,
target_cell_incarnation TEXT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, assignment_epoch)
);
CREATE TABLE IF NOT EXISTS relay_post_drain_migration_pins (
user_id TEXT NOT NULL,
relay_host_id TEXT NOT NULL,
assignment_epoch BIGINT NOT NULL,
drain_attempt_id TEXT NOT NULL,
source_cell_id TEXT NOT NULL,
source_cell_incarnation TEXT NOT NULL,
target_cell_id TEXT NOT NULL,
target_cell_incarnation TEXT NOT NULL,
source_request_units BIGINT NOT NULL,
target_reserved_units BIGINT NOT NULL,
pinned_at BIGINT NOT NULL,
PRIMARY KEY (user_id, relay_host_id, assignment_epoch)
);
CREATE INDEX IF NOT EXISTS relay_post_drain_migration_pins_attempt
ON relay_post_drain_migration_pins(drain_attempt_id);
CREATE TABLE IF NOT EXISTS relay_audit_events (
id TEXT PRIMARY KEY,
at BIGINT NOT NULL,
type TEXT NOT NULL,
user_id TEXT,
relay_host_id TEXT,
relay_device_id TEXT,
detail_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at);
`
// Replace auto-named legacy single-region checks with named bidirectional checks; current-schema databases no-op without either statement dropping the other.
export const POSTGRES_SCHEMA_MIGRATIONS = [
POSTGRES_STATEMENT_STATS_MIGRATION,
`ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE relay_region_rehome_attempts
DROP CONSTRAINT IF EXISTS relay_region_rehome_attempts_preferred_region_check`,
`ALTER TABLE relay_region_rehome_attempts
ADD CONSTRAINT relay_region_rehome_attempts_preferred_region_valid
CHECK (preferred_region IN (${REGION_LIST}))`,
`ALTER TABLE relay_region_rehome_control
ADD COLUMN IF NOT EXISTS host_cooldown_ms BIGINT NOT NULL
DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`,
`ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0`,
// Nullable with no default, so the rewrite is catalog-only; every row
// aborted before this column existed reads as an unattributed abort.
`ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS abort_reason TEXT`,
// Only same-cap roll stamps migration-only; other parked hosts stay pinned. Nullable/no-default is catalog-only, and pre-column isolates retain unmarked legacy behavior.
`ALTER TABLE relay_cell_admission ADD COLUMN IF NOT EXISTS roll_isolated_at BIGINT`,
// The cap the cell last reported enforcing; see cell-connection-cap.ts. Nullable with no default,
// so the rewrite is catalog-only, and a director that predates it neither reads nor clears it.
`ALTER TABLE relay_cell_connection_limits ADD COLUMN IF NOT EXISTS reported_hard_cap BIGINT`,
// Deferrable DROP: one boot must win ACCESS EXCLUSIVE against continuous writes; restarting 28 directors would repeat the lock queue. Once absent, catalog precheck skips it.
`-- schema-deferrable: one boot has to win ACCESS EXCLUSIVE on a table written ~475/s
DROP INDEX IF EXISTS relay_assignment_activity_expiry`,
// Dropping expiry index permits HOT; fillfactor 70 leaves room for versions (100% HOT vs 0.5% at 100). Catalog-only SHARE UPDATE EXCLUSIVE blocks vacuum/DDL, not reads/writes; defer with the drop and let pages converge through renewals.
`-- schema-deferrable: buys nothing until the drop above lands
ALTER TABLE relay_assignment_activity_leases SET (fillfactor = 70)`,
// Slow ~5.5GB-index autovacuums from ~3 to ~15min at 20ms/200 cost units to reduce shared-DB cache eviction. Deferrable: vacuum holds the same lock, and boot needs no change.
`-- schema-deferrable: a running vacuum holds the lock this needs
ALTER TABLE relay_control_connection_reservations SET (autovacuum_vacuum_cost_delay = 20)`,
`-- schema-deferrable: a running vacuum holds the lock this needs
ALTER TABLE relay_control_connection_reservations SET (autovacuum_vacuum_cost_limit = 200)`
]
// The exact statement list a Postgres boot applies, in order, so the lock-target census can read
// what production runs rather than a copy of it. The SQLite path keeps SCHEMA on its own.
export function relayPostgresSchemaStatements(): string[] {
return [...SCHEMA.split(';'), ...POSTGRES_SCHEMA_MIGRATIONS]
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0)
}
function postgresSql(sql: string): string {
let index = 0
return sql.replace(/\?/g, () => `$${++index}`)
}
function returnsRows(sql: string): boolean {
return /^\s*(select|with)/i.test(sql) || /returning/i.test(sql)
}
const POSTGRES_TRANSACTION_PHASES = [
['relay_region_rehome_', 'regional-rehome'],
['relay_assignment_activity_leases', 'activity-lease'],
['relay_assignment_migration', 'migration'],
['relay_post_drain_migration_pins', 'migration'],
['relay_cell_connection_runtime', 'cell-runtime'],
['relay_cell_connection_snapshots', 'cell-runtime'],
['relay_cell_runtime', 'cell-runtime'],
['relay_cell_drain_', 'cell-operation'],
['relay_cell_fence', 'cell-operation'],
['relay_cell_committed_fences', 'cell-operation'],
['relay_cell_legacy_fence_adoptions', 'cell-operation'],
['relay_admission_selector', 'admission'],
['relay_cell_admission', 'admission'],
['relay_control_connection_reservations', 'connection'],
['relay_connection_bases', 'connection'],
['relay_direct_authorizations', 'connection'],
['relay_confirm_results', 'connection'],
['relay_assignment_region_preferences', 'assignment'],
['relay_assignments', 'assignment'],
['relay_cell_', 'cell-inventory'],
['relay_cells', 'cell-inventory'],
['relay_invites', 'credential'],
['relay_devices', 'credential'],
['relay_install_results', 'credential'],
['relay_rate_windows', 'rate-limit'],
['relay_audit_events', 'audit']
] as const
const postgresTransactionPhaseByError = new WeakMap<object, string>()
function postgresTransactionPhase(sql: string): string {
const normalized = sql.toLowerCase()
return POSTGRES_TRANSACTION_PHASES.find(([table]) => normalized.includes(table))?.[1] ?? 'other'
}
function rememberPostgresTransactionPhase(error: unknown, sql: string): void {
if (typeof error === 'object' && error !== null) {
postgresTransactionPhaseByError.set(error, postgresTransactionPhase(sql))
}
}
function postgresTransactionErrorPhase(error: unknown): string {
return typeof error === 'object' && error !== null
? (postgresTransactionPhaseByError.get(error) ?? 'transaction')
: 'transaction'
}
class SqliteTransaction implements RelayDatabase {
readonly dialect = 'sqlite' as const
private held: { fromMs: number; site: CellLockHoldSite } | undefined
constructor(protected readonly database: DatabaseSync) {}
consumeHold(): MeasuredHold | undefined {
if (this.held === undefined) return undefined
const hold = { holdMs: performance.now() - this.held.fromMs, site: this.held.site }
this.held = undefined
return hold
}
protected noteHeld(options: RelayLockOptions): void {
if (options.measureHoldMs && this.held === undefined) {
this.held = { fromMs: performance.now(), site: options.holdSite ?? 'inventory' }
}
}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
const statement = this.database.prepare(sql)
const bound = params.map((value) => (value === undefined ? null : value)) as never[]
if (returnsRows(sql)) return statement.all(...bound) as SqlRow[]
const result = statement.run(...bound)
return [{ changes: Number(result.changes) }]
}
async queryLocked(
sql: string,
params: unknown[] = [],
options: RelayLockOptions = {}
): Promise<SqlRow[]> {
const rows = await this.query(sql, params)
this.noteHeld(options)
return rows
}
async transaction<T>(
operation: (transaction: RelayDatabase) => Promise<T>,
_options: RelayTransactionOptions = {}
): Promise<T> {
return await operation(this)
}
async close(): Promise<void> {}
}
class SqliteDatabase extends SqliteTransaction {
private tail: Promise<void> = Promise.resolve()
private readonly holds = new CellInventoryHoldSamples()
consumeHoldCounts(): CellInventoryHoldCounts {
return this.holds.consumeCounts()
}
override async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
await this.tail
return await super.query(sql, params)
}
override async transaction<T>(operation: (transaction: RelayDatabase) => Promise<T>): Promise<T> {
const previous = this.tail
let release!: () => void
this.tail = new Promise((resolve) => (release = resolve))
await previous
try {
this.database.exec('BEGIN IMMEDIATE')
const transaction = new SqliteTransaction(this.database)
try {
const result = await operation(transaction)
this.database.exec('COMMIT')
recordMeasuredHold(this.holds, transaction)
return result
} catch (error) {
this.database.exec('ROLLBACK')
throw error
}
} finally {
release()
}
}
override async close(): Promise<void> {
await this.tail
this.database.close()
}
}
// Literals for a simple-query message, which carries no bind parameters. Only safe
// integers and strings: anything else is a caller bug, not something to stringify.
function inlinePostgresParameters(sql: string, params: unknown[], client: pg.PoolClient): string {
let index = 0
const inlined = sql.replace(/\?/g, () => {
const value = params[index++]
if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value)
if (typeof value === 'string') return client.escapeLiteral(value)
throw new Error('unsupported_inline_parameter')
})
if (index !== params.length) throw new Error('inline_parameter_count_mismatch')
return inlined
}
// A per-query read timeout, when the cell's switch overrides the pool's (pg reads
// `query_timeout` from a query config object first). Undefined keeps the pool's.
type PostgresQueryTimeout = () => number | undefined
async function timedQuery(
client: pg.PoolClient,
connectionId: number,
text: string,
values: unknown[] | undefined,
queryTimeout: PostgresQueryTimeout | undefined
): Promise<pg.QueryResult> {
const startedAt = performance.now()
try {
const timeout = queryTimeout?.()
if (timeout === undefined) return values === undefined ? await client.query(text) : await client.query(text, values)
// pg@8.22 reads it at client.js:660; @types/pg does not declare it on QueryConfig.
const config: pg.QueryConfig & { query_timeout: number } = {
text,
...(values === undefined ? {} : { values }),
query_timeout: timeout
}
return await client.query(config)
} finally {
const durationMs = performance.now() - startedAt
noteStickySlotStatement(durationMs, connectionId)
noteConnectionStatement(client, durationMs)
}
}
class PostgresTransaction implements RelayDatabase {
readonly dialect = 'postgres' as const
private held: { fromMs: number; site: CellLockHoldSite } | undefined
private lockUnavailable = 0
private lockTimeouts = 0
private state: 'open' | 'committed' | 'rolled-back' = 'open'
// Lost replies leave pg’s active query running; queued COMMIT could commit caller-failed work, so poison and destroy this connection.
private lostReply: Error | undefined
constructor(
protected readonly client: pg.PoolClient,
private readonly connectionId: number,
private readonly queryTimeout?: PostgresQueryTimeout
) {}
get poisonedBy(): Error | undefined {
return this.lostReply
}
private failIfPoisoned(): void {
if (this.lostReply) throw this.lostReply
}
private noteFailure(error: unknown): void {
markRelayDatabaseError(error)
if (isPostgresReadTimeout(error)) this.lostReply ??= error
}
get open(): boolean {
return this.state === 'open'
}
async commitWithFinal(sql: string, params: unknown[] = []): Promise<boolean> {
this.assertNotCommitted()
this.failIfPoisoned()
// Zero rows divides by zero, so the message stops before COMMIT exactly when the write missed.
const message =
`WITH final_write AS (${inlinePostgresParameters(sql, params, this.client)}) ` +
'SELECT 1 / (SELECT count(*)::int FROM final_write); COMMIT'
try {
await timedQuery(this.client, this.connectionId, message, undefined, this.queryTimeout)
} catch (error) {
// Simple query stops on first error, before COMMIT; retry server errors normally, but never unknown outcomes after connection loss.
this.noteFailure(error)
if (String((error as { code?: unknown }).code) !== '22012') {
rememberPostgresTransactionPhase(error, sql)
throw error
}
await this.client.query('ROLLBACK').catch((rollbackError: unknown) => {
this.noteFailure(rollbackError)
throw rollbackError
})
this.state = 'rolled-back'
return false
}
this.state = 'committed'
return true
}
private assertNotCommitted(): void {
// A later statement would run in autocommit, outside the work it belongs to.
if (this.state === 'committed') throw new Error('postgres_transaction_already_committed')
}
consumeHold(): MeasuredHold | undefined {
if (this.held === undefined) return undefined
const hold = { holdMs: performance.now() - this.held.fromMs, site: this.held.site }
this.held = undefined
return hold
}
// Drain metrics on rollback too or contended 55P03 attempts disappear from counts.
consumeLockUnavailable(): number {
const count = this.lockUnavailable
this.lockUnavailable = 0
return count
}
consumeLockTimeouts(): number {
const count = this.lockTimeouts
this.lockTimeouts = 0
return count
}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
this.assertNotCommitted()
this.failIfPoisoned()
try {
const result = await timedQuery(
this.client,
this.connectionId,
postgresSql(sql),
params,
this.queryTimeout
)
return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }]
} catch (error) {
this.noteFailure(error)
rememberPostgresTransactionPhase(error, sql)
throw error
}
}
async queryLocked(
sql: string,
params: unknown[] = [],
options: RelayLockOptions = {}
): Promise<SqlRow[]> {
// Restore SET LOCAL after lock or later statements inherit the bound and misattribute 55P03s.
const bounded = options.lockTimeoutMs !== undefined && !options.failIfUnavailable
try {
// A blocked waiter holds its pooled client for the whole lock_timeout, so
// hot tiny-table locks bound their own wait well under the pool default.
if (bounded) await this.query(setLocalLockTimeout(options.lockTimeoutMs!))
const rows = await this.query(lockedStatement(sql, options), params)
if (options.measureHoldMs && this.held === undefined) {
this.held = { fromMs: performance.now(), site: options.holdSite ?? 'inventory' }
}
return rows
} catch (error) {
if (
options.failIfUnavailable &&
String((error as { code?: unknown }).code) === '55P03'
) {
if (options.measureHoldMs) this.lockUnavailable += 1
throw new Error('database_lock_unavailable')
}
// A bounded wait that expires raises the same 55P03 without NOWAIT. This is
// the request path, so it is counted apart from by-design sweep deferrals.
if (bounded && options.measureHoldMs && String((error as { code?: unknown }).code) === '55P03') {
this.lockTimeouts += 1
}
throw error
} finally {
// Restore on the error path too: the transaction may still be retried or
// continue with unrelated locks after a caught lock failure.
if (bounded) await this.query(setLocalLockTimeout(POSTGRES_LOCK_TIMEOUT_MS)).catch(() => undefined)
}
}
async transaction<T>(
operation: (transaction: RelayDatabase) => Promise<T>,
_options: RelayTransactionOptions = {}
): Promise<T> {
return await operation(this)
}
async close(): Promise<void> {}
}
const POSTGRES_TRANSACTION_ATTEMPTS = 3
const POSTGRES_RETRY_MAX_DELAY_MS = 25
const POSTGRES_CONNECTION_TIMEOUT_MS = 2_000
// Three attempts × 5s timeout = 15s, half the 30s renewal tick, leaving connect-timeout room.
export const POSTGRES_STATEMENT_TIMEOUT_MS = 5_000
const POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS = 5_000
// Allow routine 5–7s VM stalls to deliver already-timed replies; shorter read deadlines would destroy otherwise healthy clients (connect-burst RCA).
export const POSTGRES_READ_TIMEOUT_MARGIN_MS = 10_000
const POSTGRES_IDLE_SESSION_TIMEOUT_MS = 30_000
export function relayPostgresStatementTimeoutMs(
env: NodeJS.ProcessEnv = process.env
): number {
const configured = env.ORCA_RELAY_POSTGRES_STATEMENT_TIMEOUT_MS
if (configured === undefined || configured === '') return POSTGRES_STATEMENT_TIMEOUT_MS
const milliseconds = Number(configured)
// 0 is PostgreSQL's "no timeout"; refusing it keeps the deadline this exists
// to enforce from being disabled by a typo in an environment variable.
if (!Number.isInteger(milliseconds) || milliseconds < 1) {
throw new Error('invalid_statement_timeout')
}
return milliseconds
}
export function relayPostgresReadTimeoutMarginMs(
env: NodeJS.ProcessEnv = process.env
): number {
const configured = env.ORCA_RELAY_POSTGRES_READ_TIMEOUT_MARGIN_MS
if (configured === undefined || configured === '') return POSTGRES_READ_TIMEOUT_MARGIN_MS
const milliseconds = Number(configured)
if (!Number.isInteger(milliseconds) || milliseconds < 1) {
throw new Error('invalid_read_timeout_margin')
}
return milliseconds
}
function retryablePostgresTransactionError(error: unknown): boolean {
const code = String((error as { code?: unknown }).code)
// 57014 aborts like lock timeout; use bounded transaction retry instead of terminal failure.
return code === '40P01' || code === '40001' || code === '55P03' || code === '57014'
}
export function isRelayDatabaseTransientError(error: unknown): boolean {
// Runs inside the query catch, where a thrown null or undefined would turn a
// database failure into a TypeError that buries it.
const code = String((error as { code?: unknown } | null)?.code)
if (
[
'40P01',
'40001',
'55P03',
'57014',
'53300',
'57P03',
'08000',
'08001',
'08003',
'08006',
// The server ended the session: pg_terminate_backend or a fast/immediate shutdown.
'57P01',
'57P02',
// The server ended a session idle in a transaction past its limit (a database stall).
'25P03'
].includes(code)
) {
return true
}
// The connection dropped mid-statement: pg's own message, or the socket's reset. Only from
// the database layer, since any other socket can raise the same errno.
if (
isRelayDatabaseLayerError(error) &&
(code === 'ECONNRESET' ||
code === 'EPIPE' ||
String((error as { message?: unknown }).message).startsWith('Connection terminated'))
) {
return true
}
// Lost reply answers 503 but may have committed: no transaction retry. Startup reconcile alone retries convergent upserts (cell-admission-startup.ts).
if (isPostgresReadTimeout(error)) return true
// A pool that cannot hand out a client reports no SQLSTATE at all, so the
// acquire boundary owns that vocabulary.
return isPostgresPoolConnectFailure(error)
}
// pg may still call ended sessions queryable until socket end; destroy on release or the next caller inherits a dead connection.
const SESSION_ENDING_CODES = new Set(['57P01', '57P02', '57P03', '57P04', '57P05', '25P03'])
function endsPostgresSession(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const code = 'code' in error ? String(error.code) : ''
if (SESSION_ENDING_CODES.has(code) || code.startsWith('08')) return true
return error instanceof Error && error.message.startsWith('Connection terminated')
}
async function waitForPostgresRetry(random: () => number = Math.random): Promise<void> {
const delayMs = Math.floor(random() * (POSTGRES_RETRY_MAX_DELAY_MS + 1))
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
export class PostgresDatabase implements RelayDatabase {
readonly dialect = 'postgres' as const
private readonly pressure: PostgresPoolPressure
private readonly holds = new CellInventoryHoldSamples()
// pg-pool hands the same client object back for the same connection.
private readonly connectionIds = new WeakMap<pg.PoolClient, number>()
private nextConnectionId = 1
private readonly slowConnections: SlowConnectionRetirement | undefined
consumeHoldCounts(): CellInventoryHoldCounts {
return this.holds.consumeCounts()
}
constructor(
private readonly pool: pg.Pool,
private readonly queryTimeout?: PostgresQueryTimeout,
options: { retiresSlowConnections?: boolean } = {}
) {
this.pressure = new PostgresPoolPressure(pool)
this.slowConnections = options.retiresSlowConnections ? new SlowConnectionRetirement() : undefined
}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
return await this.queryOnLane('general', sql, params)
}
async queryPriority(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
return await this.queryOnLane('priority', sql, params)
}
private async queryOnLane(
lane: PostgresPoolLane,
sql: string,
params: unknown[]
): Promise<SqlRow[]> {
const startedAt = performance.now()
let phase: 'acquire' | 'execute' = 'acquire'
let client: pg.PoolClient | undefined
let lostReply: Error | undefined
try {
client = await this.connect(lane)
phase = 'execute'
const result = await timedQuery(
client,
this.idOfConnection(client),
postgresSql(sql),
params,
this.queryTimeout
)
return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }]
} catch (error) {
markRelayDatabaseError(error)
if (isPostgresReadTimeout(error)) lostReply = error
else if (error instanceof Error && endsPostgresSession(error)) lostReply = error
reportPostgresQueryFailure({
error,
phase,
sql,
// Passed in rather than re-derived: the log has to say what the routes
// actually did, and one classifier cannot drift from itself.
transient: isRelayDatabaseTransientError(error),
elapsedMs: performance.now() - startedAt,
pool: this.pool
})
throw error
} finally {
// An error destroys the client rather than handing the next caller a dead connection.
if (client) {
this.release(client, lostReply)
}
}
}
async queryLocked(
sql: string,
params: unknown[] = [],
options: RelayLockOptions = {}
): Promise<SqlRow[]> {
try {
// No transaction here, so options.lockTimeoutMs cannot apply: SET LOCAL
// would be discarded at the autocommit boundary before the lock is taken.
return await this.query(lockedStatement(sql, options), params)
} catch (error) {
if (
options.failIfUnavailable &&
String((error as { code?: unknown }).code) === '55P03'
) {
if (options.measureHoldMs) this.holds.recordUnavailable()
throw new Error('database_lock_unavailable')
}
throw error
}
}
async transaction<T>(
operation: (transaction: RelayDatabase) => Promise<T>,
options: RelayTransactionOptions = {}
): Promise<T> {
for (let attempt = 1; attempt <= POSTGRES_TRANSACTION_ATTEMPTS; attempt++) {
const client = await this.connect()
const connectionId = this.idOfConnection(client)
const transaction = new PostgresTransaction(client, connectionId, this.queryTimeout)
let lostReply: Error | undefined
let sessionEnded: Error | undefined
try {
await timedQuery(client, connectionId, 'BEGIN', undefined, this.queryTimeout)
const result = await operation(transaction)
// Even when the operation caught it: COMMIT must not run after a lost reply.
if (transaction.poisonedBy) throw transaction.poisonedBy
if (transaction.open) {
await timedQuery(client, connectionId, 'COMMIT', undefined, this.queryTimeout)
}
recordMeasuredHold(this.holds, transaction)
this.holds.recordUnavailable(transaction.consumeLockUnavailable())
this.holds.recordLockTimeout(transaction.consumeLockTimeouts())
return result
} catch (error) {
markRelayDatabaseError(error)
// No ROLLBACK behind a lost reply: it would queue behind the dead statement. Destroying
// the client ends the session, and the server drops its locks.
lostReply = transaction.poisonedBy ?? (isPostgresReadTimeout(error) ? error : undefined)
if (error instanceof Error && endsPostgresSession(error)) sessionEnded = error
const open = transaction.open
// An ended session cannot roll back; the untimed ROLLBACK would only wait on a dead socket.
if (open && !lostReply && !sessionEnded) {
await client.query('ROLLBACK').catch(() => undefined)
}
this.holds.recordUnavailable(transaction.consumeLockUnavailable())
this.holds.recordLockTimeout(transaction.consumeLockTimeouts())
// Once the fused commit has ended the transaction, a retry would apply the work twice.
if (
!open ||
lostReply ||
!retryablePostgresTransactionError(error) ||
attempt === POSTGRES_TRANSACTION_ATTEMPTS
) {
if (retryablePostgresTransactionError(error) && options.reportRetries !== false) {
console.warn(
JSON.stringify({
event: 'orca_relay_postgres_transaction_exhausted',
code: String((error as { code?: unknown }).code),
attempts: attempt,
phase: postgresTransactionErrorPhase(error)
})
)
}
throw error
}
if (options.reportRetries !== false) {
console.warn(
JSON.stringify({
event: 'orca_relay_postgres_transaction_retry',
code: String((error as { code?: unknown }).code),
attempt,
phase: postgresTransactionErrorPhase(error)
})
)
}
} finally {
this.release(client, lostReply ?? sessionEnded)
}
// A PostgreSQL transaction is unusable after an abort, so retry all work
// on a fresh pooled client with a small full-jitter delay.
await waitForPostgresRetry()
}
throw new Error('postgres_transaction_retry_exhausted')
}
async close(): Promise<void> {
await this.pool.end()
}
private async connect(lane?: PostgresPoolLane): Promise<pg.PoolClient> {
const startedAt = performance.now()
try {
const client = await this.pressure.connect(lane)
this.slowConnections?.track(client)
return client
} finally {
noteStickySlotPoolWait(performance.now() - startedAt)
}
}
// Destroys the client on an error, or when it has turned slow, so the pool dials a fresh one.
private release(client: pg.PoolClient, error: Error | undefined): void {
if (error) {
this.slowConnections?.forget(client)
client.release(error)
return
}
const slow = this.slowConnections?.shouldRetire(client, this.idOfConnection(client))
client.release(slow || undefined)
}
private idOfConnection(client: pg.PoolClient): number {
let id = this.connectionIds.get(client)
if (id === undefined) {
id = this.nextConnectionId++
this.connectionIds.set(client, id)
}
return id
}
consumePoolPressure(): PostgresPoolPressureCounts {
return this.pressure.consumeCounts()
}
peekPoolPressure(): PostgresPoolPressureCounts {
return this.pressure.peekCounts()
}
poolOldestWaitMs(): number {
return this.pressure.oldestWaitMs()
}
}
export function consumeRelayDatabasePoolPressure(
database: RelayDatabase
): PostgresPoolPressureCounts {
return database instanceof PostgresDatabase
? database.consumePoolPressure()
: emptyPostgresPoolPressureCounts()
}
export function consumeRelayCellInventoryHold(
database: RelayDatabase
): CellInventoryHoldCounts {
const holder = database as { consumeHoldCounts?: () => CellInventoryHoldCounts }
return holder.consumeHoldCounts?.() ?? emptyCellInventoryHoldCounts()
}
export function readRelayDatabasePoolPressure(
database: RelayDatabase
): PostgresPoolPressureCounts {
return database instanceof PostgresDatabase
? database.peekPoolPressure()
: emptyPostgresPoolPressureCounts()
}
// How long the longest-waiting pooled query has queued so far; 0 when none waits.
export function readRelayDatabasePoolOldestWaitMs(database: RelayDatabase): number {
return database instanceof PostgresDatabase ? database.poolOldestWaitMs() : 0
}
// Keep a listener across pg-pool checkout/release listener swaps so no error gap can crash the process; checked-out/idle listeners own logging.
export function keepPostgresClientErrorsHandled(pool: Pick<pg.Pool, 'on'>): void {
pool.on('connect', (client) => {
client.on('error', () => undefined)
})
}
// URL options can override pool defaults; report effective idle timeout without reading/logging the URL.
function reportPostgresIdleSessionTimeoutOnce(pool: Pick<pg.Pool, 'on'>): void {
let reported = false
pool.on('connect', (client) => {
if (reported) return
reported = true
client
.query("SELECT current_setting('idle_session_timeout') AS value")
.then((result: pg.QueryResult) => {
console.log(
JSON.stringify({
event: 'orca_relay_postgres_session_settings',
idleSessionTimeout: String(result.rows[0]?.value)
})
)
})
.catch(() => undefined)
})
}
export function absorbPostgresIdleClientErrors(pool: Pick<pg.Pool, 'on'>): void {
pool.on('error', () => {
// Why: node-postgres removes failed idle clients itself; leaving `error`
// unhandled would crash the cell and turn a SQL outage into autoheal churn.
console.warn('[orca-relay] idle PostgreSQL client failed')
})
}
async function applySchema(database: RelayDatabase): Promise<void> {
for (const statement of SCHEMA.split(';')) {
if (statement.trim()) await database.query(statement)
}
for (const [table, column, definition] of [
['relay_control_capabilities', 'idle_regional_rehome', 'BIGINT NOT NULL DEFAULT 0'],
['relay_region_rehome_attempts', 'source_generation', 'BIGINT NOT NULL DEFAULT 0'],
['relay_cell_connection_limits', 'reported_hard_cap', 'BIGINT']
]) {
const columns = await database.query('SELECT name FROM pragma_table_info(?)', [table])
if (!columns.some((existing) => existing.name === column)) {
await database.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
}
}
}
// DDL can exceed request deadlines; use a separate untimed schema connection and close it before serving or every startup repeats the same failure.
async function applySchemaOnUntimedPool(
databaseUrl: string,
applicationName: string | undefined
): Promise<void> {
const pool = new pg.Pool({
connectionString: databaseUrl,
max: 1,
application_name: applicationName ? `${applicationName}/schema` : undefined,
connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
statement_timeout: 0,
// Kept: a DDL blocked behind another director's ACCESS EXCLUSIVE lock must
// yield to the bounded schema retry instead of holding the connection.
lock_timeout: POSTGRES_LOCK_TIMEOUT_MS,
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS
})
absorbPostgresIdleClientErrors(pool)
keepPostgresClientErrorsHandled(pool)
const database = new PostgresDatabase(pool)
try {
await applyPostgresSchema(
relayPostgresSchemaStatements(),
async (statement) => await database.query(statement),
// IF NOT EXISTS takes table locks before checking existence; catalog reads skip migrated objects without queueing writers behind boot DDL.
{ catalogQuery: async (sql, params) => await database.query(sql, params) }
)
} finally {
await database.close().catch(() => undefined)
}
}
async function backfillRelayCellRegions(database: RelayDatabase): Promise<void> {
await database.query(
`INSERT INTO relay_cell_regions (cell_id, region)
SELECT cell_id, 'us-central1' FROM relay_cells WHERE true
ON CONFLICT (cell_id) DO NOTHING`
)
}
export type RelayDatabaseOpenInput = {
databaseUrl?: string
dataDir: string
poolMax?: number
applicationName?: string
statementTimeoutMs?: number
readTimeoutMarginMs?: number
// Read per query, so a cell's switch moves the margin without a restart.
readTimeoutMarginOverrideMs?: () => number | undefined
// Directors own schema; cells skip boot DB access, listen through outages and stay unready until the first successful query.
appliesPostgresSchema?: boolean
// Directors only: on cells, lock waits and batch flushes could trip the tail rule, and cells have not shown this stall.
retiresSlowConnections?: boolean
}
export async function openRelayDatabase(input: RelayDatabaseOpenInput): Promise<RelayDatabase> {
let database: RelayDatabase
const appliesPostgresSchema = input.appliesPostgresSchema !== false
if (input.databaseUrl) {
if (appliesPostgresSchema) {
await applySchemaOnUntimedPool(input.databaseUrl, input.applicationName)
}
const statementTimeoutMs = input.statementTimeoutMs ?? relayPostgresStatementTimeoutMs()
const pool = new pg.Pool({
connectionString: input.databaseUrl,
max: input.poolMax ?? 10,
application_name: input.applicationName,
connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
statement_timeout: statementTimeoutMs,
// The server answers every statement within statement_timeout, as 57014 at worst, so
// this fires only on a lost reply, or a database or event-loop stall past the margin.
query_timeout:
statementTimeoutMs + (input.readTimeoutMarginMs ?? relayPostgresReadTimeoutMarginMs()),
lock_timeout: POSTGRES_LOCK_TIMEOUT_MS,
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS,
// Bounds a backend whose client was destroyed outside a transaction (after a lost
// reply on an autocommit write or COMMIT). pg-pool closes healthy idle clients at 10 s.
options: `-c idle_session_timeout=${POSTGRES_IDLE_SESSION_TIMEOUT_MS}`
})
absorbPostgresIdleClientErrors(pool)
keepPostgresClientErrorsHandled(pool)
reportPostgresIdleSessionTimeoutOnce(pool)
const override = input.readTimeoutMarginOverrideMs
database = new PostgresDatabase(
pool,
override
? () => {
const marginMs = override()
return marginMs === undefined ? undefined : statementTimeoutMs + marginMs
}
: undefined,
{ retiresSlowConnections: input.retiresSlowConnections === true }
)
} else {
mkdirSync(input.dataDir, { recursive: true })
const sqlite = new DatabaseSync(join(input.dataDir, 'orca-relay.sqlite'))
sqlite.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
database = new SqliteDatabase(sqlite)
}
try {
if (!input.databaseUrl) await applySchema(database)
if (!input.databaseUrl || appliesPostgresSchema) await backfillRelayCellRegions(database)
return database
} catch (error) {
await database.close().catch(() => undefined)
throw error
}
}
export async function openInMemoryRelayDatabase(): Promise<RelayDatabase> {
const sqlite = new DatabaseSync(':memory:')
sqlite.exec('PRAGMA foreign_keys = ON;')
const database = new SqliteDatabase(sqlite)
await applySchema(database)
await backfillRelayCellRegions(database)
return database
}