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.
maxSizeis a hard cap across the whole pool, and when the pool is saturated, an acquire waits, returnsnull, 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
idleTimeoutMsare flagged by a background scheduler and destroyed by their owning worker on its next request. SetidleTimeoutMs: 0to disable idle eviction entirely. - Handles are RAII.
acquire()returns aHandle, 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
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
$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
$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
$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
$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
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
TypeExceptionfrom 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.
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 |
$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. |
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.
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=Nexposestype: "Pool"and, undertype_specific,{ count, in_use, idle, waiting, max_size, idle_by_thread, rebalance_strategy }.GET /__ox_shared/summaryincludes aPoolbucket withcount,bytes, andops. Per-pool gauges likewaitingand theevicted_totalcounter 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.saturatedcounts non-blockingtryAcquirecalls that found the pool full (distinct fromtimeout, 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\Channelto serialise work behind a bounded pool instead. - Many client libraries already pool internally (e.g. Guzzle's connection pool). Stacking a
Shared\Poolon 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.
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.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.Related
- 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
Poolper tenant, keyed by name. - Worker Mode: pool handles across requests within one worker thread.