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
121 lines
6.5 KiB
Markdown
121 lines
6.5 KiB
Markdown
---
|
||
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 без комментария почему
|