fix(photos): faster large-album loading and album cover lifecycle

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
This commit is contained in:
iu
2026-10-11 14:15:59 +03:00
parent 6361e48b20
commit d7a37328f0
40 changed files with 999 additions and 57 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,98 @@
# Design
## Context
Требование «Обложка по умолчанию» существовало и работало в старом инлайн-пути генерации
вариантов (`processVariants`), который вызывал `SetCoverIfEmpty`. Рефакторинг media-objects
(коммит `96268ee`) перенёс генерацию вариантов в фоновый worker и потерял этот вызов; сейчас
`SetCoverIfEmpty` вызывается только из `CopyBatch`. Дополнительно обложка не снимается при
уходе фото-обложки. См. `proposal.md — Why`.
Опорные точки:
- `internal/photos/service.go`: `Upload`, `Delete`, `MoveBatch`; `SetCoverIfEmpty` — метод
репозитория фото (`store.go`, `repository.go`, `sqlite_store.go`).
- `internal/photos/worker.go`: с недавних пор обрабатывает новые фото первыми
(`created_at DESC`) — отсюда решение не ставить обложку в worker'е.
- `albums.Update` уже поддерживает `cover_photo_id` и `clear_cover`.
- Frontend: `albums.setCover()` уже существует; контекстное меню — `openMenuForPhoto`
в `albums/[id]/+page.svelte`; секция «Действия» — `PhotoPanel.svelte` (props: `photo`, `role`).
## Goals / Non-Goals
**Goals:**
- Обложкой нового альбома становится первое загруженное фото.
- Обложка снимается, когда фото-обложка покидает альбом (удаление/перенос).
- Пункт «Установить как обложку альбома» доступен из сетки и правой панели.
**Non-Goals:**
- Backfill существующих альбомов.
- Автоперевыбор новой обложки после снятия.
- Изменение поведения Viewer и share-страниц.
## Decisions
### 1. Обложка ставится при загрузке, а не в worker'е
**Выбор:** в `Service.Upload`, после успешного сохранения фото, вызвать
`store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)`.
**Почему:** формулировка «первое загруженное фото». Worker обрабатывает новейшие фото первыми,
поэтому установка «при обработке» дала бы обложку = последнее загруженное. Загрузка идёт
последовательно по файлам запроса, поэтому первое фото детерминированно выигрывает
(`UPDATE ... WHERE cover_photo_id IS NULL`).
**Альтернативы:**
- *В worker'е по готовности вариантов* — thumb уже есть, но порядок обработки ломает «первое
загруженное».
- *В worker'е с выбором MIN(created_at)* — сложнее и не гарантирует готовность thumb
выбранного фото.
**Следствие:** `cover_thumb_url` пуст до генерации thumb; список альбомов подхватит его при
следующем запросе (JOIN по `cover_photo_id`). Реальное время смены обложки в рейле не
обновляется по SSE — вне области.
### 2. Снятие обложки — атомарный метод репозитория
**Выбор:** метод `ClearCoverIfMatches(ctx, albumID, photoID)`:
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`.
Вызывается в `Delete` (корзина) и в `MoveBatch` для исходного альбома.
**Почему:** условный UPDATE защищает от гонки (обложку могли сменить) и не требует чтения.
`PermanentDelete` уже покрыт FK `ON DELETE SET NULL`.
**Альтернативы:**
- *Снять безусловно* — риск снести чужой выбор обложки.
- *FK при soft-delete* — FK не срабатывает при мягком удалении.
### 3. `clear_cover` → NULL
**Выбор:** явный сброс обложки оставляет `cover_photo_id = NULL` (пересчёта по умолчанию нет).
Соответствует решению «очищать, без backfill».
**Альтернативы:**
- *Пересчитать «по умолчанию» (первое фото)* — тогда сброс бессмыслен (обложка мгновенно
вернётся), плюс скрытый backfill.
### 4. Frontend: та же защита прав, что и у редактирования альбома
**Выбор:** пункт показывается при `role === 'owner' || role === 'admin'`; действие вызывает
`albums.setCover(albumId, photo.id)` (существующий `PATCH /albums/:id`). Добавляется в
`openMenuForPhoto` и в секцию «Действия» `PhotoPanel`; иконка `wallpaper`.
**Почему:** переиспользуется существующий API и гейт прав; `PhotoPanel` уже получает `role`.
`albumId` берётся из `photo.album_id`/контекста страницы.
## Risks / Trade-offs
- [Пункт меню/панели меняет обложку, но рейл не обновляется мгновенно, если thumb ещё не готов]
→ допустимо; при следующем запросе обложка появится.
- [Гонка параллельных загрузок в пустой альбом] → условный UPDATE выбирает одного победителя;
«первое» при строго параллельных запросах незначительно недетерминировано.
- [Снятие обложки при переносе может неожиданно обнулить обложку] → это и есть желаемое
поведение по спеке; пользователь может назначить заново.
- [Существующие альбомы без обложки остаются пустыми] → осознанно (no backfill).
## Migration Plan
- Миграций нет. Порядок: backend (загрузка/снятие обложки) → frontend (меню/панель).
- Откат — возврат предыдущего бинаря/фронта; данные не деградируют.
@@ -0,0 +1,37 @@
# Proposal
## Why
Автобложка «первое фото» была специфицирована, но потерялась при рефакторинге media-objects:
при загрузке обложка больше не назначается, поэтому большинство альбомов (в т.ч. семенные)
остаются без обложки. Дополнительно нужно довести жизненный цикл обложки: снимать её, когда
фото-обложка покидает альбом, и сделать явный сброс предсказуемым.
## What Changes
- **Обложка по умолчанию при загрузке.** Если у альбома нет обложки, обложкой становится
первое загруженное фото; последующие фото её не меняют.
- **Снятие обложки при уходе фото-обложки.** Помещение фото-обложки в корзину или перенос
её в другой альбом очищает обложку (без перевыбора).
- **Явный сброс обложки** (`clear_cover`) обнуляет обложку и не пересчитывает её.
- **Frontend.** Пункт «Установить как обложку альбома» в контекстном меню фото сетки и в
разделе «Действия» правой панели; доступен только владельцу и админу.
- **Без backfill** существующих альбомов без обложки.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `photos`: обложка по умолчанию назначается при загрузке (первое фото) и снимается, когда
фото-обложка покидает альбом.
- `albums`: сброс обложки делает её не заданной (без возврата к значению по умолчанию).
## Impact
- Backend: `internal/photos/service.go` (`Upload`, `Delete`, `MoveBatch`),
`internal/photos/{store,repository,sqlite_store}.go` (метод снятия обложки).
- Frontend: контекстное меню фото в сетке (`albums/[id]/+page.svelte`), раздел «Действия»
в `PhotoPanel.svelte`; используется существующий `PATCH /albums/:id` через `albums.setCover`.
- Данные/миграции: не требуются; backfill не выполняется.
@@ -0,0 +1,20 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Выбор обложки
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
обложку; после сброса обложка SHALL становиться не заданной (без возврата к значению по
умолчанию).
#### Scenario: Назначение обложки
- **WHEN** владелец выбирает фото альбома как обложку
- **THEN** система сохраняет выбор и отражает его в списке и карточках
#### Scenario: Сброс обложки
- **WHEN** админ сбрасывает обложку
- **THEN** система снимает обложку, и альбом отображается без обложки, пока обложку не назначат снова
#### Scenario: Нет прав на смену обложки
- **WHEN** участник без права редактирования пытается сменить обложку
- **THEN** система отклоняет запрос
@@ -0,0 +1,24 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Обложка по умолчанию
При загрузке фото система SHALL назначать его обложкой альбома, если обложка ещё не задана,
то есть обложкой становится первое загруженное фото. Когда фото, выбранное обложкой, покидает
альбом (помещено в корзину или перенесено в другой альбом), система SHALL снять обложку.
#### Scenario: Первое фото
- **WHEN** фото загружено, и у альбома нет обложки
- **THEN** система назначает это фото обложкой альбома
#### Scenario: Обложка уже задана
- **WHEN** фото загружено, а обложка уже задана
- **THEN** система не меняет обложку
#### Scenario: Фото-обложка в корзине
- **WHEN** фото, назначенное обложкой, помещается в корзину
- **THEN** система снимает обложку, и альбом отображается без обложки
#### Scenario: Фото-обложка перенесено
- **WHEN** фото, назначенное обложкой, переносится в другой альбом
- **THEN** система снимает обложку исходного альбома
+21
View File
@@ -0,0 +1,21 @@
# Tasks
## 1. Автобложка при загрузке
- [x] 1.1 В `backend/internal/photos/service.go` (`Upload`) вызывать `store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)` после успешного сохранения фото; проверить `cd backend && go test ./internal/e2e/` — тест загружает два фото в новый альбом и проверяет, что обложкой стало первое
- [x] 1.2 Добавить в тот же тест проверку сценария «обложка уже задана»: загрузка второго фото не меняет уже назначенную обложку
## 2. Снятие обложки при уходе фото-обложки
- [x] 2.1 Добавить `ClearCoverIfMatches(ctx, albumID, photoID)` в `backend/internal/photos/repository.go`, `store.go` и `sqlite_store.go` (`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`); проверить `cd backend && go test ./internal/repo/contract/` — снятие срабатывает только при совпадении обложки
- [x] 2.2 Вызывать `ClearCoverIfMatches` в `Delete` (корзина) и `MoveBatch` (исходный альбом) в `service.go`; проверить тестом, что удаление и перенос фото-обложки снимают обложку
## 3. Пункт «Установить как обложку альбома»
- [x] 3.1 Добавить пункт в контекстное меню фото сетки (`frontend/src/routes/(app)/albums/[id]/+page.svelte`, `openMenuForPhoto`) с иконкой `wallpaper` и вызовом `albums.setCover(albumId, photo.id)`, видимый только owner/admin; проверить `cd frontend && npm run check`
- [x] 3.2 Добавить тот же пункт в секцию «Действия» компонента `frontend/src/lib/components/PhotoPanel.svelte`; проверить `npm run check` и наблюдаемо: пункт показывается только владельцу/админу
## 4. Интеграционная проверка
- [x] 4.1 `cd backend && go test ./...` и `cd frontend && npm run check` — оба зелёные
- [x] 4.2 Собрать (`make build`), загрузить фото в новый альбом (обложка = первое фото), затем удалить фото-обложку (обложка пуста) и перенести фото-обложку (обложка пуста); сбросить обложку вручную через панель — остаётся пустой
@@ -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`