8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 237,
|
||
"slug": "editorial-2021-06-practice-event-driven",
|
||
"title": "Событийный контракт: как пережить повтор, новую схему и неизвестный consumer",
|
||
"excerpt": "Очередь передаёт данные, но не объясняет их смысл и не защищает эффект от повтора. Разбираем envelope, версию схемы, consumer contract и проверку, которая останавливает опасный вход до изменения состояния.",
|
||
"contentHtml": "<p>Симптом появляется после подключения второго consumer. Заказ уже сменил статус, но обработчик не может ответить на четыре вопроса: кто создал событие, к какому объекту оно относится, какую схему он получил и записывал ли этот вход результат раньше. Иногда сообщение приходит дважды после повтора отправки. Иногда producer добавляет поле, а старый consumer принимает JSON и неверно трактует новое значение. Цена ошибки — не только красный лог. Система может дважды отправить письмо, повторно изменить внешний объект или записать правдоподобную, но неверную проекцию.</p>\n<p>Тезис простой: событийный контракт должен разделять факт, доставку и эффект. Producer фиксирует устойчивый envelope и версию payload. Consumer объявляет принимаемые версии и ключ идемпотентности. Неизвестная схема меняет маршрут на отказ или ручную обработку. Повтор той же доставки не получает новый event id. Такой контракт не даёт обещания exactly-once. Он оставляет доказательство, которое позволяет безопасно принять решение.</p>\n<h2>Событие не равно доставке</h2>\n<p>Событие описывает факт: например, заказ перешёл в состояние <code>paid</code>. Доставка описывает попытку передать этот факт конкретному consumer. Одно событие может попасть к нему дважды. Другой consumer может прочитать тот же факт позже. Поэтому время обработки не заменяет идентичность входа. Поля <code>id</code> и <code>source</code> связывают повтор с исходным сообщением, а <code>type</code> и <code>subject</code> ограничивают его смысл.</p>\n<p>Envelope отвечает за координаты события. Payload отвечает за данные конкретного типа. Результат consumer отвечает за уже применённую интерпретацию. Не смешивайте эти слои. Если producer положит в envelope всю предметную модель, любое изменение заказа станет изменением общего транспорта. Если consumer сохранит только итоговое число, расследование потеряет исходную схему и версию обработчика.</p>\n<pre><code>const event = {\n contractVersion: 1,\n id: 'evt-104',\n source: 'training://orders/checkout',\n type: 'order.status.changed',\n subject: 'order-104',\n occurredAt: '2026-07-31T12:00:00Z',\n data: {\n schemaVersion: 1,\n orderId: 'order-104',\n status: 'paid'\n }\n};\n\nconst resultKey = `${consumerId}:${event.source}:${event.id}`;</code></pre>\n<p>Идентификаторы и значения в примере учебные. Код не подключается к брокеру, не моделирует базу и не доказывает гарантию доставки. Он показывает границу контракта: один логический вход сохраняет один <code>id</code>, а результат связывает его с конкретным consumer.</p>\n<h2>Envelope проверяется до payload</h2>\n<p>Сначала проверьте обязательные поля envelope. Неполный источник, пустой тип или чужой namespace нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, права, tenant, подпись и ограничения транспорта. Этот пример ограничен полями, которые нужны для разбора повторной доставки.</p>\n<pre><code>function validateEnvelope(input) {\n const required = ['id', 'source', 'type', 'subject', 'contractVersion'];\n for (const field of required) {\n if (typeof input[field] !== 'string' || input[field] === '') {\n return { ok: false, reason: `missing-${field}` };\n }\n }\n\n if (!input.source.startsWith('training://orders/')) {\n return { ok: false, reason: 'source-outside-orders-boundary' };\n }\n\n if (!Number.isInteger(input.data?.schemaVersion)) {\n return { ok: false, reason: 'missing-schema-version' };\n }\n\n return { ok: true };\n}</code></pre>\n<p>Порядок важен. Сначала проверка границы, затем выбор consumer contract, затем чтение payload, затем запись результата. Иначе обработчик может частично изменить состояние и только потом обнаружить, что не знает владельца события или его версию.</p>\n<figure><img src=\"/assets/editorial/2021/event-topology-2021.svg\" alt=\"Producer формирует envelope и payload, доставка передаёт их consumer, а consumer проверяет contract перед записью versioned result; отдельная ветка показывает duplicate или replay с тем же event id\" loading=\"lazy\" /><figcaption>Схема разделяет факт, передачу и результат. Брокер на ней обозначает границу транспорта, а не конкретную гарантию продукта.</figcaption></figure>\n<h2>Версия схемы задаёт право на чтение</h2>\n<p>Число <code>schemaVersion</code> имеет смысл только рядом с правилами reader. Пусть версия 1 содержит <code>orderId</code> и <code>status</code>, а версия 2 добавляет необязательное поле <code>paymentReference</code>. Consumer v1 может безопасно проигнорировать это поле, если он заранее объявил, что читает только устойчивую пару. Consumer v2 может прочитать его и вернуть <code>null</code>, если получил старую запись без этого поля.</p>\n<p>Добавление поля не всегда совместимо. Если новое значение меняет смысл старого поля, меняется контракт. Переименование <code>status</code> в <code>state</code> не становится безопасным потому, что оба значения имеют тип string. То же относится к смене единицы измерения, enum, валюты и правил округления. Для такого изменения нужен новый contract или адаптер с отдельной проверкой.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Один эффект появился дважды</td><td>Повтор доставки не связан с receipt</td><td>Сравнить <code>source:id</code> и ключ consumer</td><td>Подавить повтор, сохранив evidence</td></tr><tr><td>Старый consumer падает на новом сообщении</td><td>Producer изменил обязательное поле или смысл</td><td>Сопоставить writer schema и accepted versions</td><td>Остановить вход или выпустить адаптер</td></tr><tr><td>Новый consumer видит пустое поле</td><td>В старой схеме поля не было</td><td>Проверить явное правило default</td><td>Вернуть ограниченный <code>null</code> или отказ</td></tr><tr><td>JSON валиден, результат неверен</td><td>Изменился смысл значения, но версия осталась прежней</td><td>Сверить семантику stable fields с владельцем домена</td><td>Поднять версию и запретить угадывание</td></tr><tr><td>Нельзя понять, какой код создал запись</td><td>Результат не хранит consumer contract</td><td>Найти <code>inputSchemaVersion</code> и <code>resultVersion</code></td><td>Добавить их в receipt до replay</td></tr></tbody></table>\n<h2>Consumer обязан объявить контракт</h2>\n<p>Минимальное описание consumer содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, <code>orders-projection@1</code> принимает схемы 1 и 2, читает только <code>orderId</code> и <code>status</code>, а для версии 3 возвращает <code>contract-update-required</code>. Он не пытается найти похожее поле и не превращает незнакомое значение в default.</p>\n<pre><code>function apply(event, consumer, ledger) {\n const envelope = validateEnvelope(event);\n if (!envelope.ok) return { state: 'rejected', reason: envelope.reason };\n\n if (!consumer.acceptedSchemaVersions.includes(event.data.schemaVersion)) {\n return { state: 'contract-update-required', effect: false };\n }\n\n const key = `${consumer.id}:${event.source}:${event.id}`;\n if (ledger.has(key)) {\n return { state: 'duplicate-or-replay-suppressed', key, effect: false };\n }\n\n const projection = consumer.project(event.data);\n const result = {\n key,\n inputSchemaVersion: event.data.schemaVersion,\n resultVersion: consumer.resultVersion,\n projection\n };\n ledger.set(key, result);\n return { state: 'effect-recorded', result };\n}</code></pre>\n<p>В этом учебном коде <code>Map</code> заменяет ledger только для демонстрации последовательности. В рабочей системе запись receipt и внешний эффект могут пересекать границу транзакции. Тогда одной проверки в памяти недостаточно: нужен конкретный storage contract, идемпотентный API внешней стороны или ручной маршрут для неопределённого результата.</p>\n<h2>Повтор не должен менять идентичность</h2>\n<p>Controlled replay повторяет тот же вход, поэтому сохраняет <code>source</code>, <code>id</code>, <code>type</code>, <code>subject</code> и версию payload. Создание нового id ради обхода duplicate превращает технический повтор в новый факт. Это опасная подмена: consumer уже не отличит восстановление от новой команды.</p>\n<p>Два разных consumer могут законно создать две проекции по одному событию. Но новый результат должен иметь другой явно объявленный <code>consumerId</code> и <code>resultVersion</code>. Это не разрешение повторить платеж или письмо. Внешний эффект требует отдельного ключа намерения и подтверждения того, что произошло на границе сервиса.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Назовите один наблюдаемый симптом и цену ошибки. Не начинайте с выбора брокера.</li><li>Опишите факт, который producer передаёт, и отделите его от попытки доставки.</li><li>Зафиксируйте envelope: <code>id</code>, <code>source</code>, <code>type</code>, <code>subject</code>, <code>contractVersion</code> и время факта.</li><li>Опишите payload schema и stable fields. Для каждого поля укажите тип и смысл.</li><li>Запишите accepted schema versions, projection, <code>consumerId</code>, <code>resultVersion</code> и ключ ledger.</li><li>Проверьте старую схему с новым consumer и новую схему со старым consumer. Отдельно проверьте изменение смысла.</li><li>Прогоните initial delivery, duplicate и controlled replay. Убедитесь, что replay не получает новый event id.</li><li>Для неизвестной версии верните отказ без эффекта и сохраните причину вместе с идентификатором входа.</li><li>Только после этого выберите broker, serializer, storage и правила retry. Запишите их реальные ограничения отдельно от контракта.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Этот материал не обещает ordering, exactly-once, отсутствие duplicate, бесконечный retention или атомарность между брокером и базой. Он не заменяет outbox, schema registry, transaction, authorization и integration test. CloudEvents помогает стандартизировать контекст события, но не знает, какой бизнес-эффект допустим. Kafka может повторить отправку при неопределённом результате; настройки producer не отменяют идемпотентность приложения и внешней системы. Avro описывает совместное чтение writer и reader schema, но не решает смысл доменных значений.</p>\n<p>Отрицательный путь обязателен. Если consumer не знает schema version, source не входит в его границу или значение изменило смысл, он не должен «попробовать как раньше». Сохраните вход и причину отказа без эффекта. Если внешний эффект мог завершиться, остановите автоматический replay до проверки receipt. Явный отказ дешевле тихой записи, которую потом нельзя доказать.</p>\n<h2>Критерий готовности</h2>\n<p>Контракт готов к подключению реального транспорта, когда для одного учебного события можно без догадок показать envelope, payload schema, accepted versions, consumer id, ключ повтора и результат с <code>inputSchemaVersion</code> и <code>resultVersion</code>. Проверка должна дать три наблюдаемых исхода: первая доставка записывает один result, повтор того же <code>source:id</code> не создаёт второй result, неизвестная версия возвращает отказ без эффекта. Если любой исход виден только по времени лога или требует создать новый id, граница ещё не готова.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Specification</a> — официальная спецификация контекста события и event data.</li><li><a href=\"https://kafka.apache.org/41/configuration/producer-configs/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka 4.1 Producer Configs</a> — официальные правила retries и idempotence producer.</li><li><a href=\"https://avro.apache.org/docs/1.12.0/specification/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro Specification 1.12.0</a> — официальное описание writer/reader schema resolution.</li></ul>"
|
||
}
|