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
9.2 KiB
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).