Доставка — 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 считает состояние живым. Догон — один батч-запрос на все диалоги сразу:

sql
SELECT 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 сообщения.

Статус «доставлено» (галочка «долетело до устройства, но не открыто») требует трекинга доставки по каждому получателю — в курсе обсуждаем и осознанно откладываем: продуктовая ценность мала, стоимость высока.

Догон после реконнекта

💻 Практика

  1. Перепишите WS-хендлер (internal/handlers/http/api/v1/ws_handler.go):

    • token в query, валидация до апгрейда (как в v1), лимит соединений на пользователя — оставляем;
    • после апгрейда: чтение подписок из БД, приём sync, догон, sync.complete;
    • таблица соединений пользователя: userID → set(conn) — вкладки.
  2. Хаб и каналы: канал = conversation_id. Публикация из outbox-воркера — без изменений (событие уже несёт conversation_id). Доставка соединению — по его подпискам. Проверьте себя: сообщение в диалог Алисы и Боба должно уйти обоим, включая все вкладки каждого.

  3. Догон: SyncConversations(ctx, userID, since map[uuid.UUID]int64) — выборка пропущенных по каждому диалогу, отправка, маркер.

  4. Клиент (минимальные правки shared/api/ws.ts и виджета): храните lastSeq по диалогам в localStorage; при открытии соединения отправляйте sync; обрабатывайте sync.complete. Проверьте сценарий «метро»: остановите бэкенд, отправьте сообщения через curl (со стороны второго пользователя), поднимите бэкенд — клиент догнал без потерь.

  5. Read-receipts: POST /conversations/{id}/read публикует message.read в канал. В UI пока — консольный лог; полноценные галочки — в Модуле 9.

  6. Тесты (интеграционные, с реальной БД):

    • реконнект с since → приходят ровно пропущенные, каждый один раз;
    • дубль события (публикуем дважды) → лента клиента без дублей;
    • выгнанный из группы участник не получает события её канала.

Чек-лист модуля: одно соединение на пользователя; переключение диалогов не переподключает WS; после разрыва лента догоняется по seq; дубли безопасны; receipts прилетают всем участникам.

🧩 Quiz

1. Почему статус «доставлено» отложили, а «прочитано» оставили?
2. Почему догон обязан завершиться до включения live-потока?
3. Клиент после reconnect прислал since={convA: 100}, а на сервере в convA последний seq = 100. Что вернёт догон?
4. Когда сообщение считается «прочитанным» в receipts-схеме с last_read_seq?
5. Гарантия at-least-once означает, что сообщение может прийти дважды. Где дедуплицировать?
6. Зачем сервер сам подписывает соединение на диалоги пользователя, а не клиент присылает список?

✅ Итоги модуля

  • 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 — та самая ошибка, из-за которой «все онлайн вечно».