Миграция 250 (DROP INDEX на таблице в 1061 строку) 2026-08-07 встала на боевой
БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за чужой
аналитической psql-сессией, вторая попытка деплоя — ещё 16. Записи в
_schema_migrations нет, схема не изменена — следующий деплой упёрся бы так же.
Опасность не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД
новыми запросами, поэтому за ним начинают ждать обычные SELECT приложения.
- 250: SET LOCAL lock_timeout = '5s' сразу после BEGIN. Значение не наугад:
снизу ограничено deadlock_timeout (1 s на проде) — автоотмена мешающего
autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
так что 1-2 s гонялись бы с рутинным autovacuum; сверху 5 s — потолок
простоя очереди приложения, против наблюдённых 1740 s это в 348 раз меньше.
Проверено в форме запуска раннера (psql < файл, PostgreSQL 16.4, встречная
сессия держит ACCESS SHARE): со строкой — отказ через 5 s и exit 3, без неё
команда всё ещё висела в очереди на 15-й секунде. SET LOCAL доживает до DROP
потому, что файл идёт одной psql-сессией и весь завёрнут в BEGIN/COMMIT.
- scripts/check-migration-lock-timeout.py + шаг в ci.yml: новая миграция с
блокирующим DDL обязана нести SET LOCAL lock_timeout, внутри транзакции и ДО
первого DDL. Гейт бежит на каждом PR (обоих лэйнов), у него --selftest.
Вариант «задать lock_timeout один раз в раннере» отвергнут замером, а не
вкусом: session-wide значение обрывает CREATE INDEX CONCURRENTLY (тот ждёт
параллельные транзакции через VirtualXactLock, и это ожидание тоже под
lock_timeout) и оставляет невалидный индекс — то есть изготавливало бы ровно
ту аварию, от которой заведена вторая проверка. Блокирующий DDL и
CONCURRENTLY хотят противоположной политики → granularity = файл.
- deploy.yml / deploy-tradein.yml: после цикла миграций — отказ, если в БД
есть индексы с indisvalid=false (#2752). Оборванный CIC оставляет такой
индекс молча: планировщик им не пользуется, а re-run миграции не чинит —
CREATE INDEX CONCURRENTLY IF NOT EXISTS печатает «already exists, skipping»
и выходит с кодом 0, после чего миграция помечается применённой. На проде
таких индексов сейчас 0 (обе БД) — это профилактика.
Refs #2752