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.
|
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
|
||||||
|
|
||||||
-- DDL здесь (idempotent)
|
-- DDL здесь (idempotent)
|
||||||
|
|
||||||
COMMIT;
|
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 (обязательно)
|
## Idempotency (обязательно)
|
||||||
|
|
||||||
- `CREATE TABLE IF NOT EXISTS`
|
- `CREATE TABLE IF NOT EXISTS`
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,15 @@ jobs:
|
||||||
python3 scripts/check-workflow-ports.py --selftest
|
python3 scripts/check-workflow-ports.py --selftest
|
||||||
python3 scripts/check-workflow-ports.py
|
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
|
- uses: dorny/paths-filter@v3
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,35 @@ jobs:
|
||||||
done
|
done
|
||||||
echo "All migrations applied."
|
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).
|
# Bootstrap gendesign_reader password from env (post-migration, #976).
|
||||||
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
|
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
|
||||||
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
|
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,31 @@ jobs:
|
||||||
done
|
done
|
||||||
echo "All migrations applied."
|
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).
|
# Set tradein_fdw_reader password from env (post-migration bootstrap).
|
||||||
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
|
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
|
||||||
# password lives only in /opt/gendesign/backend/.env.runtime.
|
# 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