feat(photos): separate object storage from metadata layer

Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
This commit is contained in:
iu
2026-10-10 23:31:41 +03:00
parent 9effc0fc82
commit 96268ee16c
30 changed files with 1835 additions and 136 deletions
@@ -0,0 +1,134 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, которое формирует подход:
- `photos` хранит `storage_key`, `thumb_key`, `preview_key` (`migrations/000001_init.up.sql:45-47`),
а ключи строятся как `albums/{album_id}/...` (`internal/photos/service.go:72,114,115`).
- `Clone` копирует строку фото вместе с ключами, `MoveBatch` меняет только `album_id`,
`PermanentDelete` удаляет объекты по счётчику ссылок `StorageKeyRefCount`
(`internal/photos/store.go:201`).
- `DeletePrefix` (`internal/storage/storage.go:107`) существует, но не вызывается.
- Очередь вариантов отсутствует: генерация запускается goroutine из HTTP-запроса
(`internal/photos/service.go:98`), результат не переживает рестарт.
- Внешний API уже отдаёт `thumb_url/preview_url/original_url` и
`size/width/height/mime_type`; это контракт, который надо сохранить.
- В БД только дев-данные, миграция может быть разрушительной.
## Goals / Non-Goals
**Goals:**
- Убрать сущность «альбом» (и любую модель метаданных) из ключей и операций S3.
- Сделать БД журналом истины: физический объект существует, пока на него есть ссылка.
- Дать явный, устойчивый к сбоям сборщик мусора и самовосстанавливающуюся генерацию вариантов.
- Сохранить внешний JSON API без изменений.
**Non-Goals:**
- Content-addressing и дедупликация одинаковых байтов между разными загрузками
(задел — колонка `checksum`, политика позже).
- Мультивариантность сверх фиксированных original/thumb/preview (join-таблица это выдержит).
- Перенос существующих данных: миграция разрушительная.
## Decisions
### D1. Join-таблица `photo_objects` вместо колонок-FK
Фото ссылается на объекты через `photo_objects(photo_id, object_id, role)` с
`PK(photo_id, role)`. Роль — свойство **связки**, не объекта: сам объект не знает,
кто он. Альтернатива (три FK-колонки на `photos`) проще, но фиксирует ровно три
варианта и снова смешивает представление с физикой. Выбран вариант, который
расширяется до нескольких размеров/видео без переделки схемы.
### D2. Разделение фактов: физические vs логические
`objects` — только физические факты: `storage_key`, `mime_type`, `size`,
`width`, `height`, `checksum`, `state`, `created_at`.
`photos` — членство и представление: `album_id`, `uploader_id`,
`original_filename`, `caption`, `exif`, `taken_at`, `latitude`, `longitude`,
`deleted_at`, `created_at`.
`size/width/height/mime_type` в API выводятся джойном к объекту-оригиналу.
```sql
objects(
id text pk, storage_key text unique not null, mime_type text,
size bigint, width int, height int, checksum text,
state text, created_at timestamptz)
photo_objects(
photo_id -> photos(id) on delete cascade,
object_id -> objects(id) on delete restrict,
role text check (role in ('original','thumb','preview')),
primary key (photo_id, role))
create index on photo_objects(object_id);
variant_jobs(
photo_id -> photos(id) on delete cascade,
attempts int, next_attempt_at timestamptz, last_error text,
primary key (photo_id))
```
У `photos` удаляются `storage_key`, `thumb_key`, `preview_key`.
### D3. Album-free ключи, случайный id объекта
Ключ: `objects/{object_id}/{role}.{ext}`, где `object_id` — случайный
(`auth.NewID()`). Роль в ключе — косметика для отладки, истина о роли — в
`photo_objects`. Бакет, region, path-style не меняются. `DeletePrefix` удаляется.
### D4. Ledger состояний и явный GC
Объект: `pending -> ready -> deleting`. Порядок операций — **сначала строка, потом
Put**, поэтому неубиваемый мусор «файл есть, строки нет» не возникает.
```
Upload : INSERT objects(pending) -> S3 Put -> ready -> bind original + INSERT variant_jobs
Copy : INSERT photos + bind те же objects -- 0 S3
Move : UPDATE photos.album_id -- 0 S3
Delete : tx DELETE photos -> cascade unbind -> mark orphaned 'deleting'
Worker : deleting -> re-check 0 ссылок -> S3 Delete -> DELETE row
pending старше T (Put не завершился) -> deleting -> S3 Delete -> DELETE row
```
Ссылочные гонки закрываются блокировками:
- Пометить `deleting` можно только объект с нулём связок, под `SELECT ... FOR UPDATE`.
- Перед S3 Delete воркер **перепроверяет** ноль связок под `FOR UPDATE`.
- Привязка объекта (bind) берёт `FOR UPDATE` и отвергает `state != 'ready'`.
### D5. Генерация вариантов через `variant_jobs`
Вместо goroutine из запроса — строка задания в той же транзакции, что и загрузка.
Воркер забирает задания через `FOR UPDATE SKIP LOCKED`, регенерирует thumb/preview,
создаёт объекты (pending->Put->ready), вставляет связки, публикует SSE
`photo.created`, при ошибке увеличивает `attempts` и переносит `next_attempt_at`.
Это даёт однократное выполнение, ретраи и восстановление после рестарта.
### D6. Один фоновый воркер в том же бинаре
Петля-тикер внутри процесса (соответствует модели «один бинарь + systemd»):
(1) доделать варианты, (2) удалить `deleting`, (3) дососить `pending`. Интервал и
батч — конфигурируемые. Пометка объектов при удалении — синхронно в транзакции.
## Risks / Trade-offs
- [Двойная генерация вариантов после рестарта] → задание живёт в `variant_jobs`,
забирается под `SKIP LOCKED`; повторное создание объекта безопасно, лишний объект
соберёт GC.
- [Удаление объекта, который вот-вот свяжут] → `FOR UPDATE` на объекте в bind и
повторная проверка ссылок в GC.
- [Строка `photos` есть, объекта нет при сбое Put] → состояние `pending` + sweeper.
- [Джойн добавляет стоимость в список фото] → один join к объекту-оригиналу; индексы
по `photo_objects(photo_id)` (PK) и `photo_objects(object_id)`.
- [Ошибка в GC удалит живые байты необратимо] → удаляем только при доказанном нуле
ссылок; удаление идемпотентно, состояние фиксируется до S3-операции.
- [API-совместимость] → JSON-поля сохраняются, меняется только источник значений.
## Migration Plan
Разрушительная (только дев-данные):
1. Миграция создаёт `objects`, `photo_objects`, `variant_jobs`.
2. Миграция удаляет `photos.storage_key`, `photos.thumb_key`, `photos.preview_key`.
3. Существующие строки фото и объекты S3 не переносятся.
4. Откат: down-миграция возвращает колонки и удаляет новые таблицы.
5. `project.md` §10 приводится к новой структуре ключей.
## Open Questions
- Точные значения интервала воркера, размера батча и порога `T` для `pending` —
подбираются при реализации, на схему и контракты не влияют.
- Формат будущего дедупа по `checksum` (политика, не схема) — вне этого change.