zukucode
主にWEB関連の情報を技術メモとして発信しています。

PostgreSQL 申請・承認機能を作る⑤ 同時操作と履歴を扱う

承認前に「まだ申請中か」を調べても、それだけでは二重の判断を防げません。

2人が同時に処理すると、どちらも申請中という同じ状態を読み取る可能性があります。今回はPostgreSQLの行ロックとVersionを使い、1件の更新だけが確定する保存処理を作ります。

  1. 状態とDBを設計する
  2. 下書き保存と申請API
  3. 承認・却下と権限の確認
  4. 状態に応じた画面を作る
  5. 同時操作と履歴を扱う(この記事)

第2回のIApprovalStoreとIApprovalSessionを実装するためのSQLと手順を説明します。接続や結果の読み取りは、利用しているDBドライバーに合わせてください。以下の@idなどはパラメーターで、文字列連結で埋め込む値ではありません。

行ロックとVersionは違う問題を扱う

行ロックは、APIが処理している短い時間に、別の更新が割り込むのを防ぎます。

Versionは、画面を開いてから時間がたち、その間に誰かが内容を変えていないかを確認します。

仕組み守る範囲
行ロックDBを読み、条件を確認し、更新を確定するまで
Version利用者が画面で見た内容と、現在のDBの内容の対応

画面を開いている間ずっとDBをロックすると、利用者が離席しただけで他の操作が待たされます。画面にはVersionだけを渡し、更新APIが呼ばれたときに短いトランザクションを開始します。

保存層の処理順を固定する

OpenAsyncからCommitAsyncまでを、次の順番で実装します。

  1. 接続を開き、Read Committedのトランザクションを開始する
  2. 操作者の所属行を共有ロックして取得する
  3. 有効な所属がなければMemberをnullとし、申請を取得しない
  4. 申請IDがあれば、同じ組織の申請行を更新用にロックして取得する
  5. サービスが閲覧権限、Version、操作可否、入力を確認する
  6. 申請を追加または更新する
  7. 同じトランザクションで履歴を追加する
  8. コミットする

途中で失敗した場合は、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を呼ぶ
2Version 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で通知予定を保存する方法が使えます。承認対象の本文を後から変更できるようにする場合は、どの版を判断したのかも残してください。

最初に状態と操作のルールを決め、サーバーの判定を画面へ返し、状態と履歴を一緒に確定する。この分担を保つと、仕様が増えたときも修正箇所を追いやすくなります。


関連記事