Obserwowalność Shared*
Każda instancja OxPHP\Shared\* to wpis w rejestrze, który runtime i tak już śledzi pod kątem refcount i pojemności. To śledzenie jest udostępniane operatorom jako introspekcja JSON pod /__ox_shared/* oraz jako metryki Prometheus pod oxphp_shared_*. Ta strona jest zarazem materiałem referencyjnym i praktycznym przewodnikiem.
Włączanie
Obserwowalność korzysta z serwera wewnętrznego. Ustaw INTERNAL_ADDR, aby ją uruchomić:
INTERNAL_ADDR=127.0.0.1:9090Zarówno endpointy JSON, jak i /metrics są wtedy dostępne pod tym adresem. Poza pozostawieniem włączonego SHARED_ENABLED nie jest wymagana żadna dodatkowa konfiguracja.
Każdą z nich można wyłączyć niezależnie:
| Zmienna środowiskowa | Domyślnie | Efekt |
|---|---|---|
SHARED_INTROSPECTION_ENABLED |
true |
Przełącza API JSON /__ox_shared/*. |
SHARED_INTROSPECTION_PREVIEW_ENABLED |
true |
Przełącza /preview (podglądy kształtu wartości mogą ujawniać dane). |
SHARED_METRICS_ENABLED |
true |
Przełącza metryki Prometheus oxphp_shared_*. |
Wyłącz introspekcję we wdrożeniach z wrogo nastawionymi najemcami; metryki są wyłącznie zagregowane i można je bezpiecznie pozostawić włączone.
SHARED_ENABLED=false również usuwa obie te powierzchnie, razem z resztą podsystemu. Używaj powyższych przełączników, aby zachować dostępność prymitywów, a jednocześnie ukryć to, co przechowują.
Endpointy introspekcji
Wszystkie odpowiedzi mają Content-Type: application/json; charset=utf-8. Parametry zapytania są w standardowym kodowaniu URL.
GET /__ox_shared/summary
Migawka najwyższego poziomu: zagregowane liczniki na typ, pamięć, tempo operacji oraz saturacja względem skonfigurowanych limitów.
{
"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
}
}Używaj summary w dashboardach i alertach cron. Pojedynczy scrape daje ci kondycję w rozbiciu na typy oraz zapas pojemności.
GET /__ox_shared/entries?limit=N
Wypisuje aktywne wpisy (ograniczone przez limit, domyślnie 100, maksymalnie 500). Jeden wiersz na wpis:
{
"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 to zewnętrzna liczba przetrzymań (retain count): ile wrapperów PHP oraz zagnieżdżonych wpisów Shared przetrzymuje ten wpis. Gdy spodziewasz się, że dany wpis powinien już zostać zebrany przez GC, a tak się nie dzieje, to właśnie to pole warto sprawdzić.
GET /__ox_shared/entry?id=N
Szczegóły specyficzne dla typu, dla pojedynczego wpisu:
{
"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 różni się w zależności od typu: Pool udostępnia { max_size, count, in_use, idle, waiting, idle_by_thread, rebalance_strategy }, Channel udostępnia { capacity, count, closed, senders_blocked, receivers_blocked }, Counter udostępnia { value }, i tak dalej.
GET /__ox_shared/preview?id=N
Podgląd kształtu wartości dla wartości skalarnych i małych tablic. Wartości tekstowe są przycinane do SHARED_PREVIEW_STRING_LIMIT (domyślnie 256 bajtów), z zaokrągleniem w dół do najbliższej granicy znaku, więc tekst wielobajtowy nigdy nie jest ucinany w połowie znaku; tablice pokazują pierwsze SHARED_PREVIEW_ARRAY_LIMIT wpisów (domyślnie 20). Kontrolowane przez SHARED_INTROSPECTION_PREVIEW_ENABLED.
{ "id": 42, "type": "Counter", "preview": "1420" }Używaj preview podczas programowania; wyłącz go na produkcji, gdy wartości mogą zawierać dane użytkowników.
GET /__ox_shared/types
Wylicza katalog typów v1, przydatny dla generowanych narzędzi, które potrzebują mapowania tag → klasa:
{
"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" }
]
}Odpowiedź nie zawiera Shared\Mutex.
GET /__ox_shared/graph?id=N[&depth=D][&edges=E]
Przejście BFS po wychodzących referencjach Shareable, począwszy od id=N. Zwraca węzły i krawędzie osiągalnego podgrafu. Wartości domyślne: depth=16, edges=500. Wyczerpanie budżetu przechodzenia grafu ustawia w odpowiedzi 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
}Sięgnij po graph po wystąpieniu CycleException, aby zobaczyć osiągalną ścieżkę, którą przeszedł walker, albo podczas diagnozowania „dlaczego ten Counter nie jest zbierany przez GC”: graf pokazuje każdego rodzica, który przetrzymuje na nim retain.
Metryki Prometheus
Wszystkie metryki są udostępniane pod GET /metrics obok podstawowych metryk serwera.
Dla całego rejestru
| Metryka | Typ | Etykiety | Opis |
|---|---|---|---|
oxphp_shared_objects_total |
gauge | type |
Liczba aktywnych wpisów na typ. |
oxphp_shared_operations_total |
counter | type |
Skumulowana liczba operacji skierowanych do każdego typu. |
oxphp_shared_bytes |
gauge | type |
Przybliżona liczba bajtów na typ (±30% względem mallinfo). |
oxphp_shared_total_bytes |
gauge | — | Suma dla wszystkich typów. |
oxphp_shared_capacity_saturation |
gauge | kind |
entries i bytes jako ułamki swoich limitów. |
oxphp_shared_deadlock_detected_total |
counter | — | Wykryte międzywątkowe cykle oczekiwania (wait-for). |
Channel
| Metryka | Typ | Etykiety |
|---|---|---|
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
| Metryka | Typ | Etykiety |
|---|---|---|
oxphp_shared_map_entries |
gauge | map_id |
oxphp_shared_map_max_entries |
gauge | map_id |
oxphp_shared_map_saturation |
gauge | map_id |
Pool
| Metryka | Typ | Etykiety |
|---|---|---|
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 |
Etykiety oxphp_shared_pool_evicted_total: reason=idle_timeout | evict | shutdown. idle_timeout to automatyczna eksmisja bezczynnego slotu, evict to jawne wywołanie Pool::evict(), a shutdown to sprzątanie przy zakończeniu procesu.
Counter / Flag / Once / Mutex
Poszczególne instancje Counter, Flag, Once i Mutex nie mają własnych, indywidualnych serii metryk: rozdęłyby kardynalność etykiet. Zamiast tego do inspekcji pojedynczych instancji użyj obejmującego cały rejestr licznika oxphp_shared_operations_total{type=...} oraz JSON-a z /__ox_shared/entry?id=….
Śledzone jako praca uzupełniająca; dzisiejsza widoczność odbywa się przez /__ox_shared/entry.
Detektor zakleszczeń
Zadanie działające w tle wyszukuje międzywątkowe cykle oczekiwania (wait-for) na Shared\Mutex: dwa lub więcej wątków, z których każdy czeka na muteks trzymany przez inny z nich. Co SHARED_LOCK_POLL_INTERVAL_MS (domyślnie 100) skanuje graf oczekiwań. SHARED_LOCK_DIAGNOSTICS określa, co robi, gdy skan znajdzie cykl:
| Wartość | Detektor | Przy cyklu |
|---|---|---|
off |
Nie jest uruchamiany. SHARED_LOCK_POLL_INTERVAL_MS nie jest używane, a oxphp_shared_deadlock_detected_total pozostaje na 0. |
Nic. withLockTimeout() w cyklu poddaje się z OperationTimeoutException, gdy upłynie jego limit czasu. Cykl złożony wyłącznie z wywołań withLock() nie ma własnego limitu czasu, który by go zakończył: każde z jego żądań czeka, aż zostanie zakończone, przez max_execution_time, jeśli je ma, a blokady trzymane przez zakończone żądanie są zwalniane w chwili jego zakończenia (zobacz Shared\Mutex). |
warn (domyślnie w buildach release) |
Działa. | Loguje i zlicza (patrz niżej); oczekiwania pozostają bez zmian, z takim samym skutkiem jak przy off. |
strict (domyślnie w buildach debug binarki) |
Działa. | Loguje i zlicza, a także sygnalizuje wątkom w cyklu: withLock() w cyklu rzuca DeadlockException w ciągu 100 ms od skanu, a withLockTimeout() odczekuje swój limit czasu i dopiero wtedy rzuca DeadlockException zamiast OperationTimeoutException. Oczekujący, którego blokada zostanie zwolniona wcześniej (gdy wyjątek innego oczekującego odwinie domknięcie, które ją trzymało), przejmuje ją i nie rzuca wyjątku. |
Wartość jest dopasowywana wyłącznie małymi literami. Każda inna wartość, w tym Strict czy trace, po cichu daje wartość domyślną.
Każdy cykl znaleziony przez skan daje co najmniej jeden rekord WARN z event=shared.deadlock_detected, a każdy z nich wskazuje jeden wątek w cyklu (start_thread, powtórzony jako holder_thread); rekord nie wskazuje muteksów. oxphp_shared_deadlock_detected_total rośnie z każdym rekordem.
Ponowne wejście w obrębie tego samego wątku to osobne sprawdzenie, niezależne od tego ustawienia: ponowne wejście w muteks na wątku, który już go trzyma, rzuca DeadlockException w miejscu wywołania przy każdej wartości, łącznie z off, i nigdy nie staje się cyklem.
Poradniki diagnostyczne
Pool jest nasycony (kody 429 z nieudanymi ponowieniami)
Objawy: klienci HTTP widzą przekroczenia limitu czasu, oxphp_shared_pool_waiting rośnie, oxphp_shared_pool_count jest przyszpilony na maxSize.
Sprawdź:
curl -s http://localhost:9090/__ox_shared/entry?id=<pool_id> | jq .type_specificPrzyjrzyj się idle_by_thread. Jeśli jest {} lub mocno niezrównoważone (worker 0 ma 8 bezczynnych, worker 3 ma 0), operacja acquire rywalizuje o wątki, które akurat są zajęte gdzie indziej. Powiązanie z wątkiem (affinity) w v1 nie równoważy obciążenia. Albo zwiększ maxSize, albo zredukuj punkt zapalny operacji acquire przypadający na dany wątek.
Jeśli idle_by_thread jest zrównoważone, ale wszystko jest w in_use, zwiększ maxSize.
Nasycenie pamięci
Sprawdź oxphp_shared_total_bytes oraz oxphp_shared_capacity_saturation{kind="bytes"}. Serwer niczego nie odrzuca ani nie degraduje w miarę wzrostu zużycia. Jego pierwszą reakcją jest odmowa utworzenia nowego obiektu Shared po osiągnięciu SHARED_MAX_BYTES lub SHARED_MAX_ENTRIES; obiekty, które już istnieją, rosną dalej bez ponownego sprawdzania tego limitu (Map bez ograniczenia, chyba że została utworzona z maxEntries), więc wskaźnik bytes może pokazywać wartość powyżej 1.0. Aby dostać wczesne ostrzeżenie, dodaj regułę alertu, na przykład oxphp_shared_capacity_saturation > 0.7. Jeśli któreś z nich jest wysokie:
curl /__ox_shared/entries?limit=500i posortuj wedługmem_bytes, aby znaleźć największych sprawców.curl /__ox_shared/entry?id=<N>dla każdego z nich, aby sprawdzić kształt. Dla Map przyjrzyj siękey_countwzględemmax_entries.- Najczęstsza przyczyna: nieograniczona
Shared\Mapz kluczami pochodzącymi z danych wejściowych użytkownika. Rozwiązaniem jest limitmaxEntriesoraz polityka retencji.
Wrapper nie chce zostać zebrany przez GC
refcount w /__ox_shared/entries mówi ci, ile jest zaległych przetrzymań (retain). Jeśli pozostaje powyżej 1 po tym, jak wrapper PHP wychodzi z zasięgu, przy życiu trzyma go inny wpis Shared.
curl -s http://localhost:9090/__ox_shared/graph?id=<N> | jq .nodesPrzejdź graf wstecz. Każdy węzeł prowadzący do zablokowanego wpisu przetrzymuje retain. Usuń referencję ($map->remove($key), zamknij kanał, porzuć wpis Mutex), a refcount spadnie.
CycleException wystąpił na produkcji
Komunikat wyjątku zawiera osiągalną ścieżkę, którą zbadał detektor cykli. Zmapuj te identyfikatory z powrotem na typy przez /__ox_shared/entries i poproś /__ox_shared/graph?id=<root> o pełny kształt:
# Exception message: "cycle would form: #42 → #51 → #42"
curl -s http://localhost:9090/__ox_shared/graph?id=42 | jqWynik wizualizuje łańcuch, dzięki czemu widać, gdzie wprowadzono niezamierzoną referencję zwrotną.
Zadziałał detektor zakleszczeń
oxphp_shared_deadlock_detected_total tyka: detektor zakleszczeń zobaczył dwa lub więcej wątków, z których każdy czeka na Shared\Mutex trzymany przez inny z nich. Poszukaj w logach serwera event=shared.deadlock_detected; rekordy wskazują wątki, nie muteksy. Odzyskiwanie:
- Znajdź ścieżki kodu, które biorą dwa muteksy jeden wewnątrz drugiego, i spraw, by wszędzie brały je w tej samej kolejności, albo połącz tę parę w jeden muteks.
- Przy
SHARED_LOCK_DIAGNOSTICS=warn, domyślnym w buildach release, cykl jest tylko logowany i zliczany. UstawSHARED_LOCK_DIAGNOSTICS=strictw środowisku staging, aby detektor go przerywał; co wtedy robi każde oczekujące wywołanie, opisuje Detektor zakleszczeń.
Długodziałający szkielet testów soak
tests/soak/pool_soak.sh to ręczny (poza CI) szkielet do weryfikacji stabilności Shared\Pool przez godziny lub dni ciągłego obciążenia. Działa następująco:
- Uruchamia obraz deweloperski z dynamicznym skalowaniem workerów (domyślnie
PHP_WORKERS=4:40) oraz krótkimidleTimeoutpuli, tak aby harmonogram eksmisji odpalał się bez przerwy. - Ładuje
tests/soak/workload.phpjako bootstrap workera, który tworzy 10 pul ×maxSize=8i obsługuje acquire/release przy każdym żądaniu. - Generuje ruch za pomocą
wrkprzezSOAK_DURATION_MINminut (domyślnie 1440 = 24h). - Co 60 s pobiera (scrape)
/metricsoraz RSS kontenera dotests/soak/out/<timestamp>/metrics.csv. - Na końcu zapisuje
verify.txtz wynikiem pass/fail dla pięciu kryteriów wyjścia do wydania (dryf RSS w granicach ±5%, zero panik na nieaktualnych uchwytach, zero wyciekniętych wpisów przy zamknięciu, płynnie rosnące eksmisje wskutek idle-timeout, zero zadziałań detektora zakleszczeń).
Wymagania na hoście: docker, wrk, curl, awk.
Typowe wywołania:
# 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.shArtefakty trafiają do tests/soak/out/<timestamp>/:
metrics.csv— jeden wiersz na minutę (unixowy ts, RSS, liczby wpisów per typ, liczniki eksmisji per pula, liczba zakleszczeń, operacje).server.log— stdout/stderr kontenera, w tym wszelkie ślady nieaktualnych uchwytów lub panik.wrk.out/wrk.err— surowe wyjście generatora obciążenia.metrics.final— ostatni scrape/metricspobrany tuż przed rozbiórką kontenera. Służy do potwierdzenia, że po ustaniu obciążenia liczby wpisów i bajtów wróciły do poziomu bazowego (brak wyciekniętych wpisów Shared).verify.txt— raport pass/fail dla pięciu kryteriów wyjścia.
Nie podłączaj tego do CI. Uruchomienie na 24h nie jest tanie, a jego celem jest pewność przed wydaniem, nie ciągła walidacja.
Częstotliwość scrape'owania
Endpointy rejestru chodzą po stanie na żywo pod blokadami odczytu, więc scrape'owanie jest tanie, ale nie darmowe. Zalecane częstotliwości:
/metrics— co 15 s (typowa domyślna wartość Prometheus). Wyłącznie zagregowane; narzut jest pomijalny./__ox_shared/summary— co 60 s dla dashboardów. Nieco cięższe niż/metrics./__ox_shared/entries— tylko na żądanie. Iteruje po wszystkich shardach; nie scrape'uj przy każdym takcie./__ox_shared/entry//preview//graph— na żądanie podczas analiz.
Prompty dla Twojego agenta
Te prompty zakładają agenta z podłączonym serwerem MCP dokumentacji OxPHP — sam przeczyta strony, których potrzebuje.
Zbuduj dashboard Prometheusa i reguły alertów dla prymitywów Shared\*, których faktycznie używa mój kod. Przeczytaj stronę `shared-state/shared-observability` przez serwer MCP dokumentacji OxPHP.
Znajdź każdy `Shared\Map`, `Shared\Pool`, `Shared\Channel` i inny prymityw, który konstruuje to repozytorium, i zbuduj panele oraz progi alertów z ich udokumentowanych metryk — nasycenia, oczekiwania i odrzuceń.Powiązane
- Stan współdzielony — model myślowy i przegląd prymitywów.
- Metryki Prometheus — podstawowe metryki serwera pod tym samym endpointem
/metrics. - Serwer wewnętrzny — jak endpointy
/__ox_shared/*podłączają się doINTERNAL_ADDR. - Migracja do zewnętrznego magazynu — gdy saturacja jest strukturalna, a nie do wyregulowania.