8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 236,
|
||
"slug": "editorial-2021-06-mechanism-event-driven",
|
||
"title": "Как менять схему события и не сломать consumer",
|
||
"excerpt": "Валидный JSON не гарантирует совместимость события. Разбираем writer и reader contract, безопасное добавление поля, replay, версионирование результата и отказ без тихой подмены данных.",
|
||
"contentHtml": "<p>Симптом появляется после обычного релиза: producer добавляет поле в событие, JSON проходит парсер, а старый consumer начинает падать или записывает неверное состояние. Но падение происходит не всегда: конкретный reader может отвергнуть неизвестное поле, проигнорировать его или использовать его косвенно. Хуже всего тихая ошибка. Consumer принимает новое значение за старое, в журнале оставляет «обработка успешна», а команда уже не понимает, какие заказы затронуты и каким правилом их пересчитать. Цена ошибки — потерянная история, повторные побочные эффекты и ручная сверка данных.</p>\n<p>Тезис простой: совместимость проверяют не у формата вообще, а у пары writer и reader. Producer описывает, что отправил. Consumer заранее объявляет, что умеет читать и как поступает с отсутствующим или неизвестным полем. Если смысл устойчивого поля изменился, это новый контракт, даже когда тип и JSON-ключ остались прежними. Ниже — учебная модель, которую можно прогнать в памяти Node без broker и внешнего сервиса.</p>\n<h2>Механизм: событие, контракт и результат</h2>\n<p>Событие фиксирует уже произошедший факт, а consumer строит собственный результат. Эти объекты нельзя смешивать. В учебном envelope есть идентификатор, источник, тип, время и версия payload. Версия 1 передаёт два устойчивых поля заказа: <code>orderId</code> и <code>status</code>. Версия 2 добавляет необязательный <code>paymentReference</code>. Consumer версии 1 читает только устойчивую пару. Consumer версии 2 читает оба поля и представляет отсутствие ссылки как <code>null</code>.</p>\n<p>Обязательность этих полей относится к данной модели, а не ко всем event-системам. Здесь <code>source</code> и <code>id</code> вместе образуют identity события. <code>schemaVersion</code> обозначает версию прикладного payload, а не версию Kafka, библиотеки или deploy. <code>resultVersion</code> обозначает правило, которым consumer создал projection.</p>\n<pre><code>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};</code></pre>\n<p>В этой записи число <code>2</code> не обещает, что любой v1 reader примет payload. Оно лишь даёт consumer-у возможность применить собственное правило. Если reader запрещает неизвестные поля, тот же writer потребует совместимого изменения reader или отдельного маршрута. Поэтому список принятых версий — только первый фильтр, а не доказательство совместимости.</p>\n<h2>Когда добавление поля безопасно</h2>\n<p>Добавочное поле совместимо с конкретным reader только при трёх условиях. Reader не использует неизвестные поля для обязательной валидации. Новое поле не меняет смысл прежних полей. Consumer может честно обработать отсутствие поля: назначить документированный default, оставить значение пустым или остановиться до effect.</p>\n<p>Переименование <code>status</code> в <code>state</code> этим условиям не отвечает. Даже если оба поля имеют тип string, старый consumer не знает, что <code>settled</code> равно <code>paid</code>. Та же проблема возникает при смене единицы измерения, часового пояса, валюты или смысла enum. Формально валидный payload может быть семантически несовместим.</p>\n<p>Обратное направление нужно проверять отдельно. Новый reader должен уметь прочитать старый event, в котором нет <code>paymentReference</code>. Если ссылка обязательна для действия, <code>null</code> не является безопасным default. Тогда новый reader должен вернуть отказ или выбрать маршрут миграции. Молчаливое заполнение опасно: effect выглядит успешным, хотя обязательная часть факта исчезла.</p>\n<div class=\"table-scroll\"><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>Старый consumer отклоняет v2</td><td>Неизвестное поле запрещено его contract</td><td>Отправить v2 в изолированный reader test</td><td>Явно изменить contract или выпустить новую версию consumer</td></tr><tr><td>Ошибок нет, но projection неверна</td><td>Поменялся смысл stable field</td><td>Сравнить определения поля и значения на границе</td><td>Остановить effect и оформить новый contract</td></tr><tr><td>Новый consumer теряет данные старого event</td><td>Нет правила для отсутствующего поля</td><td>Прогнать v1 payload через v2 reader</td><td>Добавить default, отдельный route или отказ</td></tr><tr><td>Replay создаёт второй result</td><td>Ключ не связывает consumer, source и event id</td><td>Повторить тот же вход с тем же id</td><td>Добавить ledger и идемпотентный ключ effect</td></tr><tr><td>Неизвестная версия вызывает действие</td><td>Consumer принимает любое число schemaVersion</td><td>Отправить v3 с изменённым полем</td><td>Вернуть <code>contract-update-required</code> без effect</td></tr></tbody></table></div>\n<h2>Воспроизводимая проверка в памяти</h2>\n<p>Ниже минимальный fixture проверяет четыре ветки: первый вход, повторную доставку, чтение старого event новым reader и неизвестную схему. Он не подключает broker, но делает порядок проверок видимым. Сначала проверяется envelope и версия, затем строится ключ ledger, потом выполняется projection. Для ясности ledger хранит только результат; в настоящей системе запись receipt и внешний effect потребуют отдельной согласованной границы.</p>\n<pre><code>const 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</code></pre>\n<p>Четвёртая строка важнее первой. Версия v3 не должна попасть в projection только потому, что поля <code>orderId</code> и <code>id</code> выглядят знакомо. В примере v3 заменяет <code>status</code> на <code>state</code>, поэтому consumer останавливается до ledger и effect. Это не означает, что любой v3 нужно отклонять навсегда: сначала владелец контракта должен объявить правило преобразования и покрыть его отдельным тестом.</p>\n<h2>Результат должен объяснять replay</h2>\n<p>Одной <code>schemaVersion</code> недостаточно. Один event могут прочитать два consumer или тот же consumer после исправления. Результат должен хранить <code>event.id</code>, <code>source</code>, <code>inputSchemaVersion</code>, <code>consumerId</code> и <code>resultVersion</code>. Первые два поля связывают запись с фактом. Версия входа показывает форму payload. Идентификатор consumer отделяет независимые projections. Версия результата показывает, каким правилом создана запись.</p>\n<pre><code>{\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}</code></pre>\n<p><code>resultVersion</code> не должен быть случайным timestamp или номером сборки. Это имя интерпретации. При replay оно отвечает на вопрос: старое или новое правило породило projection? Ledger должен хранить ключ вроде <code>consumerId:source:eventId</code>. Повтор того же входа возвращает <code>duplicate-or-replay-suppressed</code> и не выполняет effect второй раз. Однако Map процесса не защищает от падения между внешним вызовом и записью receipt, поэтому такой пример не доказывает exactly-once.</p>\n<h2>Отрицательный путь важнее счастливого</h2>\n<p>Permissive consumer, который принимает любую версию и берёт знакомые ключи, работает до первого изменения смысла. Представим v3: producer заменил <code>status</code> на <code>state</code>. Envelope остаётся похожим, но reader не может доказать, что новое значение означает прежнее действие. Он должен сохранить вход для расследования, записать причину отказа и не менять projection.</p>\n<p>Проверка версии не заменяет проверку значения. Для денег, дат, enum и ссылок нужны отдельные правила. Если v2 содержит пустой <code>paymentReference</code>, это не повод автоматически принять событие только потому, что версия разрешена. Сначала проверьте тип, обязательность и диапазон. Потом решайте, можно ли выполнять effect.</p>\n<figure><img src=\"/assets/editorial/2021/event-schema-compatibility-2021.svg\" alt=\"Матрица совместимости: schema v1 содержит orderId и status, v2 добавляет необязательный paymentReference, а v3 с изменённой семантикой направляется на обновление контракта\" loading=\"lazy\" /><figcaption>Матрица показывает направления writer и reader. Она не обещает, что одна версия формата подходит всем consumer.</figcaption></figure>\n<h2>Порядок изменения</h2>\n<ol><li>Зафиксируйте один старый event и один будущий event. Не начинайте с массового изменения producer.</li><li>Назовите stable fields: имя, тип, единицу и смысл. Любое изменение смысла считайте новым контрактом.</li><li>Проверьте новый writer со старым reader. Запишите, какие поля reader игнорирует и почему это безопасно.</li><li>Проверьте старый writer с новым reader. Для каждого отсутствующего поля задайте default, отдельный route или отказ.</li><li>Добавьте к результату event id, source, input schema version, consumer id и result version.</li><li>Повторите тот же event с тем же id. Убедитесь, что ledger подавляет второй effect.</li><li>Отправьте неизвестную версию. Ожидайте сохранённый отказ без записи бизнес-результата.</li><li>Только после этих проверок выбирайте serializer, registry, broker и retry policy. Запишите их реальные guarantees отдельно.</li></ol>\n<h2>Ограничения</h2>\n<p>Учебный код не доказывает backwards compatibility в конкретной библиотеке. Он не проверяет Avro bytes, JSON Schema, schema registry, partition, offset, transaction, retention, авторизацию, шифрование, сетевые retry или SLA доставки. Он также не доказывает exactly-once. Повторная доставка и повторная запись результата требуют отдельной модели idempotency; для внешнего платежа, письма или HTTP-вызова нужен контракт именно этой границы.</p>\n<p>Для исторического контекста июня 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.</p>\n<p>Критерий готовности проверяемый: для выбранного event type есть два направленных теста совместимости, replay сохраняет исходный id и не создаёт второй result, а неизвестная версия даёт отказ без изменения projection. В результате можно восстановить event id, input schema version, consumer id и result version. Если хотя бы одно поле приходится угадывать по времени или логам, изменение схемы ещё не готово.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://avro.apache.org/docs/1.10.1/spec.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro 1.10.1 Specification</a> — официальное описание writer schema, reader schema и schema resolution; версия существовала до июня 2021 года.</li><li><a href=\"https://raw.githubusercontent.com/cloudevents/spec/6eb8b9f4bfe92a332ad93dda56090f917e44602d/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Core Specification, immutable snapshot</a> — зафиксированный working-draft snapshot, разделяющий context attributes, event data и protocol binding. Учебный envelope выше не является его реализацией.</li><li><a href=\"https://kafka.apache.org/27/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka 2.7.0 KafkaProducer API</a> — официальная документация о retry, duplicate и границах idempotent producer.</li></ul>"
|
||
}
|