gendesign/scripts/check-migration-lock-timeout.py
bot-backend 26c35a0c18
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
fix(migrations): ограничить ожидание лока в 250 и закрепить lock_timeout гейтом
Миграция 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
2026-08-07 15:39:33 +05:00

242 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
data/sql — то есть следующий блокирующий DDL повторил бы это.
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock,
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
противоположной политики, поэтому granularity — файл, а не раннер.
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
задним числом не переписываются. Гейт смотрит только вперёд.
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# каталог миграций -> минимальный NN, с которого правило обязательно.
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
SQL_DIRS: dict[str, int] = {
"data/sql": 189,
"tradein-mvp/backend/data/sql": 250,
}
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
# в очередь и увести за собой запросы приложения.
BLOCKING = re.compile(
r"\b(?:"
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
r")\b",
re.IGNORECASE,
)
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
# по-прежнему обязан прикрыть свой ALTER.
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
NN_PREFIX = re.compile(r"^(\d+)")
def strip_noise(sql: str) -> str:
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
пробелы (сохраняя длину и переводы строк — номера строк не съезжают).
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL.
"""
out: list[str] = []
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if ch == "-" and nxt == "-":
while i < n and sql[i] != "\n":
out.append(" ")
i += 1
elif ch == "/" and nxt == "*":
depth = 1 # в PostgreSQL блочные комментарии вложенные
out.append(" ")
i += 2
while i < n and depth:
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
depth += 1
out.append(" ")
i += 2
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
depth -= 1
out.append(" ")
i += 2
else:
out.append("\n" if sql[i] == "\n" else " ")
i += 1
elif ch == "'":
out.append("'")
i += 1
while i < n:
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
out.append(" ")
i += 2
continue
if sql[i] == "'":
break
out.append("\n" if sql[i] == "\n" else " ")
i += 1
if i < n:
out.append("'")
i += 1
else:
out.append(ch)
i += 1
return "".join(out)
def scan(sql: str) -> list[str]:
"""-> список претензий к файлу; пустой список = файл в порядке."""
clean = strip_noise(sql)
statements = clean.split(";")
first_blocking: int | None = None
blocking_text = ""
for idx, stmt in enumerate(statements):
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
first_blocking = idx
blocking_text = " ".join(stmt.split())[:80]
break
if first_blocking is None:
return []
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
if set_local is None:
if any(SET_BARE_LT.search(s) for s in statements):
return [
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
]
return [
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
]
problems: list[str] = []
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
problems.append(
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
)
if set_local > first_blocking:
problems.append(
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
)
return problems
def selftest() -> None:
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert scan(ok) == [], scan(ok)
# красное: ровно случай 250 до фикса
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
# красное: правильная строка, но в местах, где она не действует
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert "без LOCAL" in scan(bare)[0]
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
mixed_ok = (
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
)
assert scan(mixed_ok) == [], scan(mixed_ok)
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
failed = False
checked = 0
for dirname, min_nn in SQL_DIRS.items():
sql_dir = Path(dirname)
if not sql_dir.is_dir():
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
return 1
for path in sorted(sql_dir.glob("*.sql")):
m = NN_PREFIX.match(path.name)
if not m or int(m.group(1)) < min_nn:
continue # применено на проде до внедрения гейта — не переписываем
checked += 1
for problem in scan(path.read_text(encoding="utf-8")):
failed = True
print(f"::error file={path}::{problem}")
if failed:
return 1
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
return 0
if __name__ == "__main__":
raise SystemExit(main())