「リアルタイムな更新」を誰もが望むが、「速さ」と「正確さ」が同じではないことに気づくまでは。分散システムでは、イベントは光速で移動しても、順序が入れ替わったまま届くことがある。WebSocketは切断と再接続を繰り返し、メッセージブローカーはパケットを再送し、バックグラウンドワーカーはタイムアウトによるキャンセルとの競争状態に陥る。その結果、クライアントはイベント42を見た後にイベント40を見、さらにシステムがすでにイベント45に到達していると主張するスナップショットを目にすることになる。長時間実行されるエージェントのワークフローを構築しているなら、その混乱はエッジケース(例外的な事象)ではなく、ベースライン(前提条件)である。配信時間を数ミリ秒削ることを心配する前に、イベントの順序を修正せよ。
「リアルタイム」の混沌とした現実
リアルタイムとは転送特性である。それはパケットがどれだけ速く回線を移動するかを表すものであり、そのパケットが伝えるストーリーが理にかなっているかどうかを表すものではない。長時間実行されるタスクは、時間の経過とともにあらゆる不整合を増幅させる。モデルのトレーニングジョブ、多段階の承認フロー、あるいはビデオレンダリングパイプラインなどは、数分から数時間にわたって数十のイベントを放出することがある。その期間中、何事でも起こり得る。
ブローカーは、確認応答(acknowledgement)が失われたためにメッセージをリトライするかもしれない。ロードバランサーは2つのイベントを異なるネットワークパスにルーティングし、新しい方のイベントを先に到着させてしまうかもしれない。ワーカープロセスは、データベースへの書き込みには成功したが、成功イベントをパブリッシュする前に死亡し、その後に別のワーカーがタスクを引き継いで独自の進捗を放出することもある。もしフロントエンドが「最新のメッセージこそが真実のメッセージである」と仮定しているなら、存在もしない状態を描画してしまうことになる。ユーザーは「完了」バッジが「処理中」にちらついたり、さらに悪いことに、キャンセルされたタスクが突然復活したりするのを目にすることになる。順序付けのないスピードは、単にフレームレートの高い混乱に過ぎない。
シーケンス番号こそが真の時計である
解決策は、プロデューサーによって生成される厳格な単調増加(monotonic)シーケンス番号である。状態を変更するすべての操作には、欠落やロールバックがなく、正確に1ずつ増加する番号を割り当てる。その番号は、イベント自体と同じトランザクション内で永続化されなければならない。データベースの行が更新されたがシーケンスのコミットに失敗した場合は、両方をロールバックする。これにより、論理的なタイムラインを状態の変化とアトミック(不可分)に保つことができる。
イベントIDも依然として有用だが、それは別の問題を解決するためのものだ。イベントIDは特定のペイロードを識別し、ブローカーが同じメッセージを2回配信したときに重複を排除(deduplicate)できるようにする。一方で、シーケンス番号は、そのペイロードが因果関係の連鎖(causal chain)のどこに位置するかを教えてくれる。それは欠落を明らかにし、順序を明らかにする。タイムスタンプはそのどちらも行わない。時計はドリフトし、NTPは逆戻りし、仮想マシンは一時停止する。タイムスタンプは「3分前に開始」といった表示目的のみに使用し、ビジネスロジックのソートキーとして決して使用してはならない。
クライアントはストリームをどのように扱うべきか
プロデューサーが単調増加するシーケンスを保証すれば、コンシューマー(消費者)には単純で厳格なルールが適用される。受信したシーケンス番号が最後に適用された番号以下であれば、それを破棄する。それは重複しているか、あるいは遅れて届いた古いものである。シーケンスが最後に適用された番号より正確に1大きい場合は、すぐに適用する。これが正常系(happy path)である。もしシーケンスが飛び越えてしまった場合、例えば12を期待していたのに15を受け取った場合は、何かが欠落している。新しいイベントをバッファリングし、次に期待されるシーケンスから始まるリプレイをサーバーに要求する。推測してはならない。欠落が問題にならないことを期待して、先読みしてはいけない。
終端状態(Terminal states)は、取り消し不能なものとして扱わなければならない。タスクが「完了」、「失敗」、または「キャンセル」とマークされたら、クライアントはその操作に対するそれ以降の状態変更を拒否すべきである。これは、実際に直面するまでは当たり前のことのように聞こえるが、
