Files
ds-galo/openspec/changes/fix-large-album-load/design.md
T
iu d7a37328f0 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
2026-10-11 14:15:59 +03:00

116 lines
9.2 KiB
Markdown
Raw 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` и 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).