is_current の降格→挿入で版が二度と発行できなくなる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
現行版の発行を「is_current を降格→新版を挿入」という非原子な2手で行うと、その間の同時発行で片方の更新が取り残され、以後は同じversion番号を指し続けてduplicate key value violates unique constraintで二度と発行できなくなる。
結論
契約書テンプレートの新版発行を、「現行版のis_current列をfalseへ降格→新版をis_current=trueでINSERT」という2つの別々の書き込みで実装すると、同時に2件の発行が走った場合に片方の降格だけが成立して取り残され、以後の発行が常に同じversion番号を計算し続けてduplicate key value violates unique constraintで恒久的に失敗するようになる。 原因は「現在の版」という状態を、複数行にまたがる非原子な2手の書き込みで表現していたことにある。直し方は可変フラグ自体を廃止し、現行版をversion列の最大値から都度導出する設計に変えること。発行も新しい行を1回INSERTするだけの単発操作にすれば、採番の衝突はunique制約がその場で弾き、やり直せば正しい番号になる。
症状 — コードレビューで検出
契約書テンプレートの発行機能を見直すコードレビューで、この設計が指摘された。指摘された時点では対象のテーブルはまだ空で、実際の発行実績は0件だった。つまり本番で実際に「二度と発行できない」事故が起きたわけではなく、事故が起きる前に設計上の欠陥として捕まえた格好になる。
指摘の内容はこうだった。発行処理は次の手順で組まれていた。
- 現行版(
is_current = trueの行)を読む - その行を
is_current = falseへUPDATEする(降格) - 新しい版を
version = 現行版 + 1・is_current = trueでINSERTする
この3手のうち②と③は別々のSQL文で、間を1つのトランザクションで縛っていなかった。ここに同時に2件の発行が走ると、次のような壊れ方をする。
- 2つの発行がどちらも同じ「現行版」を読み、同じ「次のversion番号」を計算する
- 片方の発行のINSERTが先に成功し、その番号を使い切る
- もう片方のINSERTは同じversion番号を狙うため、
unique(kind, version)制約に弾かれてduplicate key value violates unique constraintで失敗する - ここで終わればまだ「エラーが出て発行に失敗した」だけで済むが、②の降格だけが先に成立してしまっていた場合は、その版のkindについて
is_current = trueの行が1件も無い状態になる。この隙間で本文を読もうとした処理はベースラインのv1に落ちる - しかも次の発行リクエストが来ても、「現行版が無い」または「壊れた現行版を元にversionを計算する」ため、同じversion番号を計算し続けて同じ衝突を繰り返す。手動でリトライしても番号がずれない限り直らない
「現在の版」という単一の状態を、UPDATEとINSERTという2つの独立した書き込みで表現していたことが、この壊れ方の入口だった。
原因
根本の原因は、可変フラグ(is_current)で「現在どれが現行版か」を管理していたことにある。フラグを使う設計は、次の2つの操作が両方とも成立して初めて整合する。
- 旧版のフラグをfalseにする
- 新版のフラグをtrueにしてINSERTする
この2つを1つのトランザクションで囲まない限り、片方だけが成立する瞬間が必ず生まれる。今回はさらに「次のversion番号」の計算そのものが、このフラグを起点にしていたため、フラグが壊れると採番まで一緒に壊れた。採番が壊れると、同じ番号を再計算し続けるため、リトライでは直らない状態になる。
直し方
2026-07-24付けのマイグレーションで、is_current列そのものを廃止した。現行版は「そのkindの中でversion列が最大の行」として都度導出する方式に変え、発行処理も新しい行を1回INSERTするだけの単発操作にした。UPDATEをまったく行わないため、途中で状態が割れる隙間自体が無くなる。
version番号が衝突した場合はunique(kind, version)制約がその場でINSERTを拒否するだけなので、発行をやり直せば(=現行版を再取得してversionを計算し直せば)正しい番号でINSERTできる。「同じ番号を計算し続けて詰む」経路自体が設計から消えている。
あわせて、version >= 2のCHECK制約も追加した。この版管理テーブルはv2以降だけを持ち、v1はコードに同梱されたベースラインが権威という前提だったため、v1をこのテーブルに紛れ込ませるとコード側のベースラインが優先されて行が黙って無視される、という別の事故を構造的に防ぐためのものだった。
再発防止
このマイグレーションで実際に変えたのは「可変フラグに頼らない」という設計方針そのものだった。旧設計のコメントには、なぜ降格→挿入の2手が危険かという理由がそのまま残されている。「現在の状態」を複数行にまたがる2回の書き込みで表現する箇所が他に無いかは、次に版管理や状態遷移を伴うテーブルを設計する際に、まず疑う対象になった。
よくある質問
Q1duplicate key value violates unique constraintはどんな時に出ますか?
PostgreSQLでunique制約のある列に、既に存在する値をINSERTしようとした時に出るエラーです。今回のケースでは「現行版のversion番号+1」を新版のversionとしてINSERTする設計で、同時発行の競合が起きた後は毎回同じversion番号を計算し続けてしまい、その番号が既に使われているため、やり直しても同じエラーで発行できなくなっていました。
Q2is_current のようなフラグで「現在の版」を管理する設計は何が危ないのですか?
「現在の版」をUPDATE(旧版の降格)とINSERT(新版の追加)という2つの別々の書き込みで表現すると、その間に他の処理が割り込む隙間ができます。降格が終わって挿入が終わる前にもう一方の発行が読みに来ると、現行版が存在しない瞬間を観測してしまったり、同じ「次のversion番号」を2つの発行が同時に計算してしまったりします。1個のブール値の整合性を、複数行にまたがる2回の書き込みだけで保とうとしたことが根本原因でした。
Q3この欠陥は実際に本番で発行できない事故を起こしたのですか?
起きていません。コードレビューでこの設計を指摘された時点では該当テーブルはまだ空で、発行実績が0件の段階でした。もし発行が始まった後に同時発行が起きていれば、以後そのkindの版が恒久的に発行できなくなる状態でしたが、実際に事故化する前に設計を直しています。
Q4is_current を廃止した後、現行版はどうやって決めているのですか?
そのkindの版のうち、version列の最大値を持つ行を現行版として都度導出しています。可変フラグを持たないため「フラグの付け替え漏れ」自体が起こり得ず、発行も新しい行を1回INSERTするだけの単発操作で完結します。
確認した環境
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2(Supabase Postgres・PostgREST経由)
- 2026-07-24 のコードレビューで検出・同日のマイグレーションで修正(該当テーブルの発行実績0件の段階で対応)
この記事の根拠
- TypeScriptファイル 157〜196行目コミット 0a88871
- SQLファイルコミット 0a88871
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。