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
135 lines
9.0 KiB
Markdown
135 lines
9.0 KiB
Markdown
# 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.
|