{ "index": 237, "slug": "editorial-2021-06-practice-event-driven", "title": "Событийный контракт: как пережить повтор и смену схемы", "excerpt": "Повтор доставки и изменение payload выглядят как обычные ошибки очереди, пока у события нет устойчивой идентичности и версии. Разбираем envelope, контракт consumer и проверку, которая останавливает неизвестный вход до побочного эффекта.", "contentHtml": "
На учебном стенде checkout публикует событие о переходе заказа в paid. После подключения второго consumer оператор увидел два письма для одного заказа. Первое предположение — брокер повторил доставку, поэтому разработчик проверил только время обработки и перезапустил handler. В логах не было source, id и версии payload, поэтому команда не отличила повтор того же факта от нового события. Цена ошибки — повторное письмо, двойное изменение внешнего объекта или правдоподобная, но неверная проекция.
Сначала сравним два входа по устойчивой идентичности, затем проверим envelope и принятую версию схемы. После этого запишем результат вместе с ключом consumer. Такой порядок разделяет факт, доставку и эффект: producer отвечает за идентичность события, consumer — за допустимые версии и повторное применение, а транспорт — только за передачу. Гарантия exactly-once здесь не появляется сама собой; появляется проверяемая граница, на которой можно безопасно остановиться.
\nВозьмём один учебный заказ order-104. Producer создал событие и не получил подтверждение вовремя. Он отправил тот же вход ещё раз. Если retry создаст новый id, consumer увидит два разных факта и выполнит эффект дважды. Если source и id сохранятся, повтор можно связать с первой попыткой и принять решение по записи receipt. Это не доказывает, что внешний эффект был атомарным: такую границу нужно проверять отдельно.
Событие описывает факт: заказ перешёл в состояние paid. Доставка описывает попытку передать этот факт конкретному потребителю. Один event может попасть к нему дважды, а другой consumer прочитает тот же факт позже. Timestamp помогает восстановить порядок, но не заменяет идентичность. Поля source и id связывают повтор с исходным событием, а type и subject уточняют его смысл.
Разделите три слоя. Envelope содержит контекст, payload — данные типа события, receipt — запись о том, как конкретный consumer применил вход. Если положить предметную модель в envelope, любое изменение заказа становится изменением общего транспорта. Если сохранить только итоговое число, расследование потеряет исходную схему и версию обработчика.
\nconst 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}`;\nПример учебный: example.test не является рабочим registry, а дата соответствует сценарию статьи. В CloudEvents specversion обозначает версию самой спецификации и имеет значение 1.0. Доменную версию данных мы храним отдельно в schemaVersion; для неё можно также использовать dataschema с URI конкретной схемы. Нельзя называть оба поля одной «версией»: они отвечают на разные вопросы.
Сначала проверьте обязательные поля envelope и границу источника. Неполный source, пустой тип или неверная версия контекста нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, tenant, права, подпись и ограничения транспорта. Ниже оставлены только условия, необходимые для воспроизводимого разбора повтора.
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}\nПорядок важен: сначала граница и контекст, затем выбор consumer contract, потом чтение payload и запись результата. Иначе handler может частично изменить состояние и только после этого обнаружить, что не знает владельца события или его версию. Проверка должна быть чистой: один и тот же вход даёт один и тот же отказ, не записывая побочный эффект.
\nДоменная schemaVersion имеет смысл только рядом с правилами reader. Пусть версия 1 содержит orderId и status, а версия 2 добавляет необязательное paymentReference. Consumer v1 может принять версию 2 и проигнорировать это поле, если такой режим зафиксирован контрактом. Consumer v2 может прочитать старую запись, но только если для отсутствующего поля явно задано значение по умолчанию. Молчаливое угадывание не является совместимостью.
Добавление поля не всегда безопасно. Если новое значение меняет смысл старого поля, меняется контракт. Переименование status в state не становится совместимым потому, что оба значения имеют тип string. То же относится к единице измерения, enum, валюте и правилам округления. Для такого изменения нужен новый schema URI или адаптер с отдельной проверкой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один эффект появился дважды | Повтор не связан с receipt | Сравнить source:id и ключ consumer | Подавить повтор и сохранить evidence |
| Старый consumer падает на новом сообщении | Изменилось обязательное поле или его смысл | Сопоставить writer schema и accepted versions | Остановить вход или выпустить адаптер |
| Новый consumer видит пустое поле | В старой схеме поля не было | Проверить явное правило default | Вернуть ограниченный null или отказ |
| JSON валиден, результат неверен | Версия осталась прежней при смене смысла | Сверить stable fields с владельцем домена | Поднять версию и запретить угадывание |
| Неясно, какой код создал запись | Receipt не хранит контракт consumer | Найти inputSchemaVersion и resultVersion | Добавить их до controlled replay |
Минимальное описание потребителя (consumer) содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, orders-projection@1 принимает схемы 1 и 2, читает только orderId и status, а для версии 3 возвращает contract-update-required. Он не ищет «похожее» поле и не превращает незнакомое значение в default.
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}\nЗдесь Map заменяет ledger только для демонстрации последовательности. В рабочей системе проверка и запись должны быть защищены от гонки между двумя экземплярами consumer. Receipt и внешний эффект могут пересекать границу транзакции, поэтому нужна явная стратегия: идемпотентный API внешней стороны, атомарная запись в пределах одного storage или ручной маршрут для неопределённого результата.
Controlled replay повторяет тот же вход, поэтому сохраняет source, id, type, subject и версию payload. Создание нового id ради обхода duplicate превращает технический повтор в новый факт. Consumer уже не отличит восстановление от новой команды.
Два разных consumer могут законно создать две проекции по одному событию. Но каждый результат должен иметь собственный явно объявленный consumerId и resultVersion. Это не разрешение повторить платёж или письмо. Внешний эффект требует отдельного ключа намерения и подтверждения того, что произошло на границе сервиса.
Воспроизводимость появляется, когда один тест прогоняет одну и ту же последовательность. Начните с исходного события из первого примера. Затем отправьте его повторно без изменения source и id. Наконец, измените только schemaVersion на 3. Ожидаемый результат — одна запись, подавленный duplicate и отказ без эффекта. Если журнал показывает только время и текст ошибки, добавьте идентификаторы до запуска replay.
id, source, type, subject, specversion и время факта.consumerId, resultVersion и ключ ledger.Этот материал не обещает 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, но не решает смысл доменных значений.
\nОтрицательный путь обязателен. Если consumer не знает schemaVersion, source не входит в его границу или значение изменило смысл, он не должен «попробовать как раньше». Сохраните вход и причину отказа без эффекта. Если внешний эффект мог завершиться, остановите автоматический replay до проверки receipt. Явный отказ дешевле тихой записи, которую потом нельзя доказать.
Контракт готов к подключению реального транспорта, когда для одного учебного события можно без догадок показать envelope, payload schema, accepted versions, consumer id, ключ повтора и результат с inputSchemaVersion и resultVersion. Проверка должна дать три наблюдаемых исхода: первая доставка записывает один result, повтор того же source:id не создаёт второй result, неизвестная версия возвращает отказ без эффекта. Если любой исход виден только по времени лога или требует создать новый id, граница ещё не готова.
id, source, specversion, type.acks, enable.idempotence и max.in.flight.requests.per.connection.