8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 237,
|
||
"slug": "editorial-2021-06-practice-event-driven",
|
||
"title": "Событийный контракт: как пережить повтор и смену схемы",
|
||
"excerpt": "Повтор доставки и изменение payload выглядят как обычные ошибки очереди, пока у события нет устойчивой идентичности и версии. Разбираем envelope, контракт consumer и проверку, которая останавливает неизвестный вход до побочного эффекта.",
|
||
"contentHtml": "<p>На учебном стенде checkout публикует событие о переходе заказа в <code>paid</code>. После подключения второго consumer оператор увидел два письма для одного заказа. Первое предположение — брокер повторил доставку, поэтому разработчик проверил только время обработки и перезапустил handler. В логах не было <code>source</code>, <code>id</code> и версии payload, поэтому команда не отличила повтор того же факта от нового события. Цена ошибки — повторное письмо, двойное изменение внешнего объекта или правдоподобная, но неверная проекция.</p>\n<p>Сначала сравним два входа по устойчивой идентичности, затем проверим envelope и принятую версию схемы. После этого запишем результат вместе с ключом consumer. Такой порядок разделяет факт, доставку и эффект: producer отвечает за идентичность события, consumer — за допустимые версии и повторное применение, а транспорт — только за передачу. Гарантия <em>exactly-once</em> здесь не появляется сама собой; появляется проверяемая граница, на которой можно безопасно остановиться.</p>\n<h2>Сценарий: повтор после тайм-аута</h2>\n<p>Возьмём один учебный заказ <code>order-104</code>. Producer создал событие и не получил подтверждение вовремя. Он отправил тот же вход ещё раз. Если retry создаст новый <code>id</code>, consumer увидит два разных факта и выполнит эффект дважды. Если <code>source</code> и <code>id</code> сохранятся, повтор можно связать с первой попыткой и принять решение по записи receipt. Это не доказывает, что внешний эффект был атомарным: такую границу нужно проверять отдельно.</p>\n<h2>Событие не равно доставке</h2>\n<p>Событие описывает факт: заказ перешёл в состояние <code>paid</code>. Доставка описывает попытку передать этот факт конкретному потребителю. Один event может попасть к нему дважды, а другой consumer прочитает тот же факт позже. Timestamp помогает восстановить порядок, но не заменяет идентичность. Поля <code>source</code> и <code>id</code> связывают повтор с исходным событием, а <code>type</code> и <code>subject</code> уточняют его смысл.</p>\n<p>Разделите три слоя. Envelope содержит контекст, payload — данные типа события, receipt — запись о том, как конкретный consumer применил вход. Если положить предметную модель в envelope, любое изменение заказа становится изменением общего транспорта. Если сохранить только итоговое число, расследование потеряет исходную схему и версию обработчика.</p>\n<pre><code>const consumerId = 'orders-projection@1';\nconst event = {\n specversion: '1.0',\n id: 'evt-104',\n source: 'urn:checkout:orders',\n type: 'com.example.order.status.changed',\n subject: 'order-104',\n time: '2021-06-07T09:15:00Z',\n dataschema: 'https://example.test/schemas/order-status/1',\n datacontenttype: 'application/json',\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>example.test</code> не является рабочим registry, а дата соответствует сценарию статьи. В CloudEvents <code>specversion</code> обозначает версию самой спецификации и имеет значение <code>1.0</code>. Доменную версию данных мы храним отдельно в <code>schemaVersion</code>; для неё можно также использовать <code>dataschema</code> с URI конкретной схемы. Нельзя называть оба поля одной «версией»: они отвечают на разные вопросы.</p>\n<h2>Envelope проверяется до payload</h2>\n<p>Сначала проверьте обязательные поля envelope и границу источника. Неполный <code>source</code>, пустой тип или неверная версия контекста нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, tenant, права, подпись и ограничения транспорта. Ниже оставлены только условия, необходимые для воспроизводимого разбора повтора.</p>\n<pre><code>function validateEnvelope(input) {\n const required = ['id', 'source', 'type', 'subject', 'specversion'];\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.specversion !== '1.0') {\n return { ok: false, reason: 'unsupported-context-version' };\n }\n\n if (!input.source.startsWith('urn:checkout:')) {\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 и запись результата. Иначе handler может частично изменить состояние и только после этого обнаружить, что не знает владельца события или его версию. Проверка должна быть чистой: один и тот же вход даёт один и тот же отказ, не записывая побочный эффект.</p>\n<figure><img src=\"/assets/editorial/2021/event-topology-2021.svg\" alt=\"Producer формирует контекст и payload, транспорт передаёт их consumer, consumer проверяет версию до записи результата, а повтор возвращается с тем же source и 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 может принять версию 2 и проигнорировать это поле, если такой режим зафиксирован контрактом. Consumer v2 может прочитать старую запись, но только если для отсутствующего поля явно задано значение по умолчанию. Молчаливое угадывание не является совместимостью.</p>\n<p>Добавление поля не всегда безопасно. Если новое значение меняет смысл старого поля, меняется контракт. Переименование <code>status</code> в <code>state</code> не становится совместимым потому, что оба значения имеют тип string. То же относится к единице измерения, enum, валюте и правилам округления. Для такого изменения нужен новый schema URI или адаптер с отдельной проверкой.</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>Изменилось обязательное поле или его смысл</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>Receipt не хранит контракт consumer</td><td>Найти <code>inputSchemaVersion</code> и <code>resultVersion</code></td><td>Добавить их до controlled replay</td></tr></tbody></table>\n<h2>Потребитель объявляет контракт</h2>\n<p>Минимальное описание потребителя (<em>consumer</em>) содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, <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 только для демонстрации последовательности. В рабочей системе проверка и запись должны быть защищены от гонки между двумя экземплярами consumer. Receipt и внешний эффект могут пересекать границу транзакции, поэтому нужна явная стратегия: идемпотентный API внешней стороны, атомарная запись в пределах одного storage или ручной маршрут для неопределённого результата.</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<p>Воспроизводимость появляется, когда один тест прогоняет одну и ту же последовательность. Начните с исходного события из первого примера. Затем отправьте его повторно без изменения <code>source</code> и <code>id</code>. Наконец, измените только <code>schemaVersion</code> на 3. Ожидаемый результат — одна запись, подавленный duplicate и отказ без эффекта. Если журнал показывает только время и текст ошибки, добавьте идентификаторы до запуска replay.</p>\n<ol><li>Назовите наблюдаемый симптом и цену ошибки. Не начинайте с выбора брокера.</li><li>Опишите факт, который producer передаёт, и отделите его от попытки доставки.</li><li>Зафиксируйте envelope: <code>id</code>, <code>source</code>, <code>type</code>, <code>subject</code>, <code>specversion</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 2.8 документирует retries и producer idempotence: такая настройка защищает запись producer от части повторов при соблюдении условий, но не делает обработку consumer и внешний API идемпотентными. Avro описывает разрешение writer и reader schema, но не решает смысл доменных значений.</p>\n<p>Отрицательный путь обязателен. Если consumer не знает <code>schemaVersion</code>, <code>source</code> не входит в его границу или значение изменило смысл, он не должен «попробовать как раньше». Сохраните вход и причину отказа без эффекта. Если внешний эффект мог завершиться, остановите автоматический 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/v1.0/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Specification v1.0</a> — официальные определения event, producer, consumer и обязательных атрибутов <code>id</code>, <code>source</code>, <code>specversion</code>, <code>type</code>.</li><li><a href=\"https://kafka.apache.org/28/configuration/producer-configs/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka 2.8 Producer Configs</a> — официальные ограничения retries, <code>acks</code>, <code>enable.idempotence</code> и <code>max.in.flight.requests.per.connection</code>.</li><li><a href=\"https://avro.apache.org/docs/1.10.2/spec.html#Schema+Resolution\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro 1.10.2: Schema Resolution</a> — официальные правила согласования writer и reader schema.</li></ul>"
|
||
}
|