承認後に誰かが先に変更した
一言でいうと
承認したバージョンを条件に入れて一度に変更し、バッチの中で1つでも食い違ったら全体を元に戻します。完了応答を失ったときは、同じ変更IDのレシートで事実を確認します。
なぜ必要なのか
注文2件をキャンセルする作業で、1つ目の行は成功し、2つ目の行は他の担当者が先に変更していました。関数がエラーを出したという事実だけを見て、何も変わっていないと判断するのは間違いです。各UPDATEを自動コミットにしていたなら、1つ目のキャンセルはすでに残っています。顧客からは2件全体のキャンセルの承認を受けたのに、実際には半分だけ変わった状態です。例外を捕まえるコードと、業務変更のアトミック性は、別の仕組みです。
別の問題は応答です。DBに変更と監査が確定されたあとで、実行プログラムが終了すると、呼び出し元は成功を受け取れません。同じ作業をもう一度実行して現在の状態だけを見ると、pendingではないという衝突が起きます。これが元の実行の成功なのか、他の作業者の変更なのかを区別するには、適用した承認内容と変更IDを永続的に記録しておく必要があります。完了応答の代わりに、確認できるレシートが必要です。
どう動くのか
1行のキャンセルは、UPDATEのWHEREで、id・tenant・revision・qty・pendingをすべて比較します。合っていればstateをcancelledに変え、revisionを1上げて、RETURNINGで変更された行を受け取ります。返る行がなければ、承認の前提がなくなったということなのでConflictです。SELECTで比較したあとに条件のないUPDATEを行うと、2つの文の間に他の書き込みが入るおそれがあるので、比較そのものを変更文の中に入れます。
PostgreSQLのデフォルトのRead Committedでは、同じ行を別のトランザクションが変更中のとき、待つことがあります。先のトランザクションがコミットしたあとは、更新された行に条件をもう一度照合します。検査ツールはこの状況を実際に作ります。別の接続がrevisionを増やしてロックを取っている間にキャンセル要求を開始し、pg_stat_activityでLock待ちを観測してからコミットします。あとの要求は、古いrevisionで現在の行をキャンセルできてはいけません。単に2つの関数を続けて呼ぶテストとは区別される証拠です。
複数の対象には、外側の1つのトランザクションが必要です。cancel_oneが単独で使われるときの保護と、cancel_many全体の保護を、一緒に設計します。psycopgのtransactionコンテキストは、すでに開いているトランザクションの中に入るとsavepointを使うので、1行の関数が外側のバッチを早期にコミットしないように組み合わせられます。あとの行で例外が出たら、外側のコンテキストまで伝えて、前の行もロールバックします。例外を握りつぶして次の行へ進む実装は、この全体適用の契約と合いません。
レシートのためのchangesには、change_id・tenant・正規化したtargetsを入れます。行ごとのauditには、変更前後のrevisionと当時の数量を残します。レシートの挿入、全体の変更、監査行が、すべて同じトランザクションです。途中でエラーが出たら3つともなく、コミット後に終了したら3つともある必要があります。監査ファイルを別に書いてあとから合わせる方式は、今回のアトミックな変更記録ではありません。
同じchange_idで同じ承認内容がすでにあれば、新たに変更せずFalseを返します。同じIDで別の顧客や別の対象を提出すると、Conflictです。重複キーを無条件に成功として扱うと、誤って再利用された変更番号が隠れてしまいます。同一の要求の再試行と、別の要求の衝突を分けるのが、レシートの役割です。同時に同じ変更が2回入ってきても、実際に適用されるのは1つだけで、もう一方は既存のレシートを確認する必要があります。
現場での姿
レシートがあるというのは、そのとき変更を確定したという意味です。いまもその状態だという意味ではありません。他の担当者がその後に数量を変更したか、行を削除したかもしれません。そのため、inspect_changeは当時の承認内容を読み、reconcileは現在の行を別に照合します。一致・その後の変更・欠落をID別に分け、欠落を同じ件数の別の行で埋めません。照合だけで他人の変更を元に戻す機能も入れません。
たとえば、キャンセル時にrevision=4だった行がいまrevision=5なら、数量と状態が同じでも、その後の変更に分類します。別IDのcancelledの行が新たにできて全体の件数が変わらなくても、元の対象がなくなっていれば欠落です。数字1つで報告すると、この2つが正常に見えてしまうおそれがあります。顧客には、当時の確定記録と現在の照合結果を並べて説明する必要があり、差があるというだけの理由で原因を勝手に作り上げてはいけません。
ラボのDBの設定は、本番の耐久性の基準とは違います。既存のイメージでfsyncとfull_page_writesが切られているので、今回のテストは、動いているPostgreSQLサーバーにつながったクライアントの終了だけを検証します。コミット前の接続切断がロールバックされ、コミット後の再接続でレシートが見つかるという観測を、サーバーの電源障害やディスク破損からの復旧の保証にまで拡大しません。実サービスに持っていくときは、本番のストレージ・バックアップ・復元の検証が別に必要です。
次のラボですること
承認計画の検証から始めて、単一行・バッチの変更、監査レシート、現在の状態の照合、接続の寿命まで、8ステップで実装します。最後には実際のプロセスをコミットの前後で終了させ、同じ変更IDでもう一度試します。成功した要求だけを見るのではなく、別の顧客、古いバージョン、重複ID、部分コミット、現在の状態をレシートと混同した誤答も確認します。
参考: PostgreSQLの分離レベル、psycopgのトランザクション管理。数量とバージョンの範囲、レシートのスキーマ、例外の分類は、このラボの契約です。