🚀 開発環境が更新されました。以下のPRがマージされました。
#オプチャグラフ
#496: [STG] perf: ルーム統計グラフの初回表示を軽量化し、アクセス集中時のDB競合を低減(タブ判定の事前計算+表示期間だけ取得)
---
## 何が変わるか
ルーム個別ページ(/oc/{id})のグラフ初回表示で、サーバが叩くDBを大きく減らす。特に Googlebot 等のクロール集中時に出ていた `database is locked` 系のDB競合を緩和する。
## これまでの問題
グラフ初回表示で2つの重い読み取りが毎回走っていた。
1. タブ/ボタン出し分け判定(可用性メタ)のために、表示のたびに4つのDB(統計・ローソク足・順位の各SQLiteと毎時データのMariaDB)へCOUNT。
2. 系列データを、表示は週/月/全をクライアントで切り出すだけなのに、初回に全期間ぶん取得。
アクセス集中時、statistics 等のSQLite読みが競合し `database is locked`(本番エラー: [TH] Googlebot, span=hour・meta=1 の初回ロードで `getDailyMemberStatsDateAsc` が locked)の一因になっていた。
## 対処
1. 可用性メタを cron で事前計算し MySQL の `oc_page_cache.chart_meta` に持たせ、/oc 表示の既存1クエリで読みHTMLに埋め込む。初回の meta=1 リクエストを撃たない。
2. 系列は表示中の期間タブの窓だけ取得し、拡大時は足りない古い側の差分だけ取得(窓は最新日終端固定なので連続1範囲)。一度取得した範囲は再取得しない。
3. 系列を層(member / position / memberOhlc / positionOhlc)に分け、`?series=` で必要な層×不足範囲だけ取得(全期間で人数表示中に順位ONしても人数は再取得しない)。
4. 事前計算の毎時順位は実行ごとに1回の一括GROUP BY(バックフィルでも MySQL gone away を起こさない)。
5. 取得が一過性の5xx(リトライ後も不可)・403等で最終失敗したら、壊れた表示でなく再読み込みを促すエラー表示。5xx は fetcher が1回だけ自動リトライ。
## アーキテクチャ(データの流れ)
フロント → API:
- 初回: /oc ページHTMLに埋め込んだ可用性メタ `
#chart-meta` を `fetchRenderer.ts`(readEmbeddedChartMeta) が読む。埋め込みがあれば初回 meta=1 を撃たない(`App.tsx` init → `fetchChartData`)。
- 系列取得: `fetchRenderer.ts` が層別に `GET /oc/{id}/chart?series=<member,position,…>&sort&scope&category&mode&from&to` を叩く。各層を date→値でキャッシュし、必要層の足りない古い範囲だけ・同範囲の層はまとめて1リクエスト。`fetcher.ts` が URLキャッシュ+5xx 1回リトライ。操作時は `chartState.ts`(handleChangeLimit / handleChangeRankingRising / handleChangeChartMode / handleChangeCategory) → fetchChart。
API 処理:
- ルート `oc/{open_chat_id}/chart` → `OpenChatChartApiController::chart` → `OpenChatChartApiService::buildChartResponse`。
- `series` 指定で層だけ返す/`from`・`to` で範囲化/`meta=1` でメタ同梱(埋め込み無し室のフォールバック)/`span=hour` は最新24時間。
- 系列の組み立て: 人数=`StatisticsChartArrayService::buildStatisticsChartArray`、順位=`RankingPositionChartArrayService::getRankingPositionChartArray`、ローソク足=`buildCandlestickSeries`、最新24時間=`buildHourSeries`。
リポジトリ:
- 人数: `SqliteStatisticsPageRepository::getDailyMemberStatsDateAsc`(statistics SQLite・`date BETWEEN`)/日付範囲 `getMemberDateRange`。
- 順位: `SqliteRankingPositionPageRepository::getDailyPosition`(ranking_position SQLite)。
- ローソク足: `SqliteStatisticsOhlcRepository` / `SqliteRankingPositionOhlcRepository`(`getOhlcDateAsc`)。
- 最新24時間・メタのhour集計: `RankingPositionHourRepository`(MariaDB。1室 `getHourPositionCounts` / 一括 `getHourPositionCountsAll`)。
- メタの読み(primary): `OpenChatPageRepository::getOpenChatByIdWithTag` が `oc_page_cache` を LEFT JOIN(narrative と chart_meta を1クエリ)。`OpenChatPageController` が `
#chart-meta` へ埋め込む。
cron でのキャッシュ更新(条件は下表):
- `oc_page_cache.chart_meta` は `OcPageCacheGenerator::generateForIds`(per-room) が `ChartMeta\ChartMetaBuilder::build` で生成し `OcPageCacheRepository::upsertMany` で保存。毎時順位は `UpdateOcPageCacheService::handle` がチャンクループの外で一括 `getHourPositionCountsAll` を1回だけ取得して渡す(per-room MariaDB を撃たず gone away 回避)。
- 起動は `SyncOpenChat::hourlyTask`(毎時) / `dailyTask`(日次) / genetop(全件)。
フォールバック(埋め込みが無い室):
- `
#chart-meta` が null → フロントは meta=1 を撃つ → `OpenChatChartApiService` が `ChartMetaBuilder::build`(hourEntry 無し=ライブ・per-room で hour 取得)でメタを作る。primary(cron) と同一コードなので結果は一致する。
- メタ計算は `app/Services/Statistics/ChartMeta/ChartMetaBuilder.php` の1箇所のみ。`StatisticsChartArrayService` は「メンバー系列生成専用」。
## oc_page_cache の生成タイミング(マッピング)
chart_meta は分析文(narrative)と同じ `oc_page_cache` 行に同梱され、同じ再生成経路で作られる。件数は ja(本番同等ローカル)実測。
| タイミング | 起動 | 対象の室(条件) | 件数(ja実測) |
|---|---|---|---|
| 毎時 | `SyncOpenChat::hourlyTask`(毎時30分) | 直近1時間で人数が変わった室 + 新規ランク入り | 約 4,320 |
| 日次 | `dailyTask`(23:30) | `getForDaily` = 変動(過去8日) + 新規(レコード8件未満) + 週次更新(最終記録が1週間以上前)。ランキング外の室も週次更新で拾い、最長でも約1週間で全室が一巡 | 約 108,274 |
| genetop(全件) | 管理画面 | `getOpenChatIdAll` = 全室。毎時順位は一括化済みで全件でも gone away しない(ただし時間はかかる) | 約 242,386 |
| バックフィル / write-through | 管理画面 / idCsv 指定 | 指定した室のみ | 指定数 |
## 未生成室(chart_meta が無い室)の挙動
- 新規室、または初回バックフィル前の室は chart_meta が無い → ページ埋め込みが null → フロントは meta=1 でライブ計算(ChartMetaBuilder)にフォールバックする。
- このときの系列は全期間取得になり、窓・差分の最適化は効かない。ただし表示は壊れない(従来どおり=無劣化)。
## フォールバックを外せない理由
- 新規室が常時発生し、次の再生成まで chart_meta を持たないため、ライブ計算経路は恒久的に必要。
- ただしリファクタで「二重実装」は解消済み。フォールバックは同じ `ChartMetaBuilder` のライブ実行であって、余計なコードではない。
## 主な変更箇所
- 事前計算: `Statistics/ChartMeta/ChartMetaBuilder`・`Statistics/ChartMeta/ChartAvailabilityCalculator`(しきい値の単一定義)・`OcPageCacheGenerator`(毎時順位は一括 `getHourPositionCountsAll`)・`UpdateOcPageCacheService`。
- 同梱: `OpenChatPageController` / `Views/oc_content.php` / `OpenChatPageRepository`(chart_meta をJOINで読む)。
- API: `OpenChatChartApiController` / `OpenChatChartApiService`(from/to 範囲+`series` 層)と各系列リポジトリ(`date BETWEEN`)。
- フロント: `frontend/oc-app`(層別の最小差分取得、取得失敗時のエラー表示、5xx 1回リトライ)。
- genetop: 全室の `oc_page_cache` 再生成を追加。
- CLAUDE.md: 毎時/日次にキャッシュ生成を足したら genetop で全件再生成できるようにするルールを明記。
## デプロイ・運用
- `oc_page_cache` に `chart_meta` 列を追加(デプロイ時 `sync_mysql_schema` が自動反映、手動DDL不要)。CI/mock も `setup/init-database.sh` が schema から構築するので列は在る(NULL時はフォールバックで /oc は200)。`deploy.yml` 変更不要。
- 反映後に genetop を1回実行すると全室の chart_meta が揃い、全室が窓・差分の最適化経路に乗る。実行しなくても毎時/日次で順次埋まり(最長約1週間)、未生成室はそれまでフォールバックで無劣化。
## 確認
- 埋め込みメタ == meta=1 ライブ計算 が実データ複数室で一致(同一 ChartMetaBuilder)。
- `series` 未指定の全レスポンス行列が改修前とバイト一致(後方互換)。`series` 各層は該当層のみ返し、範囲も有効。順位ON時は `series=position` 1本のみで人数を再取得しないことをNetworkで実証。
- 窓→拡大が差分のみ・再取得なし・繋ぎ目で連続描画、ローソク足のタブ維持、新規室のフォールバック、最新24時間をヘッドレスで確認。
- PHPStan / PHPUnit(新規テスト含む)green。
---
🤖 Generated with Claude Code (claude-opus-4-8[1m])
Posted from: `user-B550M-Pro4:~/repos/Open-Chat-Graph`
## 追記: レスポンスのさらなる軽量化(2コミット)
層別取得のデータ形をさらに削った。
- ローソク足の日付を1本に集約: OHLCの各要素が持っていた `date` をやめ、OHLC専用の日付軸 `ohlcDate` を1本だけ返す。`memberOhlc`/`positionOhlc` はこの軸とindex整合の「値だけ」の配列にした(人数と順位の両方を表示すると日付配列が3本になっていたのを1本に。`positionOhlc` の `null` はその日が圏外)。
- 順位の時刻配列を急上昇のみに: 時刻ラベル `time` は終日時刻を持つ急上昇でだけ返し、ランキング・人数のみ・最新24時間・ローソク足では配列ごと返さない(フロントは「無ければ時刻表示なし」として扱う)。
- `frontend/oc-app/README.md` を追加し、表示状態ごとに送るAPIと返るデータ型を明記。
検証: PHPStan / 該当 PHPUnit(OpenChatChartApiServiceTest 10件)/ tsc 通過。折れ線・ローソク足 × ランキング・急上昇・なし × 期間タブの全ビューをヘッドレスで描画確認(JSエラーなし・順位オーバーレイ整合・tooltipが人数OHLCと順位OHLCの両方を表示)。
---
🤖 Generated with Claude Code (claude-opus-4-8[1m])
Posted from: `user-B550M-Pro4:~/repos/Open-Chat-Graph`