Files

8 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>"
}