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

ACKの前に止まったお菓子の自販機

空のログでも最新とは限らない

TT Labで続きを見る

一言でいうと

カーソルは再び受け取る位置であり、保管境界はその位置がまだ存在するかを判断する証拠です。空の応答を受け取ったからといって、最新の状態だという意味ではありません。

なぜ必要なのか

サークルのお菓子倉庫の在庫を、表示板に出すとします。入庫は正、配布は負の変化量として記録します。倉庫のサーバーは動き続けましたが、表示板は週末のあいだ消えていました。月曜日に、最後に処理した番号を送って再接続すれば、いつものように抜けたイベントを受け取れると期待します。ところが運用者が、ディスクを節約するために週末のログの前半を消してしまいました。サーバーが残ったイベントだけを送ると、表示板はもっともらしい数字を作りますが、実際の在庫とは永遠にずれたままです。

これは接続の失敗ではありません。TCP接続が開き、HTTPの応答が成功しても、過去がないという事実は変わりません。前のモジュールのoutboxは、まだ配信していない送信予定を保存しました。今回のモジュールは、意図的に古い配信履歴を捨てたあとでも、現在の状態を復旧する問題です。ログを無条件に永久保管しても、保管コストは避けられず、どんな状態でも初期値に戻してしまえば、実際の業務を失います。そのため、高速な再生と、全体の状態の復旧を、別々の経路として設計します。

どう動くのか

ラボでは、ストリームの番号を0から増やし、まだイベントがない最後の番号を-1で表します。lastは最後に適用した番号、floorはサーバーがまだ保管している最も早い番号です。正常に残っているイベントは、floorからlastまで連続しており、すべて切り詰められた場合、floorはlast+1です。これはこのラボの整数の契約です。ほかの製品の時間ベースのIDやパーティションのオフセットに、そのまま当てはめてはいけません。

サーバーの状態 クライアントのlast 判断
floor=4, last=8 3 4から再生できる
floor=4, last=8 2 3がないのでスナップショットが必要
floor=9, last=8 8 すでに最新で、空のバッチが正常
floor=9, last=8 -1 ログ全体が切り詰められており、復旧が必要

核心となる不等式は、クライアントのlastがfloor-1より小さいかどうかです。等しければ、次の番号がfloorなので安全です。小さければ、少なくとも1つのイベントが抜けます。サーバーより先に進んだカーソルも、正常なものとしては受け入れません。誤ったストアに接続したか、ローカルの状態が別の世代かもしれないので、別のエラーとして調べる必要があります。不等号1つのせいで、データが重複したり抜けたりするので、境界値をそれぞれテストします。

元のストアは、checkpoint、stock、eventsの3つのテーブルを使います。eventsは再生する個々の変化、stockは累積在庫、checkpointは世代・最後の番号・保管境界を持ちます。trim(through)は、through以下のイベントだけを消し、floorをthrough+1に変えます。在庫とlastは変えません。再生用の履歴を減らすことと、すでに発生した業務を取り消すことは別です。イベントが0個になってもcheckpointの1行は残すので、最初から空の倉庫と、過去が切り詰められた倉庫を区別できます。

照会するときも、メタデータとイベントを1つの読み取りトランザクションで見ます。先に、保管範囲が十分だと判断したあとで、別の書き込み作業がログを切り詰め、そのあとでイベントだけを読む構造は、検査の時点と使用の時点が違います。SQLiteの、別々の接続どうしの分離に関する説明をもとに、同じ読み取り時点を維持します。ライブラリが提供する分離を実際に使わなければ、テーブルを分けて保存した理由がなくなります。

現場での姿

表示板が毎日消える時間が4時間で、再生ログは2時間だけ保管するなら、再接続の実装をどれだけ磨いても、頻繁に全体の復旧が必要になります。ここでは、製品が許容する最大のオフライン時間、イベントの発生量、スナップショットの作成・送信にかかる時間を、あわせて考える必要があります。保管量を増やせば復旧の頻度は減りますが、保存コストと個人情報の保管範囲が大きくなることがあります。逆に、スナップショットが小さなサービスでは、短いログと速い全体復旧のほうが単純な場合もあります。

診断ログには、単に接続成功とだけ書かず、要求の世代、クライアントのlast、サーバーのfloorとlast、選んだ復旧経路を残します。このラボは、顧客の個人情報を使わず、整数の在庫だけを使います。実際のサービスなら、全体の状態をログに出力する代わりに、識別の範囲と診断用のメタデータを最小限にする必要があります。業務データを含むスナップショットは、どこかの公開リンクに置いてよいファイルではありません。

1回のreplayは、最大16個だけを返します。これは全体の復旧が完了することを保証する数ではなく、呼び出し1回のリソースバジェットです。20個が残っているなら、最初の呼び出しのあとに4個がさらに残っているのが正常です。空のバッチと欠落エラーを区別してこそ、呼び出し側が次の行動を選べます。多くの量が残っているのに、1回の成功を全体の完了として表示すると、進捗がユーザーを欺くことになります。

次の確認ですること

このモジュールは、まずクイズで境界を判断し、あとの総合ラボでappend・trim・replayを実装します。すべて切り詰められたログに古いカーソルを送ってエラーになるか、最新のカーソルには空のバッチが来るかを比べます。また、切り詰めたあとで新しい番号が0に戻らないかを検査します。次の理論では、復旧に使う在庫とカーソルを、同じ時点に束ねます。

参考: SQLiteの接続間の分離。floorとlastの意味、バッチのサイズ、エラーの分類は、上の倉庫のラボが定義したアプリケーションプロトコルです。