Claude Media
Claude Codeで楽観ロックを実装する — versionカラムと競合テストの書かせ方

Claude Codeで楽観ロックを実装する — versionカラムと競合テストの書かせ方

versionカラムによる楽観ロックを、更新件数0の扱いと同時更新の再現テストから固めます。Claude Codeに先に失敗するテストを書かせ、実行結果で確かめる流れを示します。

管理画面で二人が同じレコードを開き、先に保存した人の変更を、後から保存した人が知らずに上書きする。これが更新の取りこぼしです。楽観ロックは、行にバージョン番号を持たせ、読んだときの番号と一致するときだけ更新を通す方式で、この取りこぼしを防ぎます。

この記事では、PostgreSQLのversionカラムによる楽観ロックをClaude Codeに実装させる手順を示します。要点は実装そのものより、更新件数0をどう扱うかと、同時更新を再現するテストを先に書かせることです。

楽観ロックは何を防ぎ、どこで検知するのか

楽観ロックは、競合はあまり起きないと見なして、読むときにロックを取りません。代わりに保存の瞬間に「自分が読んだ後で誰かが更新していないか」を調べます。

RailsのActiveRecord::Locking::Optimisticの説明は、この考え方をそのまま述べています。ほかのプロセスが先に更新していればStaleObjectErrorが投げられ、更新は無視されます。競合の解消、つまりロールバックするか、マージするか、業務ロジックで決めるかは、例外を受けた側の責任です。

SQLAlchemyのバージョン管理機能も同じ構造です。version_id列を持つ行のUPDATEは、次の形で発行されます。

UPDATE user SET version_id=:version_id, name=:name
WHERE user.id = :user_id AND user.version_id = :user_version_id

別のトランザクションが先に行を更新していれば、バージョンが一致せず、UPDATEは「該当0行」と報告します。SQLAlchemyはこの条件を検知に使い、StaleDataErrorを上げます。

つまり、楽観ロックの本体はWHERE句のversion = ?と、更新件数の確認の2点です。ORMを使わない自作のSQLでも、この2点を守れば成り立ちます。逆に、どちらかが欠けると、何も検知しないまま動きます。

versionカラムとUPDATE文を決める

テーブルにはバージョン用の整数列を足します。SQLAlchemyのドキュメントは、この列をNOT NULLにするよう強く勧めており、NULLの値はバージョン管理が扱えないと明記しています。

ALTER TABLE documents
  ADD COLUMN version integer NOT NULL DEFAULT 1;

更新側は、期待するバージョンを条件に入れ、成功時に1つ進めます。TypeScriptとnode-postgresで書くと次の形です。

export class StaleVersionError extends Error {}
export class NotFoundError extends Error {}
 
export async function updateTitle(
  db: Pool,
  id: string,
  expectedVersion: number,
  title: string,
) {
  const r = await db.query(
    `UPDATE documents
        SET title = $1, version = version + 1, updated_at = now()
      WHERE id = $2 AND version = $3
      RETURNING id, title, version`,
    [title, id, expectedVersion],
  );
  if (r.rowCount === 1) return r.rows[0];
 
  const cur = await db.query(
    "SELECT version FROM documents WHERE id = $1",
    [id],
  );
  if (cur.rowCount === 0) throw new NotFoundError(id);
  throw new StaleVersionError(
    `expected v${expectedVersion}, current v${cur.rows[0].version}`,
  );
}

version = version + 1とSET句で書くのは、アプリ側で計算した値を書き込むと、別経路の更新と食い違う余地が残るためです。RETURNINGで新しい番号を返すと、呼び出し側は次の更新にそのまま使えます。

更新件数が0のとき、何が起きたのかを分ける

更新件数0は、原因が1つとは限りません。この関数では次の2つに分けています。

状況判定呼び出し側での扱いの例
行はあるがバージョンが違う判定StaleVersionError呼び出し側での扱いの例最新を読み直させる。またはマージする
行そのものがない判定NotFoundError呼び出し側での扱いの例削除済みとして伝える

HTTPのAPIなら、前者を409、後者を404に対応させる設計が自然です。ステータスの割り当ては設計判断なので、チームの規約に合わせます。

後続のSELECTは原因を知らせるための補助です。SELECTの後に行が削除されることもありえますが、そのときは返すエラーがやや不正確になるだけで、更新が誤って通ることはありません。

