Observabilité de Shared*

Chaque instance OxPHP\Shared\* est une entrée de registre que le runtime suit déjà pour son refcount et sa capacité. Ce suivi est exposé aux opérateurs sous forme d'introspection JSON sous /__ox_shared/* et de métriques Prometheus sous oxphp_shared_*. Cette page en est à la fois la référence et le guide de terrain.

Activation

L'observabilité s'appuie sur le serveur interne. Définissez INTERNAL_ADDR pour le démarrer :

bash
INTERNAL_ADDR=127.0.0.1:9090

Les endpoints JSON et /metrics sont alors accessibles à cette adresse. Aucune configuration supplémentaire n'est nécessaire, si ce n'est de laisser SHARED_ENABLED activé.

Vous pouvez désactiver l'un ou l'autre indépendamment :

Variable d'env Défaut Effet
SHARED_INTROSPECTION_ENABLED true Active/désactive l'API JSON /__ox_shared/*.
SHARED_INTROSPECTION_PREVIEW_ENABLED true Active/désactive /preview (les aperçus de forme de valeur peuvent divulguer des données).
SHARED_METRICS_ENABLED true Active/désactive les métriques Prometheus oxphp_shared_*.

Désactivez l'introspection dans les déploiements à locataires hostiles ; les métriques sont uniquement agrégées et peuvent rester activées sans risque.

SHARED_ENABLED=false supprime aussi ces deux surfaces, en même temps que le reste du sous-système. Utilisez les interrupteurs ci-dessus pour garder les primitives disponibles tout en masquant ce qu'elles contiennent.

Endpoints d'introspection

Toutes les réponses sont Content-Type: application/json; charset=utf-8. Les paramètres de requête utilisent l'encodage URL standard.

GET /__ox_shared/summary

Instantané de haut niveau : comptes agrégés par type, mémoire, débit d'opérations et saturation par rapport aux plafonds configurés.

json
{ "total_entries": 127, "total_bytes": 2_481_664, "by_type": { "Counter": { "count": 48, "bytes": 3_072, "ops": 1_402_391 }, "Map": { "count": 12, "bytes": 1_638_400, "ops": 48_201 }, "Pool": { "count": 4, "bytes": 16_384, "ops": 67_014 } }, "limits": { "max_entries": 100_000, "max_bytes": 1073741824 }, "saturation": { "entries": 0.00127, "bytes": 0.00231 }, "diagnostics": { "lock_diagnostics_level": "warn", "cycle_detect_depth": 16 } }

Utilisez summary dans les tableaux de bord et les alertes cron. Un seul scrape vous donne l'état de santé par type et la marge de capacité disponible.

GET /__ox_shared/entries?limit=N

Liste les entrées en direct (limitées à limit, 100 par défaut, 500 au maximum). Une ligne par entrée :

json
{ "items": [ { "id": 42, "type": "Map", "refcount": 2, "ops": 1820, "mem_bytes": 204_800, "age_sec": 612 }, { "id": 43, "type": "Counter", "refcount": 3, "ops": 48_014, "mem_bytes": 64, "age_sec": 612 } ], "next_cursor": null, "total_matching": 127 }

refcount est le compte de rétention externe : le nombre de wrappers PHP et d'entrées Shared imbriquées qui maintiennent celle-ci. Lorsque vous vous attendez à ce qu'une entrée soit récupérable par le GC mais qu'elle ne l'est pas, c'est le champ à vérifier.

GET /__ox_shared/entry?id=N

Détail spécifique au type pour une entrée :

json
{ "id": 42, "type": "Map", "refcount": 2, "ops": 1820, "mem_bytes": 204_800, "age_sec": 612, "type_specific": { "key_count": 1_240, "max_entries": 50_000, "saturation": 0.0248, "sample_keys": ["tenant:acme", "tenant:beta", "..."] } }

