#73: GET /admin/scrape/freshness — last_success/age/freshness/status для 5 источников (kn/objective/nspd/nspd_geo/cadastre) + overall_status (worst по critical). Prod-verified имена таблиц, FILTER/COALESCE корректны, CAST(:d AS interval). +4 теста. #83: README — GitHub→Forgejo CI/CD, секция 6 subagents, ссылки перемаплены. #72: docs/Schema_Migrations_vs_Alembic.md — _schema_migrations паттерн (не Alembic), рекомендация снести пустой alembic-скелет (отдельный PR). Closes #73 Closes #83 Closes #72
176 lines
12 KiB
Markdown
176 lines
12 KiB
Markdown
# Миграции схемы БД: `_schema_migrations`, не Alembic
|
||
|
||
> Decision-doc по issue [#72](https://git.gendsgn.ru/lekss361/gendesign/issues/72)
|
||
> (B5-2). Дата: 2026-06-13. Статус: **ACCEPTED**.
|
||
>
|
||
> Снапшот — источник правды по решениям живёт в Obsidian vault
|
||
> (`decisions/`). Здесь — версионированная в репозитории копия рядом с кодом,
|
||
> на который она ссылается.
|
||
|
||
## TL;DR
|
||
|
||
- Схема прод-БД развивается через **идемпотентные `data/sql/NN_*.sql`**, которые
|
||
`deploy.yml` (Forgejo Actions) прогоняет автоматически и ровно один раз каждый,
|
||
отмечая применённые в таблице-трекере **`_schema_migrations`**.
|
||
- **Alembic в проде НЕ используется.** `backend/alembic/versions/` содержит только
|
||
`.gitkeep` — за всё время не создано ни одной ревизии. `alembic upgrade head`
|
||
на проде — no-op (нет ревизий), а `alembic revision --autogenerate` рассинхронизирован
|
||
с реальной схемой (модели в `app/models/` описывают лишь крошечную часть из ~50 таблиц).
|
||
- **Рекомендация (принята): удалить пустой Alembic-скелет** (`backend/alembic/`,
|
||
`backend/alembic.ini`, dep `alembic` в `pyproject.toml`) — он не несёт функции и
|
||
вводит в заблуждение («есть Alembic → значит миграции через него»). Если в будущем
|
||
понадобится ORM-driven migration tool — переустановить осознанно.
|
||
- Формулировка из тела issue про «синхронизацию состояния Alembic с прод»
|
||
(`alembic current` / `alembic stamp head` / catch-up migration) **неактуальна**:
|
||
синхронизировать нечего — Alembic никогда не был источником схемы.
|
||
|
||
## Как реально работают миграции
|
||
|
||
### Артефакт: `data/sql/NN_*.sql`
|
||
|
||
~118 файлов (на 2026-06-13), нумерованных `NN_описание.sql` (`01_…`, `144_…`).
|
||
Каждый — самодостаточный idempotent DDL/ETL-фрагмент:
|
||
|
||
```sql
|
||
-- 144_audit_log.sql
|
||
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=144).
|
||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для CHECK.
|
||
BEGIN;
|
||
CREATE TABLE IF NOT EXISTS audit_log ( ... );
|
||
COMMENT ON TABLE audit_log IS '...';
|
||
COMMIT;
|
||
```
|
||
|
||
Правила для нового файла:
|
||
|
||
1. **Имя** — следующий свободный номер + snake_case описание: `NNN_<что>.sql`.
|
||
Порядок применения = лексикографическая сортировка `ls -1 | sort`, поэтому
|
||
зануляй ширину при необходимости (`089_…` vs `89_…` сортируются по-разному —
|
||
придерживайся существующей ширины в каталоге).
|
||
2. **Идемпотентность** — `CREATE … IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`,
|
||
`CREATE OR REPLACE VIEW`, `ON CONFLICT DO NOTHING`, guarded `DO $$ … $$`-блоки
|
||
для того, что не поддерживает `IF NOT EXISTS` (CHECK-констрейнты, ENUM-значения).
|
||
Файл может выполниться повторно вручную без вреда (хотя deploy и так пропустит
|
||
уже применённый).
|
||
3. **psycopg v3 / SQLAlchemy** — в коде приложения только `CAST(:x AS type)`,
|
||
никогда `:x::type` (см. `.claude/rules/backend.md`). В самих `.sql`-файлах,
|
||
которые гоняет `psql`, `::type`-каст допустим — это чистый psql, не через
|
||
SQLAlchemy bind-params.
|
||
4. **Транзакционность** — оборачивай связанные изменения в `BEGIN; … COMMIT;`.
|
||
`psql -v ON_ERROR_STOP=on`: при ошибке деплой падает (`exit 1`) и контейнеры
|
||
НЕ обновляются (схема и образы остаются консистентны).
|
||
|
||
### Трекер: таблица `_schema_migrations`
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
||
filename TEXT PRIMARY KEY,
|
||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
```
|
||
|
||
`filename` — basename файла (`144_audit_log.sql`). Применённость = наличие строки.
|
||
|
||
### Раннер: `.forgejo/workflows/deploy.yml`
|
||
|
||
После `git reset --hard origin/main` и `compose pull`, ДО `compose up -d`:
|
||
|
||
```bash
|
||
# 1) гарантировать трекер
|
||
psql … -c "CREATE TABLE IF NOT EXISTS _schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );"
|
||
|
||
# 2) применить каждый ещё-не-применённый файл в порядке сортировки
|
||
for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do
|
||
fname=$(basename "$sql_file")
|
||
applied=$(psql … -tAc "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'")
|
||
if [ "$applied" = "0" ]; then
|
||
echo "→ Applying migration: $fname"
|
||
psql … -v ON_ERROR_STOP=on < "$sql_file" \
|
||
|| { echo "FAILED on migration: $fname"; exit 1; }
|
||
psql … -c "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
|
||
else
|
||
echo "✓ Already applied: $fname"
|
||
fi
|
||
done
|
||
```
|
||
|
||
Ключевые свойства:
|
||
|
||
- **Только `*.sql`** трекаются/применяются. `data/sql/*.py` и `*.sh` (ad-hoc
|
||
загрузчики, напр. `02_load_all_quarters.sh`, `40_scrape_history.py`) раннер
|
||
игнорирует — они запускаются вручную.
|
||
- **Идемпотентность на двух уровнях**: трекер пропускает уже применённое; сам SQL
|
||
безопасен к повторному прогону (на случай ручного запуска или восстановления).
|
||
- **Fail-fast**: первый упавший файл останавливает деплой, образы не катятся.
|
||
- **Триггер деплоя**: путь `data/sql/**` в `on.push.paths` `deploy.yml` (правка
|
||
только `docs/**` деплой НЕ триггерит — см. `.claude/rules/deploy.md`).
|
||
|
||
### Bootstrap (исторический, разовый)
|
||
|
||
`scripts/bootstrap_schema_migrations.sh` сидировал `_schema_migrations` всеми
|
||
файлами, уже применёнными на проде ДО введения auto-apply (PR #150) — чтобы первый
|
||
auto-deploy не пытался переиграть всю историю. Запускался один раз вручную через
|
||
SSH-туннель. Для новых файлов больше не нужен — deploy.yml сам ведёт трекер.
|
||
|
||
## Почему raw SQL, а не Alembic
|
||
|
||
| | `data/sql/NN_*.sql` (используется) | Alembic (скелет, не используется) |
|
||
|---|---|---|
|
||
| Что описывает | Реальную прод-схему (~50 таблиц, partitions, GIST, materialized views, PostGIS, FDW, роли, гранты) | Только `Base.metadata` из `app/models/` — крошечная часть (1 модуль `parcel`) |
|
||
| PostGIS / big views / partitions | Нативно — пишем DDL как есть | autogenerate их не видит/ломает (geoalchemy типы, `CREATE MATERIALIZED VIEW`, partitioning) |
|
||
| Идемпотентность | Встроена (`IF NOT EXISTS`, guarded `DO`) | Линейная цепочка ревизий, down/up |
|
||
| Источник истины | Файлы в репе + `_schema_migrations` | `alembic_version` (в проде таблицы нет) |
|
||
| Применение | `deploy.yml` авто, fail-fast | Не подключён к деплою |
|
||
|
||
Проект на старте — data-heavy: партиционированный `rosreestr_deals` (~7M строк),
|
||
PostGIS-геометрии, materialized views, cross-DB FDW в tradein, роли/гранты. Это
|
||
**deployment artifacts**, которые естественно описывать прямым DDL. ORM-модели
|
||
покрывают лишь endpoints приложения, а не аналитический слой. Alembic autogenerate
|
||
на такой схеме генерирует мусор (drop неизвестных ему объектов) — поэтому им и не
|
||
пользовались.
|
||
|
||
## Граница: что куда
|
||
|
||
- **`data/sql/NN_*.sql`** — ВСЁ изменение схемы прод-БД сейчас идёт сюда:
|
||
- новые таблицы / колонки / индексы / констрейнты;
|
||
- materialized views и обычные views (`v_*`, `mv_*`);
|
||
- partitions `rosreestr_deals`;
|
||
- роли, гранты, FDW-bootstrap;
|
||
- data-backfill, который должен прогоняться ровно один раз.
|
||
- Владелец правок: **`database-expert`** subagent (см. `.claude/rules/sql.md`).
|
||
- **Alembic** — НЕ используется. (Гипотетически: если бы в будущем понадобилась
|
||
чистая ORM-driven эволюция небольшого набора app-таблиц с автогенерацией — это
|
||
был бы кандидат. Сейчас такой потребности нет, и смешение двух раннеров на одной
|
||
схеме опаснее единого пути.)
|
||
|
||
## Рекомендация по пустому Alembic-скелету
|
||
|
||
**Удалить**, чтобы убрать ложный сигнал «миграции через Alembic»:
|
||
|
||
- `backend/alembic/` (env.py, script.py.mako, versions/.gitkeep)
|
||
- `backend/alembic.ini`
|
||
- dep `alembic>=1.13.0` из `backend/pyproject.toml` + пересборка лок-файла
|
||
(`cd backend && uv lock`)
|
||
- таргеты `make migrate` (`alembic upgrade head`) и `make migration NAME=…`
|
||
(`alembic revision --autogenerate`) в корневом `Makefile` — оба зовут Alembic
|
||
и сейчас вводят в заблуждение (на проде upgrade — no-op, autogenerate
|
||
рассинхронизирован со схемой). Заменить на `make migrate-status` поверх
|
||
`_schema_migrations` либо удалить.
|
||
|
||
> Это правка `pyproject.toml` + удаление файлов под `backend/` — выполняется
|
||
> отдельным PR (`chore(backend): drop unused Alembic skeleton`), вне scope чисто
|
||
> документационного #72, чтобы не смешивать docs-only изменение с правкой
|
||
> зависимостей/Dockerfile-кэша. Здесь — зафиксированное **решение**; реализацию
|
||
> сноса делает backend-engineer отдельным PR после approve этого decision-doc.
|
||
|
||
Альтернатива (если решат оставить Alembic «на будущее»): держать скелет, но
|
||
добавить в `backend/alembic/README` явную пометку «NOT WIRED — схема через
|
||
`data/sql` + `_schema_migrations`, см. `docs/Schema_Migrations_vs_Alembic.md`»,
|
||
чтобы новый разработчик не пытался `alembic upgrade head`.
|
||
|
||
## Ссылки
|
||
|
||
- Раннер: `.forgejo/workflows/deploy.yml` (шаг "Apply pending SQL migrations")
|
||
- Bootstrap: `scripts/bootstrap_schema_migrations.sh`
|
||
- Конвенции SQL/деплоя: `.claude/rules/sql.md`, `.claude/rules/deploy.md`, `.claude/rules/backend.md`
|
||
- Скелет (к удалению): `backend/alembic/`, `backend/alembic.ini`
|