Files
skills/docs/gaming/games/game-server/src/performance/object-pool/object-pool.md
2026-05-29 08:20:56 +01:00

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, and withObject(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} and pool.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; throws INVALID_POOL_CONFIG on missing factory/reset or invalid numeric fields.
  • acquire(): T — returns a recycled or freshly factory-allocated object. Throws POOL_EXHAUSTED when the pool is empty AND pool+inUse >= maxSize. Throws POOL_DISPOSED after dispose.
  • release(obj: T): void — throws RELEASE_FOREIGN_OBJECT if the object was not handed out by this pool; throws POOL_DISPOSED after 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 raise POOL_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.