Shared\Map

OxPHP\Shared\Map est une map concurrente qui vit dans le registre partagé et qui est visible par chaque worker PHP du processus. C'est la primitive de référence lorsque deux workers — ou un gestionnaire de requête et une tâche d'arrière-plan — ont besoin de partager un état mutable qui survit au cycle de vie de la requête.

Vue d'ensemble

  • int|string → mixed. Les clés sont des entiers ou des chaînes PHP, gardées distinctes (123 et "123" sont des clés différentes ; il n'y a pas de coercition de clé à la manière des tableaux PHP). Les clés de type chaîne sont binary-safe — stockées comme des octets opaques (comme les tableaux PHP / Go / Redis), si bien que les clés non-UTF-8 (y compris un NUL intégré) font l'aller-retour fidèlement. Les valeurs peuvent être n'importe quel scalaire, un tableau de scalaires/tableaux, ou une autre instance Shareable.
  • null signifie absence — jamais une valeur stockée. Écrire une valeur null lève TypeException ; un retour null signifie toujours « aucune clé de ce nom ». Cela élimine à la racine la classique ambiguïté du null renvoyé par get() (le même choix que celui de java.util.concurrent.ConcurrentHashMap et du sync.Map de Go).
  • Une unique primitive conditionnelle linéarisable. compareAndSet couvre l'insertion / le remplacement / la suppression atomiques via la sentinelle d'absence null ; construisez n'importe quel read-modify-write par-dessus.
  • Concurrente. Les écritures provenant de workers différents ne nécessitent pas de verrouillage externe ; les opérations par clé sont atomiques au niveau du shard.
  • Sûre vis-à-vis des cycles. Stocker un Shareable qui pointerait à nouveau vers cette Map est rejeté avec CycleException avant toute mutation — aucune fuite sur le chemin rejeté.
  • Bornée par un plafond souple approximatif. maxEntries est un plafond de sécurité anti-OOM, pas une comptabilité exacte.

Référence de l'API

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; }
Méthode Cas d'usage
__construct Crée avec un plafond maxEntries optionnel (null = non borné ; <= 0 lève).
get Récupère par clé ; null ⟺ absent.
getMany Diffuse paresseusement key => value pour les clés connues ; les clés absentes sont ignorées (voir plus bas).
count Nombre approximatif d'entrées (faiblement cohérent sous écritures concurrentes).
maxEntries Indique le plafond configuré (ou null lorsque non borné).
set Insère ou remplace ; aucune valeur précédente matérialisée.
setIfAbsent Insertion-si-absent atomique ; renvoie la valeur existante, ou null en cas d'insertion.
setMany Insertion en masse depuis n'importe quel itérable ; renvoie le nombre d'entrées écrites.
remove Supprime une clé ; renvoie si elle existait (aucune valeur matérialisée).
removeMany Suppression en masse ; renvoie le nombre effectivement supprimé.
clear Supprime toutes les entrées (en libérant les rétentions sur les Shareable imbriqués) ; renvoie le nombre supprimé.
swap Écrase et renvoie la valeur précédente (null ⟺ était absente).
pop Supprime et renvoie la valeur précédente (null ⟺ était absente).
compareAndSet Insertion / remplacement / suppression atomiques conditionnés par le contenu courant (voir plus bas).
forEach Parcours faiblement cohérent ; le callback s'exécute sans détenir de verrou.
id Identifiant numérique dans le registre ; utile pour la journalisation + /__ox_shared/entry?id=….

Il n'y a pas de has(), update(), getOrSet(), keys(), trySet(), updateMany(), et la classe n'implémente pas Countable — voir Migration depuis l'ancienne surface d'API.

Le modèle « null comme absence »

null est réservé comme sentinelle d'absence partout :

  • set / swap / setIfAbsent avec une valeur nullTypeException.
  • get / swap / pop / setIfAbsent renvoyant null ⟺ la clé était absente.
  • Dans compareAndSet, null de part et d'autre signifie « absent » (et non « stocker null »).

Si vous devez consigner « aucune valeur », supprimez la clé (ou reposez-vous sur l'absence de clé) plutôt que de stocker null. Comme il n'y a pas de has() ni de course concurrente entre has() et get(), la présence se vérifie atomiquement avec un simple get($k) !== null.

compareAndSet — la primitive conditionnelle

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

Elle renvoie true si et seulement si l'échange a été appliqué. L'égalité se fait par contenu : les scalaires par valeur, les chaînes et les tableaux par leurs octets sérialisés, et les valeurs Shareable imbriquées par identité dans le registre. L'égalité des tableaux correspond au === de PHP pour les cas courants (listes, tableaux à clés entièrement entières ou entièrement chaînes) ; les tableaux qui entremêlent clés entières et clés chaînes sont comparés selon la forme de stockage normalisée de la Map, si bien qu'un ordre int/string particulier n'est pas distingué (la Map réordonne d'ailleurs de tels tableaux à la relecture). Construisez un read-modify-write sous forme de boucle de réessai explicite — et gardez la closure pure, car en cas de contention elle s'exécute plus d'une fois :

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

