fix(photos): faster large-album loading and album cover lifecycle
Large albums opened slowly and flickered; the default album cover was also lost in the media-objects refactor. Large-album loading: - quantize signed object URLs within a window and send Cache-Control so previews are reused instead of re-fetched - publish photo.updated (not photo.created) when variants are ready, so clients update in place without reordering the grid - worker concurrency + newest-first variant priority; raise default batch Album cover: - first uploaded photo becomes the cover when none is set - clear the cover when the cover photo is trashed or moved out - "set as album cover" in the photo context menu and actions panel OpenSpec changes: fix-large-album-load, fix-album-cover
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
Список фото отдаётся за миллисекунды; узкое место — не выборка, а доставка вариантов и
|
||||
реакция клиента. См. `proposal.md — Why` и delta-спеки `photos`, `realtime`,
|
||||
`media-objects`. Ниже — как устроены три правки.
|
||||
|
||||
Текущие точки опоры:
|
||||
- `internal/storage/signer.go`: `URL()` кладёт `e = now + ttl` и подпись `s` — уникальны
|
||||
каждую секунду.
|
||||
- `internal/app/app.go:serveObject`: единая точка выдачи объектов для `fs` и `s3`.
|
||||
- `internal/photos/worker.go`: один горутин, `tick` → `ClaimVariantJobs(batch, ...)` →
|
||||
последовательная обработка; `notify` публикует `photo.created`.
|
||||
- `internal/photos/sqlite_objects.go:ClaimVariantJobs`: `ORDER BY next_attempt_at`.
|
||||
- `frontend/src/lib/sse.ts`: `knownEvents` не содержит `photo.updated`.
|
||||
- `frontend/.../[id]/+page.svelte`: на `photo.created` делает prepend незнакомого фото.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Устранить перетасовку сетки и повторную перекачку превью при открытии больших альбомов.
|
||||
- Заполнять видимый экран превью в первую очередь и заметно быстрее, чем сейчас.
|
||||
|
||||
**Non-Goals:**
|
||||
- Изменение схемы метаданных (миграции не требуются).
|
||||
- Замена кодеков/алгоритмов ресайза, переход на отдельный воркер-сервис.
|
||||
- Синхронная генерация вариантов на upload (пункт 4 из обсуждения, отложен).
|
||||
- Инвалидация/очистка CDN-кэша (CDN вне области).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Разделение семантики событий вместо фильтрации на клиенте
|
||||
|
||||
**Выбор:** worker при готовности вариантов публикует `photo.updated`; `photo.created`
|
||||
остаётся только для реального появления фото (upload/restore/copy). На клиенте `photo.updated`
|
||||
— replace только для уже присутствующих фото; `photo.created` — по-прежнему prepend.
|
||||
|
||||
**Почему:** `photo.created` сейчас перегружен, и клиент не может отличить «новое фото»
|
||||
от «превью доготовилось». Фильтрация prepend на клиенте без разделения семантики ломает
|
||||
появление реально новых загрузок. Разделение чинит и первопричину, и заодно закрывает
|
||||
латентный баг: `photo.updated` не входит в `knownEvents`, поэтому переименование фото по
|
||||
SSE сейчас не доходит.
|
||||
|
||||
**Альтернативы:**
|
||||
- *Фильтр prepend только для фото старше самого старого загруженного* — эвристика, не
|
||||
устраняет корень, хрупко при пагинации.
|
||||
- *Вставлять в правильную позицию по `created_at`* — сохраняет перестановку/перерисовку,
|
||||
не решает проблему.
|
||||
|
||||
### 2. Детерминированные подписанные URL + Cache-Control
|
||||
|
||||
**Выбор:** квантовать срок действия: `exp = now.Truncate(step) + ttl.bucket`. URL стабилен
|
||||
внутри окна `step`. Ответы объектов получают `Cache-Control: private, max-age=<...>`.
|
||||
Заголовок ставится в `app.serveObject` до вызова `storage.ServeObject` — одна точка для
|
||||
обоих бэкендов. `http.ServeContent` уже даёт `Last-Modified`/304.
|
||||
|
||||
**Почему:** стабильный URL + явный кэш устраняют повторную загрузку при перерисовке сетки и
|
||||
reconnect. Объекты неизменяемы (один key — одни байты), поэтому кэш безопасен.
|
||||
|
||||
**Параметры (решаются здесь, значения конфигурируемы):**
|
||||
- `step` — окно стабильности подписи (по умолчанию 10 минут).
|
||||
- `max-age` — срок свежести в браузере (по умолчанию равен окну/ttl; консервативно — окно).
|
||||
|
||||
**Альтернативы:**
|
||||
- *Не менять подпись, только заголовки* — URL всё равно меняется между refetch, браузер
|
||||
делает новый запрос; мигание остаётся.
|
||||
- *Долгоживущая подпись без окна* — снижает безопасность сильнее, чем квантование.
|
||||
- *Кэш в памяти процесса на бэкенде* — не решает перекачку в браузере.
|
||||
|
||||
**Безопасность:** ссылка живёт до `ttl + step` вместо `ttl`. Приемлемо; при необходимости
|
||||
снижается уменьшением `step`.
|
||||
|
||||
### 3. Пропускная способность и приоритет очереди
|
||||
|
||||
**Выбор:** параметры worker'а (уже частично есть: `WORKER_BATCH`, `WORKER_INTERVAL`) дополняются
|
||||
уровнем параллелизма `WORKER_CONCURRENCY` (пул горутинов, по умолчанию небольшой, напр. 4).
|
||||
`ClaimVariantJobs` для свежих заданий сортирует по свежести фото (`created_at DESC`), а
|
||||
задания с `attempts > 0` — по `next_attempt_at` (сохраняя backoff повторов).
|
||||
|
||||
**Почему:** генерация CPU-bound (декод + Lanczos + WebP) и распараллеливается по ядрам;
|
||||
параллелизм даёт кратный прирост. Приоритет по свежести напрямую сокращает время до
|
||||
заполнения видимого экрана (клиент показывает новейшие фото первыми). SQLite настроен на
|
||||
single-writer с `busy_timeout(5000)` и короткими транзакциями — параллельная обработка
|
||||
безопасна.
|
||||
|
||||
**Альтернативы:**
|
||||
- *Только поднять `WORKER_BATCH`/снизить `WORKER_INTERVAL`* — простой конфиг, но
|
||||
последовательная обработка упирается в скорость одного ядра и в паузу между тиками.
|
||||
- *Приоритетная очередь с отдельной колонкой приоритета* — лишняя схема; сортировки по
|
||||
`created_at`/`next_attempt_at` достаточно.
|
||||
- *Пул воркеров с `errgroup`/`semaphore`* — выбран вариант реализации пула; конкретный
|
||||
примитив — деталь, не влияет на поведение.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Переупорядочивание событий: `photo.updated` обгонит `photo.created` при гонке] →
|
||||
обработчик `photo.updated` для отсутствующего фото — no-op (спека это закрепляет).
|
||||
- [Параллельная запись в SQLite даёт `SQLITE_BUSY`] → короткие транзакции, `busy_timeout`,
|
||||
ограниченный уровень параллелизма.
|
||||
- [Увеличение срока жизни подписанного URL] → ограничить окно `step`; сделать его
|
||||
конфигурируемым.
|
||||
- [Кэш браузера отдаёт «старое» превью] → объекты неизменяемы, thumb/preview не
|
||||
перегенерируются на месте; риск отсутствует.
|
||||
- [Старые сессии/табы ждут `photo.created` для доготовки превью] → после деплоя клиент и
|
||||
сервер должны обновиться вместе; в рамках проекта клиент и бэкенд релизятся вместе.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Изменений схемы нет → откат — возврат к предыдущему бинарю/фронту.
|
||||
- Порядок: сначала backend (событие + подпись + worker), затем frontend (набор событий и
|
||||
обработка). Взаимная совместимость в переходном окне: старый фронт игнорирует
|
||||
`photo.updated` (не в `knownEvents`), новый фронт с новым бэком работает штатно.
|
||||
- После деплоя большой альбом наполняется быстрее; уже существующие незавершённые задания
|
||||
подхватываются автоматически (очередь durable).
|
||||
Reference in New Issue
Block a user