Shared\Map

OxPHP\Shared\Map is a concurrent map that lives in the shared registry and is visible to every PHP worker in the process. Use it when two workers, or a request handler and a background task, need to share mutable state that survives the request lifecycle.

Overview

  • The map is int|string → mixed. Keys are PHP integers or strings, kept distinct (123 and "123" are different keys; there is no PHP array-style key coercion). String keys are binary-safe: they are stored as opaque bytes (like PHP arrays / Go / Redis), so non-UTF-8 keys (incl. embedded NUL) round-trip faithfully. Values may be any scalar, array of scalars/arrays, or another Shareable instance.
  • null means absence and is never a stored value. Writing a null value throws TypeException, and a null return always means "no such key". This removes the classic get()-returned-null ambiguity at the root (the same choice java.util.concurrent.ConcurrentHashMap and Go's sync.Map make).
  • There is one linearisable conditional primitive. compareAndSet covers atomic insert / replace / remove via the null absence sentinel. Build any read-modify-write on top of it.
  • Writes from different workers don't require external locking, and per-key operations are atomic at the shard level.
  • The map is cycle-safe. Storing a Shareable that would reach back into this Map is rejected with CycleException before any mutation, so nothing leaks on the rejected path.
  • Size is bounded by an approximate soft cap. maxEntries is an OOM-safety ceiling, not exact accounting.

API Reference

php
namespace OxPHP\Shared; final class Map implements Shareable { public function __construct(?int $maxEntries = null); // null = unbounded; <= 0 throws TypeException // reads public function get(int|string $key): mixed; // null ⟺ absent public function getMany(iterable $keys): \Iterator; // lazy; skips absent keys public function count(): int; // striped, weakly consistent public function maxEntries(): ?int; // writes public function set(int|string $key, mixed $value): void; public function setIfAbsent(int|string $key, mixed $value): mixed; // prev; null ⟺ inserted public function setMany(iterable $entries): int; public function remove(int|string $key): bool; // existed? public function removeMany(iterable $keys): int; public function clear(): int; // entries removed // value-returning public function swap(int|string $key, mixed $value): mixed; // prev; null ⟺ was absent public function pop(int|string $key): mixed; // prev; null ⟺ was absent // conditional (single linearisable primitive) public function compareAndSet(int|string $key, mixed $expected, mixed $new): bool; // iteration public function forEach(callable $fn): void; // weakly consistent; callback runs lock-free public function id(): int; }
Method Use case
__construct Create with an optional maxEntries cap (null = unbounded; <= 0 throws).
get Fetch by key; null ⟺ absent.
getMany Lazily stream key => value for known keys; absent keys are skipped (see below).
count Approximate entry count (weakly consistent under concurrent writes).
maxEntries Reports the configured cap (or null when unbounded).
set Insert or replace; no previous value materialised.
setIfAbsent Atomic insert-if-missing; returns the existing value, or null if it inserted.
setMany Bulk insert from any iterable; returns the number written.
remove Remove a key; returns whether it existed (no value materialised).
removeMany Bulk remove; returns the number actually deleted.
clear Drop every entry (releasing nested Shareable holds); returns the count removed.
swap Overwrite and return the previous value (null ⟺ was absent).
pop Remove and return the previous value (null ⟺ was absent).
compareAndSet Atomic insert / replace / remove keyed on the current content (see below).
forEach Weakly-consistent traversal; the callback runs with no lock held.
id Numeric registry identifier; useful for logging + /__ox_shared/entry?id=….

There is no has(), update(), getOrSet(), keys(), trySet(), updateMany(), and the class does not implement Countable. See Migrating from the old surface.

The null-is-absence model

null is reserved as the absence sentinel everywhere:

  • set / swap / setIfAbsent with a null value → TypeException.
  • get / swap / pop / setIfAbsent returning null ⟺ the key was absent.
  • In compareAndSet, null on either side means "absent" (not "store null").

If you need to record "no value", remove the key (or use a key absence) rather than storing null. Because there is no has() and no concurrent has()+get() race, presence is checked atomically with a single get($k) !== null.

compareAndSet — the conditional primitive

php
$map->compareAndSet($key, expected: null, new: $v); // insert iff absent (like setIfAbsent, but returns bool) $map->compareAndSet($key, expected: $a, new: $b); // replace iff current === $a $map->compareAndSet($key, expected: $a, new: null); // remove iff current === $a

It returns true iff the swap was applied. Equality is by content: scalars by value, strings and arrays by their serialised bytes, and nested Shareable values by registry identity. Array equality matches PHP === for the common cases (lists, all-int- or all-string-keyed arrays); arrays that interleave int and string keys are compared over the Map's normalised storage form, so a specific int/string ordering is not distinguished (the Map reorders such arrays on read-back too). Build a read-modify-write as an explicit retry loop, and keep the closure pure, because under contention it runs more than once:

php
do { $cur = $map->get('counter'); // null if absent $next = ($cur ?? 0) + 1; } while (!$map->compareAndSet('counter', $cur, $next));

There is no ABA hazard here: the store is content-addressed (a value equal in content is the same value for a value store), and nested-Shareable identity uses monotonic, never-reused registry ids. For stampede-safe lazy initialisation use Shared\Once; for pooled resources use Shared\Pool.

Memory model — where copies happen

Values are stored as a serialised representation, not as zvals, so "zero-copy" does not apply to values:

Operation Serialise into shared heap Materialise previous value into a zval
set / setMany yes no
remove / removeMany — no
setIfAbsent yes only if a previous value exists
swap / pop yes / n/a yes
get / getMany (the key only) yes
compareAndSet yes ($new) no

The write-path serialisation is unavoidable for any value entering shared memory. The read-back into a fresh zval is paid only by the methods that return a previous/looked-up value, so set/remove are "no return materialisation", not "free". The exception is a nested Shareable value: it is stored by reference (an id + a refcount bump), not deep-copied.

Concurrency

  • count() is weakly consistent. Entry counts are striped per shard and summed on read; the result is exact when the map is quiescent and a close approximation under concurrent writes (the same contract as ConcurrentHashMap::size). Striping keeps writes off a single hot counter.
  • maxEntries is a soft cap. It is checked against the striped sum, so under concurrent inserts the map may overshoot by up to the shard count before rejecting a new key with CapacityException. Treat it as an OOM-safety budget, not exact accounting. Overwriting an existing key always succeeds at the cap. There is no eviction. A cache with LRU/TTL eviction is a different primitive.
  • forEach runs the callback with no lock held. It snapshots one shard's keys at a time, releases the shard, then re-fetches each value and invokes $fn(key, value). Keys deleted between snapshot and call are skipped; keys added after a shard's snapshot may be missed; values may be fresher than snapshot time. Return false from the callback to stop early. Because only keys are snapshotted, a slow callback never pins deleted values.

Examples

Shared configuration cache

php
<?php $config = new OxPHP\Shared\Map(maxEntries: 1024); // Warm once at app bootstrap. $config->setMany([ 'rate_limit.default_rpm' => 600, 'feature.new_checkout' => true, 'timeout.downstream_ms' => 250, ]); // Any request handler reads without contention; null ⟺ not configured. $rpm = $config->get('rate_limit.default_rpm') ?? 60;

Per-tenant rate limiter

php
<?php $buckets = new OxPHP\Shared\Map(maxEntries: 50_000); $key = "tenant:{$tenantId}"; $prev = $buckets->setIfAbsent($key, ['tokens' => 100, 'refill_at' => time() + 60]); // $prev === null ⟺ we created the bucket; otherwise it holds the existing one. $state = $buckets->get($key); if ($state['tokens'] === 0) { throw new RateLimitException(); }

Coordinating counters across workers

php
<?php $counters = new OxPHP\Shared\Map(); $counters->set('requests_handled', new OxPHP\Shared\Counter()); // Any worker increments via the stored Shareable (stored by reference). $counters->get('requests_handled')->add();

Iterating a large map

php
<?php $sessions->forEach(function (int|string $key, mixed $value): bool|null { if ($value['expires_at'] < time()) { // safe: forEach holds no lock during the callback return null; // keep going } return null; }); // Or read a known subset lazily, stopping early: foreach ($cache->getMany($hotKeys) as $key => $value) { if (enoughCollected()) break; // remaining keys are never materialised handle($key, $value); }

Semantics & gotchas

Arrays are copied on read

php
<?php $m = new OxPHP\Shared\Map(); $m->set('cfg', ['timeout' => 5, 'retries' => 3]); $cfg = $m->get('cfg'); $cfg['timeout'] = 10; // mutates the returned copy only // $m->get('cfg')['timeout'] is still 5

To update an array value atomically, read it, modify the copy, and commit with compareAndSet (retrying on conflict), or store independently-changing fields as nested Shared\Counter / Shared\Map.

Nested Shareable retains are automatic

php
<?php $map = new OxPHP\Shared\Map(); $counter = new OxPHP\Shared\Counter(10); $map->set('c', $counter); $retrieved = $map->get('c'); // same Shareable identity $retrieved->add(); // mutation visible via $counter too echo $counter->get(); // 11 $counter2 = $map->pop('c'); // Map releases its hold, returns the value $counter2->add(); // still alive via the returned wrapper

Cycle detection rejects before it mutates

php
<?php $a = new OxPHP\Shared\Map(); $b = new OxPHP\Shared\Map(); $a->set('b', $b); // fine try { $b->set('a', $a); // closes the loop } catch (OxPHP\Shared\CycleException $e) { // message: "cycle would form: #… → #… (inserting into #…)" } $b->get('a'); // null — $b untouched, no leaked retains

Nested references inside arrays are checked too. The walker is bounded by SHARED_CYCLE_DETECT_DEPTH (default 16) and SHARED_CYCLE_DETECT_EDGES (default 10 000); very large graphs surface CycleException with bounds exceeded. Raise the env knobs or break the graph.

Per-value size cap

Note

A single value whose serialised size exceeds SHARED_MAX_VALUE_SIZE (default 1 MiB) is rejected with ValueTooLargeException. This guards against an allocation bomb from PHP-side input. It applies to every write path (set, setIfAbsent, swap, compareAndSet, setMany).

Batched operations are per-key atomic, not batch-atomic

Warning

setMany, getMany, and removeMany apply one key at a time. If setMany hits a CapacityException, CycleException, or ValueTooLargeException partway through, earlier keys remain stored. The partial success is intentional. Use a Shared\Mutex around the map if you need all-or-nothing semantics.

Migrating from the old surface

Breaking change

This is a breaking redesign with no compatibility shims.

Old New
has($k) get($k) !== null (atomic, with no has/get race)
get($k, $default) get($k) ?? $default
trySet($k, $v): bool setIfAbsent($k, $v): mixed (returns prev; null ⟺ inserted)
remove($k) (returned prev) remove($k): bool, or pop($k) to get the value
update($k, $fn) compareAndSet retry loop, or Shared\Once for once-init
getOrSet($k, $fn) setIfAbsent, or Shared\Once / Shared\Pool by case
updateMany(...) a loop of compareAndSet
keys(): array forEach(...), or getMany($knownKeys)
count($map) (Countable) $map->count()
storing a null value use key absence / remove

Exceptions

All methods that can fail throw subclasses of OxPHP\Shared\SharedException:

Exception Raised by
CapacityException A new key past maxEntries (set / setIfAbsent / compareAndSet / setMany).
ValueTooLargeException A value over the per-value cap (SHARED_MAX_VALUE_SIZE).
CycleException A write that would close a reachability cycle (extends TypeException).
TypeException A null value; a non-storable value (object/closure/resource); a non-int/string key; maxEntries <= 0.
StaleHandleException A method call on a handle whose registry entry was evicted.

Observability

Every Map is visible through the internal API:

  • GET /__ox_shared/summary: aggregate counts by type, including Map.
  • GET /__ox_shared/entries: list all entries with id / type / refcount / mem_bytes.
  • GET /__ox_shared/entry?id=N: per-instance details for Map include key_count, max_entries, saturation, and sample_keys (truncated by the preview limit).
  • GET /__ox_shared/graph?id=N[&depth=D][&edges=E]: BFS walk of outgoing Shareable references; handy after a CycleException.

Prometheus exposes per-Map gauges at /metrics:

Metric Meaning
oxphp_shared_map_entries{map_id="…"} Current (approximate) key count.
oxphp_shared_map_max_entries{map_id="…"} Configured cap (0 when unbounded).
oxphp_shared_map_saturation{map_id="…"} entries / max_entries, 0 when unbounded.

Configuration

Env var Default Effect
SHARED_MAX_ENTRIES 100 000 Global cap on the number of live Shared objects (each Counter, Map, Channel, … counts as one). Creating an object past it fails with CapacityException. Keys inside a Map do not count toward it; bound a Map with maxEntries.
SHARED_MAX_BYTES 1 GiB Global cap on estimated memory across all Shared objects, checked only when an object is created (CapacityException). Objects that already exist can keep growing past it, so the total can read above the cap.
SHARED_MAX_VALUE_SIZE 1 MiB Per-value serialised size cap; larger values throw ValueTooLargeException.
SHARED_CYCLE_DETECT_DEPTH 16 Max BFS depth during cycle check. Raise for deep legit graphs.
SHARED_CYCLE_DETECT_EDGES 10 000 Max edges walked during cycle check. Raise for dense legit graphs.
SHARED_PREVIEW_ARRAY_LIMIT 20 Number of entries sampled in /entry?id=… sample_keys.
SHARED_INTROSPECTION_ENABLED true Toggles the /__ox_shared/* API.

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 Map in my code instead of the per-request cache I have now. Read the `shared-state/shared-map` page through the OxPHP documentation MCP server. Find the places in this repository where the data is genuinely process-wide rather than per-request, and show me the change for one of them end to end.
  • Shared\Counter: atomic integer; store one inside a Map for per-key hit counts.
  • Shared\Once: stampede-safe lazy initialisation when setIfAbsent would re-run an expensive factory.
  • Shared\Channel: MPMC queue, for when you need FIFO pipelines rather than keyed lookup.
  • Shared\Mutex: for when you need strict mutual exclusion around a stored value.
Found a mistake? Report it →