応答は消えたが注文は残っている
一言でいうと
リクエストの失敗と、業務上の失敗は同じではありません。同じビジネスキーで再試行し、応答と元帳を照合してはじめて、重複した予約を区別できます。
なぜ必要なのか
倉庫のAPIに予約を送りました。画面には接続エラーが出ました。このとき、もう一度送って安全でしょうか。接続が予約の保存より前に切れたのなら、もう一度送らなければ予約はできません。逆に、保存は終わっていて応答だけが消えたのなら、何の保護もなくもう一度送った瞬間に、2つの予約ができてしまうかもしれません。クライアントから見えるエラーの文は似ていますが、サーバーの中の業務の状態は正反対です。
今回の架空の倉庫は、この違いを実際に作り出します。ORD-BEFOREの最初のリクエストは、保存の前に503を返します。ORD-AFTERの最初のリクエストは、SQLiteに予約をコミットしたあとで、HTTPの応答なしに接続を閉じます。この2つの状況を、失敗のフラグ1つだけで処理すると、復旧の設計を誤ります。ログを読んで終わりにせず、自分が作ったプログラムを2つの状況に直接通すのは、そのためです。
どう動くのか
送信APIは、POST /reservationsです。JSONには、order_id・sku・quantityだけが入ります。別のIdempotency-Keyヘッダーで、同じ業務上の試行を識別します。このコースでは、注文番号をキーとして使います。APIとクライアントは、次の3つの場合をあわせて約束します。
| リクエスト | 倉庫の判断 | 応答 |
|---|---|---|
| 初めて見るキーと、有効な本文 | 新しい予約を保存 | 201、予約ID |
| 同じキーと、同じ本文 | 既存の予約を返す | 200、既存の予約ID |
| 同じキーと、変わった本文 | 既存の業務と衝突 | 409、新しい予約なし |
キーを毎回新しく作ると、再試行がすべて新しい業務に見えます。処理したキーをメモリ内の集合にだけ保存しても、プロセスが再起動すると記憶が消えます。今回のサーバーは、キーと本文、予約を同じSQLiteトランザクションに保存し、キーの一意性制約を使います。アプリケーションの「先に参照して、なければ書く」だけでは、同時に入ってきた2つのリクエストが、どちらも空の状態を見ることがあります。データベースが、最終的な重複の判定を、あわせて守る必要があります。
本文全体のハッシュをキーに使うとどうなるでしょうか。同じ注文の数量が変わった瞬間に、ハッシュも変わります。サーバーはこれを別のキーと見て、新しい予約を作ります。これは衝突の解決ではなく、衝突の検査を避けて通っただけです。顧客が注文の変更を許可するなら、変更APIとバージョンの規則を、別に合意する必要があります。この練習では、409を保留とし、既存の予約を維持します。
クライアントには、リクエストごとに1秒の制限を設け、503と接続エラーにだけ、最大4回のPOSTの試行を許可します。再試行の間は0.1秒待ちます。409や契約のエラーは、待っても、リクエストの意味が変わらないので、中断します。これらの値は、短い教育用の検証のためのポリシーです。運用では、相手側の制限、遅延の分布、全体の処理期限と負荷を考えて、別に決めます。すべてのサービスに4回を推奨しているわけではありません。
応答を受け取ったというだけで、confirmedを記録しません。GET /orders/<注文番号>で業務上の結果を参照し、ちょうど1つの予約か、SKUと数量が合っているか、POSTで受け取ったIDがあれば参照のIDと同じかも確認します。予約が3つあるのに、最初の項目だけを選んで成功と書いてはいけません。参照に失敗したら、IDを1回受け取っていても、今回の契約の確認の条件を満たしていないので、unconfirmedとnullを残します。
状態の名前も区別します。confirmedは、定めた基準で結果を確認したという意味、unconfirmedは、まだ確認できていないという意味です。rejectedは、409や契約のエラーで、リクエストが拒否されたという意味です。unconfirmedを「予約なし」と解釈すると、次の担当者が新しいキーで再送するおそれがあります。観察できなかったものは、観察できなかったと残す必要があります。
2回送ったのに、なぜ1回しか予約されなかったのか
ORD-AFTERを時間の順にたどってみましょう。最初のPOSTで、サーバーはキーと本文を保存し、予約IDを作ります。その次に接続を閉じるので、クライアントにはIDが届きません。2回目のPOSTは、同じキーと同じ本文なので、新しい予約を作らず、既存のIDを返します。最後のGETは、その注文に予約が1つだけあるかを確認します。報告書にはattempts=2とありますが、実際の元帳の予約は1つです。2つの数値が違うのはエラーではなく、再試行と業務上の効果を分けて数えたからです。
ORD-BEFOREもPOSTを2回行いますが、最初のリクエストのとき、元帳は空です。2回目のリクエストで、はじめて予約ができます。最終的な予約の数だけを見ると2つの事例は同じなので、エラーの注入の試験は、保存の時点も区別する必要があります。一方、再試行のキーを変える誤答は、ORD-BEFOREでは偶然うまく動いても、ORD-AFTERでは2つの予約を作ります。1つの事例の成功だけで、復旧の戦略を検証したと言ってはいけない理由です。
現場での姿
同じファイルの再処理は、予想外の事故ではなく、よくある業務の流れです。送信の担当者が変わったり、作業が中断したりすると、同じファイルがまた入ってきます。そのため、検証も最初の実行だけを見ません。サーバーのプロセスを再起動しつつ、同じ元帳を維持したうえで、同じ入力を再び送り、予約の数だけでなくIDまで維持されているかを確認します。「エラーなしで終了した」より、業務上の不変条件を検査するほうが、この問いに直接答えます。
この練習は、単一の倉庫の、単一のSQLite元帳です。分散トランザクションや、複数の倉庫の間の在庫の一致、キーの期限切れのあとの再配信は、解決しません。この限界を、引き継ぎ文書に残すことも、実装の一部です。
次に確認すること
次のクイズでは、応答の消失・再起動・内容の変更を、それぞれ別の状況として判定します。実装するときも、エラーの種類、実際の試行回数、業務上の結果を、別々の変数として持てば、何がわかっていて何がわからないのかを、説明しやすくなります。