type_specific varie selon le type : Pool expose { max_size, count, in_use, idle, waiting, idle_by_thread, rebalance_strategy }, Channel expose { capacity, count, closed, senders_blocked, receivers_blocked }, Counter expose { value }, et ainsi de suite.

GET /__ox_shared/preview?id=N

Aperçu de la forme des valeurs scalaires et des petits tableaux. Les valeurs de type chaîne sont tronquées à SHARED_PREVIEW_STRING_LIMIT (256 octets par défaut), en arrondissant vers le bas à la frontière de caractère la plus proche afin qu'un texte multi-octets ne soit jamais coupé au milieu d'un caractère ; les tableaux affichent les SHARED_PREVIEW_ARRAY_LIMIT premières entrées (20 par défaut). Conditionné par SHARED_INTROSPECTION_PREVIEW_ENABLED.

json
{ "id": 42, "type": "Counter", "preview": "1420" }

Utilisez preview pendant le développement ; désactivez-le en production lorsque les valeurs peuvent contenir des données utilisateur.

GET /__ox_shared/types

Énumère le catalogue de types v1, utile pour l'outillage généré qui a besoin de la correspondance tag → classe :

json
{ "types": [ { "tag": 10, "name": "Counter", "php_class": "OxPHP\\Shared\\Counter" }, { "tag": 11, "name": "Flag", "php_class": "OxPHP\\Shared\\Flag" }, { "tag": 12, "name": "Once", "php_class": "OxPHP\\Shared\\Once" }, { "tag": 13, "name": "Atomic", "php_class": "OxPHP\\Shared\\Atomic" }, { "tag": 20, "name": "Map", "php_class": "OxPHP\\Shared\\Map" }, { "tag": 31, "name": "Channel", "php_class": "OxPHP\\Shared\\Channel" }, { "tag": 50, "name": "Pool", "php_class": "OxPHP\\Shared\\Pool" } ] }

La réponse ne liste pas Shared\Mutex.

GET /__ox_shared/graph?id=N[&depth=D][&edges=E]

Parcours en largeur (BFS) des références Shareable sortantes à partir de id=N. Retourne les nœuds et les arêtes du sous-graphe accessible. Valeurs par défaut : depth=16, edges=500. Lorsque le budget du walker est atteint, la réponse indique truncated: true.

json
{ "root": 42, "nodes": [ { "id": 42, "type": "Map", "refcount": 2, "mem_bytes": 204_800 }, { "id": 51, "type": "Counter", "refcount": 1, "mem_bytes": 64 } ], "edges": [ { "from": 42, "to": 51, "key": "hits" } ], "truncated": false }

Utilisez graph après une CycleException pour voir le chemin accessible qu'a emprunté le walker, ou pour diagnostiquer « pourquoi ce Counter n'est-il pas récupéré par le GC » : le graphe montre chaque parent qui maintient une rétention sur lui.

Métriques Prometheus

Toutes les métriques sont exposées sur GET /metrics aux côtés des métriques principales du serveur.

À l'échelle du registre

Métrique Type Labels Description
oxphp_shared_objects_total gauge type Nombre d'entrées en direct par type.
oxphp_shared_operations_total counter type Nombre cumulé d'opérations distribuées à chaque type.
oxphp_shared_bytes gauge type Octets approximatifs par type (±30 % par rapport à mallinfo).
oxphp_shared_total_bytes gauge — Somme sur l'ensemble des types.
oxphp_shared_capacity_saturation gauge kind entries et bytes en fractions de leurs plafonds.
oxphp_shared_deadlock_detected_total counter — Cycles d'attente inter-threads détectés.

Channel

Métrique Type Labels
oxphp_shared_channel_count gauge channel_id
oxphp_shared_channel_senders_blocked gauge channel_id
oxphp_shared_channel_receivers_blocked gauge channel_id
oxphp_shared_channel_items_sent_total counter channel_id
oxphp_shared_channel_items_dropped_total counter channel_id

Map

