- realtime: личный поток несёт события жизненного цикла альбома и изменения счётчика фото; добавлены требования о жизненном цикле, получателях, форме payload'а, догоне и потере доступа - albums: photos_count считает только фото вне корзины
183 lines
18 KiB
Markdown
183 lines
18 KiB
Markdown
# Design
|
||
|
||
## Context
|
||
|
||
Мотивация — в `proposal.md`. Состояние, важное для подхода:
|
||
|
||
- Хаб `internal/sse` держит два независимых пространства подписок: `albums[albumID]` и
|
||
`users[userID]`; публикация в личный канал уже реализована (`PublishUser`) и уже
|
||
используется — единственным продюсером является `notifications.Service.Create`.
|
||
- Пер-альбомные события публикуются из `albums.Service.Update/Delete`,
|
||
`members.Service.Accept/ChangeRole/Remove`, `photos.Service`, `social.Service`.
|
||
- Персональный поток `GET /api/sse/me` доступен клиентам, авторизация принимает токен в
|
||
query-параметре, поэтому существующий web-обёртка `SSEClient` подходит без изменений.
|
||
- Объект альбома в ответе списка собирается SQL-выборкой с джойном обложки и подзапросами
|
||
счётчиков (`photos_count`, `members_count`); `albums.Service.Create` и `Update` возвращают
|
||
объект без обложки, поэтому его нельзя переиспользовать как payload события «как есть».
|
||
- Владелец альбома не входит в `album_members`: `albums.owner_id` — отдельный источник
|
||
(владелец не меняет роль и не удаляется из состава).
|
||
- `album_members` ссылается на `albums` с `ON DELETE CASCADE`: после удаления альбома
|
||
состав участников уже недоступен.
|
||
- Web-клиент подписан на SSE только на странице альбома; список альбомов загружается один
|
||
раз за сессию и не обновляется по событиям. Android-парсер пер-альбомного потока
|
||
игнорирует неизвестные типы событий.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Доставлять события жизненного цикла альбома (создание, метаданные, состав, удаление)
|
||
тем пользователям, у которых меняется список альбомов, независимо от того, что они
|
||
смотрят.
|
||
- Обновлять список альбомов и счётчик `photos_count` при изменении состава фото
|
||
(загрузка, перемещение, копирование, восстановление, корзина, окончательное удаление).
|
||
- Отдавать в событии объект альбома той же формы, что REST-список, чтобы клиент применял
|
||
его без запроса.
|
||
- Дать web-клиенту единую подписку на персональный поток и догон состояния при реконнекте.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Персистентные уведомления, их список и UI непрочитанных — не затрагиваются.
|
||
- Android-клиент не подключается к личному потоку в этом этапе; пер-альбомный контракт
|
||
должен остаться прежним.
|
||
- Серверный обрыв пер-альбомного SSE при исключении участника не входит в этап.
|
||
- Новые HTTP-эндпоинты и изменения схемы БД не требуются.
|
||
|
||
## Decisions
|
||
|
||
**1. Канал доставки — существующий личный поток, веер по владельцу и участникам.**
|
||
Событие публикуется в личный канал каждого затронутого пользователя.
|
||
Альтернативы: (а) публиковать только в канал альбома — не доходит до тех, кто его не
|
||
смотрит, то есть не решает задачу; (б) новое пространство подписок «альбомы пользователя» —
|
||
дублирует уже существующий личный канал без выигрыша; (в) WebSocket — ломает сложившийся
|
||
механизм SSE и требует второго транспорта. Выбран веер по `users[userID]`: переиспользует
|
||
готовую инфраструктуру и не меняет транспорт.
|
||
|
||
**2. Payload — полный объект альбома, а не сигнал-инвалидация.**
|
||
Клиент применяет `upsert`/`remove` без запроса; порядок списка не меняется.
|
||
Альтернатива — тонкий сигнал «список изменился» и рефетч: проще на сервере, но добавляет
|
||
запрос на каждое изменение и оставляет окно, когда UI показывает старое состояние; при
|
||
массовых операциях (приглашение нескольких участников) даёт несколько лишних запросов.
|
||
Отвергнуто в пользу полного payload'а при том же объёме клиентской логики.
|
||
|
||
**3. Источник payload'а — тот же код, что формирует элемент списка.**
|
||
Перед публикацией альбом перечитывается через `Store.GetByID` и обогащается `attachCover`,
|
||
то есть формой он идентичен элементу `GET /api/albums`. Причина: `Create`/`Update` отдают
|
||
объект без URL обложки, а `Create` — ещё и без счётчиков; сборка объекта вручную в точке
|
||
публикации неизбежно разошлась бы с REST-формой.
|
||
|
||
**4. Резолвер аудитории — лёгкий список идентификаторов пользователей.**
|
||
Нужен метод, возвращающий id владельца и id участников без джойна с `users`.
|
||
Альтернатива — переиспользовать `members.Repository.List`, но он тянет `PublicUser`
|
||
(join с `users`) и возвращает данные, которые никуда не идут; для веера это лишняя работа
|
||
на каждое событие. Метод добавляется в репозиторий и закрывается интерфейсом в сервисах,
|
||
как уже сделано для `OwnerID`.
|
||
|
||
**5. Аудиторию удаления вычисляем до удаления альбома.**
|
||
`Remove`/`Delete` должны собрать список получателей заранее: `album_members` удаляется
|
||
каскадом вместе с альбомом, а `GetByID` после удаления недоступен. Для `album.deleted`
|
||
payload'у достаточно только id, поэтому перечитывать объект не нужно.
|
||
|
||
**6. Для исключённого участника переиспользуем `album.deleted`.**
|
||
Отдельный тип («доступ отозван») не вводим: клиентская реакция одинакова — убрать альбом
|
||
из списка и закрыть открытую страницу, а таксономия остаётся минимальной.
|
||
Альтернатива — `album.access.revoked`/`member.left` в личном потоке: семантически точнее,
|
||
но добавляет второй путь обработки и тип события ради отсутствующего сейчас UI-сообщения.
|
||
Компромисс: если позже появятся текстовые уведомления («вас исключили»), тип придётся
|
||
разделить — это зафиксировано в рисках.
|
||
|
||
**7. Точки публикации.**
|
||
|
||
| Событие | Триггер | Получатели |
|
||
| --- | --- | --- |
|
||
| `album.created` | создание альбома | владелец |
|
||
| `album.created` | принятие приглашения | приглашённый |
|
||
| `album.updated` | принятие приглашения | владелец и участники (кроме приглашённого) |
|
||
| `album.updated` | смена названия/описания/обложки | владелец и участники |
|
||
| `album.updated` | смена роли участника | владелец и участники |
|
||
| `album.updated` | исключение участника | владелец и оставшиеся участники |
|
||
| `album.updated` | загрузка/копирование/перемещение/восстановление/корзина/удаление фото | владелец и участники (личный поток) |
|
||
| `photo.created` | загрузка фото в альбом | подписчики канала альбома |
|
||
| `album.deleted` | удаление альбома | владелец и участники (аудитория до удаления) |
|
||
| `album.deleted` | исключение участника | исключённый |
|
||
|
||
**11. Изменения состава фото публикуются только в личный поток.**
|
||
`album.updated` при изменении состава фото уходит получателям в личные каналы, но не в
|
||
канал альбома: открытая страница узнаёт об изменениях из пер-альбомных `photo.created` /
|
||
`photo.deleted`, а пер-альбомный `album.updated` заставлял бы её перечитывать альбом через
|
||
REST на каждое фото (массовая загрузка = поток лишних запросов). Личные каналы для карточек
|
||
обходятся без REST. Дополнительно `Upload` публикует `photo.created` в канал альбома — это
|
||
реализация уже существующего требования способности `realtime` («События альбома»), а не
|
||
новый контракт.
|
||
|
||
**12. `photos_count` считает только фото вне корзины.**
|
||
Запросы альбома добавляют `deleted_at IS NULL` к подзапросу счётчика в sqlite- и postgres-сторах.
|
||
Причина: сейчас счётчик включал фото в корзине и расходился с сеткой, из-за чего «удаление»
|
||
не меняло бы карточку. Так счётчик совпадает с видимым составом, а перевод в корзину,
|
||
восстановление и окончательное удаление меняют его предсказуемо. Уже помещённые в корзину
|
||
фото перестают учитываться без миграции данных.
|
||
|
||
**8. Пер-альбомный `album.updated` сохраняется.**
|
||
Метаданные продолжают публиковаться и в канал альбома: его ждёт Android-парсер
|
||
(обрабатывает `album.updated`/`album.deleted` как «перечитать альбом»), а открытая
|
||
страница альбома делает идемпотентную перезагрузку. Снимать пер-альбомную публикацию
|
||
означало бы ломать совместимость мобильного клиента ради экономии одного запроса.
|
||
|
||
**9. Web: одна подписка на уровне layout'а приложения.**
|
||
Подписка на `/api/sse/me` живёт вместе с layout'ом `(app)`, а не с отдельными страницами,
|
||
чтобы соединение не пересоздавалось при навигации; обработчики вызывают методы стора
|
||
`albums` (`upsert`/`remove`), `onReconnect` вызывает `albums.load()`.
|
||
Альтернатива — подписка на каждой странице: дублирование соединений и логики.
|
||
|
||
**10. Страница альбома при потере доступа.**
|
||
При событии удаления/потери доступа страница перестаёт отображать альбом и уходит на
|
||
главную; отдельного сообщения пользователю в этом этапе не делаем (это уже область
|
||
центра уведомлений). Обработка нужна с обоих потоков: страница подписана и на канал
|
||
альбома, и косвенно на личный.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **Дублирование `album.updated`** (пер-альбомный и личный каналы) для открытой страницы →
|
||
обработчики должны быть идемпотентны: личный делает `upsert`, страница — перезагрузку;
|
||
лишний запрос на изменение метаданных принимаем осознанно.
|
||
- **Веер O(участники) на каждое изменение** → при больших альбомах это стоимость
|
||
пропорциональна числу подписанных пользователей, а не числу участников (публикация уходит
|
||
в хаб, у которого нет подписчика — no-op). Массовые операции с фото (batch-удаление,
|
||
перемещение) публикуют `album.updated` на каждое фото: для получателя это дешёвый
|
||
`upsert` в сторе, на сервере — перечитывание альбома и веер по личным каналам. Это
|
||
принято осознанно; при необходимости батчи можно схлопнуть в одно событие на альбом.
|
||
- **Backpressure хаба отключает медленного клиента** → вместе с личным потоком клиент
|
||
теряет и обновления списка; это уже существующее поведение SSE, и оно компенсируется
|
||
догоном через `albums.load()` после переподключения.
|
||
- **`album.deleted` для исключённого семантически перегружен** → если появятся текстовые
|
||
уведомления, потребуется отдельный тип события; изменение аддитивное.
|
||
- **Исключённый участник продолжает получать пер-альбомный SSE** до разрыва соединения
|
||
(доступ проверяется только при подключении) → в этом этапе не закрывается, остаётся
|
||
отдельной задачей; событие из личного потока закрывает список и открытую страницу, но не
|
||
отменяет уже установленное соединение.
|
||
- **URL обложки в payload'е может истечь** (presigned для S3) → URL формируется тем же
|
||
кодом и с тем же TTL, что в REST-ответе; при переподключении клиент получает свежий список.
|
||
- **События SSE не имеют идентификаторов** → повторная доставка и пропуски обрабатываются
|
||
только догоном через REST; история событий не хранится.
|
||
- **`photo.created` при загрузке приходит без URL вариантов** (thumb/preview ещё не готовы)
|
||
→ клиент вставляет фото-заглушку, а пришедший позже `photo.updated` заполняет URL на месте;
|
||
порядок сетки не меняется.
|
||
- **`photos_count` теперь без корзины** → у альбомов с фото в корзине отображаемое число
|
||
уменьшится сразу после выката; данные не мигрируются.
|
||
|
||
## Migration Plan
|
||
|
||
Изменение аддитивное: новых эндпоинтов и миграций нет, старые клиенты неизвестные события
|
||
игнорируют, пер-альбомный контракт событий сохраняется.
|
||
|
||
1. Задеплоить сервер целиком (статика вшита в бинарник): публикации и веер начинают
|
||
работать сразу, web-клиент подписку получает в том же артефакте.
|
||
2. Порядок отката: вернуть предыдущий бинарник — сервер снова публикует только
|
||
пер-альбомные события, клиент в том же артефакте просто не получает событий в личном
|
||
потоке и возвращается к загрузке списка при открытии страницы. Состояние БД не меняется
|
||
(счётчик снова начнёт включать корзину — это поведение старого бинарника).
|
||
3. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без
|
||
перезагрузки при переименовании, смене обложки, добавлении и исключении участника,
|
||
загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит
|
||
исчезновение альбома из списка, а открытая страница добавляет загруженное фото.
|