Initial Commit
This commit is contained in:
255
packages/api-stack/api/api-plugin-database/src/pool/pool.ts
Normal file
255
packages/api-stack/api/api-plugin-database/src/pool/pool.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user