{ "index": 236, "slug": "editorial-2021-06-mechanism-event-driven", "title": "Как менять схему события и не сломать consumer", "excerpt": "Валидный JSON не гарантирует совместимость события. Разбираем writer и reader contract, безопасное добавление поля, replay, версионирование результата и отказ без тихой подмены данных.", "contentHtml": "
Симптом появляется после обычного релиза: producer добавляет поле в событие, JSON проходит парсер, а старый consumer начинает падать или записывает неверное состояние. Но падение происходит не всегда: конкретный reader может отвергнуть неизвестное поле, проигнорировать его или использовать его косвенно. Хуже всего тихая ошибка. Consumer принимает новое значение за старое, в журнале оставляет «обработка успешна», а команда уже не понимает, какие заказы затронуты и каким правилом их пересчитать. Цена ошибки — потерянная история, повторные побочные эффекты и ручная сверка данных.
\nТезис простой: совместимость проверяют не у формата вообще, а у пары writer и reader. Producer описывает, что отправил. Consumer заранее объявляет, что умеет читать и как поступает с отсутствующим или неизвестным полем. Если смысл устойчивого поля изменился, это новый контракт, даже когда тип и JSON-ключ остались прежними. Ниже — учебная модель, которую можно прогнать в памяти Node без broker и внешнего сервиса.
\nСобытие фиксирует уже произошедший факт, а consumer строит собственный результат. Эти объекты нельзя смешивать. В учебном envelope есть идентификатор, источник, тип, время и версия payload. Версия 1 передаёт два устойчивых поля заказа: orderId и status. Версия 2 добавляет необязательный paymentReference. Consumer версии 1 читает только устойчивую пару. Consumer версии 2 читает оба поля и представляет отсутствие ссылки как null.
Обязательность этих полей относится к данной модели, а не ко всем event-системам. Здесь source и id вместе образуют identity события. schemaVersion обозначает версию прикладного payload, а не версию Kafka, библиотеки или deploy. resultVersion обозначает правило, которым consumer создал projection.
const eventV2 = {\n id: 'evt-order-104-paid-02',\n source: 'training://orders/order-104',\n type: 'order.status.changed',\n occurredAt: '2021-06-15T10:00:00Z',\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 resultVersion: 'orders-projection.1'\n};\nВ этой записи число 2 не обещает, что любой v1 reader примет payload. Оно лишь даёт consumer-у возможность применить собственное правило. Если reader запрещает неизвестные поля, тот же writer потребует совместимого изменения reader или отдельного маршрута. Поэтому список принятых версий — только первый фильтр, а не доказательство совместимости.
Добавочное поле совместимо с конкретным reader только при трёх условиях. Reader не использует неизвестные поля для обязательной валидации. Новое поле не меняет смысл прежних полей. Consumer может честно обработать отсутствие поля: назначить документированный default, оставить значение пустым или остановиться до effect.
\nПереименование status в state этим условиям не отвечает. Даже если оба поля имеют тип string, старый consumer не знает, что settled равно paid. Та же проблема возникает при смене единицы измерения, часового пояса, валюты или смысла enum. Формально валидный payload может быть семантически несовместим.
Обратное направление нужно проверять отдельно. Новый reader должен уметь прочитать старый event, в котором нет paymentReference. Если ссылка обязательна для действия, null не является безопасным default. Тогда новый reader должен вернуть отказ или выбрать маршрут миграции. Молчаливое заполнение опасно: effect выглядит успешным, хотя обязательная часть факта исчезла.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый consumer отклоняет v2 | Неизвестное поле запрещено его contract | Отправить v2 в изолированный reader test | Явно изменить contract или выпустить новую версию consumer |
| Ошибок нет, но projection неверна | Поменялся смысл stable field | Сравнить определения поля и значения на границе | Остановить effect и оформить новый contract |
| Новый consumer теряет данные старого event | Нет правила для отсутствующего поля | Прогнать v1 payload через v2 reader | Добавить default, отдельный route или отказ |
| Replay создаёт второй result | Ключ не связывает consumer, source и event id | Повторить тот же вход с тем же id | Добавить ledger и идемпотентный ключ effect |
| Неизвестная версия вызывает действие | Consumer принимает любое число schemaVersion | Отправить v3 с изменённым полем | Вернуть contract-update-required без effect |
Ниже минимальный fixture проверяет четыре ветки: первый вход, повторную доставку, чтение старого event новым reader и неизвестную схему. Он не подключает broker, но делает порядок проверок видимым. Сначала проверяется envelope и версия, затем строится ключ ledger, потом выполняется projection. Для ясности ledger хранит только результат; в настоящей системе запись receipt и внешний effect потребуют отдельной согласованной границы.
\nconst ledger = new Map();\n\nconst contracts = {\n v1: {\n id: 'orders-projection@1',\n acceptedSchemaVersions: [1, 2],\n resultVersion: 'orders-projection.1'\n },\n v2: {\n id: 'orders-projection@2',\n acceptedSchemaVersions: [1, 2],\n resultVersion: 'orders-projection.2'\n }\n};\n\nfunction project(event, contract) {\n if (!event?.id || !event.source || event.type !== 'order.status.changed') {\n return { decision: 'invalid-envelope', effect: 'blocked' };\n }\n if (!contract.acceptedSchemaVersions.includes(event.schemaVersion)) {\n return { decision: 'contract-update-required', effect: 'blocked', eventId: event.id };\n }\n if (!event.data?.orderId || !event.data?.status) {\n return { decision: 'invalid-payload', effect: 'blocked', eventId: event.id };\n }\n\n const key = [contract.id, event.source, event.id].join(':');\n if (ledger.has(key)) {\n return { decision: 'duplicate-or-replay-suppressed', effect: 'blocked', eventId: event.id };\n }\n\n const result = {\n orderId: event.data.orderId,\n status: event.data.status,\n paymentReference: contract.id.endsWith('@2')\n ? (event.data.paymentReference ?? null)\n : undefined\n };\n ledger.set(key, { inputSchemaVersion: event.schemaVersion, resultVersion: contract.resultVersion, result });\n return { decision: 'effect-recorded', effect: result, eventId: event.id };\n}\n\nconst v1 = {\n id: 'evt-order-104-paid-01', source: 'training://orders/order-104',\n type: 'order.status.changed', schemaVersion: 1,\n data: { orderId: 'order-104', status: 'paid' }\n};\nconst v3 = { ...v1, schemaVersion: 3, data: { orderId: 'order-104', state: 'settled' } };\n\nconsole.log(project(v1, contracts.v1).decision); // effect-recorded\nconsole.log(project(v1, contracts.v1).decision); // duplicate-or-replay-suppressed\nconsole.log(project(v1, contracts.v2).effect.paymentReference); // null\nconsole.log(project(v3, contracts.v1).decision); // contract-update-required\nЧетвёртая строка важнее первой. Версия v3 не должна попасть в projection только потому, что поля orderId и id выглядят знакомо. В примере v3 заменяет status на state, поэтому consumer останавливается до ledger и effect. Это не означает, что любой v3 нужно отклонять навсегда: сначала владелец контракта должен объявить правило преобразования и покрыть его отдельным тестом.
Одной 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 второй раз. Однако Map процесса не защищает от падения между внешним вызовом и записью receipt, поэтому такой пример не доказывает exactly-once.
Permissive consumer, который принимает любую версию и берёт знакомые ключи, работает до первого изменения смысла. Представим v3: producer заменил status на state. Envelope остаётся похожим, но reader не может доказать, что новое значение означает прежнее действие. Он должен сохранить вход для расследования, записать причину отказа и не менять projection.
Проверка версии не заменяет проверку значения. Для денег, дат, enum и ссылок нужны отдельные правила. Если v2 содержит пустой paymentReference, это не повод автоматически принять событие только потому, что версия разрешена. Сначала проверьте тип, обязательность и диапазон. Потом решайте, можно ли выполнять effect.
Учебный код не доказывает backwards compatibility в конкретной библиотеке. Он не проверяет Avro bytes, JSON Schema, schema registry, partition, offset, transaction, retention, авторизацию, шифрование, сетевые retry или SLA доставки. Он также не доказывает exactly-once. Повторная доставка и повторная запись результата требуют отдельной модели idempotency; для внешнего платежа, письма или HTTP-вызова нужен контракт именно этой границы.
\nДля исторического контекста июня 2021 CloudEvents полезен как язык для envelope и context attributes, но приведённый snapshot имел статус working draft. Учебный object не является полной реализацией CloudEvents и не заявляет protocol binding. Apache Avro описывает writer/reader schema resolution, включая неизвестные поля writer и default для отсутствующего поля reader, но этот fixture не читает Avro bytes. Apache Kafka предупреждает, что включённые retries открывают возможность duplicate; это не превращает локальный ledger в гарантию внешнего effect.
\nКритерий готовности проверяемый: для выбранного event type есть два направленных теста совместимости, replay сохраняет исходный id и не создаёт второй result, а неизвестная версия даёт отказ без изменения projection. В результате можно восстановить event id, input schema version, consumer id и result version. Если хотя бы одно поле приходится угадывать по времени или логам, изменение схемы ещё не готово.
\n