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.

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.

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, "soft_ratio": 0.7 }, "saturation": { "entries": 0.00127, "bytes": 0.00231 }, "diagnostics": { "lock_diagnostics_level": "warn", "cycle_detect_depth": 16, "poison_strict": false } }

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 { size, in_use, idle, waiting, idle_by_thread, max_size }, Channel expose { capacity, pending, 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) ; 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": 20, "name": "Map", "php_class": "OxPHP\\Shared\\Map" }, { "tag": 30, "name": "Mutex", "php_class": "OxPHP\\Shared\\Mutex" }, { "tag": 31, "name": "Channel", "php_class": "OxPHP\\Shared\\Channel" }, { "tag": 50, "name": "Pool", "php_class": "OxPHP\\Shared\\Pool" } ] }

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_pending (déprécié) 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
Note

oxphp_shared_channel_pending est l'ancienne orthographe de oxphp_shared_channel_count ; les deux séries portent la même valeur pendant la fenêtre de dépréciation et divergeront lorsque l'alias sera supprimé dans une version future. Branchez les nouveaux tableaux de bord sur _count.

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_size (déprécié) 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
Note

oxphp_shared_pool_size est l'ancienne orthographe de oxphp_shared_pool_count ; les deux séries portent la même valeur pendant la fenêtre de dépréciation et divergeront lorsque l'alias sera supprimé dans une version future. Branchez les nouveaux tableaux de bord sur _count.

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.

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"}. 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. Consultez les logs du serveur. Le détecteur émet un enregistrement de log par cycle, avec les IDs des mutex impliqués et les threads propriétaires. Pour récupérer :

  1. curl /__ox_shared/entry?id=<mutex_id> sur chacun et confirmez poisoned=false. S'il est empoisonné, le détecteur a déjà interrompu le cycle.
  2. Si le cycle est un véritable bug de réentrance, refactorisez pour utiliser des mutex distincts par portée de verrou.
  3. Passez SHARED_LOCK_DIAGNOSTICS=strict en préproduction pour transformer une future réentrance en échec immédiat plutôt qu'en cycle détecté.

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 :

  • /metricstoutes les 15 s (valeur par défaut typique de Prometheus). Uniquement agrégé ; le surcoût est négligeable.
  • /__ox_shared/summarytoutes 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.

Voir aussi