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
Миграция 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
242 lines
13 KiB
Python
242 lines
13 KiB
Python
#!/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())
|