Shared\Pool

OxPHP\Shared\Pool is a bounded pool of per-thread resources. It is the primitive for managing objects that are expensive to create, cannot be recreated cheaply, and should not exist in unlimited numbers: typically database connections, prepared-statement caches, reusable JSON decoders, or HTTP client sessions.

A Pool gives each PHP worker thread its own lane of ready-to-use resources, enforces a strict maximum across the whole pool, and recycles idle slots automatically so you do not pay for capacity you do not use.

Overview

  • The budget is strict. maxSize is a hard cap across the whole pool, and when the pool is saturated, an acquire waits, returns null, or throws, depending on which acquire method you call.
  • Slots have per-thread affinity. Each worker thread has its own idle queue, and an acquire pulls from the local queue first. It never hands a slot minted on thread A to thread B (v1).
  • The factory runs in the acquiring worker. Resources are minted lazily on first demand per thread, not at pool construction.
  • An optional destroy($resource) closure runs on eviction, whenever the pool drops a slot (idle timeout, manual eviction, server stop).
  • Slots sitting idle longer than idleTimeoutMs are flagged by a background scheduler and destroyed by their owning worker on its next request. Set idleTimeoutMs: 0 to disable idle eviction entirely.
  • Handles are RAII. acquire() returns a Handle, and the slot returns to the pool automatically when the handle goes out of scope (including on exception), or earlier via $handle->release().
  • Pools are Shareable. They survive request boundaries and are shared by handle (use ($pool) in closures).

API Reference

