{ "index": 237, "slug": "editorial-2021-06-practice-event-driven", "title": "Событийный контракт: как пережить повтор и смену схемы", "excerpt": "Повтор доставки и изменение payload выглядят как обычные ошибки очереди, пока у события нет устойчивой идентичности и версии. Разбираем envelope, контракт consumer и проверку, которая останавливает неизвестный вход до побочного эффекта.", "contentHtml": "

На учебном стенде checkout публикует событие о переходе заказа в paid. После подключения второго consumer оператор увидел два письма для одного заказа. Первое предположение — брокер повторил доставку, поэтому разработчик проверил только время обработки и перезапустил handler. В логах не было source, id и версии payload, поэтому команда не отличила повтор того же факта от нового события. Цена ошибки — повторное письмо, двойное изменение внешнего объекта или правдоподобная, но неверная проекция.

\n

Сначала сравним два входа по устойчивой идентичности, затем проверим envelope и принятую версию схемы. После этого запишем результат вместе с ключом consumer. Такой порядок разделяет факт, доставку и эффект: producer отвечает за идентичность события, consumer — за допустимые версии и повторное применение, а транспорт — только за передачу. Гарантия exactly-once здесь не появляется сама собой; появляется проверяемая граница, на которой можно безопасно остановиться.

\n

Сценарий: повтор после тайм-аута

\n

Возьмём один учебный заказ order-104. Producer создал событие и не получил подтверждение вовремя. Он отправил тот же вход ещё раз. Если retry создаст новый id, consumer увидит два разных факта и выполнит эффект дважды. Если source и id сохранятся, повтор можно связать с первой попыткой и принять решение по записи receipt. Это не доказывает, что внешний эффект был атомарным: такую границу нужно проверять отдельно.

\n

Событие не равно доставке

\n

Событие описывает факт: заказ перешёл в состояние paid. Доставка описывает попытку передать этот факт конкретному потребителю. Один event может попасть к нему дважды, а другой consumer прочитает тот же факт позже. Timestamp помогает восстановить порядок, но не заменяет идентичность. Поля source и id связывают повтор с исходным событием, а type и subject уточняют его смысл.

\n

Разделите три слоя. Envelope содержит контекст, payload — данные типа события, receipt — запись о том, как конкретный consumer применил вход. Если положить предметную модель в envelope, любое изменение заказа становится изменением общего транспорта. Если сохранить только итоговое число, расследование потеряет исходную схему и версию обработчика.

\n
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}`;
\n

Пример учебный: example.test не является рабочим registry, а дата соответствует сценарию статьи. В CloudEvents specversion обозначает версию самой спецификации и имеет значение 1.0. Доменную версию данных мы храним отдельно в schemaVersion; для неё можно также использовать dataschema с URI конкретной схемы. Нельзя называть оба поля одной «версией»: они отвечают на разные вопросы.

\n

Envelope проверяется до payload

\n

Сначала проверьте обязательные поля envelope и границу источника. Неполный source, пустой тип или неверная версия контекста нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, tenant, права, подпись и ограничения транспорта. Ниже оставлены только условия, необходимые для воспроизводимого разбора повтора.

\n
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
\"Producer
Схема отделяет факт, передачу и результат. Брокер на ней обозначает границу транспорта, а не гарантию отсутствия повторов.
\n

Версия схемы задаёт право на чтение

\n

Доменная schemaVersion имеет смысл только рядом с правилами reader. Пусть версия 1 содержит orderId и status, а версия 2 добавляет необязательное paymentReference. Consumer v1 может принять версию 2 и проигнорировать это поле, если такой режим зафиксирован контрактом. Consumer v2 может прочитать старую запись, но только если для отсутствующего поля явно задано значение по умолчанию. Молчаливое угадывание не является совместимостью.

\n

Добавление поля не всегда безопасно. Если новое значение меняет смысл старого поля, меняется контракт. Переименование status в state не становится совместимым потому, что оба значения имеют тип string. То же относится к единице измерения, enum, валюте и правилам округления. Для такого изменения нужен новый schema URI или адаптер с отдельной проверкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один эффект появился дваждыПовтор не связан с receiptСравнить source:id и ключ consumerПодавить повтор и сохранить evidence
Старый consumer падает на новом сообщенииИзменилось обязательное поле или его смыслСопоставить writer schema и accepted versionsОстановить вход или выпустить адаптер
Новый consumer видит пустое полеВ старой схеме поля не былоПроверить явное правило defaultВернуть ограниченный null или отказ
JSON валиден, результат неверенВерсия осталась прежней при смене смыслаСверить stable fields с владельцем доменаПоднять версию и запретить угадывание
Неясно, какой код создал записьReceipt не хранит контракт consumerНайти inputSchemaVersion и resultVersionДобавить их до controlled replay
\n

Потребитель объявляет контракт

\n

Минимальное описание потребителя (consumer) содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, orders-projection@1 принимает схемы 1 и 2, читает только orderId и status, а для версии 3 возвращает contract-update-required. Он не ищет «похожее» поле и не превращает незнакомое значение в default.

\n
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 или ручной маршрут для неопределённого результата.

\n

Повтор не меняет идентичность

\n

Controlled replay повторяет тот же вход, поэтому сохраняет source, id, type, subject и версию payload. Создание нового id ради обхода duplicate превращает технический повтор в новый факт. Consumer уже не отличит восстановление от новой команды.

\n

Два разных consumer могут законно создать две проекции по одному событию. Но каждый результат должен иметь собственный явно объявленный consumerId и resultVersion. Это не разрешение повторить платёж или письмо. Внешний эффект требует отдельного ключа намерения и подтверждения того, что произошло на границе сервиса.

\n

Проверка на трёх входах

\n

Воспроизводимость появляется, когда один тест прогоняет одну и ту же последовательность. Начните с исходного события из первого примера. Затем отправьте его повторно без изменения source и id. Наконец, измените только schemaVersion на 3. Ожидаемый результат — одна запись, подавленный duplicate и отказ без эффекта. Если журнал показывает только время и текст ошибки, добавьте идентификаторы до запуска replay.

\n
  1. Назовите наблюдаемый симптом и цену ошибки. Не начинайте с выбора брокера.
  2. Опишите факт, который producer передаёт, и отделите его от попытки доставки.
  3. Зафиксируйте envelope: id, source, type, subject, specversion и время факта.
  4. Опишите payload schema и stable fields. Для каждого поля укажите тип и смысл.
  5. Запишите accepted schema versions, projection, consumerId, resultVersion и ключ ledger.
  6. Проверьте старую схему с новым consumer и новую схему со старым consumer. Отдельно проверьте изменение смысла.
  7. Прогоните initial delivery, duplicate и controlled replay. Убедитесь, что replay не получает новый event id.
  8. Для неизвестной версии верните отказ без эффекта и сохраните причину вместе с идентификатором входа.
  9. Только после этого выберите broker, serializer, storage и retry. Запишите их реальные ограничения отдельно от контракта.
\n

Ограничения и отрицательный путь

\n

Этот материал не обещает 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. Явный отказ дешевле тихой записи, которую потом нельзя доказать.

\n

Критерий готовности

\n

Контракт готов к подключению реального транспорта, когда для одного учебного события можно без догадок показать envelope, payload schema, accepted versions, consumer id, ключ повтора и результат с inputSchemaVersion и resultVersion. Проверка должна дать три наблюдаемых исхода: первая доставка записывает один result, повтор того же source:id не создаёт второй result, неизвестная версия возвращает отказ без эффекта. Если любой исход виден только по времени лога или требует создать новый id, граница ещё не готова.

\n

Проверяемые источники

\n" }