- archive local-fs-storage-backend, sqlite-metadata-backend, storage-orphan-reconciliation, metadata-managed-jwt-secret, default-local-backends - sync specs: storage-backends, metadata-backends, storage-reconciliation, app-secrets; update photos/share-links - defaults reflected in specs: SQLite and local FS
121 lines
9.3 KiB
Markdown
121 lines
9.3 KiB
Markdown
# 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) или достаточно текущих
|
||
запросов с точным совпадением/префиксом.
|