サーバー発イベントの配送とUI反映の設計(SSE・read-and-deliver・状態同期の割り切り)
SSEのハートビートをJS側で検知したいなら、ネイティブEventSourceでは不可能—fetch+ReadableStreamの手動パースが必要
10日前
Server-Sent Eventsで「接続が生きていること」をクライアントJS側のタイマーで判定したい場合(例: 一定時間メッセージが無ければポーリングに降格するといったフォールバック制御)、サーバーが送るSSEコメント行(: で始まる行、例えば: ping)をハートビートに使う設計は成立しない。
原因: ブラウザのネイティブEventSourceはSSE仕様上のコメント行をパース段階で完全に破棄し、onmessageもaddEventListenerも一切発火しない。コメント行は仕様上「TCP/HTTP接続をインフラ層(リバースプロキ・ロードバランサのアイドルタイムアウト)から守る」目的でしか使えず、アプリケーション層の「最後に何か受信したか」タイマーをリセットする用途には使えない。
適用条件: 「ハートビート受信を契機にクライアント側の状態(例: 無音判定タイマー)を変えたい」ケースに限って問題になる。単に「中間プロキシーのアイドル切断を防ぐ」だけならコメント行で十分(ブラウザJSは関与しなくてよい)。
対処方法(クライアント側でハートビートを検知したい場合の選択肢): (1) サーバー側でハートビートをコメント行ではなく名前付きイベント(event: heartbeat\ndata: {}\n\n)や通常のdata:フレームとして送り、クライアントはaddEventListener/onmessageで受け取ってタイマーをリセットする。(2) EventSourceを使わずfetch+ReadableStreamでSSEを自前パースし、受信バイト列(コメント行も含む)を検出した時点でタイマーをリセットする。後者はSSE仕様を完全に守りつつ、コメント行も含めて「何か受信した事実」を検知できるが、自前パースの実装コストと引き換えになる。
確認方法: ブラウザの開発者ツールではなく、curl等の低レイヤツールでSSE応答を直接観察してコメント行が本当に届いているかを確認した上で、ブラウザ側の実装をEventSourceの挙動で検証する(コメント行受信時にJS側のタイマーがリセットされないことを実際に確認すれば、この制約に気付ける)。
教訓: 「SSEにハートビートを追加すればクライアントの無音判定が改善するはず」という直感は、ハートビートの送信方式(コメント行かイベントか)と受信側のパーサ(完全なEventSourceか手動パースか)の組み合わせ次第で成り立たない。仕様通りのコメント行を選ぶなら受信側も手動パースにする必要があり、実装コストを受け入れてもJS側の可視性が欲しければ名前付きイベントの方が安価。
読むと配送済みになる read-and-deliver 型APIは消費者を1経路に限定し、UIの生存更新には非消費読み取りを分ける
10日前
イベント通知を「次の読み取りに未配送分を混ぜて返し、返した時点で配送済みにする」read-and-deliver 型のエンドポイント(GET がキューの ack を兼ねる設計)は、想定消費者以外が同じエンドポイントを叩くとイベントを食い潰す。
問題の型
同じ画面を人間とエージェント(または複数のクライアント種別)が共有し、片方(エージェント)だけがイベントの宛先である構成で、UI 側の生存更新(定期ポーリング・操作後のリフレッシュ・再接続時の取得)が同じ消費型読み取りを使うと、宛先に届く前に UI が配送済み化してしまう。症状は「イベントが時々しか届かない」「連続2操作の1件目だけ消える」など確率的に見えるが、実際はポーリング周期と操作タイミングの競合で決定論的に起きる。
判断基準
- 消費(配送済み化)を伴う読み取りは、イベントの宛先である消費者1経路だけに割り当てる。設計時に「このエンドポイントを叩く呼び出し元」を全列挙し、宛先以外が混ざるなら分離する
- UI の生存更新・リフレッシュ用に、同じデータを返すが配送状態を進めない非消費読み取り(クエリパラメータや別パス)を用意する。非消費側はイベント自体を返さない形にすると誤用がさらに減る
- 「UI は push(SSE/WebSocket)だけ購読させて読み取りAPIを叩かせない」案もあるが、切断時フォールバックのポーリングが結局読み取りAPIへ戻ってくるため、非消費読み取りの分離の方が頑健
検証方法
識別テストとして「非消費読み取りを2回連続で呼んでも配送状態が進まず、その後の消費読み取りで全件届き、さらにその後は0件になる」ことを常設テストにする。消費側単体の二重配送テスト(同じ消費読み取りを2回呼ぶ)だけでは、異なる呼び出し元が消費エンドポイントを共有する衝突を識別できない。
教訓
読み取りに副作用(配送済み化・既読化・カーソル前進)を持たせた時点で、そのエンドポイントはキューの dequeue と同じ扱いになる。呼び出し元が増えるたびに競合面が増えるため、副作用付き読み取りの追加時は消費者の全列挙を契約に含める。
配送完了をクライアントから確認しづらい非同期イベントの情報をUIに見せるなら、双方向同期でなくクライアント側タイムアウトを先に検討する
10日前
サーバー側に記録したイベント(例: 別のアクター(AIエージェント等)が次の呼び出しで消費するキュー)の「消費済みかどうか」を人間向けUIの一時的な覚え書き(バッジ・インジケータ等)で表現したい場合、「サーバーが実際に消費した瞬間を検知して消灯させる」正確な実装は、サーバー側のレスポンス(例: リアルタイムスナップショット)に「どのイベントが消費されたか」の情報を新たに付与するか、クライアントが消費前後の一覧を突き合わせる仕組みが必要になり、独立していたはずの小さな見た目のために状態同期の複雑さを持ち込むことになる。
適用条件: 対象のUI要素が「完全に正確でなくても実害がない一時的な覚え書き(nudge)」であり、正確な状態(例: 実際にエージェントが読んだかどうか)がサーバーの正本のデータとして必須ではない場合に限る。鍵の開閉状態や決済完了のように正確性自体が要件なら不適切。
対処方法: 人間の典型的な次の行動を予測した適切な長さのクライアント側タイマー(setTimeout等)で自動消灯させる。同じ対象への連打は既存タイマーをリセットするだけでよい。このタイマーはサーバーの正本データを一切変えず、見た目だけの一時状態としてクライアント側のコンポーネントローカル状態(useState等)だけで完結させる。
確認方法: 実装前に「このUI要素が間違っていても(本当は消費済みなのにまだ表示されている等)実害があるか?」を自問する。実害がない(単なるヒント)ならタイムアウトで十分,実害がある(二重処理・データ不整合等)なら本格的な状態同期を実装する。
教訓: 「本当の状態を反映すべき」という正確性へのコダワりは、その情報が実際に使われる場面(ヒントか、真の状態か)を見ずに適用すると、不必要な同期コストを支払うことになる。