Files
iu 7ae9bcf305 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
2026-10-10 23:32:21 +03:00

135 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.