fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы #2791

Merged
bot-backend merged 2 commits from fix/migrations-lock-timeout into main 2026-08-07 11:21:29 +00:00
Collaborator

Что случилось

Миграция 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_timeout

scripts/check-migration-lock-timeout.py + шаг в ci.yml (рядом с гейтом портов #2757, в job changes — тот бежит на КАЖДОМ 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:

  1. CIC оборван → индекс невалидный, деплой красный, миграция НЕ помечена применённой;
  2. следующий деплой прогоняет её заново → CREATE INDEX CONCURRENTLY IF NOT EXISTS печатает NOTICE: relation "t_cic_idx" already exists, skipping и выходит с кодом 0;
  3. миграция помечается применённой, индекс остаётся битым навсегда, планировщик его не использует (EXPLAIN → Seq Scan), поддержка на записи платится.

Одна проверка после цикла вместо DO-блока в каждом файле: ловит и этот путь, и невалидные индексы любого другого происхождения (отменённый job, ручной CIC оператором). Файлов с CREATE INDEX CONCURRENTLY: 5 в data/sql, 5 в tradein (остальные вхождения CONCURRENTLYREFRESH 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 1
  • красный прогон: файл 250 без строки SET LOCAL (ровно то состояние, что встало на проде) → exit 1
  • SET LOCAL доживает до DROP в форме запуска раннера (psql ... < файл, встречная сессия держит ACCESS SHARE): со строкой — ERROR: canceling statement due to lock timeout через 5 s, exit 3, индексы целы; контроль без строки — та же команда всё ещё висела в очереди, когда её убили на 15-й секунде
  • блок проверки невалидных индексов извлечён из YAML и прогнан под set -euo pipefail: красный на реальном битом индексе, зелёный после DROP INDEX CONCURRENTLY, отдельная ветка на «psql не ответил»
  • YAML всех трёх workflow парсится
  • после мержа: оба деплоя зелёные, в логах шаг «✓ невалидных индексов нет.»

Дальше

Снос дубля индекса (собственно #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

## Что случилось Миграция `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_timeout` `scripts/check-migration-lock-timeout.py` + шаг в `ci.yml` (рядом с гейтом портов #2757, в job `changes` — тот бежит на КАЖДОМ 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: 1. CIC оборван → индекс невалидный, деплой красный, миграция НЕ помечена применённой; 2. следующий деплой прогоняет её заново → `CREATE INDEX CONCURRENTLY IF NOT EXISTS` печатает `NOTICE: relation "t_cic_idx" already exists, skipping` и выходит с **кодом 0**; 3. миграция помечается применённой, индекс остаётся битым навсегда, планировщик его не использует (`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 - [x] `--selftest` гейта: 20 утверждений (CONCURRENTLY-исключение по-стейтментно; DDL внутри `--`/`/* */`/строкового литерала не считается; смешанный файл CIC+ALTER обязан прикрыть ALTER) - [x] **красный прогон на реальном входе**: неизменённая историческая `229_trade_in_estimates_consent_proof.sql`, поднятая выше порога → `::error ... блокирующий DDL без lock_timeout (ALTER TABLE ...)`, exit 1 - [x] **красный прогон**: файл 250 без строки `SET LOCAL` (ровно то состояние, что встало на проде) → exit 1 - [x] `SET LOCAL` доживает до `DROP` в форме запуска раннера (`psql ... < файл`, встречная сессия держит `ACCESS SHARE`): со строкой — `ERROR: canceling statement due to lock timeout` через 5 s, exit 3, индексы целы; **контроль** без строки — та же команда всё ещё висела в очереди, когда её убили на 15-й секунде - [x] блок проверки невалидных индексов извлечён из YAML и прогнан под `set -euo pipefail`: красный на реальном битом индексе, зелёный после `DROP INDEX CONCURRENTLY`, отдельная ветка на «psql не ответил» - [x] YAML всех трёх workflow парсится - [ ] после мержа: оба деплоя зелёные, в логах шаг «✓ невалидных индексов нет.» ## Дальше Снос дубля индекса (собственно #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
bot-backend added 1 commit 2026-08-07 10:40:25 +00:00
fix(migrations): ограничить ожидание лока в 250 и закрепить lock_timeout гейтом
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m44s
CI / openapi-codegen-check (pull_request) Successful in 2m37s
CI Trade-In / backend-tests (pull_request) Successful in 4m28s
CI / backend-tests (pull_request) Successful in 15m57s
26c35a0c18
Миграция 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
Light1YT added 1 commit 2026-08-07 11:04:50 +00:00
Merge remote-tracking branch 'origin/main' into fix/migrations-lock-timeout
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m34s
CI / backend-tests (pull_request) Successful in 15m57s
80b40d5414
# Conflicts:
#	tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql
bot-backend changed title from fix(migrations): ограничить ожидание лока в 250 и закрепить lock_timeout гейтом to fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы 2026-08-07 11:10:34 +00:00
bot-backend merged commit 482deb4864 into main 2026-08-07 11:21:29 +00:00
bot-backend deleted branch fix/migrations-lock-timeout 2026-08-07 11:21:29 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2791
No description provided.