- realtime: личный поток несёт события жизненного цикла альбома и изменения счётчика фото; добавлены требования о жизненном цикле, получателях, форме payload'а, догоне и потере доступа - albums: photos_count считает только фото вне корзины
18 KiB
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
Изменение аддитивное: новых эндпоинтов и миграций нет, старые клиенты неизвестные события игнорируют, пер-альбомный контракт событий сохраняется.
- Задеплоить сервер целиком (статика вшита в бинарник): публикации и веер начинают работать сразу, web-клиент подписку получает в том же артефакте.
- Порядок отката: вернуть предыдущий бинарник — сервер снова публикует только пер-альбомные события, клиент в том же артефакте просто не получает событий в личном потоке и возвращается к загрузке списка при открытии страницы. Состояние БД не меняется (счётчик снова начнёт включать корзину — это поведение старого бинарника).
- Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без перезагрузки при переименовании, смене обложки, добавлении и исключении участника, загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит исчезновение альбома из списка, а открытая страница добавляет загруженное фото.