Спостережуваність 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 на дашбордах і в сповіщеннях за розкладом. Один запит дає стан кожного типу та запас місткості.

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; це зручно для згенерованих інструментів, яким потрібна відповідність тег → клас:

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]

Обхід у ширину вихідних посилань 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

Для окремих лічильників, прапорців, once-об'єктів і м'ютексів окремі ряди метрик не публікуються: вони роздули б кардинальність міток. Для інспекції окремих екземплярів натомість користуйтеся загальним лічильником oxphp_shared_operations_total{type=...} і JSON із /__ox_shared/entry?id=….

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

Це заплановано як подальша робота; сьогодні стан видно через /__ox_shared/entry.

Детектор взаємоблокувань

Фонове завдання шукає міжпотокові цикли очікування на Shared\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), але не називає м'ютекси. oxphp_shared_deadlock_detected_total зростає з кожним записом.

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

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

Пул насичено (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 вісім простійних, у воркера 3 — жодного), захоплення конкурує за потоки, які саме зайняті деінде. Прив'язка до потоків у 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), тож показник 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 показує, скільки утримань лишається. Якщо після виходу PHP-обгортки з області видимості він лишається більшим за 1, запис живим тримає інший запис Shared.

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

Пройдіть граф у зворотному напрямку. Кожен вузол, з якого досяжний завислий запис, утримує посилання на нього. Приберіть посилання ($map->remove($key), закрийте канал, видаліть запис Mutex) — і лічильник посилань зменшиться.

У продакшені спрацював 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; записи називають потоки, а не м'ютекси. Як виправити:

  1. Знайдіть ділянки коду, що захоплюють два м'ютекси один усередині іншого, і зробіть так, щоб усюди вони захоплювалися в однаковому порядку, або об'єднайте пару в один м'ютекс.
  2. За SHARED_LOCK_DIAGNOSTICS=warn, типового значення в релізних збірках, цикл лише записується в журнал і рахується. Задайте SHARED_LOCK_DIAGNOSTICS=strict на стейджингу, щоб детектор натомість розривав цикл; що тоді робить кожен виклик в очікуванні, див. у розділі Детектор взаємоблокувань.

Тривалий стрес-тест на витривалість

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

  1. Запускає dev-образ із динамічним масштабуванням воркерів (типово PHP_WORKERS=4:40) і коротким idleTimeout пулу, щоб планувальник витіснення спрацьовував безперервно.
  2. Завантажує tests/soak/workload.php як bootstrap воркера; той створює 10 пулів × maxSize=8 і на кожному запиті виконує захоплення й повернення.
  3. Подає трафік через wrk протягом SOAK_DURATION_MIN хвилин (типово 1440 = 24 год).
  4. Кожні 60 с збирає /metrics і RSS контейнера у tests/soak/out/<timestamp>/metrics.csv.
  5. Наприкінці записує verify.txt з результатом «пройдено/не пройдено» для п'яти критеріїв готовності до випуску (дрейф RSS у межах ±5%, жодної паніки через застарілі дескриптори, жодного запису, що витік, на момент завершення, рівномірне зростання витіснень за idleTimeout, жодного спрацювання детектора взаємоблокувань).

Що потрібно на хості: 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 — звіт «пройдено/не пройдено» за п'ятьма критеріями готовності.
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` та інші примітиви, які створює цей репозиторій, і підготуй панелі та пороги сповіщень на основі їхніх задокументованих метрик — насичення, очікування та втрат.

Пов'язані сторінки