Métrique Type Labels
oxphp_shared_map_entries gauge map_id
oxphp_shared_map_max_entries gauge map_id
oxphp_shared_map_saturation gauge map_id

Pool

Métrique Type Labels
oxphp_shared_pool_count gauge pool_id
oxphp_shared_pool_in_use gauge pool_id
oxphp_shared_pool_idle gauge pool_id
oxphp_shared_pool_waiting gauge pool_id
oxphp_shared_pool_acquire_total counter pool_id
oxphp_shared_pool_evicted_total counter pool_id, reason
oxphp_shared_pool_wait_seconds histogram pool_id

Labels de oxphp_shared_pool_evicted_total : reason=idle_timeout | evict | shutdown. idle_timeout est une éviction automatique d'un slot inactif, evict est un appel explicite à Pool::evict(), et shutdown correspond au démontage à la sortie du processus.

Counter / Flag / Once / Mutex

Les compteurs, flags, onces et mutex individuels n'embarquent pas de séries de métriques propres : cela ferait gonfler la cardinalité des labels. Utilisez plutôt le compteur oxphp_shared_operations_total{type=...} à l'échelle du registre et le JSON /__ox_shared/entry?id=… pour l'inspection par instance.

Les métriques Mutex sont candidates pour la v1.x

Suivi comme travail ultérieur ; aujourd'hui, la visibilité passe par /__ox_shared/entry.

Détecteur d'interblocage

Une tâche d'arrière-plan recherche les cycles d'attente inter-threads sur Shared\Mutex : deux threads ou plus, chacun attendant un mutex que détient un autre d'entre eux. Toutes les SHARED_LOCK_POLL_INTERVAL_MS (100 par défaut), elle parcourt le graphe d'attente. SHARED_LOCK_DIAGNOSTICS détermine ce qu'elle fait lorsqu'un parcours trouve un cycle :

Valeur Détecteur En cas de cycle
off Non démarré. SHARED_LOCK_POLL_INTERVAL_MS n'est pas utilisé et oxphp_shared_deadlock_detected_total reste à 0. Rien. Un withLockTimeout() pris dans le cycle abandonne avec OperationTimeoutException lorsque son délai expire. Un cycle composé uniquement d'appels withLock() n'a pas de délai propre pour y mettre fin : chacune de ses requêtes attend jusqu'à ce qu'on l'interrompe, par max_execution_time si elle en a un, et les verrous que détenait une requête interrompue sont libérés lorsqu'elle se termine (voir Shared\Mutex).
warn (par défaut dans les builds release) Démarré. Journalise et compte (voir ci-dessous) ; les attentes restent en place, avec la même issue que sous off.
strict (par défaut dans les builds debug du binaire) Démarré. Journalise et compte, et signale en outre les threads du cycle : un withLock() pris dans le cycle lève DeadlockException dans les 100 ms suivant le parcours, et un withLockTimeout() attend la fin de son délai puis lève DeadlockException au lieu de OperationTimeoutException. Un appelant en attente dont le verrou est libéré en premier (par l'exception d'un autre appelant qui déroule la closure qui le détenait) l'acquiert et ne lève rien.
Minuscules uniquement

La valeur n'est reconnue qu'en minuscules. Toute autre valeur, Strict ou trace compris, donne silencieusement la valeur par défaut.

Chaque cycle trouvé par un parcours produit au moins un enregistrement WARN avec event=shared.deadlock_detected, chacun nommant un thread du cycle (start_thread, répété sous holder_thread) ; l'enregistrement ne nomme pas les mutex. oxphp_shared_deadlock_detected_total augmente à chaque enregistrement.

La réentrance sur le même thread relève d'une vérification distincte et ne dépend pas de ce réglage : réentrer dans un mutex sur le thread qui le détient déjà lève DeadlockException à l'appel, quelle que soit la valeur, off compris, et ne devient jamais un cycle.

Guides de diagnostic

Le pool est saturé (429 avec échec des tentatives)

