infra/migrations/0055_work_code_case_uniq.sql の冒頭コメントには、実際に一度本番を止めて手に入れた一文がある。それを書く前に、私はまずひとつの判断を書いた。そしてその判断は本番環境に跳ね返された。

まず背景をはっきりさせておく。そうでなければ、あのコメントはただ慎重ぶった冗長な文章に見えるだけだ。

直すべきは「見つからない」ではない

0001_init.sql が work.code に渡しているのは生の TEXT UNIQUE、つまり UNIQUE (code) で、大文字小文字を区別する。一方、サイト全体の読み取り側は一律 lower(code) = lower($1) でマッチしている。

両者がずれることの結果は「見つからない」ではなく、同じ作品が DB の中で二行に裂けることだ。カタログ配信元の 2026-08 のバッチが番号を dass-966 から DASS-966 へと推送し直したため、upsert_work の ON CONFLICT (code) がヒットせず、新しい行が挿入された。本番では実際に三組が裂けた:DASS-966、DSOD-009、START-597 で、2026-08-29 に手作業でのマージが完了している。

裂けた後の症状もエラーではなく、二種類のサイレント障害だ:

  1. 半端なデータ。古い行が source_link、work_embedding、frame_embedding を握っているのに対し、新しい行にはメタデータしかない。しかも ... WHERE lower(w.code)=lower($1) LIMIT 1 には ORDER BY がないため、どちらの行を取るかはクエリプラン次第だ。DSOD-009 と START-597 が当時取得したのは再生ソースのない行で——視聴ページは 200 を返すのに、再生ソースが一つもない。
  2. いきなり 500。= (SELECT ... WHERE lower(code)=lower($1)) 形式のスカラーサブクエリは、複数行があると more than one row returned by a subquery used as an expression を投げる。お気に入り解除、プレイリストからの削除、類似作品のフォールバックはいずれもこの形式だ。

0046_anime_import.sql:117-131 では当時、あえて一意インデックスを直接張らなかった。理由は既存の汚いデータが --single-transaction マイグレーション全体を吹き飛ばしてしまうからだ。データのマージがきれいに終わったので、0055 でようやく締め付ける。

なぜあの大文字小文字を区別する制約を残すのか

作るのは CREATE UNIQUE INDEX work_code_lower_key ON work (lower(code)) だ。では古い work_code_key(UNIQUE (code))はついでに削除できないのか?

できない。external_source_map.code は work(code) を指す外部キーで、外部キーは参照される列に一意制約を要求するため、DROP work_code_key は依存関係のエラーで失敗する。一方、UNIQUE (lower(code)) はそれより厳密に強いので、残しておいても副作用はない。

0046 が作った非一意の関数インデックス idx_work_lower_code なら削除できる——列は同じで新しい一意インデックスに完全に覆われるため、残しても書き込み増幅が一つ増えるだけだ。

一つ目の判断:なぜ全部を一つの DO ブロックに収めるのか

LOCK TABLE はトランザクションブロックの中でしか使えないからだ。

本番のマイグレーションは migrate.sh の psql --single-transaction を通るのでトランザクションブロックがあり、問題ない。しかし tools/ugc-smoke/README.md に記録されているローカルでの実行方法はファイルごとの psql -f で、autocommit であり、トランザクションブロックがない——生の LOCK はそちらでは LOCK TABLE can only be used in transaction blocks を返し、ローカルのスモーク用 DB にはこの一意制約が欠けたままになる。そしてそれはまさに、このマイグレーションで唯一やるべきことなのだ。

DO ブロックは暗黙のトランザクションを伴うので、どちらの実行方法でも成立する。ついでに、なぜ LOCK と CREATE INDEX を二つの文に分けてはいけないのかも説明できる。

なぜ冒頭で最強のロックを取るのか

CREATE UNIQUE INDEX が取るのは SHARE ロックで、書き込みをブロックできる;DROP INDEX は ACCESS EXCLUSIVE を要求する。先に作って後に削除するのは、同一トランザクション内での二度目のロック昇格だ。

その間に割り込んでくるオンライン書き込み(increment_view のあの UPDATE work)は RowExclusive を取れずに待ち行列に入り、こちらはそちらが手放すのを待つ。環ができて deadlock detected、マイグレーション全体がロールバックする。これは本番 DB 上で実際に再現を確認している。

「マイグレーション実行中は誰も書いていない」という、つい成立してしまいがちな前提がある。これは成立しない。compose は migrate を走らせ終えてから api コンテナに触るので、マイグレーション中も旧 api はサービスを続けており、increment_view はずっと書き込んでいる。

