23503 の外部キー違反が「競合しました」に化けて本番だけ全断した
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
unique制約(23505)・check制約(23514)・外部キー制約(23503)を同じ判定関数でまとめて「制約違反」として扱うと、原因の異なる失敗が同じ「他の操作と競合しました」というメッセージに丸められ、本番でしか起きない外部キー違反が診断から遠ざかる。
結論
外部キー違反(23503)を、一意制約違反(23505)と同じ「制約違反」として一括りに判定していたため、原因の異なる失敗が同じ「他の操作と競合しました」というメッセージに丸められていました。 本当に競合している別の操作は存在しないのに、画面はそう表示し続けます。
症状
- ある操作(広告主向けの広告を運営側から入稿する処理)が、本番で実行するたびに必ず失敗する
- 画面に出るのは「他の操作と競合しました。最新の内容を読み込み直してください。」
- 読み直しても、時間を空けて再送信しても、結果は変わらない。本当に取り合っている別の操作が存在しないためです
- サーバー側は例外を投げていません。アプリの共通レスポンス形式で
{ ok: false, error: { code: "conflict" } }を正常に返しています。ログにも「想定外の例外」としては残らず、意図して用意された分岐を通っているように見えます - CI・既存のテストはすべて緑のままでした
原因
原因は2層になっていて、片方だけ直しても症状は消えません。
層1: なぜ挿入が失敗するのか
広告テーブルの created_by / updated_by は、あるマイグレーションで users(id) への外部キーが追加されました。このマイグレーションは同じ形の監査カラムを持つ5つのテーブル(grades / departments / school_configs / daily_data / ads)へ一括で外部キーを付け直しています。
一方、この操作を実行する運営ロール(system_admin)は users テーブルに行を持ちません。別テーブルで独立に管理されているアカウントです。にもかかわらず、作成処理は運営ロールの uid をそのまま created_by / updated_by に書き込んでいたため、INSERT のたびに参照先が存在せず、必ず外部キー違反(SQLSTATE 23503)になります。
同じアクションの中で、監査ログへの書き込みは先に「運営ロールなら actor を null にする」という対策が入っていました。広告本体への書き込みだけがこの対策を見送っていた、という非対称な状態でした。
層2: なぜ「競合しました」に化けるのか
このアプリには、PostgreSQL の制約違反をまとめて判定するヘルパー関数があります。
/** PostgreSQL の unique / check / FK 制約違反 (SQLSTATE 23505 / 23514 / 23503)。 */
function isConstraintViolation(error: unknown): boolean {
return isPgErrorCode(error, "23505", "23514", "23503");
}
一意制約違反(23505)・検査制約違反(23514)・外部キー違反(23503)という、原因のまったく異なる3種類のエラーを、同じ関数が「制約違反かどうか」という1つの真偽値に潰します。呼び出し側の catch は、この関数が true を返すと種類を区別せず同じメッセージを返します。
if (isConstraintViolation(error)) {
return conflict("他の操作と競合しました。最新の内容を読み込み直してください。");
}
SQLSTATE という形でコード側には原因の種類がちゃんと渡ってきているのに、アプリ層で一律のメッセージに丸めてしまったため、「本当に何か別の操作と衝突しているのではないか」という誤った仮説を持たせる結果になりました。
なお、この関数が SQLSTATE を正しく拾えていること自体は、以前の別の事故を踏んで直された経緯があります。Drizzle は postgres ドライバの元エラーを DrizzleQueryError でラップし、SQLSTATE をトップレベルの code から cause.code へ移します。error.code だけを見る古い判定はこの移動を追えず、外部キー違反を取りこぼして再 throw し、ルートのエラー境界まで届いて全画面のエラー表示になっていました。いまは cause 連鎖を最大5段辿って code を探す実装になっているため、少なくとも「捕まえ損ねて丸ごと落ちる」ことは防げています。今回の症状は、捕まえること自体はできているのに、捕まえた後の分類が粗すぎた、という一段別の問題です。
なぜテストで捕まらなかったのか
旧テストには、こういうコメントが残っていました。
ads には created_by の FK が無い(migration 0004 対象外)ので uid のままで良い
このコメントは、外部キーが追加された時点で誤りになりました。テストは DB をモックして実行されるため、実際の外部キー制約は働きません。モックの中では「制約が本当に存在するか」を検証できないので、スキーマが後から変わったことにテスト自身が気づけず、誤った前提のまま緑であり続けました。
直し方
- 運営ロールの
uidをそのまま書き込むのをやめ、user.role === "system_admin" ? null : user.uidという判定を経由させる。監査ログ側で既に使われていたのと同じ形に、広告本体側も揃える - 誤ったコメントと、それに基づくアサーションをテストから削除し、
createdBy/updatedByがnullになることを実際の制約に合わせて検証し直す - SQLSTATE を正しく
conflictに写像できているかどうかは、cause.code = 23503を模したエラーを直接渡すテストで別途確認する。DB をモックしている以上、制約そのものの有無はここでは検証できないため、「渡されたエラーコードをどう分類するか」という部分だけを切り出して検証する
再発防止
対策を1か所に入れたときは、同じデータを書いている隣の処理にも同じ条件が当てはまっていないかを確認する必要があります。今回は監査ログ側に対策が入った時点で、広告本体側にも同じ条件が成立することは分かっていたはずですが、そこまでは見送られていました。
また、「このカラムに制約は無い」という理由でテストの期待値を固定しているコメントは、スキーマ側の変更に追随する仕組みを持ちません。モックしたテストが緑であることは、その制約が現実に存在しないことの証明にはならない、という前提を持っておく必要があります。
よくある質問
Q123503 と 23505 は何が違いますか?
どちらも PostgreSQL の制約違反ですが、23503 は外部キー制約違反、23505 は一意制約違反です。原因はまったく別ですが、両方を同じ判定関数でまとめて捕まえ、同じ「他の操作と競合しました」というメッセージに変換すると、アプリのエラーメッセージからは区別がつかなくなります。
Q2なぜテストが緑のまま本番だけ壊れたのですか?
テストが DB をモックしていたため、実際の外部キー制約が働かず、違反そのものが再現されなかったからです。旧テストには「このテーブルの created_by には外部キー制約が無い」という誤ったコメントがあり、その前提のままアサーションが書かれていたため、スキーマに後から制約が追加されたことに気づけませんでした。
Q3Drizzle を使っているのに、なぜ SQLSTATE を見落とすことがあるのですか?
Drizzle は postgres ドライバの元エラーを DrizzleQueryError でラップし、SQLSTATE をトップレベルの code から cause.code へ移すためです。error.code だけを見る判定はこの移動を追わず、外部キー違反や一意制約違反を取りこぼします。cause 連鎖を辿って code を探す実装にする必要があります。
Q4同じミスをほかの箇所でも繰り返さないためには?
このケースでは、同じアクションの中で監査ログへの書き込みは先に system_admin の uid を null 化していたのに、広告本体への書き込みだけがそれを見送っていました。1か所に対策を入れたら、同じデータを書いている隣の処理にも同じ条件が当てはまるかを確認する必要があります。
この記事の根拠
- TypeScriptファイル 34〜39行目コミット b9b1260
- TypeScriptファイル 112〜172行目コミット b9b1260
- TypeScriptファイル 141〜168行目コミット b9b1260
- TypeScriptファイル 1〜35行目コミット 558ea29
- SQLファイル 87〜112行目コミット 6268962
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。