fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы (#2791)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 43s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy / build-frontend (push) Successful in 47s
Deploy / build-worker (push) Successful in 49s
Deploy / build-backend (push) Successful in 50s
Deploy / deploy (push) Successful in 1m12s
Deploy Trade-In / test (push) Successful in 3m25s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 1m50s
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 43s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy / build-frontend (push) Successful in 47s
Deploy / build-worker (push) Successful in 49s
Deploy / build-backend (push) Successful in 50s
Deploy / deploy (push) Successful in 1m12s
Deploy Trade-In / test (push) Successful in 3m25s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 1m50s
This commit is contained in:
parent
29f1000228
commit
482deb4864
5 changed files with 337 additions and 0 deletions
|
|
@ -16,11 +16,43 @@ paths:
|
|||
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
|
||||
|
||||
-- DDL здесь (idempotent)
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## lock_timeout при блокирующем DDL (обязательно)
|
||||
|
||||
Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
|
||||
`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
|
||||
сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
|
||||
на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
|
||||
|
||||
**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
|
||||
на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
|
||||
psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
|
||||
ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
|
||||
ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
|
||||
отказ, повторить позже) вместо тихой очереди перед приложением.
|
||||
|
||||
**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
|
||||
мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
|
||||
поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
|
||||
очередь запросов приложения.
|
||||
|
||||
**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
|
||||
`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
|
||||
VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
|
||||
оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
|
||||
в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
|
||||
обрежет `CONCURRENTLY` ниже по файлу.
|
||||
|
||||
Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
|
||||
`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
|
||||
CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
|
||||
|
||||
## Idempotency (обязательно)
|
||||
|
||||
- `CREATE TABLE IF NOT EXISTS`
|
||||
|
|
|
|||
|
|
@ -65,6 +65,15 @@ jobs:
|
|||
python3 scripts/check-workflow-ports.py --selftest
|
||||
python3 scripts/check-workflow-ports.py
|
||||
|
||||
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
|
||||
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
||||
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
||||
# фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
|
||||
# каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
|
||||
run: |
|
||||
python3 scripts/check-migration-lock-timeout.py --selftest
|
||||
python3 scripts/check-migration-lock-timeout.py
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -452,6 +452,35 @@ jobs:
|
|||
done
|
||||
echo "All migrations applied."
|
||||
|
||||
# (3b) Невалидные индексы после цикла (#2752). Оборванный
|
||||
# CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
|
||||
# планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
|
||||
# на записи всё равно платится. Молчит это так (воспроизведено на
|
||||
# PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
|
||||
# применённой → следующий деплой прогоняет её заново → `CREATE INDEX
|
||||
# CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
|
||||
# «relation already exists, skipping», выходит с кодом 0 → миграция
|
||||
# помечается применённой, а индекс остаётся невалидным навсегда.
|
||||
# Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
|
||||
# ловит и этот путь, и невалидные индексы любого другого
|
||||
# происхождения (отменённый job, ручной CIC оператором).
|
||||
# На 2026-08-07 на проде таких индексов 0 — это профилактика.
|
||||
invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
|
||||
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
|
||||
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|
||||
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
|
||||
if [ -n "$invalid_idx" ]; then
|
||||
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
|
||||
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
|
||||
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
|
||||
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
|
||||
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
|
||||
echo " пересоздать индекс и повторить деплой."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ невалидных индексов нет."
|
||||
|
||||
# Bootstrap gendesign_reader password from env (post-migration, #976).
|
||||
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
|
||||
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
|
||||
|
|
|
|||
|
|
@ -309,6 +309,31 @@ jobs:
|
|||
done
|
||||
echo "All migrations applied."
|
||||
|
||||
# Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX
|
||||
# CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ
|
||||
# пользуется, а re-run миграции его не чинит — `CREATE INDEX
|
||||
# CONCURRENTLY IF NOT EXISTS` печатает «relation already exists,
|
||||
# skipping» и выходит с кодом 0, после чего миграция помечается
|
||||
# применённой, а индекс остаётся битым навсегда (воспроизведено на
|
||||
# PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY.
|
||||
# Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07
|
||||
# на проде таких индексов 0 — это профилактика.
|
||||
invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
|
||||
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|
||||
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
|
||||
if [ -n "$invalid_idx" ]; then
|
||||
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
|
||||
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
|
||||
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
|
||||
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
|
||||
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
|
||||
echo " пересоздать индекс и повторить деплой."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ невалидных индексов нет."
|
||||
|
||||
# Set tradein_fdw_reader password from env (post-migration bootstrap).
|
||||
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
|
||||
# password lives only in /opt/gendesign/backend/.env.runtime.
|
||||
|
|
|
|||
242
scripts/check-migration-lock-timeout.py
Normal file
242
scripts/check-migration-lock-timeout.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Reference in a new issue