CHECK 制約を張り替えるたび、別ブランチが足した値が消えていた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
PostgreSQL の CHECK 制約を DROP して ADD し直す設計では、後続のマイグレーションが値の全体を知らないまま張り替えると、別ブランチが追加した値をそのまま消してしまう。
結論
PostgreSQL の CHECK 制約を DROP CONSTRAINT してから ADD CONSTRAINT で書き直す設計は、その時点で書いた人が知っている値でしか「追加」になりません。
別ブランチが先に追加していた値は、書いた本人が消すつもりが無くても、書き直した瞬間に制約から落ちます。しかもマイグレーション自体はエラーなく成功します。「追加専用・冪等・非破壊」というコメントは、そのブランチの中でしか成立していません。
症状
share_links テーブルには kind 列があり、invoice quote application のような公開リンクの種類を CHECK (kind in (...)) で制限しています。ここに新しい種類を1つ足すたびに、対象のマイグレーションはこう書かれていました。
alter table public.share_links
drop constraint if exists share_links_kind_check;
alter table public.share_links
add constraint share_links_kind_check
check (kind in ('invoice','quote','application','report')); -- 0040
check (kind in ('invoice','quote','application','report','file')); -- 0044
check (kind in ('invoice','quote','application','report','file','bundle')); -- 0045
check (kind in ('invoice','quote','application','report','file','bundle','group_invoice')); -- 0054
0040・0044・0045・0054、いずれのファイルの冒頭コメントにも「追加専用・冪等・非破壊」と書かれています。実際、このトランク上で見る限り値は毎回1つずつ増えているだけで、既存の行にも影響しません。
一方、これとは別の長期分岐ブランチで領収証機能が実装され、そこでは kind に receipt を追加するマイグレーションが2026-06-13に本番へ投入されていました。トランク側はこのブランチへ未マージのままで、0040以降を書いた人は誰も receipt の存在を知りません。
トランクからのデプロイが進んだ結果、kind='receipt' の share_link を作ろうとする操作が CHECK 違反で失敗するようになりました。領収証の発行そのものが本番でできない状態です。マイグレーション履歴のどこにもエラーは記録されていません。制約は「正しく」書き直され続けていただけです。
原因
DROP CONSTRAINT + ADD CONSTRAINT で許可値を管理する設計は、こう動きます。
0040 を書いた人が知っている kind = {invoice, quote, application, report}
→ CHECK をこの4つで張り替える(この時点では正しい)
0044 を書いた人が知っている kind = {invoice, quote, application, report, file}
→ CHECK をこの5つで張り替える(この時点でも正しい)
……
receipt を追加した人は、この張り替えの連鎖に一度も参加していない
→ トランク側の誰も receipt の存在を知らないまま張り替えが続く
→ 張り替えが起きるたびに receipt は「知られていない値」のまま制約の外に置かれ続ける
各マイグレーションは自分が把握している値の集合については確かに追加専用です。しかし IN リストを毎回丸ごと書き直す実装である以上、そこに列挙されなかった値は、書いた人の意図に関わらず制約から外れます。 ブランチが分岐したまま両方が本番へ向かっていると、この「列挙されなかった値」が実際に運用中の値であっても起こります。
確認する
いま本番でどの値が許可されているかは、マイグレーションファイルの新しい番号を目で追うのではなく、実際に適用されている制約定義そのものから引きます。
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'public.share_links'::regclass
AND conname = 'share_links_kind_check';
ここで返る IN リストが「いま本番で通る値の全体」です。マイグレーションファイルを新しい番号から遡って kind の出現箇所を拾っても、別ブランチだけが追加した値は拾えません。
直す
失われていた値を、それまでの全 kind の和集合へ加えて冪等に張り替え直します。
-- 0055_share_links_receipt_restore.sql
alter table public.share_links drop constraint if exists share_links_kind_check;
alter table public.share_links
add constraint share_links_kind_check
check (kind in ('invoice','quote','application','report','file','bundle','group_invoice','receipt'));
番号を0031へ巻き戻すことはしません。トランク側の番号はすでに0054まで進んでおり、今から0031を新規に割り当てると時系列の順序が崩れます。最新の番号で、その時点までに存在するはずの全ての値を書き出し直すのが直し方になります。
再発しない形にする
この記事の根拠には、その後どう設計を変えたかの記述はありません。ここで言えるのは構造的なことだけです。
「マイグレーションファイルを新しい番号から遡って読む」のと「実際に本番へ適用されている制約を引く」は、ブランチが1本しかない前提でしか一致しません。 分岐したブランチがそれぞれ独立に同じ列へ値を追加できる状況では、ファイルの履歴を追う限り、自分のブランチが一度も見ていない値の存在に気づく手段がありません。
pg_get_constraintdef で実際の制約を引く確認は、この事故の直し方の中では「値を復元する前」の手順として使いましたが、同じ確認は値を追加する側、つまり次に kind へ何か足そうとする人が事前にやるべき手順でもあります。マイグレーションファイルの最新番号ではなく、本番に今何が入っているかを見てから IN リストを書けば、少なくとも「知らずに消す」側の事故は防げます。
よくある質問
Q1マイグレーションのコメントに「追加専用・冪等・非破壊」と書いてあれば安全ではないのですか?
そのブランチの中では安全です。ただし DROP CONSTRAINT してから ADD CONSTRAINT し直す実装だと、そのマイグレーションが書いた IN リストに無い値は、書いた本人が意図していなくても制約から消えます。「追加専用」は自分の知っている値の集合についてしか成立しません。
Q2この事故はどうやって気づいたのですか?
領収証機能を実装した長期分岐ブランチが本番デプロイの元になっているトランクへ未マージのままで、トランク側で領収証を発行しようとした操作が CHECK 違反で失敗しました。マイグレーション自体はエラーなく流れているので、実際に該当の値を使う操作をするまで気づけません。
Q3直すときに0031を作り直せばよいのでは?
しません。トランクの番号はすでに0054まで進んでいて、今から0031を新規に割り当てると時系列の順序が崩れます。番号を巻き戻すのではなく、最新の番号(この場合は0055)でその時点までの全 kind の和集合へ冪等に張り替え直します。
Q4同じ事故を防ぐには CHECK 制約をどう設計すればよいですか?
この記事の根拠にその後の設計変更は含まれていません。言えるのは、DROP+ADD で書き直す限り、値の全体像はマイグレーションファイルの履歴ではなく実際に適用されている本番の制約定義そのものから確認する必要があるということです。
この記事の根拠
- SQLファイル 1〜20行目
- SQLファイル 1〜15行目
- SQLファイル 1〜10行目
- SQLファイル 29〜34行目
- SQLファイル 1〜36行目
- SQLファイル 1〜17行目
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。