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,2 @@
schema: spec-driven
created: 2026-10-10
@@ -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.
@@ -0,0 +1,49 @@
# Proposal
## Why
Слой хранения S3 сейчас знает о сущности «альбом»: ключи объектов имеют вид
`albums/{album_id}/original/{photo_id}.{ext}` (`photos/service.go:72,114,115`).
Это протекание модели метаданных в физический слой: перенос фото оставляет ключ в
старом namespace, копия делит объекты под префиксом исходного альбома, а
неиспользуемый `DeletePrefix` (`storage/storage.go:107`) создаёт латентный риск
удаления живых объектов. Нужно развести слои: S3 хранит непрозрачные объекты, а
сущности и их жизненный цикл живут в БД.
## What Changes
- Ввести таблицу `objects` — физические блобы (ключ, mime, размер, габариты,
checksum, состояние) и `photo_objects` — связка photo↔object с ролью
(`original|thumb|preview`).
- **BREAKING**: удалить `storage_key`, `thumb_key`, `preview_key` из `photos`;
ключи фото выводятся через джойн к объекту-оригиналу. Миграция разрушительная
(в базе только дев-данные).
- Сделать ключи S3 album-free: `objects/{id}/{role}.{ext}`, где `id` — случайный
идентификатор объекта.
- Ввести явный GC через ledger-состояния объектов `pending | ready | deleting`:
физический объект удаляется только когда на него не осталось ни одной связки.
- Перенести генерацию вариантов в фоновый воркер: таблица `variant_jobs`
(ровно-один-раз, ретраи с backoff), воркер также удаляет осиротевшие объекты и
досасывает зависшие `pending`. SSE `photo.created` публикует воркер.
- Удалить `DeletePrefix` из клиента хранения — entity-namespace больше нет.
## Capabilities
### New Capabilities
- `media-objects`: физическое объектное хранилище с непрозрачными ключами, журнал
объектов и их жизненный цикл, сборка мусора по отсутствию ссылок, привязка
объектов к фото по ролям.
### Modified Capabilities
- `photos`: уточняется семантика удаления (корзина не удаляет объекты хранилища) и
разделение объектов между фото при переносе и копировании.
## Impact
- Backend: `internal/photos/{service,store}.go`, `internal/storage/storage.go`,
`internal/albums/*` (косвенно, через ключи обложки), wiring фонового воркера,
`migrations/` (новая схема + удаление колонок).
- Данные: разрушительная миграция `photos` (dev-only), новые таблицы.
- Внешний JSON API не меняется: `thumb_url/preview_url/original_url` и
`size/width/height/mime_type` по-прежнему отдаются, но выводятся из объектов.
- Документация: `project.md` §10 (структура в S3).
@@ -0,0 +1,90 @@
# Spec Delta
## Purpose
Физическое объектное хранилище с непрозрачными ключами и журналом объектов:
привязка объектов к фото по ролям, совместное использование объектов несколькими
фото и сборка мусора по отсутствию ссылок.
## ADDED Requirements
### Requirement: Непрозрачные ключи хранилища
Слой хранения SHALL адресоваться непрозрачными ключами объектов, не содержащими
идентификаторов сущностей метаданных (таких как альбом или фото); сопоставление
ключей и сущностей SHALL храниться только в слое метаданных.
#### Scenario: Ключ без альбома
- **WHEN** система сохраняет объект в хранилище
- **THEN** ключ объекта не содержит идентификатора альбома
#### Scenario: Перенос не трогает ключи
- **WHEN** фото переносится в другой альбом
- **THEN** ключи его объектов остаются неизменными
### Requirement: Журнал объектов
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
ролям original, thumb и preview.
#### Scenario: Привязка ролей
- **WHEN** фото загружено и обработано
- **THEN** фото ссылается на объект-оригинал и, когда они готовы, на объекты thumb и preview
#### Scenario: Оригинал без вариантов
- **WHEN** варианты ещё не сгенерированы
- **THEN** фото доступно с оригиналом, а thumb и preview отсутствуют
### Requirement: Совместные объекты
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
объекты хранилища.
#### Scenario: Копирование
- **WHEN** пользователь копирует фото в другой альбом
- **THEN** создаётся новая запись, ссылающаяся на те же объекты, без операций с хранилищем
#### Scenario: Перенос
- **WHEN** пользователь переносит фото в другой альбом
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
### Requirement: Сборка мусора по ссылкам
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото;
удаление альбома и попадание фото в корзину SHALL сохранять объекты.
#### Scenario: Последняя ссылка удалена
- **WHEN** фото удаляется навсегда и больше ни одно фото не ссылается на его объекты
- **THEN** объекты удаляются из хранилища
#### Scenario: Остались ссылки
- **WHEN** на объект ссылается другое фото
- **THEN** объект сохраняется в хранилище
#### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом
- **THEN** объекты хранилища не удаляются
### Requirement: Устойчивость жизненного цикла объекта
Объект SHALL иметь состояние жизненного цикла, и незавершённые операции SHALL
самовосстанавливаться: неудачная загрузка не оставляет осиротевших объектов, а
удаление объекта SHALL доводиться до конца после сбоя.
#### Scenario: Сбой загрузки
- **WHEN** запись объекта создана, но загрузка в хранилище не завершилась
- **THEN** объект очищается без ручного вмешательства
#### Scenario: Сбой удаления
- **WHEN** удаление объекта из хранилища не удалось
- **THEN** попытка повторяется до успешного удаления
### Requirement: Генерация вариантов
Генерация thumb и preview SHALL выполняться через надёжную очередь заданий с
однократным выполнением и повтором при сбое; готовый результат SHALL
транслироваться подписчикам альбома.
#### Scenario: Повтор после рестарта
- **WHEN** процесс перезапускается с незавершёнными заданиями
- **THEN** генерация вариантов продолжается
#### Scenario: Событие после генерации
- **WHEN** варианты фото готовы
- **THEN** подписчикам альбома публикуется событие с этим фото
@@ -0,0 +1,14 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Удаление фото
Автор SHALL иметь возможность удалить своё фото; владелец и админ SHALL иметь возможность удалить любое фото. Удаление SHALL помещать фото в корзину и SHALL не удалять объекты хранилища.
#### Scenario: Удаление своего фото
- **WHEN** автор удаляет своё фото
- **THEN** фото помещается в корзину, а объекты хранилища сохраняются
#### Scenario: Удаление чужого фото участником
- **WHEN** участник с ролью member пытается удалить чужое фото
- **THEN** система отклоняет запрос
@@ -0,0 +1,37 @@
# Tasks
## 1. Схема и миграции
- [x] 1.1 Добавить миграцию, создающую `objects`, `photo_objects` (PK `photo_id,role`, индекс `object_id`) и `variant_jobs`; проверить, что `make migrate` применяется и таблицы видны в БД
- [x] 1.2 Добавить миграцию, удаляющую `photos.storage_key`, `photos.thumb_key`, `photos.preview_key`, `photos.size`, `photos.width`, `photos.height`, `photos.mime_type`; проверить, что up и down миграции проходят в обе стороны без ошибок
## 2. Слой хранения
- [x] 2.1 Удалить `DeletePrefix` из `internal/storage/storage.go`; проверить `go build ./...` и отсутствие вызовов метода в коде
- [x] 2.2 Ввести хелпер ключа объекта вида `objects/{id}/{role}.{ext}` без идентификатора альбома; покрыть юнит-тестом, что ключ не содержит album_id
## 3. Метаданные объектов
- [x] 3.1 Реализовать стор объектов: создание `pending`, активация `ready`, пометка `deleting`, удаление строки, выборка `pending` старше порога; покрыть тестами переходы состояний
- [x] 3.2 Реализовать привязку `photo_objects` с блокировкой объекта `FOR UPDATE` и отказом при `state != 'ready'`; тест на повторную привязку одного объекта к двум фото
- [x] 3.3 Реализовать запрос GC (объекты `ready` без связок) и тесты сценариев «последняя ссылка удалена» и «остались ссылки»
## 4. Сервис фото
- [x] 4.1 Переписать `Upload`: строка объекта `pending` → S3 Put → `ready` → привязка `original` + задание в `variant_jobs`; тест, что загрузка даёт фото с URL оригинала и ключом без альбома
- [x] 4.2 Переписать `List`/`Get`/`attachURLs` на вывод ключей и `size/width/height/mime_type` джойном к объекту-оригиналу; тест, что JSON-поля и URL присутствуют как раньше
- [x] 4.3 Переписать `CopyBatch`/`MoveBatch` на работу только через связки; тест, что объекты хранилища не меняются и делятся между фото
- [x] 4.4 Переписать `PermanentDelete`: удалить связки и пометить осиротевшие объекты `deleting`; тест, что объект с нулём ссылок становится кандидатом на GC
- [x] 4.5 Убрать обращения к удалённым колонкам во всех запросах и моделях; проверить `go build ./...` и прохождение `internal/repo` тестов
## 5. Фоновый воркер
- [x] 5.1 Реализовать цикл забора `variant_jobs` через `FOR UPDATE SKIP LOCKED`, генерацию thumb/preview, привязку и публикацию SSE; тест обработки задания и повтора при ошибке
- [x] 5.2 Реализовать в воркере удаление `deleting` (с повторной проверкой нуля ссылок) и досасывание зависших `pending`; тест повторной попытки S3 Delete и очистки `pending`
- [x] 5.3 Встроить старт/останов воркера в жизненный цикл приложения; проверить, что сервер запускается и воркер выполняет петлю (по логам/интеграционному тесту)
## 6. Интеграция и документация
- [x] 6.1 Перевести обложку альбома (`CoverThumbKey`) на вывод через `photo_objects`+`objects`; тест, что список альбомов отдаёт URL обложки
- [x] 6.2 Обновить `project.md` §10 (структура в S3 и слои); проверить, что описание совпадает с реализованными ключами
- [x] 6.3 Сквозная проверка: загрузка → генерация вариантов → копирование между альбомами (0 операций S3) → перенос → окончательное удаление → сборка объектов; проверить, что объекты удаляются только при нуле ссылок