Files
progcode/editorial/agent-rewrites/235.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
16 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": 235,
"slug": "editorial-2021-06-field-event-driven",
"title": "Replay событий: как не создать второй effect и не потерять контракт схемы",
"excerpt": "Повторная доставка события не должна превращаться в новый факт. Разбираем identity, ledger, совместимость схемы и controlled replay на учебном примере, который явно отделяет duplicate от новой версии consumer.",
"contentHtml": "<p>После сбоя consumer команда часто видит один и тот же симптом: нужно проиграть события ещё раз. Оператор запускает replay, consumer снова получает запись, а в storage появляется второй result. Если replay получил новый event id, система уже не отличает повтор старого факта от нового факта. Если id сохранился, но consumer не ведёт ledger, он всё равно может повторить effect. Цена ошибки — не только дубль строки. Платёж, письмо или изменение внешней системы могут выполниться дважды. Затем команда теряет ответ на главный вопрос: какой contract создал каждый результат.</p>\n<p>Тезис простой: replay должен сохранять логическую identity исходного события, а consumer должен проверять схему и receipt до effect. Один и тот же <code>source:id</code> в том же consumer contract даёт duplicate и подавляется. Новый расчёт требует нового явно названного contract или resultVersion. Неизвестная схема останавливает обработку. Это не обещание exactly-once. Это проверяемая граница, которая не даёт техническому повтору притвориться новым бизнес-фактом.</p>\n<h2>Что именно повторяется</h2>\n<p>Событие описывает уже произошедший факт. В учебном примере оно содержит <code>source</code>, <code>id</code>, <code>type</code>, <code>subject</code>, <code>occurredAt</code>, <code>schemaVersion</code> и <code>data</code>. Источник и id вместе образуют identity: <code>training://orders/order-104:evt-order-104-paid-01</code>. Новый запуск может иметь отдельную операционную причину, но эта причина не должна менять исходные поля.</p>\n<p>Consumer contract отвечает на другой вопрос: как этот consumer интерпретирует payload. Пусть <code>orders-projection@1</code> читает <code>orderId</code> и <code>status</code>, а <code>orders-projection@2</code> дополнительно читает <code>paymentReference</code>. Один event может законно дать два локальных projection result, если домен разрешает хранить обе версии. Но это не значит, что любой внешний effect можно повторить дважды. Для email, платежа или HTTP-вызова нужен отдельный idempotency contract на внешней границе.</p>\n<pre><code>const ledgerKey = [consumerId, event.source, event.id].join(':');\n\nif (!acceptedSchemaVersions.includes(event.schemaVersion)) {\n return { state: 'contract-update-required', effectAllowed: false };\n}\n\nif (ledger.has(ledgerKey)) {\n return { state: 'duplicate-or-replay-suppressed', effectAllowed: false };\n}\n\nconst result = project(event, consumerId);\nledger.set(ledgerKey, { resultVersion, result });\nreturn { state: 'effect-recorded', effectAllowed: true };</code></pre>\n<p>Этот фрагмент показывает порядок, а не готовую библиотеку. В реальной системе запись receipt и effect должны иметь согласованный storage contract. Map процесса не защищает от падения между внешним вызовом и записью ledger. Если такая аварийная граница существует, автоматический replay нельзя объявлять безопасным без отдельного решения.</p>\n<h2>Пример: один event и три delivery</h2>\n<p>Пусть пришёл учебный event <code>order.status.changed</code> со схемой v1. Первый consumer записывает результат для <code>orders-projection@1</code>. Сеть повторяет delivery. Ledger видит тот же ключ и подавляет второй result. Оператор запускает controlled replay. Ключ не меняется, поэтому replay тоже подавляется. Затем команда включает <code>orders-projection@2</code>. Это уже другой declared contract. Он может получить отдельный projection result, если такое решение принято явно и не смешано с внешним effect.</p>\n<div class=\"table-scroll\"><table><caption>Решение для одного учебного event</caption><thead><tr><th scope=\"col\">Delivery</th><th scope=\"col\">Identity</th><th scope=\"col\">Проверка</th><th scope=\"col\">Результат</th></tr></thead><tbody><tr><td><code>initial</code></td><td>тот же <code>source:id</code></td><td>ключа нет</td><td>записать один result</td></tr><tr><td><code>duplicate</code></td><td>тот же <code>source:id</code></td><td>ключ есть у того же consumer</td><td>подавить новый effect</td></tr><tr><td><code>controlled replay</code></td><td>тот же <code>source:id</code></td><td>ключ есть у того же consumer</td><td>сохранить evidence, не писать второй result</td></tr><tr><td><code>new contract</code></td><td>тот же event</td><td>другой <code>consumerId</code> и <code>resultVersion</code></td><td>отдельный projection, если он разрешён</td></tr><tr><td><code>schema v3</code></td><td>новый или повторный event</td><td>версия не принята consumer</td><td>остановить effect</td></tr></tbody></table></div>\n<p>Нельзя удалять старую receipt перед историческим replay. Иначе новая запись скроет, что старый contract уже обработал event. Нельзя и автоматически считать любой новый contract безопасным: два projection допустимы не во всех доменах. Сначала называют effect и его владельца, потом выбирают ключ, receipt и способ восстановления.</p>\n<figure><img src=\"/assets/editorial/2021/event-replay-diagnosis-2021.svg\" alt=\"Дерево диагностики replay: проверка envelope, consumer contract и ledger ведёт к записи result, подавлению duplicate, versioned replay или остановке при неизвестной схеме\" loading=\"lazy\" /><figcaption>Порядок проверки отделяет повтор того же result от новой явно объявленной интерпретации. Схема учебная и не описывает конкретный broker или production-систему.</figcaption></figure>\n<h2>Схема проверяется до ledger</h2>\n<p>Проверка identity не заменяет проверку schema. Если event v3 содержит поле <code>state</code>, которого consumer не знает, отсутствие ключа в ledger не даёт права выполнить effect. Сначала consumer проверяет envelope и accepted schema versions. Затем выбирает contract. Только после этого он читает ledger. Отказ должен сохранить причину: unknown type, unsupported schema или invalid field. Такой след отличает остановленный event от потерянной delivery.</p>\n<p>Добавление поля может быть совместимым для старого reader, если reader его игнорирует и обязательные поля сохраняют смысл. Новый reader может представить отсутствие поля как <code>null</code> или default, но это решение должно быть частью его contract. Переименование <code>status</code> в <code>state</code> нельзя выдавать за additive change. Учебный пример проверяет только эту пару контрактов; он не доказывает совместимость Avro, JSON Schema или вашей registry без отдельного теста.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика перед replay</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Второй result для того же event</td><td>Нет ledger или ключ построен без source</td><td>Сравнить consumer, source, id и receipt</td><td>Сделать identity явной и остановить повторный effect</td></tr><tr><td>Replay выглядит как новое событие</td><td>Оператор заменил исходный id</td><td>Сопоставить event log и операционную запись replay</td><td>Вернуть исходную identity, причину хранить отдельно</td></tr><tr><td>Старый event не читается новым consumer</td><td>Нет правила absence/default</td><td>Прогнать writer v1 через reader v2</td><td>Добавить явный compatibility rule или manual route</td></tr><tr><td>Unknown schema записывает effect</td><td>Ledger читается до проверки contract</td><td>Проверить порядок веток и отрицательный тест v3</td><td>Запрещать effect до accepted schema</td></tr><tr><td>Внешний вызов повторился после сбоя</td><td>Receipt и effect не образуют атомарную границу</td><td>Смоделировать crash между вызовами</td><td>Остановить auto-replay и согласовать внешний idempotency key</td></tr></tbody></table></div>\n<h2>Порядок controlled replay</h2>\n<ol><li><strong>Соберите evidence packet.</strong> Зафиксируйте исходные <code>source</code>, <code>id</code>, <code>type</code>, <code>subject</code>, schema version, consumer contract и причину replay.</li><li><strong>Проверьте envelope.</strong> Отклоните пустые поля, неожиданный source и неизвестный type до чтения payload и до любого effect.</li><li><strong>Проверьте схему и reader contract.</strong> Назовите принятые версии, правила отсутствующих полей и resultVersion. Не угадывайте новое поле по похожему имени.</li><li><strong>Постройте ledger key.</strong> Включите consumer identity, source и event id. Отдельно запишите, какой ключ защищает внешний business effect.</li><li><strong>Подавите duplicate.</strong> Если тот же contract уже имеет receipt, не удаляйте её и не создавайте новый id. Сохраните delivery evidence.</li><li><strong>Объявите новую интерпретацию.</strong> Если нужен новый projection, используйте новый contract или resultVersion и получите явное решение о допустимости второго результата.</li><li><strong>Проверьте аварийное окно.</strong> На интеграционном стенде повторите crash между effect и receipt, retries broker и восстановление consumer. Для внешнего сервиса подтвердите его фактический idempotency contract.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Учебный пример хранит состояние в Map процесса Node. Он не запускает broker, schema registry, database, Kubernetes, HTTP или внешний платёж. Он не измеряет throughput и не доказывает exactly-once. Идентификаторы, даты, source, payload и результаты вымышлены. Реальная гарантия зависит от transaction boundary, retry policy, partitioning, storage и внешних API. Kafka отдельно предупреждает о возможности duplicate при retry; это ограничивает формулировку, но не даёт готовой архитектуры. CloudEvents описывает envelope и protocol binding, а не receipt бизнес-операции.</p>\n<p>Готовность проверяется не фразой «replay прошёл». Для выбранного consumer должны воспроизводиться четыре результата: initial delivery создаёт один result; duplicate и controlled replay не создают второй; разрешённый новый contract создаёт отдельный versioned result; неизвестная schema останавливается без effect. Для внешней операции добавьте доказательство её idempotency key или ручной stop. Если хотя бы один результат нельзя объяснить по event identity, contract, ledger и receipt, replay ещё не готов.</p>\n<h2>Проверяемые источники</h2>\n<ul><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.</li><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.</li><li><a href=\"https://raw.githubusercontent.com/cloudevents/spec/6eb8b9f4bfe92a332ad93dda56090f917e44602d/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Core Specification</a> — официальный snapshot, разделяющий context attributes и event data. Учебный envelope выше не является реализацией CloudEvents.</li></ul>"
}