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

121 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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
```sql
-- Контекст: что делает файл, зачем, порядок применения, 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:
```sql
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 без комментария почему