Наблюдаемость Shared*

Каждый экземпляр OxPHP\Shared\* — это запись в реестре, которую среда выполнения и так отслеживает для подсчёта ссылок и ёмкости. Эти данные доступны операторам в виде JSON-интроспекции по адресу /__ox_shared/* и в виде метрик Prometheus с префиксом oxphp_shared_*. Эта страница — справочник и практическое руководство.

Включение

Наблюдаемость работает поверх внутреннего сервера. Чтобы запустить его, задайте INTERNAL_ADDR:

bash
INTERNAL_ADDR=127.0.0.1:9090

После этого и JSON-эндпоинты, и /metrics становятся доступны по этому адресу. Дополнительная настройка не требуется — достаточно оставить SHARED_ENABLED включённым.

Каждую из частей можно отключить независимо:

Переменная окружения По умолчанию Действие
SHARED_INTROSPECTION_ENABLED true Включает/отключает JSON API /__ox_shared/*.
SHARED_INTROSPECTION_PREVIEW_ENABLED true Включает/отключает /preview (превью значений могут раскрывать данные).
SHARED_METRICS_ENABLED true Включает/отключает метрики Prometheus oxphp_shared_*.

Отключайте интроспекцию в развёртываниях с недоверенными арендаторами; метрики агрегированные и их можно безопасно оставлять включёнными.

SHARED_ENABLED=false тоже убирает обе поверхности — вместе со всей остальной подсистемой. Переключатели выше нужны, чтобы примитивы оставались доступны, а их содержимое было скрыто.

Эндпоинты интроспекции

Все ответы имеют Content-Type: application/json; charset=utf-8. Параметры запроса кодируются стандартным URL-кодированием.

GET /__ox_shared/summary

Снимок верхнего уровня: агрегированные счётчики по типам, память, частота операций и насыщение относительно настроенных лимитов.

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 } }

Используйте summary в дашбордах и cron-оповещениях. Один скрейп даёт вам состояние по каждому типу и запас по ёмкости.

GET /__ox_shared/entries?limit=N

Перечисляет актуальные записи (ограничено значением limit, по умолчанию 100, максимум 500). По одной строке на запись:

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 — это внешний счётчик удержаний: сколько PHP-обёрток и вложенных записей Shared удерживают данную запись. Если вы ожидаете, что запись можно собрать сборщиком мусора, но этого не происходит, проверять нужно именно это поле.

GET /__ox_shared/entry?id=N

Детали одной записи с учётом её типа:

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 зависит от типа: Pool предоставляет { max_size, count, in_use, idle, waiting, idle_by_thread, rebalance_strategy }, Channel предоставляет { capacity, count, closed, senders_blocked, receivers_blocked }, Counter предоставляет { value } и так далее.

GET /__ox_shared/preview?id=N

Превью формы значений для скаляров и небольших массивов. Строковые значения усекаются до SHARED_PREVIEW_STRING_LIMIT (по умолчанию 256 байт) с округлением вниз до ближайшей границы символа, поэтому многобайтовый текст никогда не разрезается посреди символа; массивы показывают первые SHARED_PREVIEW_ARRAY_LIMIT элементов (по умолчанию 20). Управляется флагом SHARED_INTROSPECTION_PREVIEW_ENABLED.

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

Используйте preview во время разработки; отключайте его в продакшене, когда значения могут содержать пользовательские данные.

GET /__ox_shared/types

Перечисляет каталог типов v1; полезно для генерируемых инструментов, которым нужно соответствие tag → класс:

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" } ] }

Ответ не содержит Shared\Mutex.

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

Обход в ширину (BFS) исходящих ссылок Shareable, начиная с id=N. Возвращает узлы и рёбра достижимого подграфа. Значения по умолчанию: depth=16, edges=500. При исчерпании бюджета обходчика в ответе выставляется 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 }

Обращайтесь к graph после CycleException, чтобы увидеть достижимый путь, по которому прошёл обходчик, или при диагностике «почему этот Counter не собирается сборщиком мусора»: граф показывает всех родителей, удерживающих на нём ссылку.

Метрики Prometheus

Все метрики доступны по GET /metrics вместе с основными метриками сервера.

По всему реестру

Метрика Тип Метки Описание
oxphp_shared_objects_total gauge type Количество актуальных записей по каждому типу.
oxphp_shared_operations_total counter type Совокупное число операций, направленных каждому типу.
oxphp_shared_bytes gauge type Приблизительное число байт по каждому типу (±30% относительно mallinfo).
oxphp_shared_total_bytes gauge — Сумма по всем типам.
oxphp_shared_capacity_saturation gauge kind entries и bytes в виде долей от их лимитов.
oxphp_shared_deadlock_detected_total counter — Обнаруженные межпоточные циклы ожидания.

Channel

Метрика Тип Метки
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

Метрика Тип Метки
oxphp_shared_map_entries gauge map_id
oxphp_shared_map_max_entries gauge map_id
oxphp_shared_map_saturation gauge map_id

Pool

Метрика Тип Метки
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

Метки oxphp_shared_pool_evicted_total: reason=idle_timeout | evict | shutdown. idle_timeout — это автоматическое вытеснение простаивающего слота, evict — явный вызов Pool::evict(), а shutdown — освобождение при завершении процесса.

Counter / Flag / Once / Mutex

Для отдельных экземпляров counter, flag, once и mutex не предусмотрены собственные серии метрик: они раздули бы кардинальность меток. Для инспекции отдельных экземпляров используйте общерегистровый счётчик oxphp_shared_operations_total{type=...} и JSON /__ox_shared/entry?id=….

Метрики Mutex — кандидат на v1.x

Отслеживается как последующая работа; сегодня наблюдаемость обеспечивается через /__ox_shared/entry.

Детектор взаимоблокировок

Фоновая задача ищет межпоточные циклы ожидания на Shared\Mutex: два или более потока, каждый из которых ждёт mutex, удерживаемый другим из них. Каждые SHARED_LOCK_POLL_INTERVAL_MS (по умолчанию 100) она сканирует граф ожиданий. SHARED_LOCK_DIAGNOSTICS определяет, что происходит, когда сканирование находит цикл:

Значение Детектор При цикле
off Не запускается. SHARED_LOCK_POLL_INTERVAL_MS не используется, а oxphp_shared_deadlock_detected_total остаётся равным 0. Ничего. withLockTimeout() в цикле сдаётся с OperationTimeoutException, когда истекает его таймаут. У цикла, состоящего только из вызовов withLock(), нет собственного таймаута, который бы его прервал: каждый из его запросов ждёт, пока его не завершат — по max_execution_time, если он задан, — а блокировки, которые держал завершённый запрос, освобождаются при его завершении (см. Shared\Mutex).
warn (по умолчанию в релизных сборках) Работает. Пишет в лог и считает (см. ниже); ожидания остаются на месте, и исход тот же, что и при off.
strict (по умолчанию в отладочных сборках бинарника) Работает. Пишет в лог и считает, а также сигнализирует потокам цикла: withLock() в нём выбрасывает DeadlockException в течение 100 мс после сканирования, а withLockTimeout() дожидается своего таймаута и затем выбрасывает DeadlockException вместо OperationTimeoutException. Ожидающий, чью блокировку освободили раньше (исключение другого ожидающего раскрутило замыкание, которое её держало), захватывает её и не выбрасывает исключение.
Только в нижнем регистре

Значение распознаётся только в нижнем регистре. Любое другое значение, включая Strict или trace, молча даёт значение по умолчанию.

Каждый цикл, найденный сканированием, порождает как минимум одну запись WARN с event=shared.deadlock_detected; каждая называет поток из цикла (start_thread, повторённый как holder_thread), но не называет mutex. oxphp_shared_deadlock_detected_total растёт с каждой такой записью.

Повторный вход в том же потоке — отдельная проверка, и от этой настройки она не зависит: повторный вход в mutex из потока, который уже его удерживает, выбрасывает DeadlockException прямо в месте вызова при любом значении, включая off, и никогда не превращается в цикл.

Диагностические сценарии

Pool насыщен (429, повторные попытки не помогают)

Симптомы: HTTP-клиенты видят таймауты, oxphp_shared_pool_waiting растёт, oxphp_shared_pool_count упирается в maxSize.

Проверьте:

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

Посмотрите на idle_by_thread. Если оно равно {} или сильно несбалансировано (у воркера 0 — 8 простаивающих, у воркера 3 — 0), захват конкурирует за потоки, которые оказались заняты чем-то другим. Привязка к потокам в v1 не выполняет ребалансировку. Либо увеличьте maxSize, либо уменьшите горячую точку захвата в рамках одного потока.

Если idle_by_thread сбалансировано, но всё находится в состоянии in_use, увеличьте maxSize.

Насыщение по памяти

Проверьте oxphp_shared_total_bytes и oxphp_shared_capacity_saturation{kind="bytes"}. По мере роста потребления сервер ничего не сбрасывает и не деградирует. Первая его реакция — отказ создавать новый объект Shared, как только достигнут SHARED_MAX_BYTES или SHARED_MAX_ENTRIES; уже существующие объекты продолжают расти без повторной проверки этого лимита (Map — без ограничений, если он не был создан с maxEntries), поэтому gauge bytes может показывать больше 1.0. Для раннего предупреждения добавьте правило оповещения, например oxphp_shared_capacity_saturation > 0.7. Если хотя бы одно из них велико:

  1. curl /__ox_shared/entries?limit=500 и отсортируйте по mem_bytes, чтобы найти основных потребителей.
  2. curl /__ox_shared/entry?id=<N> для каждого, чтобы проверить форму. Для Map смотрите key_count против max_entries.
  3. Самая частая причина: неограниченный Shared\Map с ключами из пользовательского ввода. Решение — ограничение maxEntries и политика хранения.

Обёртка не собирается сборщиком мусора

refcount в /__ox_shared/entries показывает, сколько активных удержаний существует. Если оно остаётся выше 1 после выхода PHP-обёртки из области видимости, значит, её удерживает живой другая запись Shared.

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

Пройдите по графу в обратном направлении. Любой узел, достигающий застрявшей записи, удерживает ссылку. Уберите ссылку ($map->remove($key), закройте канал, удалите запись Mutex) — и refcount уменьшится.

CycleException сработало в продакшене

Сообщение исключения содержит достижимый путь, который исследовал детектор циклов. Сопоставьте эти идентификаторы с типами через /__ox_shared/entries и запросите /__ox_shared/graph?id=<root>, чтобы получить полную форму:

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

Результат визуализирует цепочку, чтобы вы могли увидеть, где была случайно введена обратная ссылка.

Сработал детектор взаимоблокировок

oxphp_shared_deadlock_detected_total растёт: детектор взаимоблокировок увидел два или более потока, каждый из которых ждёт Shared\Mutex, удерживаемый другим из них. Ищите в логах сервера event=shared.deadlock_detected; записи называют потоки, а не mutex. Восстановление:

  1. Найдите пути кода, которые захватывают два mutex один внутри другого, и сделайте так, чтобы везде они захватывались в одном порядке, либо объедините пару в один mutex.
  2. При SHARED_LOCK_DIAGNOSTICS=warn, значении по умолчанию в релизных сборках, цикл только пишется в лог и считается. Задайте SHARED_LOCK_DIAGNOSTICS=strict в staging, чтобы детектор вместо этого разрывал его; что при этом делает каждый ожидающий вызов, см. в разделе Детектор взаимоблокировок.

Стенд длительного soak-тестирования

tests/soak/pool_soak.sh — это ручной (не для CI) стенд для проверки стабильности Shared\Pool при непрерывной нагрузке в течение часов или дней. Он:

  1. Запускает dev-образ с динамическим масштабированием воркеров (PHP_WORKERS=4:40 по умолчанию) и коротким idleTimeout пула, чтобы планировщик вытеснения срабатывал непрерывно.
  2. Загружает tests/soak/workload.php в качестве бутстрапа воркера, который создаёт 10 пулов × maxSize=8 и обслуживает захват/освобождение на каждый запрос.
  3. Генерирует трафик с помощью wrk в течение SOAK_DURATION_MIN минут (по умолчанию 1440 = 24 ч).
  4. Скрейпит /metrics и RSS контейнера каждые 60 с в tests/soak/out/<timestamp>/metrics.csv.
  5. В конце записывает verify.txt с результатом pass/fail по пяти критериям выхода для релиза (дрейф RSS в пределах ±5%, ноль паник из-за устаревших дескрипторов, ноль утёкших записей при завершении, плавный рост вытеснений по idle-timeout, ноль срабатываний детектора взаимоблокировок).

Требования на хосте: docker, wrk, curl, awk.

Типичные способы запуска:

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

Артефакты сохраняются в tests/soak/out/<timestamp>/:

  • metrics.csv — одна строка в минуту (unix ts, RSS, число записей по типам, счётчики вытеснений по пулам, число взаимоблокировок, операции).
  • server.log — stdout/stderr контейнера, включая любые следы устаревших дескрипторов или паник.
  • wrk.out / wrk.err — необработанный вывод генератора нагрузки.
  • metrics.final — последний скрейп /metrics, снятый непосредственно перед остановкой контейнера. Используется, чтобы подтвердить, что число записей и байт вернулось к базовому уровню (нет утёкших записей Shared) после прекращения нагрузки.
  • verify.txt — отчёт pass/fail по пяти критериям выхода.
Warning

Не подключайте это к CI. Запуск на 24 часа обходится недёшево, и его цель — уверенность перед релизом, а не непрерывная проверка.

Периодичность скрейпинга

Эндпоинты реестра обходят актуальное состояние под блокировками на чтение, поэтому скрейпинг дёшев, но не бесплатен. Рекомендуемая периодичность:

  • /metrics — каждые 15 с (типичное значение по умолчанию для Prometheus). Только агрегаты; накладные расходы пренебрежимо малы.
  • /__ox_shared/summary — каждые 60 с для дашбордов. Немного тяжелее, чем /metrics.
  • /__ox_shared/entries — только по требованию. Проходит по всем шардам; не скрейпите на каждом тике.
  • /__ox_shared/entry / /preview / /graph — по требованию во время расследований.

Промпты для вашего агента

Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.

prompt
Собери дашборд Prometheus и правила оповещений для тех примитивов Shared\*, которые мой код действительно использует. Прочитай страницу `shared-state/shared-observability` через MCP-сервер документации OxPHP. Найди каждый `Shared\Map`, `Shared\Pool`, `Shared\Channel` и любой другой примитив, который создаёт этот репозиторий, и сделай панели и пороги оповещений из описанных для них метрик — насыщения, ожидания и отбрасываний.

Связанные материалы

Нашли ошибку? Сообщите →