From 26c35a0c18d1911b308ef829edec6730d3fcaf0e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 15:39:33 +0500 Subject: [PATCH] =?UTF-8?q?fix(migrations):=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B6=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=BA=D0=B0=20=D0=B2=20?= =?UTF-8?q?250=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20lock=5Ftimeout=20=D0=B3=D0=B5=D0=B9=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 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 --- .claude/rules/sql.md | 32 +++ .forgejo/workflows/ci.yml | 9 + .forgejo/workflows/deploy-tradein.yml | 29 +++ .forgejo/workflows/deploy.yml | 25 ++ scripts/check-migration-lock-timeout.py | 242 ++++++++++++++++++ .../250_drop_duplicate_expires_at_index.sql | 36 +++ 6 files changed, 373 insertions(+) create mode 100644 scripts/check-migration-lock-timeout.py diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md index f221b4ef..136bc01a 100644 --- a/.claude/rules/sql.md +++ b/.claude/rules/sql.md @@ -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` diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0719e2a3..53e1d59b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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: diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index b93af07c..ee062b18 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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. diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7303d4cf..f267444a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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. diff --git a/scripts/check-migration-lock-timeout.py b/scripts/check-migration-lock-timeout.py new file mode 100644 index 00000000..92b1e3ff --- /dev/null +++ b/scripts/check-migration-lock-timeout.py @@ -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()) diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql index b0866aaa..9a8a5580 100644 --- a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -72,6 +72,39 @@ -- значит файл пришлось бы оставить без BEGIN/COMMIT (см. разбор механики -- раннера в 225_listing_source_snapshots_run_id_idx.sql). -- +-- ── ЧТО ПОШЛО НЕ ТАК ПРИ ПЕРВОМ ПРИМЕНЕНИИ (2026-08-07) ────────────────────── +-- Разбор выше верен ровно в одном: дёшево УДЕРЖАНИЕ лока. Дорого ОЖИДАНИЕ его +-- выдачи, и об этом файл молчал. На проде шла чужая ручная аналитическая +-- psql-сессия (ACCESS SHARE на этой же таблице, ~час), и DROP встал в очередь: +-- первая попытка деплоя ждала 29 минут, вторая — ещё 16, обе сняты вручную. +-- Схема не изменилась, оба индекса на месте. +-- +-- Вред не в простое деплоя, а в том, что ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами: любой SELECT приложения по +-- trade_in_estimates начал бы ждать за ним. За 40 минут наблюдения ни один +-- запрос приложения в очередь не встал — обошлось, но механика такова. +-- +-- Отсюда `SET LOCAL lock_timeout` ниже. Он ограничивает ТОЛЬКО ожидание; +-- на саму работу (единицы мс) не влияет никак. Почему 5 s: +-- - снизу: deadlock_timeout на проде = 1 s (default, замер 2026-08-07). +-- Автоотмена мешающего autovacuum срабатывает только после того, как +-- ждущий отстоял deadlock_timeout, поэтому 1 s гонялся бы с рутинным +-- autovacuum и делал деплой хрупким на ровном месте. 5 s = 5× запас. +-- - сверху: столько максимум может простоять очередь запросов приложения. +-- Против наблюдённых 1740 s это в 348 раз меньше. +-- Срабатывание таймаута = миграция НЕ применилась и деплой красный +-- (ON_ERROR_STOP=on в раннере) — честный отказ вместо тихой очереди. +-- Лечение: повторить деплой, когда чужая сессия закончится. +-- +-- Проверено, что `SET LOCAL` доживает до DROP именно в форме запуска раннера +-- (`psql ... < файл`, PostgreSQL 16.4, встречная сессия держит ACCESS SHARE): +-- со строкой — `ERROR: canceling statement due to lock timeout` через 5 s, +-- exit 3, оба индекса на месте; без неё — та же команда всё ещё висела в +-- очереди, когда её убили на 15-й секунде. Работает это потому, что файл идёт +-- ОДНОЙ psql-сессией и весь завёрнут в BEGIN/COMMIT: `SET LOCAL` живёт до +-- COMMIT. В файле без BEGIN каждая команда — своя транзакция, и `SET LOCAL` +-- не дожил бы до следующей строки. +-- -- IDEMPOTENCY / SAFETY: -- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. -- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. @@ -90,6 +123,9 @@ BEGIN; +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке. +SET LOCAL lock_timeout = '5s'; + DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; COMMENT ON INDEX trade_in_estimates_expires_idx IS -- 2.45.3