{ "index": 236, "slug": "editorial-2021-06-mechanism-event-driven", "title": "Как менять схему события и не сломать consumer", "excerpt": "Валидный JSON не гарантирует совместимость события. Разбираем writer и reader contract, безопасное добавление поля, replay, версионирование результата и отказ без тихой подмены данных.", "contentHtml": "
Симптом появляется после обычного релиза: producer добавляет поле в событие, JSON проходит парсер, а старый consumer начинает падать или записывает неверное состояние. Хуже, когда он не падает. Он принимает новое значение за старое и создаёт правдоподобный результат. В журнале остаётся только «обработка успешна». Затем трудно понять, какие заказы затронуты и каким правилом их пересчитать. Цена ошибки — потерянная история, повторные побочные эффекты и ручная сверка данных.
\nТезис простой: совместимость проверяют не у формата вообще, а у пары writer и reader. Producer описывает, что отправил. Consumer заранее объявляет, что умеет читать и как поступает с отсутствующим или неизвестным полем. Если смысл устойчивого поля изменился, это новый контракт, даже когда тип и JSON-ключ остались прежними.
\nСобытие фиксирует уже произошедший факт. В envelope нужны как минимум идентификатор, источник, тип, время и версия payload. В учебной модели заказ передаёт два устойчивых поля: orderId и status. Версия 2 добавляет необязательный paymentReference. Consumer версии 1 читает только устойчивую пару. Consumer версии 2 читает оба поля и представляет отсутствие ссылки как null.
Такой пример не запускает broker и не имитирует production delivery. Все значения, идентификаторы и результаты вымышлены. Состояние хранится в памяти процесса. Код показывает только границу контракта и маршрут для неизвестной версии.
\nconst eventV2 = {\n id: 'evt-order-104-paid-02',\n source: 'training://orders/order-104',\n type: 'order.status.changed',\n schemaVersion: 2,\n data: {\n orderId: 'order-104',\n status: 'paid',\n paymentReference: 'training-pay-77'\n }\n};\n\nconst consumerV1 = {\n id: 'orders-projection@1',\n acceptedSchemaVersions: [1, 2],\n fields: ['orderId', 'status'],\n resultVersion: 'orders-projection.1'\n};\nЗдесь число 2 не означает версию Kafka, библиотеки или deploy. Это версия прикладной схемы. Массив fields — часть reader contract. Он делает намерение явным: consumer не обязан использовать каждое поле, которое встретил в payload.
Добавочное поле совместимо с конкретным reader только при трёх условиях. Reader не использует неизвестные поля для обязательной валидации. Новое поле не меняет смысл прежних полей. Consumer может честно обработать отсутствие поля, например назначить документированный default или оставить значение пустым.
\nПереименование status в state этим условиям не отвечает. Даже если оба поля имеют тип string, старый consumer не знает, что settled равно paid. Та же проблема возникает при смене единицы измерения, часового пояса, валюты или смысла enum. Формально валидный payload может быть семантически несовместим.
Обратное направление тоже нужно проверять. Новый reader должен уметь прочитать старый event, в котором нет paymentReference. Если значение обязательно для действия, default не подходит. Тогда новый reader должен вернуть отказ или выбрать маршрут миграции. Молчаливое заполнение пустого значения опасно: effect может выглядеть успешным, хотя обязательная часть факта исчезла.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый consumer отклоняет v2 | Он считает любое неизвестное поле ошибкой | Отправить v2 в изолированный reader test | Изменить contract явно или выпустить новую версию consumer |
| Ошибок нет, но projection неверна | Поменялся смысл stable field | Сравнить определения поля и два реальных значения на границе | Остановить effect и сделать новый contract |
| Новый consumer теряет данные старого event | Нет правила для отсутствующего поля | Прогнать v1 payload через v2 reader | Добавить явный default или вернуть отказ |
| Replay создаёт второй result | Результат не связан с source и event id | Повторить тот же вход с тем же id | Добавить ledger и идемпотентный ключ effect |
| Неизвестная версия вызывает действие | Consumer принимает любое число schemaVersion | Отправить v3 с изменённым полем | Вернуть contract-update-required без effect |
Одной schemaVersion недостаточно. Один event могут прочитать два consumer или тот же consumer после исправления. Результат должен хранить event.id, source, inputSchemaVersion, consumerId и resultVersion. Первое поле связывает запись с фактом. Версия входа показывает форму payload. Идентификатор consumer отделяет независимые projections. Версия результата показывает, каким правилом создана запись.
{\n \"eventId\": \"evt-order-104-paid-02\",\n \"source\": \"training://orders/order-104\",\n \"inputSchemaVersion\": 2,\n \"consumerId\": \"orders-projection@1\",\n \"resultVersion\": \"orders-projection.1\",\n \"decision\": \"effect-recorded\",\n \"projection\": {\n \"orderId\": \"order-104\",\n \"status\": \"paid\"\n }\n}\nresultVersion не должен быть случайным timestamp или номером сборки. Это имя интерпретации. При replay оно отвечает на вопрос: старое или новое правило породило projection? Ledger должен хранить ключ вроде consumerId:source:eventId. Повтор того же входа возвращает duplicate-or-replay-suppressed и не выполняет effect второй раз.
Permissive consumer, который принимает любую версию и берёт знакомые ключи, работает до первого изменения смысла. Представим v3: producer заменил status на state. Envelope остаётся похожим, но reader не может доказать, что новое значение означает прежнее действие. Он должен сохранить вход для расследования, записать причину отказа и не менять projection.
function project(event, contract) {\n if (!contract.acceptedSchemaVersions.includes(event.schemaVersion)) {\n return {\n decision: 'contract-update-required',\n effect: 'blocked',\n eventId: event.id\n };\n }\n\n const projection = {\n orderId: event.data.orderId,\n status: event.data.status\n };\n\n return {\n decision: 'effect-recorded',\n effect: projection,\n resultVersion: contract.resultVersion\n };\n}\nПроверка версии не заменяет проверку значения. Для денег, дат, enum и ссылок нужны отдельные правила. Если v2 содержит пустой paymentReference, это не повод автоматически принять событие только потому, что версия разрешена. Сначала проверьте тип, обязательность и диапазон. Потом решайте, можно ли выполнять effect.
Учебный код не доказывает backwards compatibility в конкретной библиотеке. Он не проверяет Avro bytes, JSON Schema, schema registry, partition, offset, transaction, retention, авторизацию, шифрование, сетевые retry или SLA доставки. Он также не доказывает exactly-once. Повторная доставка и повторная запись результата требуют отдельной модели idempotency.
\nCloudEvents стандартизирует envelope и context attributes, но не знает бизнес-смысл status. Schema resolution в Avro помогает сопоставлять writer и reader schema, но не решает, допустима ли замена одного доменного значения другим. Документация Kafka предупреждает о duplicate при retry, но сама настройка producer не делает effect идемпотентным. Поэтому общий стандарт не отменяет локальный reader contract.
Критерий готовности проверяемый: для выбранного event type есть два направленных теста совместимости, replay сохраняет исходный id и не создаёт второй effect, а неизвестная версия даёт отказ без изменения projection. В результате можно восстановить event id, input schema version, consumer id и result version. Если хотя бы одно поле приходится угадывать по времени или логам, изменение схемы ещё не готово.
\n