Files
ds-galo/openspec/changes/sqlite-metadata-backend/design.md
T
iu 176a8d8ced feat(metadata): SQLite backend, storage reconciliation and local defaults
- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls
- SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx)
- separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite
- cmd/reconcile compares object storage with the journal (report, then --delete)
- storage.Backend.List and photos.Repository.AllStorageKeys
- DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults
  (sqlite ./galo.db, fs ./data)
- Makefile reconcile/reconcile-delete; docs and .env.example updated
2026-10-11 10:08:31 +03:00

9.3 KiB
Raw Blame History

Design

Context

См. proposal.md — Why. Текущее состояние, определяющее подход:

  • Каждый домен имеет конкретный Store с полем *pgxpool.Pool и SQL диалекта Postgres. Всего ~72 метода: auth(9), albums(7), members(8), photos(20), photos/objects(15), social(6), notifications(3), sharelinks(4).
  • Postgres-специфика: FOR UPDATE SKIP LOCKED, ANY($1), make_interval/power/least, now(), timestamptz, jsonb/::jsonb, ON CONFLICT, RETURNING.
  • Кросс-доменные зависимости уже выражены интерфейсами (AlbumAccess, PhotoLookup, Notifier, MemberLookup, AlbumReader); свои store сервисы берут конкретными.
  • Сборка CGO_ENABLED=0; model: один экземпляр приложения на инсталляцию.
  • Ограничение: SQLite допускает один писатель, много читателей (WAL); один файл на один экземпляр приложения.

Goals / Non-Goals

Goals:

  • Ввести интерфейсы репозиториев по доменам и вторую реализацию на SQLite с паритетом.
  • Сохранить текущее поведение PostgreSQL по умолчанию и не менять сервисную логику.
  • Обеспечить атомарность объектов, GC и очереди вариантов на обоих бэкендах.
  • Гарантировать паритет общим контрактным набором тестов.

Non-Goals:

  • Горизонтальное масштабирование и несколько экземпляров на один файл SQLite.
  • Миграция данных между PostgreSQL и SQLite.
  • Изменение наблюдаемого поведения API/спек (см. proposal.md, изменений требований нет).
  • Изменение слоя хранения (см. local-fs-storage-backend).

Decisions

D1. Параллельные реализации, а не слой диалекта

Выбрано (по решению пользователя): Postgres-store не трогаем, пишем отдельные SQLite-store. Интерфейс Repository становится швом между доменом и хранилищем.

  • Альтернатива: единая логика + Dialect — отвергнута: правки во всех существующих store и риск регрессии Postgres ради экономии дублирования.
  • Цена: каждая будущая фича реализуется дважды; снижается контрактными тестами (D9).

D2. Именование и размещение

Интерфейс домена — Repository; существующий pgx-тип остаётся Store (NewStore(pool)), SQLite-тип — SQLiteStore и живёт в том же пакете (photos/sqlite_store.go и т.п.).

  • В том же пакете — доступ к models и sentinel-ошибкам без циклов импорта и без перемещения существующего кода.
  • Сервисы типизируются интерфейсом (albums.Repository и т.д.); кросс-доменные интерфейсы удовлетворяются любой реализацией.

D3. Драйвер SQLite — modernc.org/sqlite

Чистый Go, совместим с CGO_ENABLED=0.

  • Альтернатива mattn/go-sqlite3 (CGO) — отвергнута, ломает модель сборки.

D4. Роль $1-плейсхолдеров

Плейсхолдеры остаются $1: и pgx, и modernc понимают именованные параметры. Поскольку реализации раздельные, каждая пишет свой SQL; отдельный слой диалекта не нужен.

D5. Отдельный набор миграций для SQLite

migrations/sqlite/ рядом с Postgres-набором, драйвер golang-migrate/database/sqlite. Отображение типов: timestamptz→TEXT, jsonb→TEXT, bigint→INTEGER, double precision→REAL, now()→strftime('%Y-%m-%dT%H:%M:%fZ','now'), REFERENCES ... ON DELETE CASCADE и CHECK сохраняются.

  • Альтернатива: переносимый единый набор — отвергнута, тянет переписывание обоих.

D6. Настройка соединения и конкурентность

journal_mode=WAL, busy_timeout=5000, foreign_keys=ON через DSN-параметры _pragma. Писатель один; читатели — параллельно.

  • ANY($1) переписывается в IN (?, ...) с построением списка в Go; батчи малы.
  • FOR UPDATE/SKIP LOCKED заменяются транзакцией с немедленной блокировкой записи (BEGIN IMMEDIATE); порядок «проверить состояние — вставить/заявку» сохраняется.

D7. Атомарность жизненного цикла объектов

BindObject и claim-методы (ClaimGarbage, ClaimStalePending, ClaimVariantJobs) выполняются в BEGIN IMMEDIATE-транзакции, что сериализует запись и воспроизводит инварианты Postgres-версии (нельзя привязать объект, уходящий в GC; нельзя удалить используемый; одинаковая роль без дублей).

D8. Backoff и now()

FailVariantJob считает задержку в Go (min(300, 10*2^attempts)) вместо make_interval/power/least. Время пишется/читается через хелперы преобразования time.Time <-> RFC3339 TEXT; запросы сравнения используют datetime('now')/значения Go, а не неявное приведение драйвера.

D9. Контрактные тесты на интерфейс

Один набор тестов на каждый Repository, прогоняемый на обоих бэкендах: Postgres под TEST_DATABASE_URL (пропуск без него), SQLite на временном файле (всегда). Это механизм удержания паритета при параллельных реализациях.

D10. Фабрика и сборка зависимостей

store.NewPostgres(pool) и store.NewSQLite(db) возвращают бандл репозиториев; app.New принимает бандл и storage.Backend. Выбор бэкенда — на старте по DB_BACKEND=postgres|sqlite (дефолт postgres), путь — SQLITE_PATH.

  • Альтернатива: ветвление внутри app.New по типу пула — отвергнута, размазывает выбор.

D11. Маппинг ошибок и RETURNING

schema-уровневые sentinel-ошибки домена сохраняются; каждая реализация сама отображает pgx.ErrNoRows/sql.ErrNoRows в sentinel. RETURNING используется и в SQLite (>=3.35), поэтому вставки с возвратом created_at переносятся один-в-один.

Risks / Trade-offs

  • [Дрейф параллельных реализаций] → общий контрактный набор тестов на интерфейс (D9).
  • [Пропускная способность записи SQLite под многими пользователями] → WAL, busy_timeout, один писатель; документируем как режим малой инсталляции.
  • [Потеря валидации jsonb] → хранение как TEXT, проверка JSON при необходимости в Go.
  • [Ошибки преобразования времени/JSON между бэкендами] → хелперы + контрактные тесты.
  • [Расхождение миграций] → симметричные наборы, прогон up на обоих в тестах.
  • [Лимит параметров SQLite в IN (...)] → малые батчи (WORKER_BATCH), построение списка в Go.

Migration Plan

  1. Ввести интерфейсы, не меняя Postgres-реализации и дефолт postgres.
  2. Добавить SQLite-реализации, миграции и фабрику; включение — DB_BACKEND=sqlite.
  3. Существующие развёртывания не меняются; откат — вернуть DB_BACKEND=postgres.
  4. Перенос данных между бэкендами не автоматизируется и вне рамок изменения.

Open Questions

  • Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих запросов с точным совпадением/префиксом.