もう一つ決めておくのが、競合時の自動リトライの扱いです。Railsのドキュメントが言うとおり、競合の解消は業務ロジックの仕事です。タイトルの上書きのように最後の入力を優先してよい更新と、在庫数のように前の値に依存する更新では、答えが変わります。自動で読み直して再実行してよいのは後者のうち、更新が前の値からの純粋な計算になっているものに限られます。迷う箇所は、まず409を返して人に判断させる形が安全です。

Claude Codeに競合テストを先に書かせる

ベストプラクティスのページは、バグ修正の依頼で「再現する失敗テストを書いてから直す」形を例に挙げています。「fix the login bug」より具体的に症状と期待を書くと、修正の往復が減るという趣旨です。楽観ロックでも同じ順で進めます。

手順

楽観ロックの進め方

  1. 1

    CLAUDE.mdに実行コマンドを書く

    テストの実行コマンドと、必要な環境変数を書いておきます。ベストプラクティスの例にも、統合テストにDATABASE_URLが要るという注意書きが載っています。

  2. 2

    失敗する競合テストを先に書かせる

    実装が無い段階で、同時更新を再現するテストを書かせ、赤になることを確かめます。

  3. 3

    UPDATE文と例外を実装させる

    テストが通るまで、Claude Code自身にテストを実行させて直させます。

  4. 4

    ほかの更新経路を洗い出させる

    同じテーブルを更新する箇所が、versionを見ずに書いていないかを調べさせます。

最初のプロンプトは、症状と期待する結果をまとめて渡します。

documents テーブルに version 列(integer NOT NULL DEFAULT 1)を足し、
src/documents/update.ts の updateTitle を楽観ロックにしたい。
まず実装は変えず、src/documents/update.test.ts に次のテストだけを書いて。
実DBを使い、各テストの前にテーブルを空にすること。
1. 順次: 同じバージョン1を持つ2つの更新を順に実行すると、
   1つ目は成功し version が2になり、2つ目は StaleVersionError になる
2. 同時: 2つの接続から Promise.all で同時に更新すると、
   成功がちょうど1件、StaleVersionError がちょうど1件になり、
   最終的な version は2
3. 存在しない id では NotFoundError になる
書いたらテストを実行して、期待どおり失敗することを見せて。

「期待どおり失敗することを見せて」の一文が重要です。テストが最初から通ってしまうなら、そのテストは競合を再現できていません。赤を確認してから実装に進むと、後で緑になったとき、通ったのが実装のおかげだと言えます。

同時更新のテストは、本当に同時になっているか

2つ目の「同時」のテストが肝心です。次の形のテストは、接続が1つしかないと直列に実行されるだけで、競合になりません。

it("同時に更新すると片方だけ成功する", async () => {
  await db.query(
    "INSERT INTO documents (id, title) VALUES ('d1', 'first')",
  );
  const a = new Pool({ connectionString: process.env.DATABASE_URL });
  const b = new Pool({ connectionString: process.env.DATABASE_URL });
 
  const results = await Promise.allSettled([
    updateTitle(a, "d1", 1, "from A"),
    updateTitle(b, "d1", 1, "from B"),
  ]);
 
  const ok = results.filter((r) => r.status === "fulfilled");
  const stale = results.filter(
    (r) => r.status === "rejected" && r.reason instanceof StaleVersionError,
  );
  expect(ok).toHaveLength(1);
  expect(stale).toHaveLength(1);
 
  const { rows } = await db.query(
    "SELECT version FROM documents WHERE id = 'd1'",
  );
  expect(rows[0].version).toBe(2);
  await Promise.all([a.end(), b.end()]);
});

PostgreSQLの既定の分離レベルはRead Committedです。ドキュメントによれば、UPDATEが探した行をほかのトランザクションが先に更新していた場合、後から来た側は先の側のコミットかロールバックを待ちます。先の側がコミットすれば、後の側は更新後の行に対してWHERE句を評価し直します。この評価し直しでversion = 1が偽になるため、後の側は0件更新になります。上のテストが期待する結果は、この仕様から出てきます。

分離レベルがRepeatable Readの場合は、挙動が変わります。同じドキュメントによれば、先の側がコミットすると後の側のトランザクションはcould not serialize access due to concurrent updateというエラーでロールバックされます。このときは更新件数0ではなく、データベースのエラーとして届きます。SQLAlchemyのドキュメントも、Repeatable Read以上ではバージョン列による検知が、トランザクション内の競合検知としてはあまり役に立たないと書いています。

使っている分離レベルをCLAUDE.mdに書き、競合テストをそのレベルで走らせてください。Repeatable Readを使う接続では、StaleVersionErrorに加えて、このシリアライズ失敗も同じ「競合」として扱う設計にし、テストも1つ足します。

実装後に、結果を読ませて終わらせる

