fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы #2791
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2791
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/migrations-lock-timeout"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что случилось
Миграция
250_drop_duplicate_expires_at_index.sql(DROP INDEXна таблице в 1061 строку) 2026-08-07 встала на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за чужой аналитической psql-сессией, вторая попытка — ещё 16. Четыре прогона деплоя подряд красные, четыре смерженных PR не доехали до прода. Саму миграцию снял с деплоя #2792 (на проде она не была применена — записи в_schema_migrationsнет). Этот PR — про то, чтобы следующий блокирующий DDL не повторил историю.Опасность не в простое деплоя: ждущий
ACCESS EXCLUSIVEвстаёт в очередь перед новыми запросами, поэтому за ним начинают ждать обычные SELECT приложения.1. Гейт: блокирующий DDL обязан нести
SET LOCAL lock_timeoutscripts/check-migration-lock-timeout.py+ шаг вci.yml(рядом с гейтом портов #2757, в jobchanges— тот бежит на КАЖДОМ PR обоих лэйнов, иначе гейт видел бы только половину миграций). Проверяет не только наличие строки, но и место: внутри транзакции (вне блокаSET LOCALмолча ничего не делает, только WARNING) и до первого DDL. ГолыйSET(безLOCAL) — отдельная ошибка: он доживает до конца сессии и обрежетCONCURRENTLYниже по файлу.Грандфазеринг по NN (
data/sql≥ 189, tradein ≥ 250): применённые миграции задним числом не переписываются, гейт смотрит вперёд. Сегодня под ним 0 файлов — честный ноль: 250 снята с деплоя, новых миграций пока нет. Механику держит--selftest(20 утверждений), он бежит тем же шагом.Почему НЕ
lock_timeoutодин раз в раннереОтвергнуто замером, а не вкусом.
PGOPTIONS="-c lock_timeout=5s"действительно доезжает до сервера черезdocker exec -e(SHOW lock_timeout→ 5s), но session-wide значение обрываетCREATE INDEX CONCURRENTLY: тот ждёт завершения параллельных транзакций через VirtualXactLock, и это ожидание тоже подlock_timeout. В замере (PostgreSQL 16.4) CIC упал через 5 s, когда встречная сессия просто держала открытую транзакцию (ACCESS SHARE— с CIC вообще не конфликтует), и оставил невалидный индекс. То есть runner-wide значение изготавливало бы ровно ту аварию, от которой заведена проверка №2. Блокирующий DDL иCONCURRENTLYхотят противоположной политики → granularity = файл, а не раннер.Значение 5 s
Снизу ограничено
deadlock_timeout(1 s, замерено на обеих боевых БД): автоотмена мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным autovacuum и делали деплой хрупким на ровном месте. Сверху 5 s — потолок простоя очереди приложения; против наблюдённых 1740 s это в 348 раз меньше. На работу под локом значение не влияет вообще.2. Невалидные индексы — отказ после цикла миграций (
deploy.yml,deploy-tradein.yml)Оборванный CIC оставляет
indisvalid=falseмолча. Механика воспроизведена целиком на PostgreSQL 16.4:CREATE INDEX CONCURRENTLY IF NOT EXISTSпечатаетNOTICE: relation "t_cic_idx" already exists, skippingи выходит с кодом 0;EXPLAIN→ Seq Scan), поддержка на записи платится.Одна проверка после цикла вместо DO-блока в каждом файле: ловит и этот путь, и невалидные индексы любого другого происхождения (отменённый job, ручной CIC оператором). Файлов с
CREATE INDEX CONCURRENTLY: 5 вdata/sql, 5 в tradein (остальные вхожденияCONCURRENTLY—REFRESH MATERIALIZED VIEW, невалидный индекс оставить не могут). На проде таких индексов сейчас 0 в обеих БД — профилактика, не пожар.3.
.claude/rules/sql.mdПравило + почему
CONCURRENTLY— наоборот, безlock_timeout.Test plan
--selftestгейта: 20 утверждений (CONCURRENTLY-исключение по-стейтментно; DDL внутри--//* *//строкового литерала не считается; смешанный файл CIC+ALTER обязан прикрыть ALTER)229_trade_in_estimates_consent_proof.sql, поднятая выше порога →::error ... блокирующий DDL без lock_timeout (ALTER TABLE ...), exit 1SET LOCAL(ровно то состояние, что встало на проде) → exit 1SET LOCALдоживает доDROPв форме запуска раннера (psql ... < файл, встречная сессия держитACCESS SHARE): со строкой —ERROR: canceling statement due to lock timeoutчерез 5 s, exit 3, индексы целы; контроль без строки — та же команда всё ещё висела в очереди, когда её убили на 15-й секундеset -euo pipefail: красный на реальном битом индексе, зелёный послеDROP INDEX CONCURRENTLY, отдельная ветка на «psql не ответил»Дальше
Снос дубля индекса (собственно #2752) вернуть отдельным PR с
SET LOCAL lock_timeout = '5s', когда таблица тиха. Готовый текст файла с разбором и обоснованием значения — в истории ветки, коммит26c35a0c. Критерий «тиха», записанный заранее:SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid=l.relation WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid();→ 0.Refs #2752
fix(migrations): ограничить ожидание лока в 250 и закрепить lock_timeout гейтомto fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы