ブラウザとサーバーの間は Socket.IO でつないでいる。サーバーと STT プロバイダ(Gladia)の間は別の WebSocket だ。
Socket.IO はネットワークが少し揺れただけで切れる。そのたびに上流の STT ジョブまで落とすと、文字起こしもそのたびに途切れてしまう。そこで「Socket.IO の瞬断では上流の STT ジョブを殺さない」という方針にした。
この方針で問題になるのが Gladia の課金単位だ。Gladia は上流 WebSocket が開いている実時間(wall-clock)で課金する。クライアントがつながっているかどうかは見ていない。クライアント側が切れていても、上流を生かしている間はこちらにコストがかかり続ける。
カウンターが1本だと話が合わない
1本のカウンターで両方を表そうとすると、どちらかの数字がずれる。
- 実時間で数え続けると、ユーザーがいない時間までユーザーに請求してしまう
- 切断した時点で止めると、プロバイダに払う額を少なく見積もってしまう
そこでカウンターを2本に分けた。
| カウンター | 切断後の扱い | 用途 |
|---|---|---|
| プロバイダコスト | 動き続ける | 日次の支出キルスイッチの入力 |
| ユーザー課金 | 切断時点で凍結 | ユーザーへの請求 |
プロバイダコストは日次の支出キルスイッチに入る。切断中の分を数えないと、実際に使った額より低い数字でキルスイッチが判断することになる。だからこちらは止めない。
切断時の処理と定期 flush
切断を検知したら、次の順番で処理する。
- プロバイダコストとユーザー課金を、その時点までの分だけ flush する
-
socketDetached = trueにする
この順番は崩せない。flushWallClockBilling は切断済みのセッションを飛ばすので、先にフラグを立てると、ユーザー課金の最後の区間が確定されないまま捨てられる。
定期 flush では、2本のカウンターが違う動きをする。
// 切断を検知したとき
socket.on("disconnect", () => {
flushPeriodicCost(session); // プロバイダコストをここまで確定
flushWallClockBilling(session); // ユーザー課金をここまで確定
session.socketDetached = true; // ここから先、ユーザー課金は凍結
});
// 定期的に呼ばれる
function flushWallClockBilling(session: Session) {
if (session.socketDetached) return; // 切断済みは飛ばす
// ...ユーザー課金の区間を加算
}
function flushPeriodicCost(session: Session) {
// socketDetached は見ない(意図的)
// ...プロバイダコストを加算し、日次キルスイッチへ
}
flushPeriodicCost が切断後も動いているのは仕様だ。コメントに書いておかないと、「切断済みなのにまだ処理している」と思った誰かが、よかれと思ってガードを足してしまう。
猶予は8秒
切断を検知しても、上流はすぐには閉じない。8秒の猶予を置いていて、この値は環境変数の設定から読んでいる。
猶予中もプロバイダコストは増えていくが、ユーザー課金は凍結されたままだ。ユーザーに請求されるのはつながっていた時間だけで、瞬断を吸収するためのコストはこちらが持つ。
リロードは再接続とは別物
瞬断と混同しやすいのがページのリロードだ。リロードすると Gladia 側では新しいセッション ID になる。これは resume ではない。しかも同時に使える枠は1つしかない。
猶予は、同じ上流を生かしておくための仕組みにすぎない。リロードしたあとに前のセッションの続きを拾えるわけではない。「切断して戻る」経路と「リロードして新しいセッションを始める」経路は、同時枠が1つという制約も含めて、別々に設計しておく必要がある。
この構成は LiveSuggest の中で動いている。