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:
@@ -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`
|
||||
Reference in New Issue
Block a user