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 :
INTERNAL_ADDR=127.0.0.1:9090Les 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.
{
"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 :
{
"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 :
{
"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.
{ "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 :
{
"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.
{
"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.
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. |
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 :
curl -s http://localhost:9090/__ox_shared/entry?id=<pool_id> | jq .type_specificRegardez 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é :
curl /__ox_shared/entries?limit=500et triez parmem_bytespour trouver les principaux contributeurs.curl /__ox_shared/entry?id=<N>sur chacune pour vérifier la forme. Pour Map, regardezkey_countpar rapport àmax_entries.- Cause la plus fréquente : un
Shared\Mapnon borné indexé par une entrée utilisateur. Le remède est un plafondmaxEntrieset 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.
curl -s http://localhost:9090/__ox_shared/graph?id=<N> | jq .nodesParcourez 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 :
# Exception message: "cycle would form: #42 → #51 → #42"
curl -s http://localhost:9090/__ox_shared/graph?id=42 | jqLe 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 :
- 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.
- Sous
SHARED_LOCK_DIAGNOSTICS=warn, la valeur par défaut dans les builds release, le cycle est seulement journalisé et compté. PassezSHARED_LOCK_DIAGNOSTICS=stricten 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 :
- Démarre l'image de dev avec la mise à l'échelle dynamique des workers (
PHP_WORKERS=4:40par défaut) et unidleTimeoutde pool court, afin que le planificateur d'éviction se déclenche en continu. - Charge
tests/soak/workload.phpcomme bootstrap du worker, qui construit 10 pools ×maxSize=8et sert des acquire/release à chaque requête. - Génère du trafic avec
wrkpendantSOAK_DURATION_MINminutes (1440 par défaut = 24h). - Scrape
/metricset le RSS du conteneur toutes les 60 s danstests/soak/out/<timestamp>/metrics.csv. - É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 :
# 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.shLes 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/metricspris 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.
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.
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
- État partagé — modèle mental et vue d'ensemble des primitives.
- Métriques Prometheus — métriques principales du serveur sous le même endpoint
/metrics. - Serveur interne — comment les endpoints
/__ox_shared/*se branchent surINTERNAL_ADDR. - Migrer vers un stockage externe — lorsque la saturation est structurelle et non ajustable.