Миграция 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
6.5 KiB
| paths | ||
|---|---|---|
|
SQL conventions — PostgreSQL 16 / PostGIS 3.4
File naming
NN_topic.sql где NN — следующий sequential номер. Проверка: ls data/sql/ | sort | tail -5.
Structure
-- Контекст: что делает файл, зачем, порядок применения, 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 EXISTSALTER TABLE ... ADD COLUMN IF NOT EXISTSALTER TABLE ... DROP COLUMN IF EXISTSCREATE OR REPLACE VIEWCREATE INDEX IF NOT EXISTSON CONFLICT DO NOTHINGдля seed dataDROP INDEX IF EXISTSпередCREATE INDEXесли меняется тип column
Auto-apply на prod
data/sql/NN_*.sql применяются автоматически через deploy.yml (tracking через _schema_migrations). Каждый файл — ровно один раз, по NN order. Idempotency критична.
Если auto-apply падает → deploy exit 1, containers не обновляются. Diagnose: GHA log → "Apply DB migrations". Fix: psql -f file.sql через SSH tunnel + INSERT INTO _schema_migrations (filename) VALUES ('NN_xxx.sql') ON CONFLICT DO NOTHING.
Migration order
- SQL migration first (схема) — deploy first
- Backend code matching new schema — deploy second
- Никогда наоборот — deployed code напорется на несовместимую схему
psycopg v3 CAST trap
В Python через psycopg v3 / SQLAlchemy text(...):
- ❌
:param::type— игнорируется парсером - ✅
CAST(:param AS type)— canonical
В чистом SQL (.sql файл, без bind params) — обычный ::type работает.
Reference: vault Pattern_CAST_AS_Type.
VIEW dependencies
Перед ALTER COLUMN type column'а:
- Найти зависимые view:
\d+ table_nameилиpg_get_viewdef('view_name'::regclass, true) DROP VIEW <dependent>;ALTER COLUMN ...;CREATE OR REPLACE VIEW <dependent> AS SELECT ...;
Reference: 93_cad_parcels_geom_multipolygon.sql (Polygon → MultiPolygon migration).
Агрегация по pre-aggregated строкам (обязательно weighted AVG)
Если источник содержит строки вида «одна строка = один период (месяц) + уже посчитанный
avg_value + count» (например objective_corpus_room_month), то наивный AVG(avg_value)
неверен: строки с нулевыми сделками занижают результат в 2-10x.
Правильная формула — count-weighted AVG:
SUM(avg_value * cnt) / NULLIF(SUM(cnt), 0)
NULLIF(..., 0)обязателен — предотвращаетdivision by zeroпри all-zero периодах и возвращаетNULLвместо фейкового0.- Без весов:
AVG()равноправно учитывает «пустые» месяцы → занижение.
Reference: fix #295 (100_fix_mv_layout_velocity_weighted_avg.sql),
тест backend/tests/sql/test_mv_layout_velocity_weighted_avg.py.
Запреты
- ❌
DROP TABLE/TRUNCATEбез явного approval пользователя - ❌ Не-idempotent файлы (без
IF EXISTS) — на случай ре-apply - ❌ Миграции без
BEGIN...COMMITобёртки - ❌ Hardcoded даты / IDs без комментария почему