На 31 июля 2026 года строгий аудит проходит 121 из 358 созданных материалов. Остальные 237 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 124 из 358 созданных материалов. Остальные 234 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
Revision-модуль экспортирует три revision, но не содержит полей
<code>date</code>/<code>author</code> и не подключает registry.
<code>articles.json</code>, README, очередь, редакционный стандарт, package
config и Git не менялись. Команда <code>--print-revisions</code> выводит
import-safe export, а <code>--verify-fixture</code> запускает только
детерминированную модель в памяти.
## Проход 1. Факты, техника и историческая рамка — пройдено
| Утверждение | Первичный или официальный источник | Зафиксированная граница |
| --- | --- | --- |
| На 16 апреля 2021 существовал CloudEvents Core Specification snapshot с заголовком <code>v1.0.2-wip</code> и статусом working draft; он различает context attributes, event data и protocol binding | [Immutable CloudEvents snapshot, commit 6eb8b9f](https://raw.githubusercontent.com/cloudevents/spec/6eb8b9f4bfe92a332ad93dda56090f917e44602d/spec.md), [история commit](https://github.com/cloudevents/spec/commit/6eb8b9f4bfe92a332ad93dda56090f917e44602d) | Текст не называет snapshot стабильной спецификацией и не выдаёт собственный object за CloudEvent. Поля <code>contractVersion</code> и <code>schemaVersion</code> принадлежат учебному договору; binding, JSON format, SDK и transport не реализованы. |
| Архив Apache содержит артефакты Avro 1.10.1, датированные ноябрём/декабрём 2020 года; спецификация описывает reader/writer schema resolution | [Apache archive: Avro 1.10.1](https://archive.apache.org/dist/avro/avro-1.10.1/), [Apache Avro 1.10.1 Specification](https://avro.apache.org/docs/1.10.1/spec.pdf) | Авро служит источником для вопроса «какая пара writer/reader совместима». Fixture не читает Avro bytes, не имеет writer schema, fingerprint, alias, schema registry или формата Avro. |
| Kafka 2.7.0 был выпущен 21 декабря 2020 года; его producer API предупреждает, что retry может открыть путь к duplicate | [Apache Kafka downloads: 2.7.0](https://kafka.apache.org/community/downloads/), [KafkaProducer 2.7.0 API](https://kafka.apache.org/27/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html) | Ссылка объясняет, почему retry не равен единственному effect. Пакет не запускает Kafka, не использует topic, offset, partition, producer transaction, consumer group или exactly-once semantics. |
Исторические версии не выводились по памяти: для CloudEvents проверен
неизменяемый commit до июня 2021 года; для Avro и Kafka — официальные archive
и release page. В тексте удалено опасное утверждение о стабильной CloudEvents
v1.0: документирован именно historical working draft <code>v1.0.2-wip</code>.
### Техническая граница fixture
<code>runEventIntegrationFixture()</code> работает только с двумя
<code>Map</code> и локальными учебными objects:
1. <code>orderStatusV1</code> имеет stable fields
<code>orderId</code>/<code>status</code>;
2. <code>orderStatusV2</code> добавляет optional
<code>paymentReference</code>;
3. <code>orderStatusV3</code> меняет ожидаемую семантику на
<code>state</code>, поэтому existing consumer contract не принимает его;
4. один ledger записывает result v1, затем suppress duplicate delivery и
controlled replay того же <code>source:id</code>; такой же id из другого
source получает отдельный receipt;
5. второй ledger показывает допустимый historical replay отдельного consumer
v2 и его <code>resultVersion</code>.
Проверены одиннадцать assertions:
- envelope принимает v1/v2 и отклоняет source вне учебной границы;
- v1 consumer читает stable fields v2 и осознанно не включает
<code>paymentReference</code> в projection;
- v2 consumer даёт <code>null</code> для отсутствующего optional field при
чтении v1;
- первый delivery пишет один versioned result;
- duplicate и controlled replay с тем же source:id не пишут второй result;
- одинаковый id из другого source не подавляет отдельный учебный result;
- historical replay другого declared consumer получает отдельный
<code>resultVersion</code>;
- v3 направляется в <code>contract-update-required</code> без effect;
- модель прямо названа не broker, не schema registry и не production event.
Fixture не моделирует persistence, crash window между внешним effect и receipt,
outbox, retry сети, delivery confirmation, broker retention, права, внешнее
API, database или транзакцию. Поэтому ни один текст не обещает universal
exactly-once или production compatibility.
Вердикт прохода: **пройден**. Исторические ограничения названы рядом с
источниками, а техническая модель имеет явный scope.
## Проход 2. Редактура, глубина и голос М4 — пройдено
| Revision | Симптом и цена в первых двух абзацах | Главный вопрос | Объём основного текста |
| --- | --- | --- | --- |
| Практика | Consumer видит событие без доказанного происхождения, схемы и результата; цена — случайный replay и потеря связи между фактом, delivery и effect | Как зафиксировать envelope, payload и consumer result до первого broker | **11 001** знак body |
| Механизм | Добавленное или переосмысленное поле даёт либо явную ошибку, либо тихо неверную projection; цена — нельзя объяснить, какая версия входа создала result | Где проходит граница совместимости writer/reader и почему versioned result нужен для replay | **10 051** знак body |
| Полевой разбор | После восстановления повтор приходит с тем же или новой схемой; цена — второй effect или потеря evidence | Как отличить duplicate, versioned replay и неизвестную schema до записи result | **10 272** знака body |
- Все три текста лежат в целевом коридоре П40 8–11 тыс. знаков и в обязательном
диапазоне стандарта 5–15 тыс. знаков.
- Каждый открывается конкретным symptom и стоимостью ошибки. Дальше держит
порядок «симптом → причина → проверка → действие», а не объясняет
событийность общими словами.
- В каждом revision есть пять или больше смысловых разделов, table с
<code>caption</code>/<code>thead</code>, figure с самостоятельными
<code>alt</code>/<code>figcaption</code>, три или более technical examples,
нумерованный маршрут и три официальных источника.
- Голос М4 / июня 2021 показывает развитие от retry, delivery и versioned
cache к контракту между модулями: author называет producer, consumer,
schema, ledger и result owner, но не приписывает себе опыт эксплуатации
event platform или руководства организацией.
- Слова <code>compatible</code>, <code>replay</code> и
<code>exactly-once</code> не используются как универсальные ярлыки.
Совместимость определена для конкретной пары v1/v2 contracts; replay
сохраняет event identity; внешний effect вынесен за границу Map.
- Отдельно вычитаны анахронизмы: нет claims о production schema registry,
| Import-safe export и draft gate | PASS: итоговые **11 001 / 10 051 / 10 272** знака body; найдены три stable slug, sections, tables, figures, code, routes, sources и локальные visual assets |
| In-memory fixture | PASS: итоговые все одиннадцать assertions равны <code>true</code>; проверены validation, v1/v2 compatibility, source:id receipt, duplicate, controlled replay, versioned result и unknown v3 |
| <code>xmllint --noout</code> | PASS, все три SVG — корректный XML |
| SVG safety scan | PASS: нет <code>script</code>, <code>foreignObject</code>, external asset reference или raster data URI |
| Sharp mobile preflight | PASS: финальные PNG шириной 375 px просмотрены вручную; clipping, overlap и horizontal overflow не обнаружены |
| Scope/self-review | PASS: создано ровно пять разрешённых файлов; revision не меняют <code>date</code>/<code>author</code>; registry, archive, README, очередь, package config, Git и чужие untracked files не изменялись |
<code>npm run audit:draft</code> завершилась с code 0. npm вывел старые
предупреждения о пользовательских <code>store-dir</code>, <code>cache-dir</code>
и <code>public-hoist-pattern</code>; эти настройки не относятся к П40 и не
менялись пакетом.
Не запускались: strict audit после подключения к registry, production build,
формулировку о retry и duplicate. Все три источника сверены независимо.
| Проверка после интеграции | Реальный результат |
| --- | --- |
| Import-safe export | PASS: три revision, без <code>date</code>/<code>author</code> |
| Строгий audit трёх slug | PASS: **11 001 / 10 051 / 10 272** знака; у каждой статьи есть figure, table и code examples |
| Fixture после редакторского исправления | PASS: все 11 assertions истинны, включая отдельный receipt для одного id из другого source |
| Независимый SVG review | PASS: XML и active/external asset scan прошли; три PNG 375 px просмотрены повторно, clipping, overlap и overflow не обнаружены |
| Production build | PASS: Next.js собрал 374 статические страницы |
Ни этот отчёт, ни интеграция не утверждают запуск broker, schema registry,
SDK, HTTP, базы, external API, browser или assistive technology.
Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной
публикационной операцией; Git остаётся источником её фактической записи.
<titleid="title">Диагностика duplicate и replay учебного события</title>
<descid="desc">Вертикальное дерево: проверка envelope, проверка consumer contract и schema version, lookup в ledger. Из веток выходят effect recorded, duplicate or replay suppressed и contract update required.</desc>
<descid="desc">Вертикальная схема показывает событие v1, v2 с добавочным необязательным полем и v3 с изменённой семантикой. Consumer v1 читает stable fields, consumer v2 умеет нормализовать отсутствие нового поля, v3 требует обновления договора.</desc>
<descid="desc">Вертикальная схема: producer формирует envelope и payload, граница доставки передаёт их consumer, consumer валидирует договор и записывает versioned result в ledger. Повтор и controlled replay несут тот же source и event id.</desc>
note:'исторический working draft существовал к июню 2021 года и различает context attributes, event data и protocol binding. Учебный envelope ниже не является CloudEvent и не реализует binding.',
note:'официальная спецификация различает writer и reader schema и описывает resolution. Здесь она служит ориентиром для явного consumer contract, а не форматом payload или schema registry.',
note:'версионная документация линии 2.7 предупреждает, что retry может привести к duplicate. Fixture не запускает Kafka, не использует offset, partition, transaction или producer API.',
};
consttrainingBoundary='Все идентификаторы, даты, source, payload, результаты и правила ниже учебные. Fixture хранит состояние только в Map процесса Node: это не broker, не schema registry, не production event, не реализация CloudEvents или Kafka, не transport, не база и не измерение throughput.';
constconsumerContracts=Object.freeze({
'orders-projection@1':Object.freeze({
id:'orders-projection@1',
resultVersion:'2021-06.orders-projection.1',
acceptedSchemaVersions:Object.freeze([1,2]),
fields:Object.freeze(['orderId','status']),
compatibilityRule:'reads stable fields and intentionally ignores paymentReference',
excerpt:'Событие не становится контрактом только потому, что его отправили в очередь. Разбираем минимальный envelope, границу payload, версию схемы, consumer contract и доказательство для повторной доставки.',
readingMinutes:15,
},
[
paragraph('Симптом появляется не в момент отправки события, а после первого независимого consumer. Заказ уже сменил статус, но обработчик не может сказать, кто создал сообщение, какую схему он читает и можно ли безопасно повторить вход. Иногда событие приходит второй раз. Иногда producer добавляет поле, а consumer начинает трактовать его как обязательное. Цена — не один красный лог. Команда теряет связь между доменным изменением, конкретной доставкой и результатом обработчика; затем повтор превращается в случайный запуск.'),
paragraph('В июне 2021 года я бы начал не с выбора broker, а с одного маленького договора. Producer обязан отдать envelope с происхождением и типом, payload обязан назвать свою schemaVersion, consumer обязан заранее записать, какие версии и поля он читает, а результат обязан сохранить версию самого consumer. Ниже это проверяется только в памяти Node. '+trainingBoundary+' Поэтому пример помогает обсудить границу, но не доказывает настройку реальной инфраструктуры.'),
heading('Сначала отделяем событие от доставки'),
paragraph('Событие описывает факт, который producer решил передать: в учебном случае изменился статус заказа. Доставка описывает попытку передать этот факт конкретному consumer. Это не одно и то же. Одно событие может быть доставлено дважды после неясного результата подтверждения. Один consumer может прочитать событие позже другого. Если в коде есть только JSON без идентификатора, source и версии, эти случаи уже нельзя отличить по факту, остаётся угадывать по времени лога.'),
paragraph('Envelope не должен становиться свалкой всей предметной модели. Его задача — дать устойчивые координаты: какой договор envelope применён, какой event id наблюдаем, кто его сформировал, к какому предмету он относится, когда producer его создал и по какой схеме лежит payload. Payload содержит данные конкретного типа. Result consumer содержит уже другое: какую версию своего договора применил consumer и записал ли он эффект. Это три разных объекта с разными владельцами.'),
dataTable(
'Минимальный учебный contract: каждый слой отвечает на свой вопрос',
['Слой','Поле или правило','Владелец','Что проверить до действия'],
[
['Envelope','<code>contractVersion</code>, <code>id</code>, <code>source</code>, <code>type</code>','producer контракта','все обязательные поля непустые и type ожидаем consumer-ом'],
['Связь с предметом','<code>subject</code>','producer и владелец домена','subject указывает на объект, для которого имеет смысл разбор'],
['Payload','<code>schemaVersion</code> и <code>data</code>','producer payload','consumer явно принимает именно эту версию и stable fields'],
['Consumer','список versions и интерпретация полей','владелец конкретного consumer','нет молчаливого предположения о новом поле или значении'],
['Result','<code>resultVersion</code> и ключ <code>consumerId:source:id</code>','consumer и его хранилище результата','повтор того же source:id не создаёт второй учебный result без нового договора'],
],
),
paragraph('CloudEvents полезен здесь как дисциплина метаданных: исторический snapshot апреля 2021 года отдельно описывает context события, event data и protocol binding. Но нельзя взять несколько похожих имён и назвать любой object CloudEvent. У формата есть свои обязательные атрибуты и bindings. В этой статье <code>contractVersion</code> и <code>schemaVersion</code> принадлежат нашему учебному договору. Fixture не проверяет bindings и SDK.'),
heading('Пишем envelope так, чтобы его можно было проверить'),
paragraph('Поле <code>id</code> должно быть неизменным для одной логической записи. Оно не обязано одновременно быть ключом любого бизнес-эффекта: например, письмо может иметь отдельный idempotency key. Но без id нельзя доказать, что controlled replay относится к тому же входу. <code>source</code> отделяет producer от consumer и не должен строиться из случайного hostname. В учебном примере разрешён только закрытый prefix <code>training://orders/</code>; это делает ошибочный внешний source наблюдаемым до работы consumer.'),
codeBlock(envelopeCode),
paragraph('Время <code>occurredAt</code> не назначает порядок обработки. Это время, которое сообщил producer, а не позиция в topic и не время, когда consumer выполнил effect. Если домен требует порядок, нужен отдельный sequence contract и его owner. Если порядок не нужен, не стоит создавать ложную гарантию из timestamp. В этой партии это ограничение остаётся явным: fixture не сортирует delivery, не моделирует clock skew и не выбирает partition.'),
codeBlock(validationCode),
paragraph('Проверка envelope должна завершиться до любой интерпретации payload. Иначе consumer сначала создаст effect, а потом обнаружит, что source или type были неожиданными. В результате <code>validateTrainingEnvelope()</code> возвращает причину вроде <code>missing-or-empty-type</code> или <code>source-outside-training-orders-boundary</code>. Это не универсальный validator. Реальный сервис может потребовать подпись, tenant, permissions, content type и ограничения размера. Важен принцип: причина отказа должна быть в result, а не скрываться за общим exception.'),
figure(
'/assets/editorial/2021/event-topology-2021.svg',
'Вертикальная схема учебной топологии: producer формирует envelope и payload, delivery передаёт их consumer, consumer сначала валидирует contract, затем записывает versioned result в ledger; отдельная стрелка показывает duplicate или controlled replay с тем же source:id',
'Топология разделяет факт, доставку и результат. Broker на схеме — граница передачи, а не обещание конкретного продукта или гарантии.',
),
heading('SchemaVersion — не декоративное число'),
paragraph('Версия payload нужна не для красивого суффикса в названии события. Она говорит consumer, по каким правилам он может прочитать data. В учебном <code>schemaVersion: 1</code> содержит <code>orderId</code> и <code>status</code>. Версия 2 добавляет <code>paymentReference</code>. Старый consumer v1 читает только стабильную пару и осознанно игнорирует новое поле. Новый consumer v2 умеет отдать <code>paymentReference: null</code>, когда воспроизводит старый v1 event. Это ограниченная, проверяемая политика, а не слово «backward compatible» без границы.'),
paragraph('Apache Avro разделяет writer schema и reader schema, а правила resolution зависят от конкретного формата и схем. Из этого полезно перенести не кодек, а вопрос: что именно writer записал и что reader имеет право ожидать. Наш JSON object не является Avro record. В нём нет writer schema, fingerprint, default из Avro и реального registry. Поэтому добавление поля здесь совместимо только потому, что два наших consumer contract явно так определены.'),
codeBlock(compatibilityCode),
dataTable(
'Матрица совместимости учебных contracts',
['Вход','Consumer contract','Разрешённый результат','Чего не обещает правило'],
[
['schema v1: <code>orderId</code>, <code>status</code>','<code>orders-projection@1</code>','stable projection из двух полей','что v1 понимает будущие значения status'],
['schema v2: v1 + <code>paymentReference</code>','<code>orders-projection@1</code>','то же stable projection, поле v2 намеренно игнорируется','что любой added field всегда безопасен'],
['schema v1 без нового поля','<code>orders-projection@2</code>','<code>paymentReference: null</code> в versioned result','что null равен неизвестному business state'],
['schema v2 с необязательным полем','<code>orders-projection@2</code>','projection с проверенным string или null','что значение ссылки корректно в соседней системе'],
['schema v3 с изменённым смыслом <code>state</code>','любой contract этой fixture','<code>contract-update-required</code>','что consumer может угадать семантику'],
],
),
heading('Consumer contract виден до replay'),
paragraph('У consumer должна быть короткая объявленная граница: accepted schema versions, набор читаемых полей, результат и ключ, по которому он видит повтор. Иначе внедрение становится опасной схемой «сначала обновим producer, потом посмотрим на ошибки». В примере <code>orders-projection@1</code> и <code>orders-projection@2</code> не обозначают версии broker. Это два независимых договора чтения одного type. Их результат хранит <code>resultVersion</code>, чтобы replay можно было связать с интерпретацией, которая действовала в момент обработки.'),
paragraph('Не надо автоматически принимать каждую большую версию, если JSON проходит синтаксис. Версия 3 в fixture меняет привычный <code>status</code> на <code>state</code>. Мы не считаем <code>settled</code> синонимом <code>paid</code> без решения владельца домена. Consumer возвращает <code>contract-update-required</code> и не пишет effect. Такой отказ дешевле тихой подмены смысла: в логе остаётся event id, input schemaVersion, consumer id и причина, по которым можно подготовить миграцию или отдельный адаптер.'),
codeBlock(unsupportedCode),
heading('Маршрут перед подключением реального broker'),
orderedList([
'Выбрать один event type и назвать symptom: какой consumer сейчас не может безопасно понять или повторить вход.',
'Отделить envelope, payload и consumer result. Для каждого записать владельца и только необходимые поля.',
'Определить стабильные поля, которые v1 consumer действительно читает, и одно добавочное поле следующей версии. Не менять семантику под именем «добавили поле».',
'Написать validator envelope до кода effect: id, source, type, subject, contractVersion, schemaVersion и обязательные stable fields.',
'Зафиксировать consumer contract: accepted versions, projection, resultVersion и исход для неизвестной версии.',
'Проверить controlled fixture с v1, v2, duplicate и replay. Убедиться, что replay сохраняет source:id, а не создаёт новый вход для обхода ledger.',
'Только после этого выбрать конкретный broker, serializer, storage result и integration test. Отдельно записать их реальные guarantees и failure modes.',
]),
heading('Что остаётся за границей этого шага'),
paragraph('У этой модели нет настоящего topic, consumer group, offset, transaction, outbox, schema registry, authorisation, encryption, retries сети, retention или delivery SLA. Нет и real production event: значения order-104 и training-pay-77 специально вымышлены. Kafka 2.7 documentation показывает, почему retry нельзя автоматически отождествлять с единственной доставкой, но этот факт не превращает Map в Kafka client. Аналогично CloudEvents не даёт бизнес-совместимость просто наличием envelope.'),
paragraph('Практический следующий шаг — взять один безобидный event type проекта и сделать такой же evidence packet: serialized envelope, declared payload schema, consumer version, sample result и controlled replay. Если хотя бы одно поле нельзя объяснить владельцем, не публикуйте его в общий contract. Сначала сузьте событие до проверяемого факта. Тогда новый consumer будет явным договором, а не догадкой по JSON.'),
],
[cloudEvents202104,avro101,kafkaProducer27],
);
constmechanismArticle=createRevision(
{
slug:'editorial-2021-06-mechanism-event-driven',
title:'Эволюция схемы события: где проходит граница совместимого consumer',
excerpt:'Новое поле не равно совместимой схемe. Разбираем writer, reader, stable fields, semantic change, versioned consumer result и путь, при котором неизвестная версия останавливает effect вместо тихой подмены данных.',
readingMinutes:16,
},
[
paragraph('Симптом обычно выглядит безобидно: producer добавил поле <code>paymentReference</code>, JSON по-прежнему валиден, а старый consumer либо падает на строгой проверке, либо начинает использовать значение не по договору. Хуже другой случай: поле переименовали или поменяли его смысл, consumer продолжил работать и записал правдоподобный, но неверный результат. Цена тихой совместимости выше явного отказа: затем невозможно восстановить, какая версия входа породила конкретную запись.'),
paragraph('Здесь важно развести две вещи. Формат может суметь распарсить bytes, а consumer может не иметь права интерпретировать бизнес-смысл. В учебной модели v2 добавляет одно необязательное поле к устойчивой паре <code>orderId</code> и <code>status</code>. Consumer v1 объявляет, что читает только устойчивую пару. Consumer v2 умеет сохранить новое поле, но при replay v1 нормализует его к null. Модель не использует Avro, Kafka, CloudEvents или schema registry. '+trainingBoundary),
heading('Совместимость начинается с пары writer и reader'),
paragraph('Фраза «схема совместима» бесполезна без двух участников. Нужно назвать writer schema, reader contract и направление проверки. Новый writer v2 может быть совместим со старым reader v1, если reader действительно игнорирует добавленное поле и его смысл не меняет старые поля. Старый writer v1 может быть совместим с новым reader v2, если новый reader знает, как честно обработать отсутствие нового поля. Но изменение <code>status</code> на <code>state</code> не становится совместимым только потому, что оба значения строки. Это уже смена интерпретации.'),
paragraph('Apache Avro формулирует это через writer и reader schema resolution. Конкретные правила зависят от record, default, alias и serialization. Для автора прикладного contract полезна более простая привычка: на каждый change показать одну старую запись, один новый consumer и один новый event для старого consumer. Если эти два направления не проверены, словом compatible называют только надежду. В нашей fixture оба направления являются отдельными assertions.'),
['v1: <code>orderId</code>, <code>status</code>','v1','готово','оба используют один набор stable fields'],
['v2: v1 + optional <code>paymentReference</code>','v1','готово с игнорированием поля','v1 contract читает только описанную пару'],
['v1 без <code>paymentReference</code>','v2','готово с <code>null</code>','v2 contract явно определяет default представления'],
['v2 с пустой или неверной ссылкой','v2','rejected envelope','валидность поля проверяется до projection'],
['v3: <code>state</code> вместо <code>status</code>','v1 или v2','contract update required','смысл stable field больше не доказан'],
],
),
heading('Стабильное поле имеет не только имя'),
paragraph('Стабильность — это имя, тип и договорённость о смысле. <code>status: "paid"</code> нельзя заменить на <code>state: "settled"</code> и сказать старому consumer, что он должен «как-нибудь понять». Даже если домен считает значения близкими, у consumer могут быть ветки, аудит, SQL-проекция или внешняя команда, которые используют старое значение как ключ. Поэтому schemaVersion должна расти при таком change, а consumer должен либо получить отдельный адаптер с тестом, либо остановить effect до решения.'),
paragraph('Добавочное поле тоже не автоматически безопасно. Оно безопасно для конкретного reader, когда reader не использует unknown fields для валидации и новое поле не меняет meaning предыдущих. Например, <code>paymentReference</code> в нашем v2 — optional string, который v1 не читает. Но если producer вводит <code>currency</code> и одновременно начинает иначе понимать <code>amount</code>, это не «добавили currency». Это изменение смысла суммы, требующее отдельного type или миграции. Контракт удобнее держать маленьким, чем потом спасать широкую схему исключениями.'),
'Вертикальная схема матрицы совместимости: schema v1 содержит orderId и status, v2 добавляет optional paymentReference; consumer v1 игнорирует добавление, consumer v2 нормализует старый event к null, schema v3 направляется в contract update вместо автоматического effect',
'Схема показывает направления reader и writer, а не обещает, что одна версия формата подходит всем consumer.',
),
heading('Versioned result связывает replay с интерпретацией'),
paragraph('Event schemaVersion недостаточно, когда один и тот же event воспроизводят разные consumer. В fixture результат содержит <code>inputSchemaVersion</code> и <code>resultVersion</code>. Первый отвечает на вопрос, с каким payload пришёл вход. Второй отвечает, какой consumer contract создал projection. Это особенно важно при исправлении consumer: нельзя сказать, что replay «пересчитал данные», если не видно, старый или новый код дал результат.'),
paragraph('Здесь resultVersion не является номером deploy, Git commit или версией broker. Это стабильное имя интерпретации <code>2021-06.orders-projection.1</code> или <code>2021-06.orders-projection.2</code>. В реальном проекте к нему могут добавиться build, schema fingerprint или migration id. Но не стоит приклеивать всё сразу: достаточно обеспечить один ответ на вопрос расследования — по какому договору consumer прочёл event и что он записал.'),
['<code>event.id</code> и <code>source</code>','связать result с исходным логическим входом','только время обработки'],
['<code>inputSchemaVersion</code>','понять форму data, которую видел consumer','название topic или queue'],
['<code>consumerId</code>','отделить два самостоятельных read contract','общее имя сервиса'],
['<code>resultVersion</code>','отделить старую и новую интерпретацию при replay','случайный build timestamp'],
['решение <code>effect-recorded</code> или отказ','не спутать обработанный input с безопасно интерпретированным input','одна строка «consumer finished»'],
],
),
heading('Unknown version должна менять маршрут, а не парсинг'),
paragraph('Некоторые команды делают consumer permissive: он принимает любое число schemaVersion и берёт знакомые поля, надеясь, что остальное неважно. Такой подход удобен до первого semantic change. В нашем примере v3 содержит <code>state</code>, а не <code>status</code>. Envelope по форме всё ещё даёт origin, id и type, но v1/v2 contracts не объявили это значение. Поэтому <code>projectForConsumer()</code> возвращает <code>contract-update-required</code> и запрещает effect.'),
paragraph('Это не означает, что каждое новое поле останавливает весь поток. Значит другое: разные категории изменений имеют разные правила. Новый optional field, который reader не читает, может пройти по заранее описанной ветке. Новая обязательная семантика, удаление stable field, смена единицы или переименование требуют explicit consumer change. Если такой change нельзя выполнить быстро, лучше сохранить event и manual evidence, чем превратить неизвестное значение в default без владельца.'),
codeBlock(unsupportedCode),
heading('Schema registry полезен, но не заменяет договор'),
paragraph('Schema registry может хранить definitions, compatibility modes и историю. Но сам факт регистрации не доказывает, что конкретный consumer хранит result idempotently, что event type соответствует доменному действию или что versioned replay безопасен. И наоборот, маленький проект может начать с versioned fixture и JSON-schema-like checks без registry, пока договор виден в коде и review. В обоих случаях остаются одни и те же вопросы: кто публикует schema, что принимает reader, где записан migration и как остановить неизвестный вход.'),
paragraph('CloudEvents в историческом snapshot апреля 2021 года описывает общую форму event metadata, а Kafka 2.7 documentation различает producer send и возможность duplicate при retry. Ни один источник не говорит, что добавление поля в любую JSON data автоматически совместимо со всеми business consumer. Вся совместимость в этой статье ограничена двумя contracts и одной функцией projection. Так и должно быть: общий стандарт помогает передать envelope, но не владеет семантикой заказа.'),
heading('Маршрут изменения схемы'),
orderedList([
'Записать одну старую запись и один будущий event в отдельном fixture. Не начинать с массового изменения producer.',
'Назвать stable fields, их тип и смысл. Если смысл меняется, считать это новым contract, даже когда JSON key похож.',
'Проверить новый writer со старым reader: какие поля reader читает, какие игнорирует и почему это безопасно.',
'Проверить старый writer с новым reader: какой explicit default или отдельный route получает отсутствующее поле.',
'Добавить <code>inputSchemaVersion</code> и <code>resultVersion</code> к result consumer, чтобы replay был объясним.',
'Для unknown schemaVersion вернуть contract update required без effect. Не пытаться перевести незнакомые данные по имени поля.',
'После fixture выбрать реальный serialization format, registry policy и integration test; их правила записать отдельно от учебной модели.',
]),
heading('Граница знания и следующий тест'),
paragraph('Fixture не читает Avro bytes, не проверяет JSON Schema, не общается с registry и не запускает Kubernetes, broker или database. Она не доказывает backwards compatibility продукта и не измеряет lag consumer. Она проверяет только конкретный контракт: v2 добавляет поле, v1 его не читает, v2 consumer умеет представить absence как null, v3 не вызывает effect. Если ваш consumer использует enum, money, locale или permission, это должны быть отдельные assertions, а не перенос нашей пары полей.'),
paragraph('Следующий практичный шаг — добавить к изменению схемы review-таблицу из этой статьи и один replay test на выбранном хранилище результата. В хорошем результате будет видно event id, writer schema, consumer contract и исход effect. Если такой след не получается собрать без догадок, schema evolution пока рано выпускать: сначала надо сделать наблюдаемой границу reader и writer.'),
],
[avro101,cloudEvents202104,kafkaProducer27],
);
constfieldArticle=createRevision(
{
slug:'editorial-2021-06-field-event-driven',
title:'Replay события: как не записать второй result и не скрыть новую схему',
excerpt:'Replay полезен только тогда, когда видно исходный event, consumer contract и уже записанный result. Разбираем evidence packet, duplicate, controlled replay, schema mismatch и маршрут без обещания exactly-once.',
readingMinutes:16,
},
[
paragraph('Симптом после восстановления consumer звучит просто: «нужно проиграть события ещё раз». Затем один event приходит повторно, второй consumer уже обновлён, а в storage появляется непонятный result. Если replay создаёт новый id, невозможно отличить повтор старого факта от нового факта. Если он сохраняет id, но consumer не ведёт ledger, можно записать второй effect. Цена — не только дубль. Команда теряет доказательство, по какому contract был получен каждый результат, и не может безопасно решить, что делать со старой схемой.'),
paragraph('Полезная точка старта — не ручной запуск всех consumer, а evidence packet из пяти частей: envelope id и source, event type, input schemaVersion, consumer id/resultVersion, решение по ledger. В этой статье replay обозначает повторную delivery того же учебного <code>source:id</code>. Он не открывает реальный topic, не перемещает offset и не повторяет Kafka record. '+trainingBoundary+' Поэтому result <code>duplicate-or-replay-suppressed</code> означает только, что Map уже видела тот же consumer contract и source:id.'),
paragraph('Самая опасная «починка» — сделать новое event id, чтобы consumer не счёл запись duplicate. Так обходят проверку, но меняют вопрос. Новый id может означать новый факт, исправленную команду или технический replay; эти случаи нельзя сливать. Для controlled replay неизменным остаётся исходный id, source, type, subject и payload schema. Дополнительная причина replay может жить рядом с операционной записью, но не должна подменять исходный event. Тогда ledger способен ответить: этот contract уже обработал данный вход или нет.'),
paragraph('Ключ ledger в fixture — <code>consumerId:source:event.id</code>. Source входит в identity: один и тот же id из другого producer не должен случайно подавить отдельный факт. Он подходит только для демонстрации одного projection result. В реальном домене этого может быть мало: внешний effect иногда нужно ключевать по business intent, а два разных consumer могут законно создать разные projections по одному event. Не надо переносить ключ как готовую идемпотентность. Сначала надо назвать effect и его owner, затем проверить, где хранится receipt вместе с результатом.'),
codeBlock(deliveryCode),
dataTable(
'Одна запись, три delivery: ожидаемый учебный результат',
['Delivery kind','Event identity','Ledger до шага','Решение','Effect'],
[
['<code>initial-delivery</code>','тот же <code>source:id</code>','нет ключа consumer','<code>effect-recorded</code>','один учебный result записан'],
['<code>duplicate-delivery</code>','тот же <code>source:id</code>','ключ уже есть','<code>duplicate-or-replay-suppressed</code>','новый result не пишется'],
['<code>controlled-replay</code>','тот же <code>source:id</code>','ключ уже есть','<code>duplicate-or-replay-suppressed</code>','replay не обходил ledger'],
['<code>historical-replay</code> в другом contract','тот же <code>source:id</code>','другой ledger consumer','<code>effect-recorded</code> с новой resultVersion','разный reader result допустим'],
heading('Duplicate и новая интерпретация — разные развилки'),
paragraph('Duplicate по тому же contract не должен создавать второй result. Но новое правило consumer иногда действительно требует пересчитать projection. Тогда не стоит удалять старую ledger запись и притворяться, что история не существовала. В fixture <code>orders-projection@2</code> имеет другой ledger key и <code>resultVersion</code>; historical replay v1 может создать свой result, потому что это другой declared reader contract. Такой выбор не делает результат «истиннее», он делает видимой новую интерпретацию.'),
paragraph('Перед этим шагом нужно определить, разрешён ли второй projection в домене. Для email, платёжа или изменения внешней системы одного <code>consumerId:source:event.id</code> почти наверняка недостаточно: потребуется effect key на внешней границе, транзакция или ручное решение. Для локальной read projection иногда допустимо хранить две версии и переключать reader после сверки. Статья не выбирает между этими архитектурами. Она требует, чтобы автор replay написал, какой effect будет создан и почему второй contract имеет право его создавать.'),
'Вертикальное дерево диагностики replay: сначала проверяются source, id, type и schemaVersion, затем consumer contract и ledger; ветки ведут к первому result, suppress duplicate, отдельному versioned replay или contract update required для неизвестной схемы',
'Диагностика не считает replay ошибкой сама по себе. Она отделяет повтор того же result от новой явно объявленной интерпретации.',
),
heading('Схема не должна исчезать за duplicate'),
paragraph('Проверка ledger не заменяет проверку schema. Если v3 event пришёл с незнакомым <code>state</code>, consumer не должен сначала посмотреть id, не найти его и записать effect только потому, что это первый delivery. В учебном алгоритме порядок другой: validate envelope, выбрать consumer contract, проверить accepted schema version, затем читать ledger. Это сохраняет важный факт: новый event был получен, но effect запрещён из-за договора, а не потерян как «неизвестная ошибка».'),
paragraph('Также нельзя делать обратное: любой duplicate автоматически игнорировать до записи diagnostics. Result suppress содержит <code>deliveryKind</code>, <code>ledgerKey</code>, input schema и result version первого результата. Это не production audit trail, но минимальное evidence помогает отличить повтор сети от operator replay. Если реальная система не сохраняет эти факты, расследование начнётся с догадки: очередной запуск создал запись или просто повторил уже завершённую delivery.'),
codeBlock(unsupportedCode),
heading('Fixture задаёт узкую, но полезную проверку'),
paragraph('Фикстура использует два event v1/v2, один намеренно несовместимый v3, два consumer contract и две Map. Она проверяет envelope boundary, foreign source, additive v2 для v1 reader, normalisation old event в v2 reader, один записанный result, suppress duplicate, suppress controlled replay, отдельный resultVersion второго consumer и остановку v3. Assertions не измеряют время, не моделируют crash между внешним effect и receipt и не говорят ничего о exactly-once. Их задача скромнее: не дать редактуре незаметно поменять «тот же id подавляется» на «replay всегда пишет ещё раз».'),
codeBlock(fixtureCode),
paragraph('Apache Kafka 2.7 producer API прямо рассматривает retries и возможность duplicate в некоторых режимах. Это хороший повод не обещать exactly-once одним словом. Конкретные guarantees зависят от producer, broker, consumer, storage и внешней границы. CloudEvents в историческом snapshot апреля 2021 года помогает разделить context и event data, но не задаёт business receipt. В статье оба источника ограничивают формулировку, а не дают готовую implementation.'),
dataTable(
'Диагноз перед replay: факт, проверка, действие',
['Наблюдение','Что собрать','Безопасное действие','Чего не делать'],
[
['тот же <code>source:id</code>, same consumer contract','ledger key и первый resultVersion','suppress duplicate, сохранить evidence delivery','создавать новый id ради обхода проверки'],
['тот же event, новый declared consumer contract','старый и новый resultVersion, тип effect','отдельный controlled replay после явного решения','удалять старую receipt и терять историю'],
['schemaVersion не объявлена consumer','envelope, input schema, причина отказа','contract update или manual route без effect','угадывать поле по похожему имени'],
['source или type неожиданны','validation reason до payload','rejected envelope и проверка producer boundary','выполнять partial effect для «похожего» входа'],
['внешний effect уже мог быть сделан','idempotency contract внешней системы и receipt','остановить автоматический replay до доказательства','считать Map заменой транзакции'],
],
),
heading('Маршрут controlled replay'),
orderedList([
'Зафиксировать исходный event id, source, type, subject, payload schema и причину replay. Не заменять их новым JSON.',
'Проверить envelope и consumer contract до ledger. Unknown type или schema должен дать объяснимый отказ без effect.',
'Выбрать key, который соответствует именно данному consumer result; отдельно обсудить key доменного или внешнего effect.',
'Если ledger уже содержит same consumer contract + source:id, suppress replay и сохранить delivery evidence.',
'Если нужен новый расчёт, ввести новый declared consumer contract/resultVersion. Не перезаписывать старую интерпретацию без следа.',
'Для historical event проверить old writer с new reader: default, null или manual route должны быть записаны явно.',
'Перед реальным запуском выполнить integration test выбранного broker, storage и external API. Проверить их actual retries, crash window и rollback отдельно.',
]),
heading('Граница этого разбора'),
paragraph('Здесь нет реального broker, schema registry, consumer offset, listener, HTTP, database, external payment, audit store, browser, CI или deployment. Нет данных пользователей, measured throughput, lag или историй production incident. Также нет claim, что <code>consumerId:source:event.id</code> гарантирует exactly-once. Он даёт один воспроизводимый answer внутри Map: текущий учебный consumer уже записал result для этого source:id или ещё нет.'),
paragraph('После чтения стоит выбрать один невысокорисковый projection, а не внешний effect, и пройти тот же маршрут на интеграционном стенде. Хороший тест покажет initial delivery, duplicate, controlled replay и unknown schema с реальными версиями выбранных компонентов. Если для внешнего effect нет подтверждённого idempotency contract, автоматический replay не следует включать. В таком случае честный результат — сохранить evidence и передать решение владельцу операции.'),
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.