gendesign/docs/Schema_Migrations_vs_Alembic.md
Light1YT d38417d8af
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m39s
Deploy / build-worker (push) Successful in 7m20s
Deploy / deploy (push) Successful in 2m48s
feat(admin): freshness endpoint (#73) + README Forgejo refresh (#83) + migrations doc (#72)
#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
2026-06-13 18:25:32 +00:00

12 KiB
Raw Permalink Blame History

Миграции схемы БД: _schema_migrations, не Alembic

Decision-doc по issue #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-фрагмент:

-- 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

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:

# 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