Files
progcode/editorial/agent-rewrites/236.json
T

8 lines
20 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 начинает падать или записывает неверное состояние. Но падение происходит не всегда: конкретный 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>"
}