Il n'y a pas de risque d'ABA ici : le store est adressé par contenu (une valeur égale par le contenu est la même valeur pour un store de valeurs), et l'identité des Shareable imbriqués utilise des ids de registre monotones et jamais réutilisés. Pour une initialisation paresseuse à l'épreuve des ruées, utilisez Shared\Once ; pour des ressources mutualisées, utilisez Shared\Pool.

Modèle mémoire — où se produisent les copies

Les valeurs sont stockées sous une représentation sérialisée, pas sous forme de zvals — donc le « zero-copy » ne s'applique pas aux valeurs :

Opération Sérialisation dans le tas partagé Matérialisation de la valeur précédente en zval
set / setMany oui non
remove / removeMany non
setIfAbsent oui seulement si une valeur précédente existe
swap / pop oui / — oui
get / getMany (la clé seulement) oui
compareAndSet oui ($new) non

La sérialisation du chemin d'écriture est inévitable pour toute valeur entrant en mémoire partagée. La relecture dans un zval neuf n'est payée que par les méthodes qui renvoient une valeur précédente ou consultée — ainsi set/remove sont « sans matérialisation de retour », pas « gratuits ». L'exception est une valeur Shareable imbriquée : elle est stockée par référence (un id + une incrémentation du refcount), pas copiée en profondeur.

Concurrence

  • count() est faiblement cohérent. Les décomptes d'entrées sont répartis (striped) par shard et sommés à la lecture ; le résultat est exact lorsque la map est au repos et une approximation proche sous écritures concurrentes (le même contrat que ConcurrentHashMap::size). La répartition (striping) évite de concentrer les écritures sur un unique compteur chaud.
  • maxEntries est un plafond souple. Il est vérifié par rapport à la somme répartie (striped), donc sous insertions concurrentes la map peut dépasser jusqu'à concurrence du nombre de shards avant de rejeter une nouvelle clé avec CapacityException. Considérez-le comme un budget de sécurité anti-OOM, pas une comptabilité exacte. L'écrasement d'une clé existante réussit toujours, même au plafond. Il n'y a aucune éviction — un cache avec éviction LRU/TTL est une primitive différente.
  • forEach exécute le callback sans détenir de verrou. Il capture les clés d'un shard à la fois, relâche le shard, puis récupère à nouveau chaque valeur et invoque $fn(key, value). Les clés supprimées entre l'instantané et l'appel sont ignorées ; les clés ajoutées après l'instantané d'un shard peuvent être manquées ; les valeurs peuvent être plus fraîches que l'instant de l'instantané. Renvoyez false depuis le callback pour arrêter plus tôt. Comme seules les clés sont capturées, un callback lent ne fige jamais de valeurs supprimées.

Exemples

Cache de configuration partagée

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;

Limiteur de débit par tenant

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(); }

Coordination de compteurs entre 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();

Itération sur une grande 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); }

Sémantique et pièges

Les tableaux sont copiés à la lecture

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

Pour mettre à jour une valeur de type tableau atomiquement, lisez-la, modifiez la copie et validez avec compareAndSet (en réessayant en cas de conflit), ou stockez les champs qui évoluent indépendamment sous forme de Shared\Counter / Shared\Map imbriqués.

Les rétentions de Shareable imbriqués sont automatiques

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

La détection de cycles rejette avant de muter

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

Les références imbriquées à l'intérieur des tableaux sont vérifiées elles aussi. Le parcours est borné par SHARED_CYCLE_DETECT_DEPTH (16 par défaut) et SHARED_CYCLE_DETECT_EDGES (10 000 par défaut) ; les très grands graphes font apparaître CycleException avec bounds exceeded — augmentez ces réglages d'environnement ou cassez le graphe.

Plafond de taille par valeur

Note

Une seule valeur dont la taille sérialisée dépasse SHARED_MAX_VALUE_SIZE (1 MiB par défaut) est rejetée avec ValueTooLargeException. Cela protège contre une bombe d'allocation issue d'une entrée côté PHP. Cela s'applique à tous les chemins d'écriture (set, setIfAbsent, swap, compareAndSet, setMany).

