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
9.0 KiB
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
Разрушительная (только дев-данные):
- Миграция создаёт
objects,photo_objects,variant_jobs. - Миграция удаляет
photos.storage_key,photos.thumb_key,photos.preview_key. - Существующие строки фото и объекты S3 не переносятся.
- Откат: down-миграция возвращает колонки и удаляет новые таблицы.
project.md§10 приводится к новой структуре ключей.
Open Questions
- Точные значения интервала воркера, размера батча и порога
Tдляpending— подбираются при реализации, на схему и контракты не влияют. - Формат будущего дедупа по
checksum(политика, не схема) — вне этого change.