だから冒頭で一度 LOCK TABLE work IN ACCESS EXCLUSIVE MODE によって最強のロックを取り、最後まで昇格しなければ、環は形成されようがない。

二つ目の判断:ここで一度間違えた

初めて prd に上げたとき、lock_timeout の後ろに付いていたのは「ロックが取れなければ即座に失敗する」だった。当時の推論はこうだ:migrate が非ゼロで終了すれば api は再ビルドされず、旧 api がサービスを続けるので、これは安全な失敗の方向だ、と。

この判断は間違っていた。

docker compose up -d は migrate が非ゼロで終了したからといって旧コンテナを保ってはくれない。いつも通り api/web を作り直し、ただ新しいコンテナが Created で止まって起動しない——depends_on: migrate: service_completed_successfully が満たされないのだ。

差し引きの結果はこうだ:マイグレーションは効かず、サイトはそのまま落ちる。しかも prd の work テーブルには常に increment_view が書き込んでおり、15 秒ではもともと ACCESS EXCLUSIVE ロックを取れるとは限らない。こうして、一度の普通のデプロイがサイトを消し飛ばした。

正しいやり方は短いタイムアウト + 複数回のリトライだ:毎回 2 秒だけ待ち、待ち行列に並んだ書き込みトランザクションを走らせてから再試行する。オンライン書き込みはどれも短いトランザクションなので、数ラウンドのうちに必ず窓が開く。60 秒経っても取れないときに初めて RAISE する——そういう状況は長いトランザクションが work を押さえているのだから、無理に割り込むのではなく、本来は人手で対応すべきものだ。

FOR i IN 1..30 LOOP
    BEGIN
        PERFORM set_config('lock_timeout', '2s', true);
        LOCK TABLE work IN ACCESS EXCLUSIVE MODE;
        EXIT;
    EXCEPTION WHEN lock_not_available THEN
        IF i = 30 THEN
            RAISE EXCEPTION '60 秒内拿不到 work 的 ACCESS EXCLUSIVE 锁(有长事务压着?先查 pg_stat_activity 再重试)';
        END IF;
        PERFORM pg_sleep(1);
    END;
END LOOP;
PERFORM set_config('lock_timeout', '0', true);

各ラウンドが独立した BEGIN/EXCEPTION を持つと暗黙に savepoint が作られ、ロック取得に失敗してもそのラウンドだけがロールバックされ、マイグレーションのトランザクション全体を汚さない。

エラーメッセージは人間の言葉で

一意インデックスを作る前には、もう一道の関門がある。重複があれば、duplicate key value の一言ではなく、そのまま実行できる一文を返すようにする:

SELECT count(*), string_agg(lc, ', ' ORDER BY lc)
  INTO dup, sample
  FROM (SELECT lower(code) AS lc FROM work GROUP BY 1 HAVING count(*) > 1 LIMIT 20) t;
IF dup > 0 THEN
    RAISE EXCEPTION
      'work 存在 % 组大小写重复 code,须先人工合并(保留握有 source_link/向量的那行)再跑本迁移:%',
      dup, sample;
END IF;

「source_link を持つ行を残す」というこの一文は必ず書き込んでおく必要がある——マージの方向を逆にしてしまうと、データは同じように失われ、一意インデックスも同じように作成できてしまい、ただそれ以降その作品には再生ソースがなくなるだけだ。

仕上げ

EXECUTE 'DROP INDEX IF EXISTS idx_work_lower_code';
EXECUTE 'CREATE UNIQUE INDEX IF NOT EXISTS work_code_lower_key ON work (lower(code))';

どちらも EXECUTE を通す:DO ブロックの中に直接 DDL を書くこともできるが、スクリプト全体で EXECUTE を使って文字列を組み立てる方式に統一しており、上の変数と結びついているので、前後を通して読むと一つの事柄として理解できる。

マイグレーションファイルの末尾には ANALYZE work; もあり、新しいインデックスをプランナーにできるだけ早く認識させる。

今振り返ると、このファイルは全部で 86 行、うちコメントが 52 行を占めている。その中で最も長いのはあの停止時の記録で、「⚠️ この一行は本番停止と引き換えに得たものだ」と書いてある…… 書き残しておかなければ、次の人(あるいは三ヶ月後の自分)が「2 秒 × 30 回」を見て冗長だと感じるだけで、すぐに「高速失敗」に最適化し直してしまうだろう。