- Node.js/TypeScriptベースのバックエンドで、大規模なリアルタイム更新を処理しなければならない状況だった
- バックエンドとしてPostgreSQLを使用し、数百個のワーカーノードが新しいジョブを継続的に確認し、エージェントが実行およびチャットの状態更新を受け取る必要があった
- WebSocketの検討から始まったが、驚くほど効果的な「昔ながらの」解決策にたどり着いた
→ "Postgresを使ったHTTP Long Polling"
問題状況: 大規模リアルタイム更新
- ワーカーノードの更新 :
- Node.js/Golang/C# SDKを実行する数百のワーカーノードがある
- 新しいジョブが提供されるとすぐにそれを把握する必要があったため、Postgresデータベースをダウンさせないクエリ戦略が必要だった
- エージェント状態の同期 :
- エージェントには実行およびチャット状態に関するリアルタイム更新が必要で、それを効率的にストリーミングしなければならなかった
ロングポーリングとWebSocketの比較
- ショートポーリングは、時刻表どおりに厳密に出発する列車のようなもので、乗客がいるかどうかに関係なく決まった間隔で出発する
- ロングポーリングは、サーバーが応答を待機し、データが発生したらすぐに返し、一定時間が経つとタイムアウトとして応答を返す
- つまり、「待っていてデータが生じたら出発する」列車のようなもの。特定の時間(TTL)内に乗客が現れないときだけ空のまま出発する
- データ(乗客)があるときは即座に出発し、ないときはリソースを効率的に使えるという2つの利点を同時に提供する
- WebSocketは、接続を常時維持して双方向にデータをやり取りする方式である
- 組織環境、インフラ、ファイアウォールの問題などから、WebSocket構成よりロングポーリングのほうがシンプルで互換性が高い
ロングポーリング実装の詳細
getJobStatusSync 関数が重要な役割を担う
jobId, owner, ttl などのパラメータを受け取り、特定のジョブ状態を一定時間のあいだ繰り返し照会する
- 次の条件のいずれかが満たされるまで繰り返し照会を行う
- ジョブ状態が
success または failure になる
ttl(タイムアウト)が経過する
- 500ms間隔でデータベースを照会し、結果が確定していなければ待って再度照会する
- タイムアウト超過時にはエラーを投げ、成功時には結果を返す
データベース最適化
- Postgresに適切なインデックスを置いて照会コストを最小化する
- 例:
CREATE INDEX idx_jobs_status ON jobs(id, cluster_id);
ロングポーリングの利点
- モニタリングの維持が容易 : 既存のHTTPベースのロギング、モニタリングスタックをそのまま活用できる
- 認証の単純さ : 新しい認証方式を実装せず、既存のHTTP認証をそのまま使用できる
- インフラ互換性 : ファイアウォールやロードバランサーに別途設定が不要で、通常のHTTPトラフィックとして扱われる
- 運用の単純性 : サーバー再起動時にも接続状態を別途処理する必要がなく、デバッグしやすい
- クライアント実装の容易さ : 標準的なHTTPリクエスト/レスポンス構造に再試行ロジックを加えるだけで動作する
ElectricSQLとの比較
- ElectricSQLは、Postgresデータをフロントエンドと同期するソリューションである
- WebSocketの代わりにHTTPを使いながらもリアルタイム性を保証する構造を備えている
- 実際にリアルタイム更新を処理するために極端な制御や低レベル構造が必要ない場合は、ElectricSQLを推奨する
私たちがRaw Long Pollingを選んだ理由
- メッセージ配信メカニズムは単なる実装の詳細ではなく、製品の中核要素である
- 中核機能をサードパーティライブラリに依存することはできない(どれほど優れたライブラリであっても)
- 要件
- 中核製品の制御 : メッセージ配信メカニズムを完全に制御する必要がある。インフラレベルではなく製品そのものだからだ
- 外部依存性の排除 : セルフホスティングを簡素化するため、外部依存性を最小化する
- 低レベル制御 : ポーリングメカニズムおよび接続管理を直接制御する
- 最大限の制御可能性 : 動的なポーリング間隔の実装など、細部をきめ細かく調整できなければならない
- コードの単純性 : ユーザーがコードベースを容易に理解・修正できるよう、シンプルに設計する
- 結論として、シンプルなHTTP Long Polling実装を選ぶことで、直接的な制御と単純性を確保した
ロングポーリング実装時の注意点
- TTL設定 : サーバー側で必ず最大TTLを強制し、クライアントが要求したTTLがそれを超えないように処理する
- インフラのタイムアウトを考慮 : ロードバランサー、エッジサーバー、プロキシなどのタイムアウト設定より十分短いTTLである必要がある
- DBポーリング間隔 : 500ms程度の遅延を設けてDB負荷を減らす
- バックオフ戦略(オプション) : 徐々にポーリング間隔を伸ばす方式により、システム資源をさらに効率的に使用できる
WebSocketを検討すべき状況
- WebSocket自体が誤りというわけではなく、別の側面では有用である
- 状態を多く持つ接続を監視し、複雑なイベントを常時やり取りしなければならない場合
- 認証、インフラ、観測性の問題を解決するためのリソースと時間が十分にある場合
- 運用やロギング、再接続処理、認証メカニズムなどを自前で構築しなければならない複雑さが存在する
WebSockets: もう一つの選択肢について
- Long Pollingは私たちの要件に適していたが、WebSocketsも十分に検討する価値がある
- WebSockets自体が悪いわけではなく、多くの注意と管理を必要とするだけだ
- WebSocketsの主な課題と解決の方向性
- 可視性 : WebSocketsは状態ベースであるため、継続的な接続に対するロギングとモニタリングの追加が必要
- 認証 : WebSocket接続のための新しい認証メカニズムの実装が必要
- インフラ : WebSocketをサポートするため、ロードバランサーやファイアウォールなどのインフラを適切に構成する必要がある
- 運用管理 : WebSocket接続および再接続の管理。接続タイムアウトとエラー処理
- クライアント実装 : クライアント側のWebSocketライブラリ実装。再接続および状態管理機能を含む
5件のコメント
MLモデルのサービングで、ここでいう「ショートポーリング」構造を使っているのですが、何が効率的なのかかなり悩んでいます。自分なりにあちこち調べた範囲では、WebSocketやSSEなどの再接続処理にかかる大きなコストのため、ショートポーリングのほうが一般的により安全だという話があって、ショートポーリングを選んではみたのですが…… 😭
Long polling は少し hacky に感じられるので、敬遠されがちな気がしますね。ブラウザではおそらくずっとリクエストが完了していないように見えるでしょうし。時々、読み込みが終わらないサイトがありますが、私は「コンテンツが全部読み込めていないのかな?」と思ってしまって、あまり好きではありません。
アプリケーションでも結局、どこかで hang させてレスポンスを待つ状態になるわけで……ちょっと不自然に見えますね。
「エージェントが実行およびチャットの状態更新を受け取る必要がある」
これを見てすぐにSSEを思い浮かべましたが、やはりHacker Newsの意見でもSSEへの言及が多いですね。
Hacker Newsのコメント
Long polling にはそれ自体の問題がある
Phoenix と LiveView を毎日使えるのがうれしい
Server-Sent Events (SSE) を使うより技術的な利点があるのか気になる
この記事は "Websocket" と "Long-polling" を独立した選択であるかのように結びつけている
Node.js で setTimeout を使うより簡単な方法
import { setTimeout } from "node:timers/promises"; await setTimeout(500);を使うlong polling は好きだ。理解しやすく、クライアントの観点では非常に遅い接続のように動作する
Server-Sent Events や WebSockets は long polling のすべてのユースケースを置き換えられるわけではない
Postgres の非同期通知機能を使うのがよい
短いタイムアウトと適切に終了されるリクエストを伴う long polling に、まだ意味があるのか分からない
WebSockets の比較的シンプルな代替手段を思い出させてくれるのが新鮮だ
Elixir、Phoenix framework、LiveViewを通じてWebSocketsを使ってみたいですね。