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:
@@ -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) → перенос → окончательное удаление → сборка объектов; проверить, что объекты удаляются только при нуле ссылок
|
||||
Reference in New Issue
Block a user