import type { SurrealAdapter } from "@suaveplan/surrealdb"; import type { Clock } from "@suaveplan/types"; import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js"; import { getDatabaseInstruments } from "../otel/instruments.js"; import type { AdapterFactory, AdapterFactoryInput, PoolConfig, } from "../types/types.js"; interface PooledEntry { readonly adapter: SurrealAdapter; inUse: boolean; lastUsedAt: number; } interface Waiter { resolve(entry: PooledEntry): void; reject(error: Error): void; timer: ReturnType; } /** Construction options for {@link ConnectionPool}. */ export interface ConnectionPoolOptions { /** Connection target for new pool members. */ readonly connection: AdapterFactoryInput; /** Pool sizing / timeout knobs. */ readonly pool: PoolConfig; /** Required factory — pool only depends on the adapter constructor. */ readonly adapterFactory: AdapterFactory; /** Optional wall-clock injection for the idle-reaper. Defaults to `Date.now`. */ readonly clock?: Clock; /** Injectable timer set — defaults to `globalThis.setTimeout`. */ readonly setTimer?: typeof globalThis.setTimeout; /** Injectable timer clear — defaults to `globalThis.clearTimeout`. */ readonly clearTimer?: typeof globalThis.clearTimeout; /** Injectable interval set — defaults to `globalThis.setInterval`. */ readonly setIdleInterval?: typeof globalThis.setInterval; /** Injectable interval clear — defaults to `globalThis.clearInterval`. */ readonly clearIdleInterval?: typeof globalThis.clearInterval; } /** Snapshot returned by {@link ConnectionPool.size}. */ export interface PoolSize { readonly total: number; readonly idle: number; readonly inUse: number; readonly waiting: number; } /** * Bounded pool of {@link SurrealAdapter} instances. * * Members are created lazily up to `pool.max`; idle members are reaped past * `pool.idleMs` down to `pool.min`. `acquire()` returns an idle adapter, * lazily grows to `max`, or queues a waiter when at capacity. `release()` * hands the adapter to the next waiter or returns it to the idle set. */ export class ConnectionPool { private readonly options: ConnectionPoolOptions; private readonly entries: PooledEntry[] = []; private readonly waiters: Waiter[] = []; private reaperHandle: ReturnType | undefined; private closed = false; public constructor(options: ConnectionPoolOptions) { this.options = options; } /** Eagerly create `pool.min` members and start the idle reaper. */ public async init(): Promise { for (let i = 0; i < this.options.pool.min; i += 1) { const entry = await this.createEntry(); this.entries.push(entry); } const setInterval = this.options.setIdleInterval ?? globalThis.setInterval; this.reaperHandle = setInterval( () => this.reapIdle(), this.options.pool.idleMs ); } /** * Acquire an idle adapter or queue a waiter. Rejects with * `DatabaseError(POOL_CLOSED)` when the pool is closed, or * `DatabaseError(POOL_EXHAUSTED)` when the wait exceeds * `pool.acquireTimeoutMs`. */ public async acquire(): Promise { if (this.closed) { throw new DatabaseError( DATABASE_CODES.POOL_CLOSED, "ConnectionPool is closed" ); } const idle = this.entries.find((e) => !e.inUse); if (idle !== undefined) { this.checkout(idle); return idle.adapter; } if (this.entries.length < this.options.pool.max) { const entry = await this.createEntry(); this.entries.push(entry); this.checkout(entry); return entry.adapter; } return this.queueWaiter(); } /** * Release an adapter back to the pool. If a waiter is queued, the * adapter is handed directly to it. Unknown adapters are ignored. */ public release(adapter: SurrealAdapter): void { const entry = this.entries.find((e) => e.adapter === adapter); if (entry === undefined) return; entry.inUse = false; entry.lastUsedAt = this.now(); const instruments = getDatabaseInstruments(); instruments.poolCheckinsTotal.add(1); instruments.poolSaturation.add(-1); const waiter = this.waiters.shift(); if (waiter !== undefined) { const clearTimer = this.options.clearTimer ?? globalThis.clearTimeout; clearTimer(waiter.timer); this.checkout(entry); waiter.resolve(entry); } } /** Close the pool, drain every adapter, and reject every pending waiter. */ public async close(): Promise { this.closed = true; if (this.reaperHandle !== undefined) { const clearInterval = this.options.clearIdleInterval ?? globalThis.clearInterval; clearInterval(this.reaperHandle); this.reaperHandle = undefined; } const clearTimer = this.options.clearTimer ?? globalThis.clearTimeout; for (const waiter of this.waiters) { clearTimer(waiter.timer); waiter.reject( new DatabaseError( DATABASE_CODES.POOL_CLOSED, "ConnectionPool was closed while waiting for a connection" ) ); } this.waiters.length = 0; const drains = this.entries.map(async (e) => { try { await e.adapter.disconnect(); } catch { // adapter close failures are non-fatal at pool teardown. } }); await Promise.allSettled(drains); this.entries.length = 0; } /** Return a snapshot of pool sizing for observability and tests. */ public size(): PoolSize { const inUse = this.entries.filter((e) => e.inUse).length; const idle = this.entries.length - inUse; return { total: this.entries.length, idle, inUse, waiting: this.waiters.length, }; } // ─── internal ──────────────────────────────────────────────────────── private checkout(entry: PooledEntry): void { entry.inUse = true; entry.lastUsedAt = this.now(); const instruments = getDatabaseInstruments(); instruments.poolCheckoutsTotal.add(1); instruments.poolSaturation.add(1); } private now(): number { return (this.options.clock ?? Date.now)(); } private async createEntry(): Promise { const adapter = this.options.adapterFactory(this.options.connection); try { await adapter.connect(); } catch (cause) { throw new DatabaseError( DATABASE_CODES.CONNECTION_FAILED, "Failed to construct pool member", { cause: cause as Error, context: { url: this.options.connection.url }, } ); } return { adapter, inUse: false, lastUsedAt: this.now() }; } private queueWaiter(): Promise { return new Promise((resolve, reject) => { const setTimer = this.options.setTimer ?? globalThis.setTimeout; const timer = setTimer(() => { // The waiter is always still queued when the timer fires — // `release` clears the timer before shifting the waiter out, // so this `indexOf` is always non-negative. this.waiters.splice(this.waiters.indexOf(waiter), 1); reject( new DatabaseError( DATABASE_CODES.POOL_EXHAUSTED, `Failed to acquire a connection within ${this.options.pool.acquireTimeoutMs}ms` ) ); }, this.options.pool.acquireTimeoutMs); const waiter: Waiter = { resolve: (entry) => resolve(entry.adapter), reject, timer, }; this.waiters.push(waiter); }); } private async safeDisconnect(entry: PooledEntry): Promise { try { await entry.adapter.disconnect(); } catch { // reaper-side disconnect errors are non-fatal — the entry is // already removed from the pool, so the next acquire builds // a fresh member. } } private reapIdle(): void { const now = this.now(); for (let i = this.entries.length - 1; i >= 0; i -= 1) { const entry = this.entries[i]!; if (entry.inUse) continue; if (this.entries.length <= this.options.pool.min) break; if (now - entry.lastUsedAt < this.options.pool.idleMs) continue; this.entries.splice(i, 1); void this.safeDisconnect(entry); } } }