gendesign/.claude/rules/sql.md
bot-backend 26c35a0c18
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
fix(migrations): ограничить ожидание лока в 250 и закрепить lock_timeout гейтом
Миграция 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
2026-08-07 15:39:33 +05:00

6.5 KiB
Raw Blame History

paths
data/sql/**/*.sql
tradein-mvp/backend/data/sql/**/*.sql

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 EXISTS
  • ALTER TABLE ... ADD COLUMN IF NOT EXISTS
  • ALTER TABLE ... DROP COLUMN IF EXISTS
  • CREATE OR REPLACE VIEW
  • CREATE INDEX IF NOT EXISTS
  • ON CONFLICT DO NOTHING для seed data
  • DROP 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

  1. SQL migration first (схема) — deploy first
  2. Backend code matching new schema — deploy second
  3. Никогда наоборот — 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'а:

  1. Найти зависимые view: \d+ table_name или pg_get_viewdef('view_name'::regclass, true)
  2. DROP VIEW <dependent>;
  3. ALTER COLUMN ...;
  4. 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 без комментария почему