Конверт события: контракт, который переживёт рост

Перед началом: Модуль 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.

Контракт сначала фиксируется кодом:

go
type 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_iduser.updated (pub/sub) + TTL-подстраховка
Карточка диалогаconversation_idconversation.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-serviceat-least-onceACK после доставки; дубль возможен
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 в миграциях схемы.

💻 Практика

  1. Конверт v1 в Go (45 мин). Зафиксируйте структуру Envelope и напишите JSON-тесты туда-обратно: marshal → unmarshal → reflect.DeepEqual. Отдельный тест прямой руки: JSON с лишним неизвестным полем парсится без ошибки — обратная совместимость работает.

  2. Дедупликация по event_id (45 мин). TTL-сет: второй приход того же события отбрасывается, записи чистятся по TTL.

go
type 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 дважды — потребитель обработал один.

  1. Разбор «что сломается, если положить sender_name в payload» (30 мин). Выпишите письменно: имя замораживается на момент отправки — переименование доедет только в новые кадры, старые навсегда несут устаревшее; нагрузка каталога переехала бы в messenger на путь записи; потребители начали бы зависеть от данных Identity — граница контекстов стёрта. Это аргумент для ADR, а не вопрос вкуса.

Чек-лист модуля: конверт v1 зафиксирован структурой и тестами туда-обратно; дубль события безопасен на входе event-service; на вопрос «можно ли в payload имя» есть письменный ответ; в кадре нет recipients.

🧩 Quiz

1. Почему сквозной exactly-once не обещан, а сообщений в ленте нет в двойном экземпляре?
2. Нужна смена смысла поля payload. Как поступить правильно?
3. Зачем в конверте event_id?
4. Почему в payload нет sender_name?
5. Что получает event-service при стратегии «recipients в конверте»?

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

  • Зафиксировать конверт v1 как Go-структуру с JSON-тестами туда-обратно.
  • Объяснить все поля конверта и правило «payload — домен, кадр — отображение».
  • Сравнить три стратегии резолва получателей и обосновать выбор A до ~1k участников.
  • Прочитать таблицу гарантий end-to-end и показать, где какая реализована.
  • Дедуплицировать по event_id и сформулировать, зачем это, если корректность даёт seq.

🔗 Мостик к Модулю 5

Конверт подписан, версия зафиксирована, дедуп стоит на входе. Осталось довести доставку до масштаба: в следующем модуле разворачиваем event-service ×N — consumer group на реплику в Redis Streams, presence в multi-replica и механику насыщения кадров; и разберёмся, когда «каждая реплика читает всё» ещё помещается, а когда уже нет.