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

9.0 KiB
Raw Permalink Blame History

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 выводятся джойном к объекту-оригиналу.

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.