PostgreSQL 申請・承認機能を作る⑤ 同時操作と履歴を扱う
承認前に「まだ申請中か」を調べても、それだけでは二重の判断を防げません。
2人が同時に処理すると、どちらも申請中という同じ状態を読み取る可能性があります。今回はPostgreSQLの行ロックとVersionを使い、1件の更新だけが確定する保存処理を作ります。
第2回のIApprovalStoreとIApprovalSessionを実装するためのSQLと手順を説明します。接続や結果の読み取りは、利用しているDBドライバーに合わせてください。以下の@idなどはパラメーターで、文字列連結で埋め込む値ではありません。
行ロックとVersionは違う問題を扱う
行ロックは、APIが処理している短い時間に、別の更新が割り込むのを防ぎます。
Versionは、画面を開いてから時間がたち、その間に誰かが内容を変えていないかを確認します。
| 仕組み | 守る範囲 |
|---|---|
| 行ロック | DBを読み、条件を確認し、更新を確定するまで |
| Version | 利用者が画面で見た内容と、現在のDBの内容の対応 |
画面を開いている間ずっとDBをロックすると、利用者が離席しただけで他の操作が待たされます。画面にはVersionだけを渡し、更新APIが呼ばれたときに短いトランザクションを開始します。
保存層の処理順を固定する
OpenAsyncからCommitAsyncまでを、次の順番で実装します。
- 接続を開き、Read Committedのトランザクションを開始する
- 操作者の所属行を共有ロックして取得する
- 有効な所属がなければ
Memberをnullとし、申請を取得しない - 申請IDがあれば、同じ組織の申請行を更新用にロックして取得する
- サービスが閲覧権限、Version、操作可否、入力を確認する
- 申請を追加または更新する
- 同じトランザクションで履歴を追加する
- コミットする
途中で失敗した場合は、DisposeAsyncでロールバックします。SQLごとに別接続を使うと、この単位での確定ができなくなるので注意してください。
トランザクションの基本はPostgreSQLのトランザクションも参照できます。
所属と権限を取得する
最初に、次のSQLを実行します。
操作者の所属を共有ロックするSELECT user_id, organization_id, is_active, can_approve
FROM organization_member
WHERE organization_id = @organization_id
AND user_id = @actor_id
FOR SHARE;取得できない、またはis_activeがfalseなら、有効なメンバーではありません。Memberをnullにしてサービスの403へつなげます。
FOR SHAREは、同じ所属で複数の読み取りを許可しながら、処理中の権限変更や無効化を待たせるために使います。権限の変更処理も、このorganization_member行を更新する前提です。
権限の取り消しが先に確定していれば、その後の操作は取り消し後の値で判定します。申請操作が先に所属のロックを取得していれば、その操作が終わってから権限変更が進みます。こうして、どちらが先に成立するかをDB上で決めます。
権限を別のロールテーブルから計算するアプリでは、この行だけをロックしても十分とは限りません。実際に権限の根拠となるデータに合わせて設計してください。
申請を組織IDと一緒に取得する
次に、対象の申請を取得します。
申請行を更新用にロックするSELECT id, organization_id, applicant_id, title, description,
status, version, decision_reason
FROM approval_request
WHERE id = @id
AND organization_id = @organization_id
FOR UPDATE;IDだけで検索しないようにします。別組織のIDをURLへ入れられても、操作対象として取得しないためです。
同じ申請を操作する別のトランザクションは、先行する処理が終わるまで待ちます。Read Committedでは、先行する更新が確定すると、待っていた側は更新後の行を取得します。そこからVersionと状態を再確認します。PostgreSQLの行ロックに詳しい説明があります。
所属、申請の順でロックを取る順序は、他の更新処理でもそろえます。逆順に取得する処理が混ざると、お互いのロック解放を待つデッドロックの原因になります。
今回のGetAsyncも同じ保存層を使うため、短時間ですが更新用のロックを取ります。説明を共通化するための選択です。閲覧が多いシステムでは、所属と閲覧条件を確認する読み取り専用クエリへ分け、更新を待たせる範囲を減らせます。
下書きの作成と更新を保存する
InsertAsyncは、サービスで生成したIDとVersion 1を保存します。
下書きの追加INSERT INTO approval_request
(id, organization_id, applicant_id, title, description,
status, version, decision_reason)
VALUES
(@id, @organization_id, @applicant_id, @title, @description,
'Draft', 1, NULL);SaveAsyncでは、更新前のVersionと状態も条件に含めます。
更新前の状態を条件に含めるUPDATE approval_request
SET title = @title,
description = @description,
status = @next_status,
version = @next_version,
decision_reason = @decision_reason
WHERE id = @id
AND organization_id = @organization_id
AND version = @previous_version
AND status = @previous_status;previous_versionとprevious_statusは、サービスから渡されたbeforeの値です。更新後の値はafterから渡します。next_versionは必ずprevious_version + 1になるよう、保存層でも確認すると契約が明確になります。
更新件数が1件でなければ、WorkflowError(409, "request_changed")を投げます。履歴を追加したり、成功のレスポンスを返したりしてはいけません。
このシリーズでは先に行をロックしているため、通常はサービスのVersion確認で競合を検出できます。UPDATEにも条件を残すことで、保存層が期待している更新前の値をSQL上でも表しています。
履歴も同じ接続とトランザクションで保存する
AppendHistoryAsyncは次のSQLです。
操作履歴の追加INSERT INTO approval_history
(request_id, version, actor_id, action,
from_status, to_status, reason, occurred_at)
VALUES
(@request_id, @version, @actor_id, @action,
@from_status, @to_status, @reason, @occurred_at);履歴の時刻や操作者はサーバーで決めます。ブラウザーから送られた「承認者」や「承認日時」は使いません。
申請を更新したあとで履歴のINSERTが失敗したら、トランザクション全体をロールバックします。これにより、状態だけ承認済みで、誰が承認したのか分からない状態を防ぎます。
保存層の実装ができたら、たとえばクラス名をPostgresApprovalStoreとして、DIへ登録します。
保存層を実装したあとに追加する登録builder.Services.AddScoped<IApprovalStore, PostgresApprovalStore>();IApprovalSession自身はトランザクションごとに生成し、別のリクエストと共有しません。
2人が同時に判断したときの動き
両者がVersion 3の申請を見ていたとします。
| 順番 | 承認者A | 承認者B |
|---|---|---|
| 1 | 承認APIを呼び、申請行をロックする | 却下APIを呼ぶ |
| 2 | Version 3と申請中を確認する | 申請行のロックを待つ |
| 3 | 承認済み、Version 4、履歴を保存する | 待機する |
| 4 | コミットする | Version 4の行を取得する |
| 5 | 成功レスポンスを受け取る | 送ったVersion 3と異なるため409になる |
結果は承認か却下のどちらか1つです。待っていた側が最新のVersionへ書き換えて自動再試行すると、本人が見ていない状態に対して判断することになります。競合は画面へ返し、再確認してもらいます。
トランザクション分離レベルを変更した場合は、待機後の動きやエラーも変わります。この例の前提はRead Committedです。PostgreSQLの分離レベルの説明を参照してください。
Versionは冪等性キーの代わりではない
Versionは古い更新を拒否するための値です。「同じリクエストを再送したら前回の結果を返す」という保証はありません。
特に下書きの作成APIは、新しいIDをサーバーで生成します。同じ作成リクエストを2回送ると、下書きが2件できる可能性があります。今回のコードは、作成の重複排除までは実装していません。
作成から通信再試行に対応したい場合は、冪等性キーで重複実行を防ぐ方法を組み合わせます。Version、二重クリック防止、冪等性キーは、守る範囲が異なります。
保存層を含めて確認する項目
権限判定の単体テストだけでなく、DBに2本の接続を開くテストも用意します。
| 確認内容 | 期待する結果 |
|---|---|
| 本人の下書きを保存する | Versionが1増え、履歴も1件増える |
| 別人の下書きを取得する | 閲覧できない |
| 承認者が自分の申請を承認する | 拒否され、履歴も増えない |
| 別組織の申請IDを送る | 対象として取得できない |
| 古いVersionで保存する | 409になり、内容も履歴も変わらない |
| 承認と却下を同時に送る | 片方だけ成功し、判断の履歴は1件 |
| 履歴INSERTを意図的に失敗させる | 状態の更新も取り消される |
| 権限取り消しを先にコミットする | その後の承認は拒否される |
| コミット後に通信を切る | 再取得すれば確定済みの状態を確認できる |
同時実行のテストでは、単にリクエストを2回順番に呼ぶだけでは不十分です。先行するトランザクションがロックを取ったところで待機させ、後続が同じ行を操作する状況を作ります。
ロック待ちのタイムアウトやデッドロックも、成功として返さず、アプリの共通エラー処理で記録します。トランザクションの中で外部APIや利用者の入力を待たないことも、待機時間を短く保つために重要です。
機能を広げるときの確認点
差し戻しや再提出を追加するときは、状態遷移の表から変更します。状態だけ追加するのではなく、閲覧条件、操作可否、Versionの更新、履歴、画面の表示も合わせて見直します。
通知を追加するときは、Transactional Outboxで通知予定を保存する方法が使えます。承認対象の本文を後から変更できるようにする場合は、どの版を判断したのかも残してください。
最初に状態と操作のルールを決め、サーバーの判定を画面へ返し、状態と履歴を一緒に確定する。この分担を保つと、仕様が増えたときも修正箇所を追いやすくなります。