注文は保存されたのに送信予定が消えた
一言でいうと
業務を保存したという事実と、ほかのサービスに知らせたという事実は別なので、まだ伝える必要のある送信予定を、業務と一緒に残します。
なぜ必要なのか
前のモジュールのお菓子の自販機は、1つのSQLiteファイルの中で在庫とカーソルを一緒に保存しました。今回は、注文の受付側と倉庫が分かれます。受付側は注文を受け付け、倉庫はその注文を受け取って、出荷準備の数量を増やします。受付DBに注文7個が記録されたのに、倉庫には何も届いていません。運用者は注文テーブルが正常だという理由で成功と答えますが、ユーザーの立場から見ると、お菓子は用意されていません。データベースのトランザクション1回が、2つのシステムを自動的に束ねてくれるわけではありません。
「DBコミットの次の行でHTTPを呼び出せばよいのではないか」と考えるかもしれません。しかし、その次の行を実行する前に、プロセスが終了することがあります。順序を入れ替えて先に送信すると、逆の問題が起こります。倉庫は準備したのに、注文DBのコミットが失敗するかもしれません。2行の順序を入れ替えるだけでは、途中の障害区間をなくせません。このモジュールでは、成功の証拠がどこに残り、再試行する仕事がどこに残るのかを、まず設計します。
どう動くのか
送信ではなく送信予定をアトミックにする
受付側は、1つのトランザクションでordersとoutboxを一緒に記録します。ordersは受け付けた業務で、outboxはあとで伝える項目です。両方とも保存されるか、両方とも消えるかのどちらかでなければなりません。COMMITのあと、別の送信ループがoutboxの未完了の項目を読んで、倉庫へ送ります。注文を保存する関数自体は、HTTPの応答を待ちません。外部システムが遅いときにも、DBの書き込みロックをネットワークの待機中ずっと握り続けないためです。
접수 DB: BEGIN → orders 삽입 → outbox 삽입 → COMMIT
전달 루프: 미완료 조회 → HTTP 요청 → 업무 ACK 확인 → sent=1
창고 DB: BEGIN → inbox 삽입 → stock 갱신 → COMMIT → ACK
この図でアトミックなのは、各DBのBEGINとCOMMITの間だけです。矢印全体が1つのトランザクションなのではありません。outboxのおかげで、受付プロセスがコミット直後に落ちても、再起動した送信ループが未完了の送信予定を見つけられます。ただし、倉庫が反映したあとで応答が消えることがあるため、重複送信は依然として起こりえます。「送る仕事を失わない」ことと「送信がちょうど1回起こる」ことは、区別する必要があります。
時間軸に表す成功と不確実性
例えば、IDがsnack-7、数量が7の注文を入れます。ordersの挿入のあとで終了すると、独立した接続では、ordersとoutboxがどちらも0行でなければなりません。outboxの挿入のあと、まだコミットしていないときも同じです。コミットのあとで終了すれば、どちらも1行でなければなりません。最後のケースで、呼び出し側が応答を受け取れなかったからといって注文を消すと、すでに受け付けた業務を取り消す別の行為になります。同じIDと内容でもう一度提出して、既存の受付を確認する必要があります。
テストでは、同じ接続の中で変更が見えることだけを確認するわけではありません。まだコミットしていない接続は、自分の変更を読めます。別のSQLite接続が何を読むかも突き合わせてはじめて、外部に確定した状態がわかります。また、try/finallyがエラーを後始末する場合と、os._exitで後始末のコード自体が実行されない場合を分けて見ます。前者は例外処理の正確さを、後者はプロセスが消えたあとにデータベースファイルから読める状態を示します。
IDを決めるのは業務設計の仕事
このラボのIDはASCIIの英数字・アンダースコア・ハイフンからなる1–64文字の文字列で、数量は1–1000の整数です。TrueはPythonでは整数のように見えることがありますが、数量としては受け付けません。この制限は実際の注文サービスの標準ではなく、教育用の契約です。同じIDと同じ数量は再試行、同じIDで数量が違えばConflict、違うIDで数量が同じなら別々の注文として処理します。価格・顧客・商品の種類が加わると、どのフィールドが同一業務の内容なのかを、改めて決める必要があります。
生成時刻や現在の時間をIDとして毎回新しく作ると、同じ注文の再試行が、別々の注文になってしまいます。逆に、数量そのものをIDに使うと、たまたま7個を注文した2人が1つに合わさってしまいます。ネットワークの再接続回数、リクエスト時刻、業務の識別子は、それぞれ別の意味を持ちます。今回の実装は、呼び出し側が安定した業務IDをすでに決めているという前提から出発します。IDを発行するサービスや、複数のユーザーの認証体系を作ったとは主張しません。
現場での姿
FDEが顧客の既存DBと新しい通知・作業システムを接続するとき、「片方は成功したが、もう片方は失敗した」という状況が出てきます。要件には成功のシナリオだけが書かれていることが多いので、コミットの前後、送信の前後、ACKの前後での中断を、受け入れテストに入れる必要があります。このラボの2つのSQLiteファイルは、そのような境界を小さく再現するための仕掛けです。実際の決済会社とアトミックコミットをした証拠でも、メッセージブローカーを構築した証拠でもありません。
outboxを使うと、運用の責任も生じます。まだ未完了の行がどのくらい古いか、送信ループが動いているか、失敗した項目があとの項目を塞いでいないかを見る必要があります。未完了の行を消して数字を0にするのは、復旧ではありません。何が受信されたかわからない状態では、再送と確認を先に設計する必要があります。保存期間と容量の制限がなく、無限にたまるテーブルも、完成した運用設計ではありません。今回のラボは、少量のデータでの失敗の意味を検証し、長期保管・クリーンアップは次の設計課題として残します。
次の確認ですること
すぐあとのクイズで、コミット前後のテーブルの状態と、2通りの送信順序での失敗を判断します。次のモジュールでは、送信ループが同じ項目をもう一度送っても、倉庫の業務上の効果を繰り返さないように、inboxと在庫を一緒に保存します。最後のラボで、それぞれの中断ポイントを、実際のプロセス終了で確認します。
公式ドキュメントでさらに読む
- AWS Transactional outbox: DBとイベント配信の二重書き込みの問題と、重複受信の考慮事項を読みます。
- SQLite Transaction: 明示的なBEGIN・COMMIT・ROLLBACKの境界を確認します。