Les opérations par lots sont atomiques par clé, pas par lot

Warning

setMany, getMany et removeMany traitent une clé à la fois. Si setMany rencontre une CapacityException, une CycleException ou une ValueTooLargeException en cours de route, les clés déjà traitées restent stockées — ce succès partiel est intentionnel. Utilisez un Shared\Mutex autour de la map si vous avez besoin d'une sémantique tout-ou-rien.

Migration depuis l'ancienne surface d'API

Changement incompatible

Il s'agit d'une refonte incompatible, sans aucune couche de compatibilité.

Ancien Nouveau
has($k) get($k) !== null (atomique — pas de course entre has et get)
get($k, $default) get($k) ?? $default
trySet($k, $v): bool setIfAbsent($k, $v): mixed (renvoie la précédente ; null ⟺ insérée)
remove($k) (renvoyait la précédente) remove($k): bool, ou pop($k) pour obtenir la valeur
update($k, $fn) boucle de réessai compareAndSet, ou Shared\Once pour une init unique
getOrSet($k, $fn) setIfAbsent, ou Shared\Once / Shared\Pool selon le cas
updateMany(...) une boucle de compareAndSet
keys(): array forEach(...), ou getMany($knownKeys)
count($map) (Countable) $map->count()
stocker une valeur null reposez-vous sur l'absence de clé / remove

Exceptions

Toutes les méthodes susceptibles d'échouer lèvent des sous-classes de OxPHP\Shared\SharedException :

Exception Levée par
CapacityException Une nouvelle clé au-delà de maxEntries (set / setIfAbsent / compareAndSet / setMany).
ValueTooLargeException Une valeur dépassant le plafond par valeur (SHARED_MAX_VALUE_SIZE).
CycleException Une écriture qui fermerait un cycle d'accessibilité (extends TypeException).
TypeException Une valeur null ; une valeur non stockable (object/closure/resource) ; une clé non int/string ; maxEntries <= 0.
StaleHandleException Un appel de méthode sur un handle dont l'entrée de registre a été évincée.

Observabilité

Chaque Map est visible via l'API interne :

  • GET /__ox_shared/summary — décomptes agrégés par type, y compris Map.
  • GET /__ox_shared/entries — liste toutes les entrées avec id / type / refcount / mem_bytes.
  • GET /__ox_shared/entry?id=N — les détails par instance pour Map incluent key_count, max_entries, saturation et sample_keys (tronqués par la limite d'aperçu).
  • GET /__ox_shared/graph?id=N[&depth=D][&edges=E] — parcours BFS des références Shareable sortantes ; pratique après une CycleException.

Prometheus expose des jauges par Map sur /metrics :

Métrique Signification
oxphp_shared_map_entries{map_id="…"} Nombre de clés courant (approximatif).
oxphp_shared_map_max_entries{map_id="…"} Plafond configuré (0 lorsque non borné).
oxphp_shared_map_saturation{map_id="…"} entries / max_entries, 0 lorsque non borné.

Configuration

Variable d'env Défaut Effet
SHARED_MAX_ENTRIES 100 000 Plafond global sur l'ensemble des entrées Shared combinées.
SHARED_MAX_BYTES 1 GiB Plafond global sur la mémoire estimée de toutes les entrées Shared.
SHARED_MAX_VALUE_SIZE 1 MiB Plafond de taille sérialisée par valeur ; les valeurs plus grandes lèvent ValueTooLargeException.
SHARED_CYCLE_DETECT_DEPTH 16 Profondeur BFS maximale pendant la vérification de cycle. Augmentez pour des graphes légitimes profonds.
SHARED_CYCLE_DETECT_EDGES 10 000 Nombre maximal d'arêtes parcourues pendant la vérification de cycle. Augmentez pour des graphes légitimes denses.
SHARED_PREVIEW_ARRAY_LIMIT 20 Nombre d'entrées échantillonnées dans sample_keys de /entry?id=….
SHARED_INTROSPECTION_ENABLED true Active/désactive l'API /__ox_shared/*.

Voir aussi

  • Shared\Counter — entier atomique ; à stocker dans une Map pour des compteurs de hits par clé.
  • Shared\Once — initialisation paresseuse à l'épreuve des ruées lorsque setIfAbsent réexécuterait une fabrique coûteuse.
  • Shared\Channel — file MPMC ; complémentaire lorsque vous avez besoin de pipelines FIFO plutôt que d'un accès par clé.
  • Shared\Mutex — lorsque vous avez besoin d'une exclusion mutuelle stricte autour d'une valeur stockée.