Files
progcode/editorial/agent-rewrites/236.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
15 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 236,
"slug": "editorial-2021-06-mechanism-event-driven",
"title": "Как менять схему события и не сломать consumer",
"excerpt": "Валидный JSON не гарантирует совместимость события. Разбираем writer и reader contract, безопасное добавление поля, replay, версионирование результата и отказ без тихой подмены данных.",
"contentHtml": "<p>Симптом появляется после обычного релиза: producer добавляет поле в событие, JSON проходит парсер, а старый consumer начинает падать или записывает неверное состояние. Хуже, когда он не падает. Он принимает новое значение за старое и создаёт правдоподобный результат. В журнале остаётся только «обработка успешна». Затем трудно понять, какие заказы затронуты и каким правилом их пересчитать. Цена ошибки — потерянная история, повторные побочные эффекты и ручная сверка данных.</p>\n<p>Тезис простой: совместимость проверяют не у формата вообще, а у пары writer и reader. Producer описывает, что отправил. Consumer заранее объявляет, что умеет читать и как поступает с отсутствующим или неизвестным полем. Если смысл устойчивого поля изменился, это новый контракт, даже когда тип и JSON-ключ остались прежними.</p>\n<h2>Механизм: событие, контракт и результат</h2>\n<p>Событие фиксирует уже произошедший факт. В envelope нужны как минимум идентификатор, источник, тип, время и версия payload. В учебной модели заказ передаёт два устойчивых поля: <code>orderId</code> и <code>status</code>. Версия 2 добавляет необязательный <code>paymentReference</code>. Consumer версии 1 читает только устойчивую пару. Consumer версии 2 читает оба поля и представляет отсутствие ссылки как <code>null</code>.</p>\n<p>Такой пример не запускает broker и не имитирует production delivery. Все значения, идентификаторы и результаты вымышлены. Состояние хранится в памяти процесса. Код показывает только границу контракта и маршрут для неизвестной версии.</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 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};</code></pre>\n<p>Здесь число <code>2</code> не означает версию Kafka, библиотеки или deploy. Это версия прикладной схемы. Массив <code>fields</code> — часть reader contract. Он делает намерение явным: consumer не обязан использовать каждое поле, которое встретил в payload.</p>\n<h2>Когда добавление поля безопасно</h2>\n<p>Добавочное поле совместимо с конкретным reader только при трёх условиях. Reader не использует неизвестные поля для обязательной валидации. Новое поле не меняет смысл прежних полей. Consumer может честно обработать отсутствие поля, например назначить документированный default или оставить значение пустым.</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>. Если значение обязательно для действия, 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>Он считает любое неизвестное поле ошибкой</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 или вернуть отказ</td></tr><tr><td>Replay создаёт второй result</td><td>Результат не связан с 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>Результат должен объяснять 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 второй раз.</p>\n<h2>Отрицательный путь важнее счастливого</h2>\n<p>Permissive consumer, который принимает любую версию и берёт знакомые ключи, работает до первого изменения смысла. Представим v3: producer заменил <code>status</code> на <code>state</code>. Envelope остаётся похожим, но reader не может доказать, что новое значение означает прежнее действие. Он должен сохранить вход для расследования, записать причину отказа и не менять projection.</p>\n<pre><code>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}</code></pre>\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.</p>\n<p>CloudEvents стандартизирует envelope и context attributes, но не знает бизнес-смысл <code>status</code>. Schema resolution в Avro помогает сопоставлять writer и reader schema, но не решает, допустима ли замена одного доменного значения другим. Документация Kafka предупреждает о duplicate при retry, но сама настройка producer не делает effect идемпотентным. Поэтому общий стандарт не отменяет локальный reader contract.</p>\n<p>Критерий готовности проверяемый: для выбранного event type есть два направленных теста совместимости, replay сохраняет исходный id и не создаёт второй effect, а неизвестная версия даёт отказ без изменения 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.12.0/specification/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro Specification 1.12.0</a> — правила schema resolution и различие writer/reader schema.</li><li><a href=\"https://github.com/cloudevents/spec/blob/ce%40stable/cloudevents/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Core Specification</a> — обязательные атрибуты envelope и идентичность source + id.</li><li><a href=\"https://kafka.apache.org/documentation/#producerconfigs_retries\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka Producer Configs</a> — ограничение retry и риск duplicate при сетевой ошибке.</li></ul>"
}