イベントリプレイを安全に行うには?過去データの再処理で副作用を繰り返さない
不具合を直したあと、保存していたイベントをもう一度流せば集計や検索索引を作り直せる。しかし、同じ経路にメール送信、チケット更新、決済、公開処理が含まれていると、過去の外部操作まで再実行する危険がある。イベントリプレイは「失敗した処理を再試行する」だけではなく、過去の入力を現在のコードへ改めて通す運用である。
安全に再処理するには、何を再構築したいのか、どの期間と対象を流すのか、どの副作用を禁止するのかを先に決める。リプレイを本番イベントと同じ入口へ無条件に戻すと、重複、順序逆転、schema差、現在状態との衝突が同時に起こる。
再試行、再配信、リプレイを分ける
似た操作でも目的が違う。
- 再試行は、直前に失敗した一つの操作をもう一度実行する
- 再配信は、acknowledgeできなかったメッセージを配送し直す
- リプレイは、保存した過去イベントの範囲を選び、再び処理経路へ流す
リプレイは件数と時間範囲が大きくなりやすく、当時は成功したイベントも含む。処理済みIDの記録が残っていればすべてskipされ、再構築できない場合もある。逆に重複排除を無効にすれば、外部作用まで繰り返す可能性がある。通常配送の設定をそのまま使えるとは限らない。
目的を一つに絞る
「過去データを直す」では範囲が広すぎる。例えば次の目的は別の計画にする。
- 修正した分類ロジックで分析テーブルを再計算する
- 欠落した検索索引だけを作り直す
- 新しい監査項目を過去イベントから補う
- 障害中に配送できなかった通知を回復する
- 新しい受信側を過去データで検証する
集計の再計算では外部通知を止められるが、障害中の通知回復では対象の通知だけを許可する必要がある。目的が違う処理を一つのリプレイへまとめると、「全部止める」と「全部動かす」の二択になる。
本番経路ではなく隔離した出力先で始める
最初から本番のevent bus全体へ戻さず、専用のsubscription、queue、consumer group、またはshadow用の保存先へ流す。新しい受信側が過去イベントを読めるか、件数、エラー、出力差分、推定費用を確認する。
Amazon EventBridgeのarchive replayは、元のevent busへイベントを戻し、対象ruleを指定できる。リプレイ時には replay-name が追加されるため、受信側は通常配送と区別できる。ただし製品が印を付けるだけで、副作用を自動的に止めてくれるわけではない。どのruleを対象にするか、各targetがリプレイをどう扱うかを設計する必要がある。
リプレイ専用の実行IDを発行し、すべてのログ、出力、費用へ付ける。停止判断に使うエラー率、処理速度、残件数、最古イベント時刻も通常運用と分けて見る。
副作用を種類ごとに許可する
受信側を三つに分けると判断しやすい。
- 派生データを再構築する処理
- 現在状態を更新する処理
- 外部へ不可逆な作用を起こす処理
検索索引や集計表のような派生データは、別テーブルへ再構築して比較後に切り替えられる。現在状態の更新は、古いイベントで新しい変更を上書きしないよう、対象ごとの版や発生順を確認する。メール、送金、公開、削除のような外部作用は既定で無効にし、回復対象として明示したものだけを許可する。
「dry run」という一つのbooleanだけでは足りない。データベース書き込み、通知、外部API、モデル呼び出しを個別に制御する。モデルを再実行すると、当時と同じ結果になる保証がなく、費用も発生する。過去のモデル出力を使うのか、新しいモデルで再計算するのかを目的に合わせて決める。
冪等性だけで安全だと思わない
同じevent IDを処理済みとして記録していれば、通常の重複配信は抑えられる。しかし、リプレイで派生データを作り直すには、あえてもう一度処理する必要がある。このとき通常の処理済み表を消すと、外部作用も再び可能になる。
通常配送の重複排除と、リプレイ実行内の重複排除を分ける。例えば (replay_id, event_id, consumer) を処理単位として記録し、同じリプレイ中の重複は防ぎつつ、過去イベントを新しい派生先へ一度だけ適用する。外部作用は元のoperation IDを維持し、送信済みなら再送しない。
Google Cloud Pub/Subのseekは、以前acknowledgeしたメッセージを再配送できる。公式資料はsnapshot内のメッセージがat-least-onceで配送されることを保証しており、リプレイが重複なしを意味しない。受信側の冪等性は通常時だけでなくリプレイ時にも必要である。
順序が同じとは限らない
アーカイブに入った順で戻ると仮定しない。EventBridgeは、イベントが必ずしもarchiveへ追加された順でリプレイされないと明記している。対象ごとの順序が重要なら、イベントに対象IDとsequenceを持たせ、受信側で古い版を拒否するか、対象ごとに並べ直す。
全体の時刻順を厳密に再現しようとすると、無関係な対象まで待たせる。注文、会話、チケットなど、整合性を守る単位ごとに順序を考える。同じ秒に発生したイベントを時刻だけで確定順にしない。
現在も通常イベントが流れている場合、リプレイした古い更新と新しい更新が交差する。別の派生先へ構築してから切り替えるか、現在流を一時的に別queueへ退避し、境界となるsequenceを決める。古いイベントを現在の本番テーブルへ直接書き戻す方法は避ける。
当時のschemaと現在のコードを橋渡しする
保存イベントの形式が現在の受信側と一致するとは限らない。古いイベントを読めるupcasterを用意し、現在の内部形式へ変換してから処理する。履歴そのものを書き換えず、変換版、元event ID、元schema版を記録する。
変換で欠けた情報を推測しない。現在のデータベースから補う場合、それは当時の値ではなく現在値である。再計算結果へ使用した入力時点を残し、再現と最新化を混同しない。
Microsoft Azure Architecture CenterのEvent Sourcing資料も、コード修正だけでは過去イベントが直らず、リプレイ時にcompensating eventやupcasterが必要になる場合を挙げている。履歴へ黙って修正を上書きするのではなく、訂正の根拠を追える形にする。
小さな窓から段階的に広げる
まず数件の既知イベント、次に短い時間帯、一つの対象群、全期間の順に広げる。各段階で次を比較する。
- 選択件数と実際に読んだ件数
- 成功、skip、隔離、失敗の件数
- 対象ごとの最終版
- 再構築前後の件数や合計
- 外部API呼び出し件数
- モデル呼び出しと費用
- 通常処理の遅延とエラー
リプレイは通常処理と容量を奪い合う。並列数、秒あたり件数、費用、終了時刻に上限を設け、通常処理のSLOが悪化したら止める。停止後に最初からやり直さず済むよう、最後に完了した対象やsequenceをcheckpointとして残す。
完了は「投入した」ではなく結果で確認する
アーカイブから全件を送信しただけでは完了ではない。受信側の隔離queue、処理中、書き込み失敗、順序待ちが残っている場合がある。開始前に期待件数を記録し、処理結果の合計と照合する。
再構築したデータは、旧版と件数、代表例、業務上の不変条件を比較する。例えば注文金額の合計、完了タスク数、対象ごとの最新版が意図せず減っていないかを見る。差分がある場合、コード修正による期待差か、欠落や重複かを説明できる状態で切り替える。
リプレイ実行の期間、選択条件、コード版、schema変換版、許可した副作用、停止条件、実行者、結果を監査記録へ残す。再び同じ範囲を流すとき、前回何が起きたかを判断できる。
イベントリプレイは障害回復と再構築に有効だが、過去の副作用まで再現する操作ではない。隔離した出力先から始め、目的に必要なconsumerだけを動かし、重複と順序を前提にする。小さな範囲で差分を確認してから広げることが、過去データを現在の事故へ変えないための基本になる。