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,2 @@
schema: spec-driven
created: 2026-10-11
@@ -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).
@@ -0,0 +1,46 @@
# Proposal
## Why
Большие альбомы (например `seed-1791714157-0 #2`, 1000 фото) открываются медленно и
«мерцают». Замеры на реальной базе: запрос списка фото выполняется за ~1 мс, но при
открытии первые 50 ячеек — это ещё не обработанные фото («обработка…»), генерация
вариантов идёт со скоростью ~2 фото/с (порядка 8 минут на альбом), а сетка перетасовывается
из-за семантики SSE-событий и перекачивается из-за нестабильных подписанных URL.
## What Changes
- **Стабильные ссылки на объекты.** Подписанный URL перестаёт меняться каждую секунду:
срок действия квантуется по окну, ответы бэкенда получают `Cache-Control`, чтобы браузер
переиспользовал уже загруженные превью вместо повторной загрузки.
- **Корректная семантика SSE-событий.** Готовность вариантов worker'а публикуется как
изменение фото (не как новое фото), поэтому клиент обновляет уже показанное фото и не
вставляет чужие фото в начало сетки. Событие изменения фото добавляется в набор
отслеживаемых на клиенте.
- **Ускорение и приоритезация очереди вариантов.** Повышается пропускная способность
генерации вариантов (параллелизм и параметры пакета) и свежие фото обрабатываются первыми,
чтобы видимый экран заполнялся превью быстрее.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `photos`: подписанные URL становятся детерминированными в пределах окна и отдаются с
заголовками кэширования.
- `realtime`: готовность вариантов публикуется как изменение фото; набор событий включает
изменение фото.
- `media-objects`: очередь генерации вариантов получает настраиваемую пропускную способность
и приоритет по свежести фото.
## Impact
- Backend: `internal/storage/signer.go`, `internal/app/app.go` (заголовки объектов),
`internal/photos/worker.go`, `internal/photos/sqlite_objects.go` (порядок и пакет claim),
`internal/config/config.go` (параметры worker'а).
- Frontend: `src/lib/sse.ts` (набор событий), `src/routes/(app)/albums/[id]/+page.svelte`
(обработка событий и вставка в сетку).
- Поведение/совместимость: подписанный URL живёт до `ttl + окно` вместо `ttl`; событие
`photo.created` больше не переиспользуется для готовности вариантов.
- Данные/миграции: изменений схемы не требуется.
@@ -0,0 +1,25 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Генерация вариантов
Генерация thumb и preview SHALL выполняться через надёжную очередь заданий с
однократным выполнением и повтором при сбое; готовый результат SHALL
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
пропускную способность и SHALL обрабатывать более свежие фото раньше остальных.
#### Scenario: Повтор после рестарта
- **WHEN** процесс перезапускается с незавершёнными заданиями
- **THEN** генерация вариантов продолжается
#### Scenario: Событие после генерации
- **WHEN** варианты фото готовы
- **THEN** подписчикам альбома публикуется событие обновления этого фото
#### Scenario: Приоритет свежих фото
- **WHEN** в очереди есть готовые к обработке задания разного возраста
- **THEN** система обрабатывает более свежие фото раньше
#### Scenario: Настраиваемая пропускная способность
- **WHEN** задан уровень параллелизма генерации
- **THEN** система обрабатывает варианты с этим уровнем параллелизма
@@ -0,0 +1,32 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Получение фото по требованию
Клиент SHALL иметь возможность получить актуальные данные фото с подписанным URL,
обслуживаемым бэкендом, независимо от бэкенда хранения. Подпись SHALL быть
детерминированной в пределах окна действия, чтобы повторные выдачи одного и того же
объекта давали идентичный URL.
#### Scenario: Выдача свежего URL
- **WHEN** клиент запрашивает фото по id
- **THEN** система возвращает подписанный URL бэкенда для просмотра
#### Scenario: Повторная выдача совпадает
- **WHEN** один и тот же объект запрашивается повторно в пределах окна действия подписи
- **THEN** система возвращает идентичный URL
## ADDED Requirements
### Requirement: Кэширование объектов
Доставка объектов SHALL помечать ответы как кэшируемые, чтобы браузер переиспользовал
уже полученные превью, а повторное открытие альбома не вызывало повторную загрузку
неизменившихся объектов.
#### Scenario: Заголовки кэширования
- **WHEN** клиент получает объект (оригинал, превью или thumb) по подписанному URL
- **THEN** ответ содержит заголовок кэширования с ограниченным сроком годности
#### Scenario: Повторный показ без перезагрузки
- **WHEN** сетка превью перерисовывается, а URL объектов не изменились
- **THEN** браузер отдаёт объекты из кэша без повторного запроса к бэкенду
@@ -0,0 +1,31 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: События альбома
Система SHALL публиковать события photo.created, photo.updated, photo.deleted,
comment.created, like.added, like.removed, member.joined, member.left, album.updated.
Событие photo.created SHALL обозначать появление фото в альбоме, а готовность вариантов
существующего фото SHALL публиковаться как photo.updated.
#### Scenario: Новое фото
- **WHEN** в альбоме появляется фото
- **THEN** подписчики получают событие photo.created с метаданными и URL превью
#### Scenario: Готовы варианты
- **WHEN** для существующего фото сгенерированы thumb и preview
- **THEN** подписчики получают событие photo.updated с этим фото
## ADDED Requirements
### Requirement: Различение добавления и обновления фото
Клиент SHALL различать добавление нового фото в альбом и обновление уже показанного:
обновление SHALL изменять фото на месте, не меняя порядок сетки.
#### Scenario: Обновление отображаемого фото
- **WHEN** приходит событие обновления для фото, которое уже показано
- **THEN** клиент обновляет его на месте, порядок сетки не меняется
#### Scenario: Обновление отсутствующего фото
- **WHEN** приходит событие обновления для фото, которого нет в текущем списке
- **THEN** клиент не добавляет его в сетку
@@ -0,0 +1,24 @@
# Tasks
## 1. Стабильные подписанные URL и кэширование объектов
- [x] 1.1 Добавить параметр окна подписи (`OBJECT_URL_SIGN_WINDOW`, по умолчанию 10m) в `internal/config/config.go` и квантовать срок в `Signer.URL` (`exp = now.Truncate(window) + ttl`); проверить `cd backend && go test ./internal/storage/` — новый тест подтверждает, что URL совпадает в пределах окна и меняется за его границей
- [x] 1.2 Выставить `Cache-Control` для ответов объектов в `internal/app/app.go` (`serveObject`), используя параметр срока свежести; проверить `cd backend && go test ./internal/app/` — тест утверждает наличие заголовка в ответе
- [x] 1.3 Задокументировать новые переменные окружения в `.env.example` и проверить, что описанные значения совпадают с дефолтами в `config.go`
## 2. Семантика событий готовности вариантов
- [x] 2.1 Переключить уведомление worker'а на `photo.updated` в `internal/photos/worker.go` (`notify`), оставив `photo.created` только для реального появления фото; проверить тестом с подпиской на hub, что после генерации вариантов публикуется `photo.updated`
- [x] 2.2 Добавить `photo.updated` в `knownEvents` в `frontend/src/lib/sse.ts`; проверить `cd frontend && npm run check`
- [x] 2.3 В `frontend/src/routes/(app)/albums/[id]/+page.svelte` сделать `photo.updated` заменой только уже присутствующих фото, а `photo.created` — по-прежнему добавлением; проверить `npm run check` и наблюдаемым поведением: событие обновления для отсутствующего фото не добавляет его в сетку
## 3. Пропускная способность и приоритет очереди вариантов
- [x] 3.1 Добавить `WORKER_CONCURRENCY` в `internal/config/config.go` и пул параллельной обработки в `internal/photos/worker.go`; проверить `cd backend && go test ./internal/photos/` — тест подтверждает обработку не менее N заданий за проход при concurrency > 1
- [x] 3.2 Изменить порядок в `ClaimVariantJobs` (`internal/photos/sqlite_objects.go`): свежие задания — по свежести фото (`created_at DESC`), задания с ошибками (`attempts > 0`) — по `next_attempt_at`, сохраняя backoff; проверить тестом на порядок выдачи
- [x] 3.3 Задокументировать `WORKER_CONCURRENCY` и пороги очереди в `.env.example`; проверить соответствие дефолтов `config.go`
## 4. Интеграционная проверка
- [x] 4.1 Собрать (`make build`), запустить и открыть существующий большой альбом (`seed-1791714157-0 #2`): первый экран заполняется превью первыми, сетка не перетасовывается при доготовке вариантов; подтвердить повторным открытием без перекачки неизменившихся превью (Network)
- [x] 4.2 Прогнать полный набор: `cd backend && go test ./...` и `cd frontend && npm run check` — оба зелёные; замерить рост пропускной способности по убыванию `variant_jobs`