chore(openspec): archive media-object-layer and sync main specs
- move change media-object-layer to openspec/changes/archive/2026-10-10-media-object-layer - add main spec specs/media-objects (opaque keys, object journal, shared objects, reference-based GC, lifecycle recovery, variant generation queue) - update specs/photos deletion requirement to place photos in the trash without deleting storage objects
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user