php
namespace OxPHP\Shared; final class Pool implements Shareable { public function __construct( callable $factory, // fn(): object — create a resource ?callable $destroy = null, // fn(object): void — tear down a resource int $maxSize = 32, // hard cap on live slots; > 0 int $idleTimeoutMs = 300_000, // idle ms before eviction; 0 disables it ); // acquire family — millisecond timeout trichotomy public function acquire(): Pool\Handle; // wait forever public function tryAcquire(): ?Pool\Handle; // non-blocking; null if saturated public function acquireTimeout(int $ms): Pool\Handle; // bounded; $ms > 0 // with family — scope-guard around the raw resource public function with(callable $body): mixed; // wait forever public function withTimeout(callable $body, int $ms): mixed; // bounded; $ms > 0 public function stats(): Pool\Stats; // point-in-time snapshot of counters public function evict(): int; // force-evict all idle slots now; returns count public function id(): int; } namespace OxPHP\Shared\Pool; class Handle { public function get(): mixed; // the underlying resource (throws after release) public function release(): void; // return the slot now; idempotent; also runs on destruct } final class Stats { public function inUse(): int; // slots currently checked out public function idle(): int; // free slots ready to hand out public function waiting(): int; // callers blocked in acquire public function size(): int; // inUse() + idle() (live slots) public function maxSize(): int; // configured cap public function utilization(): float; // inUse() / maxSize(), 0.0 if maxSize() == 0 }
Method Returns Use case
acquire Handle Check out a resource, waiting forever for a free slot.
tryAcquire ?Handle Non-blocking check-out. Returns null immediately if the pool is saturated.
acquireTimeout Handle Check out within a bounded budget of $ms (> 0); throws OperationTimeoutException on expiry.
with mixed Scope-guard: acquire (forever), run $body($resource) with the raw resource, release even on exception. The closure's return value passes through.
withTimeout mixed Like with, but the acquire is bounded by $ms.
stats Pool\Stats Point-in-time snapshot of pool counters.
evict int Force-evict all idle slots now (regardless of idleTimeoutMs); returns the count dropped.
id int Registry identifier; useful for logging / observability.
Handle::get mixed The underlying resource. Throws StaleHandleException after release.
Handle::release void Return the slot to the pool now. Idempotent; also runs automatically on destruct (RAII).

Timeouts follow the same trichotomy as Shared\Mutex and Shared\Channel: a bare method waits forever, a try* method is non-blocking, and a *Timeout(int $ms) method waits a bounded number of milliseconds. There is no float-seconds timeout.

Examples

Database connection pool

php
<?php $db = new OxPHP\Shared\Pool( factory: function () { return new PDO( getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); }, destroy: function (PDO $conn) { // Nothing to do — PDO closes on destruct. The callback exists // for resources that need explicit teardown (sockets, handles). }, maxSize: 16, idleTimeoutMs: 60_000, // free idle connections after 1 min ); // In a request handler $users = $db->with(function (PDO $conn) use ($userId) { $stmt = $conn->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$userId]); return $stmt->fetch(); });

with() is the shortest-lifetime pattern: acquire happens on entry, release happens on return or on exception, and you cannot leak a handle. It also hands your closure the raw resource directly, which skips the Handle::get() step.

Manual acquire / release

php
<?php $h = $pool->acquire(); // waits forever for a free slot $conn = $h->get(); $conn->beginTransaction(); doWork($conn); $conn->commit(); $h->release(); // or just let $h fall out of scope (RAII)

The handle returns its slot automatically when it is destroyed, including when an exception unwinds the stack, so an explicit release() is optional. Prefer a manual handle over with() only when the resource must survive multiple calls in a handler sequence.

Reusable parser pool

php
<?php $parsers = new OxPHP\Shared\Pool( factory: fn () => new JsonMachine\Parser(), maxSize: 8, ); $doc = $parsers->with(fn ($p) => $p->parse($body));

Non-blocking acquire with fallback

php
<?php $h = $pool->tryAcquire(); if ($h === null) { // Pool saturated — degrade gracefully without waiting. http_response_code(503); header('Retry-After: 1'); return; } // ... use $h->get(); slot returns on scope exit.

Bounded acquire with a timeout

php
<?php try { $h = $pool->acquireTimeout(100); // wait up to 100 ms } catch (OxPHP\Shared\OperationTimeoutException $e) { http_response_code(503); header('Retry-After: 1'); return; } // ... use $h->get();

Factory and destroy semantics

The factory runs lazily on the acquiring worker thread. A pool with maxSize: 32 does not pre-allocate 32 resources; it mints them as demand arrives, bounded by maxSize across all threads combined.

  • The factory must return a PHP object. Returning a non-object surfaces as TypeException from the acquire call, and the slot is not counted against the budget.
  • A factory that throws propagates its own exception to the acquire caller unchanged, and the slot is not counted against the budget.
  • A fatal error inside the factory ends the request, and the slot is not counted against the budget either.
  • The destroy callback (if supplied) runs when the pool drops a slot: idle timeout expiry, explicit evict(), or server shutdown. It runs on a worker thread (not on the Tokio thread driving the eviction scheduler), so PHP is safe to call.
  • A destroy callback that throws is logged but does not poison the pool. The slot is already being destroyed, so there is nothing useful to roll back.

Per-thread affinity

v1 pools are strict per-thread: a slot minted on worker thread A cannot be acquired on worker thread B. In practice, stats()->idle() can be non-zero on worker A while worker B is blocking in acquire(). This keeps slots hot in the thread that uses them (DB connections, OPcache-primed objects) and avoids shuffling resources across cores.

Sizing under per-thread affinity

Cross-thread work stealing is a v1.x candidate. Until then, size maxSize against the number of worker threads × expected per-thread concurrency, not just aggregate demand.

Idle-timeout eviction

Idle slots are evicted by a background scheduler. When a slot has been idle longer than idleTimeoutMs, the scheduler flags it; the owning worker destroys it on its next request (with the PHP engine alive, so $destroy runs in a normal request context). Budget is released at the same point.

Tune idleTimeoutMs to the cost of recreation:

  • For resources that are cheap to re-mint (JSON decoder, string pool), set 10_000 to 60_000 ms to free memory fast when traffic dies down.
  • For resources that are expensive to re-mint (DB connection, TLS session), set 300_000 ms (the default) to 900_000 ms so you pay the recreation cost less often.
  • To never evict, pass 0. Idle slots then live until the pool is dropped.

$pool->evict() force-evicts all idle slots reachable from the calling worker right now, regardless of idleTimeoutMs, and returns how many were dropped. Use it as the operational "flush idle now" escape hatch (e.g. when a downstream service restarted and you want the next acquire to mint fresh resources). In-use slots are untouched.

Budget & acquire semantics

Every acquire variant first tries to satisfy the request immediately, by reusing an idle slot or, if the pool is below maxSize, minting a new one via the factory. The variants behave differently only when the pool is saturated, with no idle slot and at maxSize:

State at call time acquire() tryAcquire() acquireTimeout($ms)
Idle slot in the local thread's queue reused immediately reused immediately reused immediately
No idle slot, but below maxSize factory mints a slot factory mints a slot factory mints a slot
Saturated (at maxSize, all in use) waits forever returns null waits up to $ms, then OperationTimeoutException
No float-seconds, no infinite sentinel

$ms must be > 0; 0 or negative raises TypeException. There is deliberately no float-seconds form and no "infinite" sentinel argument. Use the bare acquire() for an unbounded wait.

Exceptions

Exception Raised by
OperationTimeoutException acquireTimeout / withTimeout exceeded $ms without a free slot. Extends Async\AsyncException, not SharedException.
TypeException Non-positive maxSize, negative idleTimeoutMs, $ms <= 0, or a factory that returned a non-object.
StaleHandleException Handle::get() after the handle was released.
UninitializedException A method call on a pool wrapper that has not finished __construct.
Acquire timeouts are not a SharedException

tryAcquire() does not throw on saturation. It returns null. Because OperationTimeoutException extends OxPHP\Async\AsyncException (not SharedException), a catch (SharedException) will not catch an acquire timeout; use catch (OxPHP\Async\AsyncException) or catch OperationTimeoutException directly.

Why this differs from Mutex::tryWithLock()

Both are non-blocking try* calls, but Pool returns null on contention while Mutex throws ContentionException. The difference comes from how the two APIs are structured. Pool is handle-first: every acquire hands back a Handle, so the "saturated" outcome has a natural carrier in ?Handle, where null means "no slot" and never collides with a real value (a Handle is never itself a user value). Mutex is closure-only by design. It deliberately never hands a lock guard back to PHP, so a held lock cannot leak past the closure. That leaves tryWithLock with no object to return as nullable, and because the closure's own mixed result may legitimately be null, null cannot double as "not acquired". With neither a handle nor a free sentinel, the only unambiguous contention signal left is an exception. Catch accordingly: test tryAcquire for null, and wrap tryWithLock in catch (ContentionException).

Exceptions thrown inside the factory propagate to the acquire caller unchanged and do not consume budget. Exceptions inside the with() / withTimeout() body propagate to the caller after the slot is released. A fatal error inside the body ends the request, and the slot goes back to the pool all the same, in whatever state the body left it. That is the same as after a throw, except that no finally block in the body has run. If a body can be ended halfway through an exchange (a reply not yet read, a transaction still open), start each body by putting the resource back into a known state.

Observability

See Shared Observability for the full reference. The pool-specific parts:

  • GET /__ox_shared/entry?id=N exposes type: "Pool" and, under type_specific, { count, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.
  • GET /__ox_shared/summary includes a Pool bucket with count, bytes, and ops. Per-pool gauges like waiting and the evicted_total counter are exposed on /metrics (below), not aggregated in the summary.
  • Prometheus metrics per pool:
    • oxphp_shared_pool_count{pool_id="…"}: gauge, total slots (in-use + idle).
    • oxphp_shared_pool_in_use{pool_id="…"}: gauge.
    • oxphp_shared_pool_idle{pool_id="…"}: gauge.
    • oxphp_shared_pool_waiting{pool_id="…"}: gauge, queued acquires.
    • oxphp_shared_pool_acquire_total{pool_id="…",result="ok|timeout|closed|saturated"}: counter. saturated counts non-blocking tryAcquire calls that found the pool full (distinct from timeout, which means a wait elapsed).
    • oxphp_shared_pool_evicted_total{pool_id="…",reason="idle_timeout|evict|shutdown"}: counter.
    • oxphp_shared_pool_wait_seconds_*{pool_id="…"}: acquire-wait histogram (bucket / sum / count).

Alert-worthy combinations: rising waiting with flat count means the pool is saturated and should be resized; rising acquire_total{result="timeout"} with normal in_use means the factory is slow (or blocking); rising acquire_total{result="saturated"} means callers keep hitting tryAcquire on a full pool (backpressure firing).

When not to use

  • Cheap or immutable resources do not need one. A pool's overhead is larger than re-creating a simple object, so use it for resources where creation costs milliseconds or kilobytes.
  • Some objects cannot be reused safely. If the resource accumulates per-request state (open transactions, pending reads) and you cannot reliably reset it, pooling leaks state between requests. Return slots to a known state in your request-finishing code, or do not pool.
  • A pool is in-process, so it does not cover cross-host resources. For multi-host connection pooling, prefer a connection-bucket service or a sidecar (pgbouncer, proxy-sql).
  • Unbounded fan-out is a different problem. If you need one connection per in-flight HTTP call, you have an N-per-request problem, which a pool does not solve. Use a Shared\Channel to serialise work behind a bounded pool instead.
  • Many client libraries already pool internally (e.g. Guzzle's connection pool). Stacking a Shared\Pool on top of a resource with its own pool semantics is double bookkeeping, so prefer the library's own pooling.

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Use an OxPHP Shared Pool in my code instead of constructing a database connection, HTTP client, or parser fresh on every request. Read the `shared-state/shared-pool` page through the OxPHP documentation MCP server. Find that per-request construction in this repository and show me the Pool plus `with()` replacement, sized against the worker count this deployment actually runs.
prompt
My OxPHP pool is saturated and requests are timing out on acquire. Read the `shared-state/shared-pool` page through the OxPHP documentation MCP server. Pull this pool's `/__ox_shared/entry` and `/metrics` from my running deployment and tell me, from `waiting`, `idle_by_thread`, and the acquire-result counters, whether I need a bigger `maxSize` or the factory itself is slow.
  • Shared State: overview and mental model.
  • Shared\Once: for when you need exactly one resource (not a pool of N).
  • Shared\Channel: pair it with a pool for producer/consumer pipelines.
  • Shared\Map: one Pool per tenant, keyed by name.
  • Worker Mode: pool handles across requests within one worker thread.
Found a mistake? Report it →