切断は正常、抜けが事故だ
一言でいうと
SSEの再接続はブラウザがただで行ってくれますが、空白を埋めるのはサーバーの仕事です。
その連結点は、idフィールドとLast-Event-IDヘッダーの2つだけです。
なぜ必要なのか
地下鉄で通知画面を開いておくと、接続は数分に1回切れます。ブラウザは
自動で再接続します。ここまではEventSourceがやってくれます。問題は、切れていた
間にサーバーが送ったものです。
ただ再接続するだけでは、その区間は永遠に失われます。通知3件がなかったことになり、 注文状態は「決済完了」から突然「配送中」に飛びます。逆に、サーバーが毎回 最初から送り直すと、同じ通知が2回表示されます。どちらもユーザーがすぐに気づく 種類の故障です。
仕様は、この問題をごく小さな仕組み1つで解きます。サーバーがイベントごとにidを付けると、
ブラウザが最後に見たidを覚えておき、再接続するときにLast-Event-ID
リクエストヘッダーとして返します。サーバーは、そのあとから送れば済みます。
どう動くのか
仕様が定めていることは、思ったより細かい
HTML標準9.2
のパースルールは、1行ずつ読みながら次のように動きます。覚えておくべきものは3つです。
dataバッファ、event typeバッファ、そしてlast event IDバッファです。
빈 줄 → 이벤트를 내보낸다
콜론으로 시작 → 그 줄은 무시 (주석 · 하트비트)
콜론이 있다 → 앞이 필드 이름, 뒤가 값. 값이 공백으로 시작하면 하나만 뗀다
콜론이 없다 → 줄 전체가 필드 이름, 값은 빈 문자열
このコードブロックの韓国語の各行は、順に、空行ならイベントを出力する、コロンで始まる行は無視する(コメント・ハートビート)、コロンがあれば前がフィールド名で後ろが値(値が空白で始まるなら1つだけ取り除く)、コロンがなければ行全体がフィールド名で値は空文字列、という意味です。
フィールドごとの処理も、仕様にそのまま書かれています。
| フィールド | ルール |
|---|---|
data |
値をバッファに追加し、さらに改行を1つ追加します |
event |
イベントタイプのバッファをその値に置き換えます |
id |
値にU+0000 NULLが含まれていないときだけ、最後のidバッファを置き換えます |
retry |
値がASCII数字だけで構成されているときだけ、再接続時間を置き換えます |
| それ以外 | 無視します |
ここで、人が最もよく間違えることが2つあります。1つ目は、最後のidバッファは、
イベントを出力したあとも初期化されないことです。dataバッファとevent typeバッファだけが
空になります。そのため、idのないイベントが続いても、再接続に使う番号はそのまま残ります。
2つ目は、dataバッファが空文字列なら、イベントを出力しないことです。retry:だけが書かれた
ブロックや、コメントだけのブロックは、設定であってイベントではありません。
途切れたイベントは捨てる
仕様ははっきり定めています。ファイルが最後の空行の前で終わった場合、その不完全なイベントは
出力しません。接続がイベントの途中で切れたときに、半分だけのJSONを画面に
出さないためのルールであり、同時に、Last-Event-IDが実際に全部受け取ったところまで
しか指さないようにしてくれます。続きの受け取りが正確になる理由が、ここにあります。
再接続はブラウザが、空白はサーバーが
再接続のタイミングも、仕様に書かれています。ブラウザはエラーを投げ、readyStateを
CONNECTINGに変えたあと、再接続時間だけ待ってから再び接続します。その
時間の初期値は実装に任されていて(仕様は「数秒程度」とだけ書いています)、
サーバーがretry:で変更できます。前回の試行が失敗していた場合、ブラウザが指数バックオフを
さらに加えてもよいと、仕様は認めています。
そして再接続するとき、最後のid文字列が空文字列でないときだけ
Last-Event-IDヘッダーを載せます。サーバーは、ヘッダーがなければ「最初から」、あれば「その
後から」と分けて処理すれば足ります。
サーバー側の対になるのは、送り直せるウィンドウです。直近のN個をリングバッファに保持しておき、 要求されたidのあとを返します。ウィンドウから外れたidなら、残っているものを送る代わりに「続きを受け取れない」と知らせて、 全体を取り直させる必要があります。知らないidに対して、手元にあるものだけを送ると、 途中が抜けても、そのことを誰も知りません。
続きの受け取りは、サーバーが覚えている分しかできない
クライアントがヘッダーをきちんと載せて送っても、サーバーが何も覚えていなければ、続きの受け取りは 成立しません。送り直しウィンドウの大きさは、結局、どれくらいの時間切れていても許されるかを 決める値です。ウィンドウが100個なのに毎秒50個を送るストリームなら、2秒分の保険にしか なりません。ウィンドウを時間で決めるか個数で決めるか、そしてウィンドウから外れたときに全体の再送が 耐えられる大きさかを先に計算してから、数字を決める必要があります。
現場での姿
LLMのトークンストリーミングでよくある事故があります。トークンごとにidを付けていないため、再接続が いつも最初からになり、ユーザーは同じ文が2回表示されるのを目にします。逆に、idは 付けたのにサーバーが何も覚えておらず、続きの受け取りのリクエストに対して毎回空のストリームを 返してしまうケースもあります。こちらは「たまに回答が途中で止まる」として報告されます。
idを整数としてパースしたチームもありました。仕様ではidはNULL・LF・CRが含まれていなければよい
文字列なので、シャーディングのためにb7-1042のようなidを使い始めた日に、続きの受け取りが丸ごと
壊れました。例外はサーバーのログにだけ残り、画面は黙って最初から取得し直しました。
次のラボですること
仕様のパースルールをそのまま実装します。断片に分割された入力、3種類の行末、
コメント、コロンのない行、NULLを含むid、数字でないretry、そして途切れた最後の
イベントです。これに、サーバー側の送り直しウィンドウと再接続ヘッダーまで付けます。