Shared* オブザーバビリティ
すべての OxPHP\Shared\* インスタンスは、ランタイムが refcount と容量のためにすでに追跡しているレジストリエントリです。その追跡内容は、/__ox_shared/* 配下の JSON イントロスペクションと、oxphp_shared_* 配下の Prometheus メトリクスとして運用者に公開されます。このページはそのリファレンスであり、実践ガイドでもあります。
有効化
オブザーバビリティは内部サーバーに相乗りしています。起動するには INTERNAL_ADDR を設定します。
INTERNAL_ADDR=127.0.0.1:9090これで JSON エンドポイントと /metrics の両方が、そのアドレスで到達可能になります。SHARED_ENABLED を有効のままにしておく以外に、追加の設定は不要です。
どちらも個別に無効化できます。
| 環境変数 | デフォルト | 効果 |
|---|---|---|
SHARED_INTROSPECTION_ENABLED |
true |
/__ox_shared/* の JSON API を切り替えます。 |
SHARED_INTROSPECTION_PREVIEW_ENABLED |
true |
/preview を切り替えます(値の形状プレビューはデータを漏らす可能性があります)。 |
SHARED_METRICS_ENABLED |
true |
oxphp_shared_* の Prometheus メトリクスを切り替えます。 |
敵対的なテナントが同居するデプロイではイントロスペクションを無効にしてください。メトリクスは集計値のみなので、有効なままにしておいても安全です。
SHARED_ENABLED=false にすると、サブシステムの他の部分とともに、この両方の公開面もなくなります。プリミティブは使えるようにしたまま、その中身だけを隠したい場合は、上のスイッチを使ってください。
イントロスペクションエンドポイント
すべてのレスポンスは Content-Type: application/json; charset=utf-8 です。クエリパラメータは標準の URL エンコードです。
GET /__ox_shared/summary
トップレベルのスナップショット。型ごとの集計数、メモリ、オペレーションレート、そして設定された上限に対する飽和度が得られます。
{
"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
}
}ダッシュボードや cron アラートでは summary を使ってください。1 回のスクレイプで、型ごとの健全性と容量の余裕が得られます。
GET /__ox_shared/entries?limit=N
ライブのエントリを一覧します(limit で上限を指定、デフォルト 100、最大 500)。1 エントリにつき 1 行です。
{
"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 エントリがこのエントリを保持しているかを表します。あるエントリが GC 可能になるはずなのにならない場合は、このフィールドを確認します。
GET /__ox_shared/entry?id=N
1 つのエントリに対する型ごとの詳細です。
{
"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 によって制御されます。
{ "id": 42, "type": "Counter", "preview": "1420" }preview は開発中に使ってください。値にユーザーデータが含まれる可能性がある本番環境では無効にします。
GET /__ox_shared/types
v1 の型カタログを列挙します。tag → class のマッピングを必要とする自動生成ツールに便利です。
{
"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]
id=N を起点として、外向きの Shareable 参照を BFS で辿ります。到達可能な部分グラフのノードとエッジを返します。デフォルトは depth=16、edges=500 です。ウォーカーの予算に達すると、レスポンスで 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
}CycleException の後にウォーカーが辿った到達可能なパスを確認したいときや、「なぜこの Counter は GC されないのか」を診断するときには graph を使ってください。グラフは、そのエントリに対して保持を持つすべての親を示します。
Prometheus メトリクス
すべてのメトリクスは、コアサーバーのメトリクスと並んで GET /metrics で公開されます。
レジストリ全体
| メトリクス | 型 | ラベル | 説明 |
|---|---|---|---|
oxphp_shared_objects_total |
gauge | type |
型ごとのライブエントリ数。 |
oxphp_shared_operations_total |
counter | type |
各型にディスパッチされた累積オペレーション数。 |
oxphp_shared_bytes |
gauge | type |
型ごとの概算バイト数(mallinfo に対して ±30%)。 |
oxphp_shared_total_bytes |
gauge | — | 全型の合計。 |
oxphp_shared_capacity_saturation |
gauge | kind |
上限に対する割合としての entries と bytes。 |
oxphp_shared_deadlock_detected_total |
counter | — | 検出されたスレッド間の wait-for サイクル数。 |
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=...} カウンターと /__ox_shared/entry?id=… の JSON を使ってください。
フォローアップ作業として追跡しています。現時点での可視性は /__ox_shared/entry 経由です。
デッドロック検出器
バックグラウンドタスクが、Shared\Mutex 上のスレッド間の wait-for サイクル、つまり 2 つ以上のスレッドがそれぞれ、互いのうち別のスレッドが保持している mutex を待っている状態を探します。SHARED_LOCK_POLL_INTERVAL_MS(デフォルト 100)ごとに wait-for グラフをスキャンします。スキャンでサイクルが見つかったときの動作は 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() はスキャンから 100 ms 以内に DeadlockException を投げ、withLockTimeout() はタイムアウトまで待ってから、OperationTimeoutException の代わりに DeadlockException を投げます。先にロックが解放された待機者(ロックを保持していたクロージャが、別の待機者の例外による巻き戻しで抜けた場合)は、そのロックを取得し、例外を投げません。 |
値は小文字でのみ照合されます。Strict や trace を含め、それ以外の値はすべて黙ってデフォルトになります。
スキャンで見つかったサイクルごとに、event=shared.deadlock_detected を持つ WARN レコードが少なくとも 1 件出力され、それぞれがサイクル上のスレッドを 1 つ示します(start_thread、holder_thread として繰り返し)。レコードは mutex を示しません。oxphp_shared_deadlock_detected_total はレコードごとに増加します。
同じスレッドでの再入は別のチェックであり、この設定には左右されません。mutex をすでに保持しているスレッド上でその mutex に再入すると、off を含むすべての値で、その呼び出し時点で DeadlockException が投げられ、サイクルになることはありません。
診断プレイブック
Pool が飽和している(リトライが失敗する 429)
症状: HTTP の呼び出し元がタイムアウトを受け取り、oxphp_shared_pool_waiting が上昇し、oxphp_shared_pool_count が maxSize に張り付きます。
確認:
curl -s http://localhost:9090/__ox_shared/entry?id=<pool_id> | jq .type_specificidle_by_thread を確認します。これが {} であるか、大きく偏っている場合(ワーカー 0 に 8 個のアイドル、ワーカー 3 に 0 個)、acquire は、たまたま別の場所でビジーになっているスレッドを奪い合っています。v1 のスレッドごとのアフィニティはリバランスしません。maxSize を引き上げるか、スレッドごとの acquire のホットスポットを減らしてください。
idle_by_thread が均衡しているのにすべてが in_use になっている場合は、maxSize を引き上げてください。
メモリの飽和
oxphp_shared_total_bytes と oxphp_shared_capacity_saturation{kind="bytes"} を確認します。使用量が増えても、サーバーは何かを削ったり縮退させたりはしません。最初の反応は、SHARED_MAX_BYTES または SHARED_MAX_ENTRIES に達した時点で新しい Shared オブジェクトの作成を拒否することです。すでに存在するオブジェクトは、その上限が再度チェックされることなく成長を続けます(maxEntries を指定して構築されていない Map には上限がありません)。そのため、bytes ゲージは 1.0 を超えることがあります。早期警告には、たとえば oxphp_shared_capacity_saturation > 0.7 のようなアラートルールを追加してください。どちらかが高い場合:
curl /__ox_shared/entries?limit=500を実行し、mem_bytesでソートして、上位の要因を見つけます。- それぞれに対して
curl /__ox_shared/entry?id=<N>を実行して形状を確認します。Map の場合はkey_countとmax_entriesを見ます。 - 最も一般的な原因: ユーザー入力をキーとする、上限のない
Shared\Map。対処法はmaxEntriesの上限と保持ポリシーです。
ラッパーがガベージコレクトされない
/__ox_shared/entries の refcount は、未解放の保持がいくつあるかを教えてくれます。PHP ラッパーがスコープを抜けた後も 1 を超えたままの場合、別の Shared エントリがそれを生かし続けています。
curl -s http://localhost:9090/__ox_shared/graph?id=<N> | jq .nodesグラフを逆向きに辿ります。行き詰まったエントリに到達するノードはいずれも保持を持っています。参照を取り除けば($map->remove($key)、チャネルのクローズ、Mutex エントリの破棄)、refcount は下がります。
本番環境で CycleException が発生した
例外のメッセージには、サイクル検出器が探索した到達可能なパスが含まれています。それらの ID を /__ox_shared/entries で型に対応付け、全体の形状は /__ox_shared/graph?id=<root> に問い合わせます。
# Exception message: "cycle would form: #42 → #51 → #42"
curl -s http://localhost:9090/__ox_shared/graph?id=42 | jq結果はチェーンを可視化するので、意図しない逆参照がどこで導入されたかを確認できます。
デッドロック検出器が発火した
oxphp_shared_deadlock_detected_total が増加しています。デッドロック検出器が、2 つ以上のスレッドがそれぞれ、互いのうち別のスレッドが保持している Shared\Mutex を待っている状態を検出しました。サーバーログで event=shared.deadlock_detected を確認してください。レコードが示すのはスレッドであり、mutex ではありません。復旧するには:
- 2 つの mutex を入れ子で取得しているコードパスを見つけ、どこでも同じ順序で取得するようにするか、その 2 つを 1 つの mutex にまとめます。
SHARED_LOCK_DIAGNOSTICS=warn(リリースビルドでのデフォルト)では、サイクルはログに記録されカウントされるだけです。検出器にサイクルを断ち切らせるには、ステージングでSHARED_LOCK_DIAGNOSTICS=strictを設定してください。その場合に待機中の各呼び出しがどうなるかは、デッドロック検出器を参照してください。
長時間ソークハーネス
tests/soak/pool_soak.sh は、数時間から数日にわたる継続的な負荷をかけて Shared\Pool の安定性を検証するための、手動(非 CI)ハーネスです。このハーネスは次のことを行います。
- 動的なワーカースケーリング(デフォルトで
PHP_WORKERS=4:40)と短いプールidleTimeoutを指定して dev イメージを起動し、エビクションスケジューラーが継続的に発火するようにします。 tests/soak/workload.phpをワーカーのブートストラップとして読み込みます。これは 10 個のプール ×maxSize=8を構築し、リクエストごとに acquire/release を処理します。wrkでSOAK_DURATION_MIN分間(デフォルト 1440 = 24 時間)トラフィックを流します。/metricsとコンテナの RSS を 60 秒ごとにスクレイプしてtests/soak/out/<timestamp>/metrics.csvに記録します。- 最後に、5 つのリリース終了基準(RSS のドリフトが ±5% 以内、stale-handle パニックがゼロ、シャットダウン時のリークエントリがゼロ、idle-timeout エビクションが滑らかに増加、デッドロック検出器の発火がゼロ)に対する合否を
verify.txtに書き出します。
ホスト側の前提条件: docker、wrk、curl、awk。
典型的な実行例:
# 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— 1 分ごとに 1 行(unix タイムスタンプ、RSS、型ごとのエントリ数、プールごとのエビクションカウンター、デッドロック数、オペレーション数)。server.log— stale-handle やパニックの痕跡を含む、コンテナの stdout/stderr。wrk.out/wrk.err— 負荷生成ツールの生の出力。metrics.final— コンテナのティアダウン直前に取得した最後の/metricsスクレイプ。負荷が止まった後にエントリ数とバイト数がベースラインに戻ったこと(Shared エントリのリークがないこと)を確認するために使います。verify.txt— 5 つの終了基準に対する合否レポート。
これを CI に組み込まないでください。24 時間の実行は安くはなく、その目的はリリース前の信頼確認であって、継続的な検証ではありません。
スクレイプ間隔
レジストリエンドポイントは読み取りロックの下でライブ状態を走査するため、スクレイプは安価ですが無料ではありません。推奨する間隔は次のとおりです。
/metrics— 15 秒ごと(一般的な Prometheus のデフォルト)。集計値のみで、オーバーヘッドは無視できます。/__ox_shared/summary— ダッシュボード向けに 60 秒ごと。/metricsよりやや重いです。/__ox_shared/entries— オンデマンドでのみ。すべてのシャードを反復するため、毎ティックでスクレイプしないでください。/__ox_shared/entry//preview//graph— 調査中に オンデマンドで。
エージェント向けプロンプト
これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。
私のコードが実際に使っている Shared\* プリミティブ向けに、Prometheus のダッシュボードとアラートルールを作ってください。OxPHP ドキュメント MCP サーバー経由で `shared-state/shared-observability` ページを読んでください。
このリポジトリが構築している `Shared\Map`、`Shared\Pool`、`Shared\Channel`、およびその他のプリミティブをすべて洗い出し、ドキュメントに載っているメトリクス — 飽和、待機、ドロップ — からパネルとアラートのしきい値を作ってください。関連
- 共有状態 — メンタルモデルとプリミティブの概要。
- Prometheus メトリクス — 同じ
/metricsエンドポイント配下のコアサーバーメトリクス。 - 内部サーバー —
/__ox_shared/*エンドポイントがINTERNAL_ADDRにどう接続されるか。 - 外部ストアへの移行 — 飽和がチューニング可能ではなく構造的である場合。