Files
suaveplan/packages/api-stack/api/api-plugin-database/src/pool/pool.ts
2026-05-27 23:29:53 +01:00

256 lines
9.2 KiB
TypeScript

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<typeof globalThis.setTimeout>;
}
/** 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<typeof globalThis.setInterval> | 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<void> {
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<SurrealAdapter> {
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<void> {
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<PooledEntry> {
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<SurrealAdapter> {
return new Promise<SurrealAdapter>((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<void> {
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);
}
}
}