{ "index": 235, "slug": "editorial-2021-06-field-event-driven", "title": "Replay событий: как не создать второй effect и не потерять контракт схемы", "excerpt": "Повторная доставка события не должна превращаться в новый факт. Разбираем identity, ledger, совместимость схемы и controlled replay на учебном примере, который явно отделяет duplicate от новой версии consumer.", "contentHtml": "
После сбоя consumer команда часто видит один и тот же симптом: нужно проиграть события ещё раз. Оператор запускает replay, consumer снова получает запись, а в storage появляется второй result. Если replay получил новый event id, система уже не отличает повтор старого факта от нового факта. Если id сохранился, но consumer не ведёт ledger, он всё равно может повторить effect. Цена ошибки — не только дубль строки. Платёж, письмо или изменение внешней системы могут выполниться дважды. Затем команда теряет ответ на главный вопрос: какой contract создал каждый результат.
\nТезис простой: replay должен сохранять логическую identity исходного события, а consumer должен проверять схему и receipt до effect. Один и тот же source:id в том же consumer contract даёт duplicate и подавляется. Новый расчёт требует нового явно названного contract или resultVersion. Неизвестная схема останавливает обработку. Это не обещание exactly-once. Это проверяемая граница, которая не даёт техническому повтору притвориться новым бизнес-фактом.
Событие описывает уже произошедший факт. В учебном примере оно содержит source, id, type, subject, occurredAt, schemaVersion и data. Источник и id вместе образуют identity: training://orders/order-104:evt-order-104-paid-01. Новый запуск может иметь отдельную операционную причину, но эта причина не должна менять исходные поля.
Consumer contract отвечает на другой вопрос: как этот consumer интерпретирует payload. Пусть orders-projection@1 читает orderId и status, а orders-projection@2 дополнительно читает paymentReference. Один event может законно дать два локальных projection result, если домен разрешает хранить обе версии. Но это не значит, что любой внешний effect можно повторить дважды. Для email, платежа или HTTP-вызова нужен отдельный idempotency contract на внешней границе.
const ledgerKey = [consumerId, event.source, event.id].join(':');\n\nif (!acceptedSchemaVersions.includes(event.schemaVersion)) {\n return { state: 'contract-update-required', effectAllowed: false };\n}\n\nif (ledger.has(ledgerKey)) {\n return { state: 'duplicate-or-replay-suppressed', effectAllowed: false };\n}\n\nconst result = project(event, consumerId);\nledger.set(ledgerKey, { resultVersion, result });\nreturn { state: 'effect-recorded', effectAllowed: true };\nЭтот фрагмент показывает порядок, а не готовую библиотеку. В реальной системе запись receipt и effect должны иметь согласованный storage contract. Map процесса не защищает от падения между внешним вызовом и записью ledger. Если такая аварийная граница существует, автоматический replay нельзя объявлять безопасным без отдельного решения.
\nПусть пришёл учебный event order.status.changed со схемой v1. Первый consumer записывает результат для orders-projection@1. Сеть повторяет delivery. Ledger видит тот же ключ и подавляет второй result. Оператор запускает controlled replay. Ключ не меняется, поэтому replay тоже подавляется. Затем команда включает orders-projection@2. Это уже другой declared contract. Он может получить отдельный projection result, если такое решение принято явно и не смешано с внешним effect.
| Delivery | Identity | Проверка | Результат |
|---|---|---|---|
initial | тот же source:id | ключа нет | записать один result |
duplicate | тот же source:id | ключ есть у того же consumer | подавить новый effect |
controlled replay | тот же source:id | ключ есть у того же consumer | сохранить evidence, не писать второй result |
new contract | тот же event | другой consumerId и resultVersion | отдельный projection, если он разрешён |
schema v3 | новый или повторный event | версия не принята consumer | остановить effect |
Нельзя удалять старую receipt перед историческим replay. Иначе новая запись скроет, что старый contract уже обработал event. Нельзя и автоматически считать любой новый contract безопасным: два projection допустимы не во всех доменах. Сначала называют effect и его владельца, потом выбирают ключ, receipt и способ восстановления.
\nПроверка identity не заменяет проверку schema. Если event v3 содержит поле state, которого consumer не знает, отсутствие ключа в ledger не даёт права выполнить effect. Сначала consumer проверяет envelope и accepted schema versions. Затем выбирает contract. Только после этого он читает ledger. Отказ должен сохранить причину: unknown type, unsupported schema или invalid field. Такой след отличает остановленный event от потерянной delivery.
Добавление поля может быть совместимым для старого reader, если reader его игнорирует и обязательные поля сохраняют смысл. Новый reader может представить отсутствие поля как null или default, но это решение должно быть частью его contract. Переименование status в state нельзя выдавать за additive change. Учебный пример проверяет только эту пару контрактов; он не доказывает совместимость Avro, JSON Schema или вашей registry без отдельного теста.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Второй result для того же event | Нет ledger или ключ построен без source | Сравнить consumer, source, id и receipt | Сделать identity явной и остановить повторный effect |
| Replay выглядит как новое событие | Оператор заменил исходный id | Сопоставить event log и операционную запись replay | Вернуть исходную identity, причину хранить отдельно |
| Старый event не читается новым consumer | Нет правила absence/default | Прогнать writer v1 через reader v2 | Добавить явный compatibility rule или manual route |
| Unknown schema записывает effect | Ledger читается до проверки contract | Проверить порядок веток и отрицательный тест v3 | Запрещать effect до accepted schema |
| Внешний вызов повторился после сбоя | Receipt и effect не образуют атомарную границу | Смоделировать crash между вызовами | Остановить auto-replay и согласовать внешний idempotency key |
source, id, type, subject, schema version, consumer contract и причину replay.Учебный пример хранит состояние в Map процесса Node. Он не запускает broker, schema registry, database, Kubernetes, HTTP или внешний платёж. Он не измеряет throughput и не доказывает exactly-once. Идентификаторы, даты, source, payload и результаты вымышлены. Реальная гарантия зависит от transaction boundary, retry policy, partitioning, storage и внешних API. Kafka отдельно предупреждает о возможности duplicate при retry; это ограничивает формулировку, но не даёт готовой архитектуры. CloudEvents описывает envelope и protocol binding, а не receipt бизнес-операции.
\nГотовность проверяется не фразой «replay прошёл». Для выбранного consumer должны воспроизводиться четыре результата: initial delivery создаёт один result; duplicate и controlled replay не создают второй; разрешённый новый contract создаёт отдельный versioned result; неизвестная schema останавливается без effect. Для внешней операции добавьте доказательство её idempotency key или ручной stop. Если хотя бы один результат нельзя объяснить по event identity, contract, ledger и receipt, replay ещё не готов.
\n