Symptômes : les appelants HTTP subissent des délais d'expiration, oxphp_shared_pool_waiting grimpe, oxphp_shared_pool_count reste bloqué à maxSize.

Vérifiez :

bash
curl -s http://localhost:9090/__ox_shared/entry?id=<pool_id> | jq .type_specific

Regardez idle_by_thread. S'il vaut {} ou s'il est fortement déséquilibré (le worker 0 a 8 slots inactifs, le worker 3 en a 0), l'acquisition entre en contention pour des threads qui se trouvent occupés ailleurs. En v1, l'affinité par thread ne se rééquilibre pas. Soit augmentez maxSize, soit réduisez le point chaud d'acquisition par thread.

Si idle_by_thread est équilibré mais que tout est en in_use, augmentez maxSize.

Saturation mémoire

Vérifiez oxphp_shared_total_bytes et oxphp_shared_capacity_saturation{kind="bytes"}. Le serveur ne déleste ni ne dégrade rien à mesure que l'utilisation augmente. Sa première réaction est de refuser la création d'un nouvel objet Shared une fois SHARED_MAX_BYTES ou SHARED_MAX_ENTRIES atteint ; les objets déjà existants continuent de grossir sans que ce plafond soit revérifié (un Map sans limite, sauf s'il a été construit avec maxEntries), si bien que la jauge bytes peut dépasser 1.0. Pour être alerté à temps, ajoutez une règle d'alerte, par exemple oxphp_shared_capacity_saturation > 0.7. Si l'un des deux est élevé :

  1. curl /__ox_shared/entries?limit=500 et triez par mem_bytes pour trouver les principaux contributeurs.
  2. curl /__ox_shared/entry?id=<N> sur chacune pour vérifier la forme. Pour Map, regardez key_count par rapport à max_entries.
  3. Cause la plus fréquente : un Shared\Map non borné indexé par une entrée utilisateur. Le remède est un plafond maxEntries et une politique de rétention.

Un wrapper refuse d'être récupéré par le GC

refcount dans /__ox_shared/entries vous indique le nombre de rétentions en cours. S'il reste supérieur à 1 après que le wrapper PHP est sorti de portée, c'est qu'une autre entrée Shared le maintient en vie.

bash
curl -s http://localhost:9090/__ox_shared/graph?id=<N> | jq .nodes

Parcourez le graphe à rebours. Tout nœud qui atteint l'entrée bloquée détient une rétention. Supprimez la référence ($map->remove($key), fermez le channel, supprimez l'entrée Mutex) et le refcount diminue.

Une CycleException s'est déclenchée en production

Le message de l'exception inclut le chemin accessible qu'a exploré le détecteur de cycles. Faites correspondre ces IDs aux types via /__ox_shared/entries, et interrogez /__ox_shared/graph?id=<root> pour obtenir la forme complète :

bash
# Exception message: "cycle would form: #42 → #51 → #42" curl -s http://localhost:9090/__ox_shared/graph?id=42 | jq

Le résultat visualise la chaîne pour vous permettre de voir où la référence arrière involontaire a été introduite.

Le détecteur d'interblocage s'est déclenché

oxphp_shared_deadlock_detected_total s'incrémente : le détecteur d'interblocage a vu deux threads ou plus, chacun attendant un Shared\Mutex que détient un autre d'entre eux. Cherchez event=shared.deadlock_detected dans les logs du serveur ; les enregistrements nomment des threads, pas des mutex. Pour récupérer :

  1. Trouvez les chemins de code qui prennent deux mutex l'un dans l'autre, et faites-leur prendre ces mutex dans le même ordre partout, ou fusionnez la paire en un seul mutex.
  2. Sous SHARED_LOCK_DIAGNOSTICS=warn, la valeur par défaut dans les builds release, le cycle est seulement journalisé et compté. Passez SHARED_LOCK_DIAGNOSTICS=strict en préproduction pour que le détecteur le brise à la place ; voir Détecteur d'interblocage pour ce que fait alors chaque appel en attente.

