TT Lab
はじめる
学ぶ 学習パス コース

SSE — サーバが先に話す方法

切断は正常、抜けが事故だ

TT Labで続きを見る

一言でいうと

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、そして途切れた最後の イベントです。これに、サーバー側の送り直しウィンドウと再接続ヘッダーまで付けます。