chore(openspec): archive completed changes and sync main specs

- fix-large-album-load -> photos, realtime, media-objects
- fix-album-cover -> photos, albums
This commit is contained in:
iu
2026-10-11 14:16:38 +03:00
parent d7a37328f0
commit d99ac77587
17 changed files with 69 additions and 14 deletions
@@ -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).