- 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
9.3 KiB
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
- Ввести интерфейсы, не меняя Postgres-реализации и дефолт
postgres. - Добавить SQLite-реализации, миграции и фабрику; включение —
DB_BACKEND=sqlite. - Существующие развёртывания не меняются; откат — вернуть
DB_BACKEND=postgres. - Перенос данных между бэкендами не автоматизируется и вне рамок изменения.
Open Questions
- Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих запросов с точным совпадением/префиксом.