Shared\Registry

OxPHP\Shared\Registry is the name-keyed companion to the rest of OxPHP\Shared\*. Where new Shared\Map() produces an anonymous entry shared only by handle propagation (use capture, async fibers, nesting), Registry::map('cache', fn() => new Shared\Map(...)) binds an entry under a string key. Every caller of Registry::map('cache', …), on any worker thread, in any request, gets the same entry.

It answers one question: "how do I share one Shared\Map across all workers, or across all requests in traditional mode?" The other Shared\* types are still the right unit of mutable state; Registry is just how you put a name on one of them.

Mental model

graph TD
  R["Registry::map('cache', $factory)"]
  W1["worker #1"] --> R
  W2["worker #2"] --> R
  W3["worker #3"] --> R
  R --> S["SharedRegistry (process-global)<br/>names: { 'cache' → Bound(Arc&lt;E&gt;) }<br/>entries: { id=7: Map { … } }"]
  • The first caller of Registry::map($key, $factory) for an unbound key runs the factory and pins the resulting entry under the name.
  • Every subsequent caller (same thread, other workers, later requests) receives the same entry. The factory is not re-run; it is ignored on a hit.
  • Concurrent first-touches block on a per-key gate: exactly one thread runs the factory, the others wait and get the winner's entry. That prevents double resource acquisition for connection pools.

Identity-by-name complements identity-by-handle. Anonymous (new Shared\*()) and named entries coexist in the same process-global registry. The names index adds a string lookup on top.

Quick start — one counter across every worker

worker.php
<?php // worker.php — entry script in worker mode, executed once per worker thread require __DIR__ . '/vendor/autoload.php'; $requests = OxPHP\Shared\Registry::counter( 'request-counter', fn() => new OxPHP\Shared\Counter(), ); oxphp_worker(function () use ($requests) { $n = $requests->add(); // atomic across ALL workers — one shared int64 header('X-Request-Count: ' . $n); echo "hello\n"; });

Compare to the captured-handle pattern ($x = new Shared\Counter() in the bootstrap). That pattern produces one counter per worker thread: each worker runs its own bootstrap and gets its own anonymous entry. Aggregate counts diverge by a factor of the worker pool size. Registry::counter('request-counter', …) instead converges every worker on a single entry, so the count is the actual total.

The same shape works in traditional mode (no WORKER_MODE_ENABLED). The first request that touches 'request-counter' creates the entry; every subsequent request (on any worker thread) sees it. This is the same-host APCu replacement story, with typed primitives and atomic operations instead of apcu_fetch / apcu_store.

API reference

