gendesign/.claude/rules/sql.md
bot-backend 482deb4864
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
fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы (#2791)
2026-08-07 11:21:28 +00: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 без комментария почему