Files
ds-galo/openspec/changes/archive/2026-10-11-fix-large-album-load/design.md
T
iu d99ac77587 chore(openspec): archive completed changes and sync main specs
- fix-large-album-load -> photos, realtime, media-objects
- fix-album-cover -> photos, albums
2026-10-11 14:16:38 +03:00

9.2 KiB
Raw Blame History

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