php
namespace OxPHP\Shared; final class Registry { // Typed get-or-create. On hit, the factory is ignored; on miss it // runs at most once across all workers (block-losers) and must // return a fresh instance of the matching type. public static function map(string $key, callable $factory): Map; public static function counter(string $key, callable $factory): Counter; public static function atomic(string $key, callable $factory): Atomic; public static function flag(string $key, callable $factory): Flag; public static function once(string $key, callable $factory): Once; public static function mutex(string $key, callable $factory): Mutex; public static function channel(string $key, callable $factory): Channel; public static function pool(string $key, callable $factory): Pool; // Untyped escape hatch — returns whatever is bound (no type guard). public static function global(string $key, callable $factory): Shareable; // Namespace management — operates on the name index, NOT the objects. public static function remove(string $key): bool; public static function keys(): array; // list<string> // Layer-wide introspection. public static function memoryUsage(): int; // estimated bytes, all Shared\* entries public static function count(): int; // live Shared\* entries (named + anonymous) }
Method Returns Use
map / counter / atomic / flag / once / mutex / channel / pool the requested Shared\* type Primary surface. Type-guarded on hit; type-validated on factory return.
global Shareable Untyped get-or-create. Reach for it only when you genuinely don't know the bound type up front.
remove bool Drop the name binding + pin. Does not destroy the object.
keys list<string> Currently-bound keys (Bound only; in-flight Creating slots are not listed).
memoryUsage int Process-wide estimated bytes (see Memory and introspection).
count int Process-wide live entries (named and anonymous).

Registry is a static facade: new Registry() throws Shared\SharedException.

Lifecycle — pinned by default

A bound key holds a strong reference to its entry; the entry is alive for the lifetime of the process unless you explicitly call remove(key) or the process tears down. This is deliberate: in traditional mode, where each request creates its own PHP-side handles and they die at request end, the name index's pin is the only reason the entry survives between requests.

Invalidate the contents of a named entry by mutating it in place ($cache->clear(), $counter->set(0), $bucket->remove($k)), not by removing the name. Mutation is shared by reference: every holder of the same key sees the change immediately.

remove is namespace management, not object destruction

remove($key) drops the binding and the pin. The entry itself survives while any other handle references it (a captured bootstrap variable, a value nested in another Shared\Map, an in-flight oxphp_async). When the last handle drops, the entry self-deregisters as usual.

After remove, the key is free. The next Registry::map($key, …) creates a new entry with a distinct id.

Warning

Captured handles to the previous binding continue to operate on the old (now-anonymous) entry; they do not auto-converge on the new one.

php
$cache = Registry::map('cache', fn() => new Shared\Map()); $id_a = $cache->id(); Registry::remove('cache'); $cache->set('x', 1); // still mutates the OLD entry — fine, but it's no longer "cache" $fresh = Registry::map('cache', fn() => new Shared\Map()); $id_b = $fresh->id(); // different id — this is a new entry assert($id_a !== $id_b); assert($cache->get('x') === 1); // OLD entry retained value assert($fresh->get('x') === null); // NEW entry is empty

If you rotate keys (per-tenant entries that come and go, key-versioning), address them by name per call (Registry::map($key, …) per request) instead of capturing the handle once in bootstrap. Captured handles + key rotation diverges silently; address-by-name converges on whatever the current binding is.

remove returns true if a bound key was dropped, false if the key was absent.

Errors

Exception When
Shared\TypeException Typed method on a key bound to a different type; factory returned the wrong Shared\* type or a non-Shareable.
Shared\CapacityException Creation would exceed SHARED_MAX_ENTRIES / SHARED_MAX_BYTES caps.
Shared\DeadlockException (reentrant) Registry::map($key, …) for the same $key from inside its own factory on the same thread.
Shared\DeadlockException (cross-key cycle) Waited longer than 30 s on another thread's Creating slot. The most plausible cause is factory A holding key K1 while waiting on K2, whose factory is held by thread B waiting on K1. The message differs from the reentrant case.
Shared\SharedException (draining) The server is shutting down, so the registry refuses new acquires and binds. This is expected during graceful shutdown and is not a code bug.
Shared\SharedException (bind race) A peer creator was already settled into the slot while this thread's factory was running (the factory's entry was NOT pinned under the key). Retry the call.
\InvalidArgumentException (SPL) Empty $key. Argument validation, distinct from domain type errors.
(factory's exception) If the factory throws, the slot is aborted (Creating → absent, waiters wake to retry) and the original exception propagates to the creator. A fatal error inside the factory aborts the slot the same way, and still ends the request.

Shared\DeadlockException extends OxPHP\Async\AsyncException, so catch (AsyncException) sweeps it together with bounded-wait timeouts elsewhere in Shared\*. The two distinct DeadlockException cases share the class; tell them apart by the message ("reentrant get-or-create" vs "waited too long … cross-key cycle").

Memory and introspection

Registry::memoryUsage() and Registry::count() report the whole Shared* layer, not just named entries. Anonymous entries created via new Shared\*() (the bulk of current Shared\* usage: bootstrap captures, in-flight values inside Map and Channel, async-fiber captures) are included.

This is deliberate. The two numbers exist for capacity / OOM monitoring; that monitoring must see per-worker and in-flight anonymous state, not just the named namespace. As a result:

  • Both numbers are transient: they rise and fall with in-flight requests and per-worker handles.
  • Registry::count() is not equal to count(Registry::keys()). keys() is the named namespace only.
  • memoryUsage() is a static accounting estimate, not actual RSS. It is the same number that SHARED_MAX_BYTES is checked against when a Shared object is created, and it can read above that cap. For the real heap footprint, use a heap profiler (heaptrack, jemalloc_stats_print, mi_stats_print) or container memory metrics.

Per-entry detail (id, type, refcount, byte cost) lives on the internal introspection endpoint at /__ox_shared/entries. There is intentionally no per-entry PHP API, to avoid duplicating that surface.

When not to use

  • The registry does not reach across processes or hosts. It lives inside one OxPHP process, and multiple OxPHP instances don't share it. Use Redis / NATS / your existing broker; see Migrating to an external store.
  • Nothing in it survives a restart: the registry evaporates on process exit. Persist via the same external store.
  • Avoid high-churn ephemeral keys. Pinned-by-default semantics mean dynamic keys you generate per-request leak entries until you call remove. The SHARED_MAX_* caps bound the leak, but it is still poor form. For short-lived per-request state, use a normal PHP variable.
  • The registry is not a cache invalidation primitive. remove($key) is for retiring a name, not for "clear the cache." Invalidate contents in place ($map->clear(), $map->remove($member_key)); the name binding survives.

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
Audit the Shared\* handles for ones built bare inside a worker bootstrap — that gives one instance per worker thread, not one shared entry. Read the `shared-state/shared-registry` page through the OxPHP documentation MCP server. Find them in this repository and show me the `Registry::` call that names each one so every worker converges on the same entry.

See also

Found a mistake? Report it →