Harnais de soak longue durée

tests/soak/pool_soak.sh est un harnais manuel (hors CI) permettant de vérifier la stabilité de Shared\Pool sur des heures ou des jours de charge continue. Il :

  1. Démarre l'image de dev avec la mise à l'échelle dynamique des workers (PHP_WORKERS=4:40 par défaut) et un idleTimeout de pool court, afin que le planificateur d'éviction se déclenche en continu.
  2. Charge tests/soak/workload.php comme bootstrap du worker, qui construit 10 pools × maxSize=8 et sert des acquire/release à chaque requête.
  3. Génère du trafic avec wrk pendant SOAK_DURATION_MIN minutes (1440 par défaut = 24h).
  4. Scrape /metrics et le RSS du conteneur toutes les 60 s dans tests/soak/out/<timestamp>/metrics.csv.
  5. Écrit verify.txt à la fin avec un statut réussite/échec pour les cinq critères de sortie de release (dérive du RSS dans une plage de ±5 %, zéro panique de handle obsolète, zéro entrée fuitée à l'arrêt, évictions par idle-timeout en hausse régulière, zéro déclenchement du détecteur d'interblocage).

Prérequis sur l'hôte : docker, wrk, curl, awk.

Invocations typiques :

bash
# 24h full soak before a release tests/soak/pool_soak.sh # 1h smoke for validating the harness itself SOAK_DURATION_MIN=60 tests/soak/pool_soak.sh # Heavier concurrency SOAK_CONCURRENCY=400 SOAK_THREADS=8 tests/soak/pool_soak.sh

Les artefacts atterrissent dans tests/soak/out/<timestamp>/ :

  • metrics.csv — une ligne par minute (ts unix, RSS, nombre d'entrées par type, compteurs d'éviction par pool, nombre d'interblocages, ops).
  • server.log — stdout/stderr du conteneur, y compris toute trace de handle obsolète ou de panique.
  • wrk.out / wrk.err — sortie brute du générateur de charge.
  • metrics.final — le dernier scrape de /metrics pris juste avant le démontage du conteneur. Sert à confirmer que les nombres d'entrées et d'octets sont revenus au niveau de référence (aucune entrée Shared fuitée) une fois la charge arrêtée.
  • verify.txt — rapport réussite/échec pour les cinq critères de sortie.
Warning

Ne branchez pas ceci dans la CI. Une exécution de 24h n'est pas bon marché et sa raison d'être est la confiance avant release, pas la validation continue.

Cadence de scrape

Les endpoints du registre parcourent l'état en direct sous des verrous en lecture ; le scraping est donc peu coûteux, mais pas gratuit. Cadences recommandées :

  • /metrics — toutes les 15 s (valeur par défaut typique de Prometheus). Uniquement agrégé ; le surcoût est négligeable.
  • /__ox_shared/summary — toutes les 60 s pour les tableaux de bord. Légèrement plus lourd que /metrics.
  • /__ox_shared/entries — à la demande uniquement. Itère sur tous les shards ; ne le scrapez pas à chaque tick.
  • /__ox_shared/entry / /preview / /graph — à la demande lors des investigations.

Prompts pour votre agent

Ces prompts supposent un agent connecté au serveur MCP de la documentation OxPHP — il lira lui-même les pages dont il a besoin.

prompt
Construis un tableau de bord Prometheus et des règles d'alerte pour les primitives Shared\* que mon code utilise vraiment. Lis la page `shared-state/shared-observability` via le serveur MCP de la documentation OxPHP. Trouve chaque `Shared\Map`, `Shared\Pool`, `Shared\Channel` et autre primitive que ce dépôt construit, et produis des panneaux et des seuils d'alerte à partir de leurs métriques documentées — saturation, attente et rejets.

Voir aussi

Une erreur ? Signalez-la →