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