Конверт события: контракт, который переживёт рост
Перед началом: Модуль 3 — токены офлайн, каталоги двигаются событиями
user.*. Инструменты: Go, Redis (Streams), JSON-тесты. Трудоёмкость: ~5 ч.
🔥 Инцидент: «переименовали поле — у клиентов пошли пустые сообщения»
Прототип шины жил без версии: конверт был «как получится». Потом рефакторинг
завернул text внутрь вложенного объекта message — и messenger задеплоили
первым. event-service всё ещё ждал плоский payload: анмаршалинг проходил,
но кадры уходили клиентам без текста, и лента показывала пустые пузыри.
Откатили messenger — стало «веселее»: в шине теперь чередовались два
формата, а понять, какой прилетел, было нечем — ни version, ни правил
совместимости. Чинили стоп-краном: согласованное окно деплоя «сначала
останови publish, потом обнови consume», то есть простоями.
Правильный контракт снимает саму возможность такого инцидента: version в
конверте, обратная совместимость для новых полей и срок жизни старой схемы
при смене смысла. В этом модуле фиксируем конверт как Published Language.
📖 Теория
4.1 Конверт: единственная точка связи
Между контекстами Messaging и Realtime нет ни вызовов, ни общих таблиц —
только версионируемый контракт событий (Published Language). messenger
не знает о соединениях, event-service — о схеме messenger_db. Всё, что
между ними, — этот JSON:
jsonc{ "event_id": "uuid", // идемпотентность потребителя "type": "message.new", // message.new | message.read | conversation.updated | member.changed "version": 2, // версионирование контракта "occurred_at": "2026-10-30T18:42:11Z", "conversation_id": "uuid", "recipients": ["uuid", "uuid"], // резолвлено отправителем (§4.3) "payload": { } // доменные данные, без данных для отображения }
Правила конверта:
event_idобязателен: шина даёт at-least-once по умолчанию, дубль возможен, потребитель дедуплицирует;payloadнесёт доменные данные (sender_id,seq,text) — но неsender_name: имена — это каталог, насыщение делает event-service (§4.2);- новое поле — обратно совместимо; смена смысла — новая
version, старая живёт переходный период; - клиентский WS-кадр — не конверт: наружу уходит уже насыщенный вид
без
recipients.
Контракт сначала фиксируется кодом:
gotype Envelope struct { EventID uuid.UUID `json:"event_id"` Type string `json:"type"` Version int `json:"version"` OccurredAt time.Time `json:"occurred_at"` ConversationID uuid.UUID `json:"conversation_id"` Recipients []uuid.UUID `json:"recipients"` Payload json.RawMessage `json:"payload"` }
4.2 WS-кадр ≠ конверт
Доменный конверт не пригоден для показа: в нём есть sender_id, но нет
имени; есть conversation_id, но нет карточки диалога. Насыщение
(enrichment) в event-service собирает клиентский кадр:
jsonc// конверт (шина) { "type": "message.new", "conversation_id": "c1", "recipients": ["u1", "u2"], "payload": { "id": "…", "seq": 42, "sender_id": "u1", "text": "…" } }
jsonc// WS-кадр (клиенту) { "type": "message.new", "conversation_id": "c1", "message": { "id": "…", "seq": 42, "sender_id": "u1", "sender_name": "Алиса", // ← каталог "avatar_url": "…", // ← каталог "text": "…" }, "conversation": { "title": "Команда" } } // ← карточка
Обратите внимание: в кадре нет recipients — это служебное поле шины, а не
данные для клиента. Каталог — два кэша event-service:
| Кэш | Ключ | Инвалидация |
|---|---|---|
| Карточка пользователя | user_id | user.updated (pub/sub) + TTL-подстраховка |
| Карточка диалога | conversation_id | conversation.updated, member.changed |
Правила насыщения: холодный промах закрывается одним internal-запросом
карточки у владельца, дальше из памяти; насыщение не имеет права падать —
нет карточки, кадр уходит с fallback-именем и флагом stale:true. Доставка
важнее полноты.
4.3 Резолв получателей: три стратегии
Проблема: событие диалога должны получить все участники. Кто знает список? Участия — данные messenger, он владелец membership. Возможны три стратегии:
| Стратегия | Суть | Цена | Когда уместна |
|---|---|---|---|
| A. recipients в конверте | messenger резолвит участия в момент публикации и кладёт полный список | размер конверта: 1k участников ≈ 16KB идентификаторов на каждое сообщение | наш выбор, до ~1k участников |
| B. per-user inbox | копия события пишется в персональный стрим каждого получателя (fan-out on write) | стоимость записи ×N: те же 1k записей на сообщение большой группы | гигантские списки «на подписчика» |
| C. гибрид с порогом | до FANOUT_LIMIT (~500) — стратегия A; крупнее — лёгкий конверт без recipients с pull:true, клиент доигрывает по REST по seq | кадр-пустышка + запрос истории на каждое событие | большие группы: O(1) вместо O(N) |
Почему выбрана A. Event-service остаётся без доменных данных и без
внутреннего API на каждый кадр — подписки соединений управляются самими
конвертами (recipient → мои соединения → send). Адресация корректна на
момент отправки: только что добавленный участник сразу получает события.
Цена — размер конверта — терпима до ~1k участников. Порог и переключение
A↔C — конфиг messenger-service; стратегии сосуществуют одновременно по типу
диалога.
4.4 Гарантии end-to-end
| Участок | Гарантия | Механизм |
|---|---|---|
| messenger: транзакция | exactly-once в БД | сообщение + outbox атомарно |
| outbox → шина | at-least-once | воркер с retry; дубль возможен |
| шина → event-service | at-least-once | ACK после доставки; дубль возможен |
| event-service → клиент (WS) | at-most-once | потеря — норма (транспорт) |
| клиент: итог | фактически exactly-once | дедуп по message.id + догон по seq |
Это ответ на вопрос «почему в топологии модуля 2 нет распределённых
транзакций»: каждый участок несёт свою честную гарантию, а итоговую
картинку собирает клиент seq-механикой. Дедупликация по event_id на входе
event-service (TTL-сет) — оптимизация CPU, не корректность: корректность
гарантирует seq-механика клиента.
4.5 Версионирование контракта
versionв конверте; event-service поддерживает N и N−1;- клиент игнорирует неизвестные
type— forward-compat бесплатно; - смена payload-схемы — это новый
typeилиversionсо сроком жизни старой: окно совместимости, при котором оба производителя живут одновременно, и rollback-safe деплой — по той же причине, что expand→contract в миграциях схемы.
💻 Практика
-
Конверт v1 в Go (45 мин). Зафиксируйте структуру
Envelopeи напишите JSON-тесты туда-обратно: marshal → unmarshal →reflect.DeepEqual. Отдельный тест прямой руки: JSON с лишним неизвестным полем парсится без ошибки — обратная совместимость работает. -
Дедупликация по event_id (45 мин). TTL-сет: второй приход того же события отбрасывается, записи чистятся по TTL.
gotype DedupSet struct { mu sync.Mutex seen map[uuid.UUID]time.Time ttl time.Duration } func (d *DedupSet) First(id uuid.UUID, now time.Time) bool { // true — событие новое d.mu.Lock() defer d.mu.Unlock() d.gc(now) if _, ok := d.seen[id]; ok { return false } d.seen[id] = now return true }
Интеграционный тест: опубликуйте конверт в Streams дважды — потребитель обработал один.
- Разбор «что сломается, если положить sender_name в payload» (30 мин). Выпишите письменно: имя замораживается на момент отправки — переименование доедет только в новые кадры, старые навсегда несут устаревшее; нагрузка каталога переехала бы в messenger на путь записи; потребители начали бы зависеть от данных Identity — граница контекстов стёрта. Это аргумент для ADR, а не вопрос вкуса.
Чек-лист модуля: конверт v1 зафиксирован структурой и тестами
туда-обратно; дубль события безопасен на входе event-service; на вопрос
«можно ли в payload имя» есть письменный ответ; в кадре нет recipients.
🧩 Quiz
✅ Итоги модуля
- Зафиксировать конверт v1 как Go-структуру с JSON-тестами туда-обратно.
- Объяснить все поля конверта и правило «payload — домен, кадр — отображение».
- Сравнить три стратегии резолва получателей и обосновать выбор A до ~1k участников.
- Прочитать таблицу гарантий end-to-end и показать, где какая реализована.
- Дедуплицировать по event_id и сформулировать, зачем это, если корректность даёт seq.
🔗 Мостик к Модулю 5
Конверт подписан, версия зафиксирована, дедуп стоит на входе. Осталось довести доставку до масштаба: в следующем модуле разворачиваем event-service ×N — consumer group на реплику в Redis Streams, presence в multi-replica и механику насыщения кадров; и разберёмся, когда «каждая реплика читает всё» ещё помещается, а когда уже нет.