実装を依頼したら、結果の読み方も指示します。ベストプラクティスは、完了の報告だけでなく、実行したコマンドとその出力という証拠を求めるよう勧めています。

updateTitle を実装して、update.test.ts の全テストが通るまで直して。
最後に、実行したコマンドとテスト出力をそのまま見せて。

終わらせる条件を強制したいなら、Stop hookを使えます。ベストプラクティスのページには、型検査が通るまでClaude Codeがターンを終えられない設定例があります。同じ形で、競合テストに置き換えられます。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "pnpm vitest run src/documents 1>&2 || exit 2"
          }
        ]
      }
    ]
  }
}

終了コード2が停止のブロックで、標準エラーに出した内容がClaude Codeに理由として戻ります。8回続けてブロックされると、次のブロックは上書きされてターンが終わる点は、ページの記載どおりです。DBを使うテストが重い環境では、毎回走るこの設定が遅さの原因になるため、対象をsrc/documentsのように絞っています。

CLAUDE.mdには、コードから読み取れない事柄だけを書きます。

# Commands
- 統合テスト: pnpm vitest run src/documents
- 前提: DATABASE_URL が必要(.env.example 参照)
 
# Gotchas
- documents の更新は必ず WHERE version = ? を付け、
  更新件数0を StaleVersionError か NotFoundError に分ける
- 分離レベルは Read Committed。変える場合は競合テストも見直す

実装から漏れやすい更新経路

楽観ロックは、全部の更新経路が守って初めて意味を持ちます。1か所でもversionを見ない更新があれば、そこから取りこぼしが起きます。

最初に疑うのは次の箇所です。

  • 管理用スクリプトや移行のバッチが、UPDATE documents SET ...をWHERE id = ?だけで実行している
  • ORMで複数行をまとめて更新する呼び出し。SQLAlchemyのドキュメントは、Query.update()とQuery.delete()のような複数行のUPDATE・DELETEにはバージョン検査が働かないと書いています
  • 削除。Railsのドキュメントは、オブジェクトの破棄でも古さを検査すると述べています。削除にもversionを条件に入れるかを決めます

Claude Codeには、実装後に次のように頼めます。

documents テーブルを更新・削除している箇所を src/ と scripts/ から
すべて列挙して。WHERE に version を使っていないものを表にして、
それぞれ意図的に外してよいかを理由つきで示して。

表に出た候補は人が目を通します。意図的に外した更新が、実は競合する経路になっていないかは、業務を知っている側にしか判断できません。

もう一つの落とし穴が、versionの持ち回りです。画面やAPIがバージョンを往復させなければ、サーバー側は常に最新を読んで更新できてしまい、検知が働きません。Railsのドキュメントは、リクエストをまたいで効かせるためにlock_versionをフォームの隠しフィールドに入れる方法を勧めています。HTTP APIでは、レスポンスにバージョンを含め、更新リクエストでそれを必須の入力にします。ここも、「versionを省いた更新は400を返す」テストで固定できます。

悲観ロックとの使い分けと、関連する話題

楽観ロックは、競合が少ない前提で読み取り時にロックを持ちません。競合が頻繁なデータや、更新の失敗が許されない処理では、SELECT ... FOR UPDATEのような悲観ロックが候補になります。Railsのドキュメントも、代替として悲観ロックの存在に触れています。

ただし、悲観ロックでは複数の行を取る順序が食い違うとデッドロックが起きえます。その調査はClaude Codeでデッドロックの原因を調べる手順で扱っています。楽観ロックは更新時にしか行を触らないので、この種の待ち合わせは増やしません。

同時実行のテストの考え方は、Claude CodeでRedisのレート制限を実装するにも共通します。「読んでから書く」処理を原子的にするという課題です。テストで境界の挙動を先に固定する進め方は、カーソルページネーションの実装の記事が同じ型で書いています。

まとめ

楽観ロックの実装で外せないのは、WHERE version = ?と更新件数の確認です。Claude Codeに任せるときは、実装より先に、同時更新の再現テストを書かせて赤を確認します。続けて、実行したコマンドと出力を見せてもらい、同じテーブルを更新するほかの経路を列挙させます。

分離レベルがRead Committedなら更新件数0、Repeatable Readならシリアライズ失敗のエラーとして競合が届きます。使っている分離レベルをCLAUDE.mdに書いておくと、テストと実装が同じ前提で進みます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. docs.sqlalchemy.org/en/20/orm/versioning.html
  3. api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
  4. postgresql.org/docs/current/transaction-iso.html
この記事を共有:XはてブLinkedIn