4.4 KiB
ObjectPool
(no submodules)
Purpose
ObjectPool<T> is a generic typed object pool that dampens GC pressure
in hot allocation paths — game-loop frame allocations, network message
envelopes, session metadata containers — by recycling typed objects
instead of letting them churn through the GC. Each pool is configured
with a factory (creates a new instance), a reset (clears state
before an instance returns to the pool), and an optional dispose
(invoked on cleanup paths so callers can release native handles).
The pool is dependency-free apart from the typed
GameServerError registry, Zod (for numeric-config validation), and
the OpenTelemetry metrics API. Misuse is loud: releasing an object
that didn't come from this pool raises RELEASE_FOREIGN_OBJECT;
acquiring past the configured maxSize raises POOL_EXHAUSTED; using
a pool after dispose() raises POOL_DISPOSED.
Features
- Configurable
initialSize,maxSize, and pre-allocation toggle. - Typed factory + reset + optional dispose hooks.
acquire,release, andwithObject(callback)(auto-release with finally semantics).- Hard cap enforcement at acquisition time, with typed-error rejection.
- Lifecycle:
clear()(resets stats + drops pool, fires dispose),dispose()(terminal, all subsequent calls throw). - Two OTel counters —
pool.acquire_total{outcome=reused|created}andpool.release_total. - Stats snapshot — size, available, in-use, totals, hit-rate.
Usage: Basic
import { ObjectPool } from "@suaveplan/game-server/performance/object-pool";
interface Vec3 { x: number; y: number; z: number; }
const pool = new ObjectPool<Vec3>({
factory: () => ({ x: 0, y: 0, z: 0 }),
reset: (v) => { v.x = v.y = v.z = 0; },
initialSize: 64,
});
const v = pool.acquire();
v.x = 1; v.y = 2;
pool.release(v);
Usage: Advanced
import { ObjectPool } from "@suaveplan/game-server/performance/object-pool";
import { metrics } from "@opentelemetry/api";
interface PacketBuffer { bytes: Uint8Array; offset: number; }
const pool = new ObjectPool<PacketBuffer>(
{
factory: () => ({ bytes: new Uint8Array(2048), offset: 0 }),
reset: (b) => {
b.bytes.fill(0);
b.offset = 0;
},
dispose: (b) => {
// Optionally hand the underlying buffer to an async ring.
ringFree.push(b.bytes);
},
initialSize: 32,
maxSize: 512,
},
{ meter: metrics.getMeter("game-server.network") },
);
// Hot loop: borrow a buffer per packet, auto-release on errors.
await pool.withObject(async (buffer) => {
await readInto(buffer.bytes);
await transport.send(buffer.bytes);
});
const stats = pool.getStats();
console.log(`hit rate ${stats.hitRate}`);
API Reference
new ObjectPool<T>(config: ObjectPoolConfig<T>, deps?: ObjectPoolDeps)— validates the numeric config via Zod; throwsINVALID_POOL_CONFIGon missingfactory/resetor invalid numeric fields.acquire(): T— returns a recycled or freshly factory-allocated object. ThrowsPOOL_EXHAUSTEDwhen the pool is empty ANDpool+inUse >= maxSize. ThrowsPOOL_DISPOSEDafter dispose.release(obj: T): void— throwsRELEASE_FOREIGN_OBJECTif the object was not handed out by this pool; throwsPOOL_DISPOSEDafter dispose.withObject(cb): Promise<R>— borrows + finally-releases.clear()— drops every pooled object and resets stats (fires dispose hook).dispose()— terminal, idempotent. Subsequent acquire/release/clear raisePOOL_DISPOSED. Fires dispose hook on every pooled object.getStats(): PoolStats.disposed: boolean.
Implementation Notes
The cap check at acquire is pool.length + inUse.size >= maxSize. When
hit AND pool.length === 0, acquisition fails with POOL_EXHAUSTED.
When hit AND pool.length > 0, the pool reuses an existing instance —
this is the steady-state happy path for a saturated pool. Release
always succeeds back into the pool because the cap is enforced at
acquire, never letting pool + inUse exceed maxSize, so there is no
unreachable eviction branch to test. The dispose hook fires only on
clear() and dispose() paths, where the pool is being torn down or
emptied. Pre-allocation runs the factory synchronously inside
the constructor, which keeps every acquire constant-time in steady
state.