Доставка — user-centric WS, sync, receipts
Перед началом: группы и права (Модуль 5). Инструменты: WebSocket, Go, PostgreSQL. Трудоёмкость: ~7 ч.
🔥 Инцидент: «Метро съело сообщения»
Пользователь спускается в метро — связь рвётся на две минуты. Возвращается:
лента показывает сообщения «до» и «после», а между ними — дыра. Хуже:
переписка шла в трёх диалогах, и во всех разное. Вчерашний инцидент на
стенде: клиент на каждое переключение диалога переподключал WebSocket
(соединение было привязано к комнате из query-строки) — на активном
пользователе это сотни реконнектов в час и шторм на сервере. В этом
модуле переделываем доставку: одно соединение на пользователя,
синхронизация по seq после каждого разрыва и receipt-ы «прочитано».
📖 Теория
6.1 Соединение принадлежит пользователю, подписки — диалогам
Меняем модель v1 «соединение = комната»:
GET /api/v1/ws?token=<JWT>— безroom_id;- на коннекте сервер читает участия пользователя (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1) и подписывает соединение на их каналы; - состав подписок меняется на сервере: создали диалог, добавили в
группу — сервер сам подписал соединения пользователя (событие
conversation.updatedклиенту тоже уходит); - переключение диалога в UI не трогает соединение вообще.
6.2 Sync-протокол: догон до live
Порядок восстановления после коннекта строгий: сначала догон, потом живой поток. Иначе есть окно, в котором live-событие обгонит догон и клиент применит их в неверном порядке.
Клиент присылает, докуда дошёл по каждому диалогу:
jsonc{"type": "sync", "since": [ {"conversation_id": "…", "seq": 41}, {"conversation_id": "…", "seq": 117} ]}
Сервер отвечает пачками message.new (только seq > since, по порядку) и
завершает маркером:
jsonc{"type": "sync.complete", "conversations": [{"conversation_id": "…", "seq": 128}]}
Клиент применяет догон без анимаций и только после sync.complete
считает состояние живым. Догон — один батч-запрос на все диалоги сразу:
sqlSELECT m.conversation_id, m.seq, m.id, m.sender_id, m.text, m.created_at FROM unnest($1::uuid[], $2::bigint[]) AS s(conversation_id, since_seq) JOIN messages m ON m.conversation_id = s.conversation_id AND m.seq > s.since_seq ORDER BY m.conversation_id, m.seq;
Параллельные массивы ($1, $2) — это присланные клиентом пары
«диалог → докуда дошёл»; JOIN по ним одним запросом достаёт всё
пропущенное. В Go это pgx-вызов с []uuid.UUID и []int64.
Trade-off: REST-fallback GET /api/v1/sync?cursor= оставлен: он нужен,
когда догон «дорогой» (глубокая дыра, сотни диалогов) или когда клиент —
не браузер (интеграция, бот). WS-only проще в клиенте, REST-догон
устойчивее при штормах — оба протокола опираются на один и тот же seq.
6.3 At-least-once и дедупликация
Гарантия: сообщение может прийти дважды (ретрай outbox-воркера,
догон после live-дублирования). Это правильная гарантия: «доставить хотя
бы раз» реализуемо, «ровно раз» — нет. Следствие для клиента: лента
дедуплицируется по message.id — та же механика, что в бойлерплейте.
События, которые сервер отправляет в канал диалога:
| Событие | Когда | Кому |
|---|---|---|
message.new | сообщение записано (через outbox) | всем участникам |
message.read | участник отметил прочтение | всем участникам |
typing | эфемерно (Модуль 7) | участникам |
conversation.updated | новый диалог, переименование | участникам |
member.changed | добавили/удалили | участникам (+ ушедшему — «доступ закрыт») |
6.4 Read-receipts
«Прочитано» — уже готовое число last_read_seq из Модуля 3. Событие
message.read {conversation_id, user_id, seq} фан-аутится в канал;
клиент сравнивает: моё сообщение «прочитано», если минимальный
last_read_seq других участников ≥ seq сообщения.
Статус «доставлено» (галочка «долетело до устройства, но не открыто») требует трекинга доставки по каждому получателю — в курсе обсуждаем и осознанно откладываем: продуктовая ценность мала, стоимость высока.
💻 Практика
-
Перепишите WS-хендлер (
internal/handlers/http/api/v1/ws_handler.go):tokenв query, валидация до апгрейда (как в v1), лимит соединений на пользователя — оставляем;- после апгрейда: чтение подписок из БД, приём
sync, догон,sync.complete; - таблица соединений пользователя:
userID → set(conn)— вкладки.
-
Хаб и каналы: канал =
conversation_id. Публикация из outbox-воркера — без изменений (событие уже несётconversation_id). Доставка соединению — по его подпискам. Проверьте себя: сообщение в диалог Алисы и Боба должно уйти обоим, включая все вкладки каждого. -
Догон:
SyncConversations(ctx, userID, since map[uuid.UUID]int64)— выборка пропущенных по каждому диалогу, отправка, маркер. -
Клиент (минимальные правки
shared/api/ws.tsи виджета): хранитеlastSeqпо диалогам вlocalStorage; при открытии соединения отправляйтеsync; обрабатывайтеsync.complete. Проверьте сценарий «метро»: остановите бэкенд, отправьте сообщения через curl (со стороны второго пользователя), поднимите бэкенд — клиент догнал без потерь. -
Read-receipts:
POST /conversations/{id}/readпубликуетmessage.readв канал. В UI пока — консольный лог; полноценные галочки — в Модуле 9. -
Тесты (интеграционные, с реальной БД):
- реконнект с
since→ приходят ровно пропущенные, каждый один раз; - дубль события (публикуем дважды) → лента клиента без дублей;
- выгнанный из группы участник не получает события её канала.
- реконнект с
Чек-лист модуля: одно соединение на пользователя; переключение диалогов не переподключает WS; после разрыва лента догоняется по seq; дубли безопасны; receipts прилетают всем участникам.
🧩 Quiz
✅ Итоги модуля
- WS-соединение user-centric: одно на пользователя, подписки серверные, переключение диалогов бесплатно.
- Sync-протокол: догон по seq → live; маркер
sync.completeотделяет восстановление от живого потока. - At-least-once плюс дедупликация по id — честная и достаточная гарантия.
- Read-receipts стоят на уже существующем
last_read_seq; «доставлено» отложено осознанно. - Событийная модель диалога:
message.new,message.read,conversation.updated,member.changed.
🔗 Мостик к Модулю 7
Доставка надёжна, но безжизненна: собеседник печатает — а выглядит, будто он исчез. Добавим присутствие и typing — и сделаем это правильно: без записи в БД и с открытыми глазами на приватность. Presence в Postgres — та самая ошибка, из-за которой «все онлайн вечно».