#!/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())