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