revise June 2021 event integration articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 12:46:37 +03:00
parent 32e0550f4f
commit 2da66c5725
7 changed files with 1185 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 121 из 358 созданных материалов. Остальные 237 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 124 из 358 созданных материалов. Остальные 234 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+205
View File
@@ -0,0 +1,205 @@
# Автономное тройное ревью П40 · июнь 2021 · «Событийная интеграция»
Статус: **авторское тройное ревью пройдено, затем пакет принят независимым
редактором в выпусковой набор**. Пакет содержит revision ровно для трёх
стабильных slug:
- <code>editorial-2021-06-practice-event-driven</code>;
- <code>editorial-2021-06-mechanism-event-driven</code>;
- <code>editorial-2021-06-field-event-driven</code>.
Созданы только пять разрешённых файлов П40:
1. <code>web/scripts/upgrade-2021-06.mjs</code>;
2. <code>editorial/reviews/2021-06-draft.md</code>;
3. <code>web/public/assets/editorial/2021/event-topology-2021.svg</code>;
4. <code>web/public/assets/editorial/2021/event-schema-compatibility-2021.svg</code>;
5. <code>web/public/assets/editorial/2021/event-replay-diagnosis-2021.svg</code>.
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,
CloudEvents implementation, Kafka configuration, measured throughput,
user data, инциденте или успешной массовой миграции.
Вердикт прохода: **пройден**. Тексты достаточно подробны, но не переходят в
техлидский тон 2027 года; каждый вывод можно связать с contract, fixture,
таблицей или источником.
## Проход 3. Визуал, preflight и выпуск — пройдено в пределах пакета
- <code>event-topology-2021.svg</code> показывает границу между producer,
delivery, consumer и result ledger. Небольшая боковая ветка подчёркивает,
что duplicate/replay несёт тот же <code>source:id</code>, а не создаёт новый факт.
- <code>event-schema-compatibility-2021.svg</code> показывает две проверяемые
пары v1/v2 и отдельную blocked branch v3. Стрелки не выдают added field за
автоматическую совместимость.
- <code>event-replay-diagnosis-2021.svg</code> ведёт от envelope к consumer
contract и ledger, затем разделяет first result, suppress duplicate и
contract update required.
- У каждого SVG есть <code>title</code>, <code>desc</code>,
<code>role="img"</code>, вертикальный viewBox, короткие строки и
контрастные карточки. XML валиден. Static scan не нашёл
<code>script</code>, <code>foreignObject</code>, external
<code>href</code>/<code>src</code> или raster data URI.
- Sharp отрендерил финальные SVG в PNG шириной 375 px. Все три рендера
просмотрены вручную: заголовки, стрелки, карточки и нижние подписи
читаются; clipping, overlap и horizontal overflow внутри схем не найдены.
Это статический visual preflight, не browser-run и не проверка screen reader.
### Фактически выполненные проверки
Команды запускались из <code>web/</code> после финальной правки:
<pre><code>node --check scripts/upgrade-2021-06.mjs
npm run audit:draft -- scripts/upgrade-2021-06.mjs
node scripts/upgrade-2021-06.mjs --verify-fixture
xmllint --noout \
public/assets/editorial/2021/event-topology-2021.svg \
public/assets/editorial/2021/event-schema-compatibility-2021.svg \
public/assets/editorial/2021/event-replay-diagnosis-2021.svg</code></pre>
| Проверка | Реальный результат |
| --- | --- |
| <code>node --check</code> | PASS, code 0 |
| 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,
browser, screen reader, реальный broker, schema registry, SDK, HTTP, база,
external API, CI, deployment, commit и публикация. Это намеренная граница
автономной author party.
## Итог
Тройное авторское ревью пройдено. П40 была готова к независимой
интеграционной приёмке; commit и push на автономном этапе намеренно не
выполнялись.
## Независимая интеграционная приёмка
Основной редактор 31 июля 2026 года подключил три revision к
<code>web/data/editorial-revisions.mjs</code>, сохранив базовый
<code>articles.json</code>, даты и автора архивных записей. В registry стало
115 revision, строгий аудит проходит 124 из 358 материалов.
Независимый code review нашёл несовпадение между текстом и fixture: статья
правильно собирала identity как <code>source:id</code>, но receipt-key был
<code>consumerId:event.id</code>. До выпуска ключ исправлен на
<code>consumerId:source:event.id</code>. Fixture теперь проверяет, что
одинаковый id из другого допустимого source создаёт отдельный учебный result,
а SVG и маршруты используют ту же identity. Добавлен одиннадцатый assertion.
Это не выдано за общий idempotency contract внешнего эффекта.
Факт-чек также убрал слишком точную дату Avro: архив показывает артефакты
1.10.1 с датами ноября/декабря 2020, чего достаточно для исторической
границы июня 2021. Immutable CloudEvents commit имеет дату 16 апреля 2021 и
working-draft заголовок <code>v1.0.2-wip</code>; Avro specification описывает
writer/reader resolution; Kafka 2.7 producer documentation ограничивает
формулировку о 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 остаётся источником её фактической записи.
+2
View File
@@ -36,6 +36,7 @@ import { revisions as february2021Revisions } from '../scripts/upgrade-2021-02.m
import { revisions as march2021Revisions } from '../scripts/upgrade-2021-03.mjs';
import { revisions as april2021Revisions } from '../scripts/upgrade-2021-04.mjs';
import { revisions as may2021Revisions } from '../scripts/upgrade-2021-05.mjs';
import { revisions as june2021Revisions } from '../scripts/upgrade-2021-06.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -77,4 +78,5 @@ export const editorialRevisions = [
...march2021Revisions,
...april2021Revisions,
...may2021Revisions,
...june2021Revisions,
];
@@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1190" viewBox="0 0 720 1190" role="img" aria-labelledby="title desc">
<title id="title">Диагностика duplicate и replay учебного события</title>
<desc id="desc">Вертикальное дерево: проверка envelope, проверка consumer contract и schema version, lookup в ledger. Из веток выходят effect recorded, duplicate or replay suppressed и contract update required.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#4bc0c8"/>
</marker>
<marker id="warnarrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#e7aa55"/>
</marker>
<style>
.bg { fill: #0d1724; }
.panel { fill: #162437; stroke: #36516b; stroke-width: 2; }
.check { fill: #123d4b; stroke: #4bc0c8; stroke-width: 2; }
.ok { fill: #1d3b30; stroke: #77c893; stroke-width: 2; }
.warn { fill: #3b3030; stroke: #e7aa55; stroke-width: 2; }
.line { stroke: #4bc0c8; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.warnline { stroke: #e7aa55; stroke-width: 4; fill: none; marker-end: url(#warnarrow); }
.title { fill: #f3fbff; font: 32px Arial, sans-serif; font-weight: 700; }
.label { fill: #d9edf6; font: 24px Arial, sans-serif; font-weight: 700; }
.body { fill: #a8bed0; font: 21px Arial, sans-serif; }
.code { fill: #92e3e8; font: 22px "SFMono-Regular", Consolas, monospace; }
</style>
</defs>
<rect class="bg" width="720" height="1190" rx="28"/>
<text class="title" x="48" y="62">Перед replay собираем evidence</text>
<text class="body" x="48" y="96">id · source · schema · contract · ledger</text>
<rect class="check" x="54" y="140" width="612" height="142" rx="18"/>
<text class="label" x="84" y="182">1. Envelope валиден?</text>
<text class="code" x="84" y="222">source · id · type · subject</text>
<text class="body" x="84" y="256">Нет → rejected envelope, без effect.</text>
<path class="line" d="M360 282 L360 354"/>
<rect class="check" x="54" y="370" width="612" height="148" rx="18"/>
<text class="label" x="84" y="412">2. Contract принимает schema?</text>
<text class="code" x="84" y="452">consumerId · schemaVersion</text>
<text class="body" x="84" y="486">Нет → contract update required.</text>
<path class="line" d="M360 518 L360 590"/>
<rect class="panel" x="54" y="606" width="612" height="138" rx="18"/>
<text class="label" x="84" y="648">3. Ledger уже содержит key?</text>
<text class="code" x="84" y="688">consumerId : source : id</text>
<text class="body" x="84" y="722">Проверяется до нового учебного result.</text>
<path class="line" d="M230 744 L230 816"/>
<rect class="ok" x="54" y="832" width="334" height="178" rx="18"/>
<text class="label" x="82" y="874">Нет ключа</text>
<text class="code" x="82" y="916">effect-recorded</text>
<text class="body" x="82" y="954">record resultVersion</text>
<text class="body" x="82" y="984">и input schema</text>
<path class="line" d="M490 744 L490 816"/>
<rect class="warn" x="420" y="832" width="246" height="178" rx="18"/>
<text class="label" x="446" y="874">Есть ключ</text>
<text class="code" x="446" y="916">suppress</text>
<text class="body" x="446" y="954">duplicate или</text>
<text class="body" x="446" y="984">controlled replay</text>
<path class="warnline" d="M652 444 C704 444 704 1092 602 1092"/>
<rect class="warn" x="54" y="1052" width="612" height="92" rx="18"/>
<text class="label" x="84" y="1090">Unknown schema: effect запрещён</text>
<text class="body" x="84" y="1122">Сохраняем причину для contract update.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1160" viewBox="0 0 720 1160" role="img" aria-labelledby="title desc">
<title id="title">Матрица совместимости схемы события</title>
<desc id="desc">Вертикальная схема показывает событие v1, v2 с добавочным необязательным полем и v3 с изменённой семантикой. Consumer v1 читает stable fields, consumer v2 умеет нормализовать отсутствие нового поля, v3 требует обновления договора.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#4bc0c8"/>
</marker>
<style>
.bg { fill: #0d1724; }
.event { fill: #163544; stroke: #4bc0c8; stroke-width: 2; }
.consumer { fill: #1b293d; stroke: #607d9a; stroke-width: 2; }
.blocked { fill: #3b3030; stroke: #e7aa55; stroke-width: 2; }
.line { stroke: #4bc0c8; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.warnline { stroke: #e7aa55; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.title { fill: #f3fbff; font: 32px Arial, sans-serif; font-weight: 700; }
.label { fill: #d9edf6; font: 24px Arial, sans-serif; font-weight: 700; }
.body { fill: #a8bed0; font: 21px Arial, sans-serif; }
.code { fill: #92e3e8; font: 22px "SFMono-Regular", Consolas, monospace; }
</style>
</defs>
<rect class="bg" width="720" height="1160" rx="28"/>
<text class="title" x="48" y="62">Схема меняется по парам</text>
<text class="body" x="48" y="96">writer event ↔ declared reader contract</text>
<rect class="event" x="48" y="140" width="286" height="202" rx="18"/>
<text class="label" x="76" y="180">Event schema v1</text>
<text class="code" x="76" y="222">orderId</text>
<text class="code" x="76" y="256">status</text>
<text class="body" x="76" y="300">старый writer</text>
<rect class="consumer" x="386" y="140" width="286" height="202" rx="18"/>
<text class="label" x="414" y="180">Consumer v1</text>
<text class="code" x="414" y="222">reads stable fields</text>
<text class="body" x="414" y="266">resultVersion .1</text>
<text class="body" x="414" y="300">v1 accepted</text>
<path class="line" d="M334 241 L374 241"/>
<rect class="event" x="48" y="438" width="286" height="238" rx="18"/>
<text class="label" x="76" y="478">Event schema v2</text>
<text class="code" x="76" y="520">orderId · status</text>
<text class="code" x="76" y="554">paymentReference?</text>
<text class="body" x="76" y="600">добавлено optional field</text>
<text class="body" x="76" y="632">stable meaning сохранён</text>
<rect class="consumer" x="386" y="414" width="286" height="128" rx="18"/>
<text class="label" x="414" y="454">Consumer v1</text>
<text class="body" x="414" y="490">игнорирует declared optional</text>
<text class="body" x="414" y="520">resultVersion .1</text>
<path class="line" d="M334 500 L374 478"/>
<rect class="consumer" x="386" y="570" width="286" height="154" rx="18"/>
<text class="label" x="414" y="610">Consumer v2</text>
<text class="body" x="414" y="648">читает поле или null</text>
<text class="body" x="414" y="680">resultVersion .2</text>
<path class="line" d="M334 592 L374 646"/>
<rect class="blocked" x="48" y="838" width="624" height="206" rx="18"/>
<text class="label" x="78" y="882">Event schema v3: semantic change</text>
<text class="code" x="78" y="926">state вместо status</text>
<text class="body" x="78" y="966">JSON может распарситься, но meaning не объявлен.</text>
<text class="label" x="78" y="1014">contract update required</text>
<path class="warnline" d="M360 724 L360 816"/>
<text class="body" x="48" y="1112">Compatible — свойство конкретной пары writer и reader.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1140" viewBox="0 0 720 1140" role="img" aria-labelledby="title desc">
<title id="title">Учебная топология событийной интеграции</title>
<desc id="desc">Вертикальная схема: producer формирует envelope и payload, граница доставки передаёт их consumer, consumer валидирует договор и записывает versioned result в ledger. Повтор и controlled replay несут тот же source и event id.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#4bc0c8"/>
</marker>
<style>
.bg { fill: #0d1724; }
.panel { fill: #162437; stroke: #36516b; stroke-width: 2; }
.accent { fill: #123d4b; stroke: #4bc0c8; stroke-width: 2; }
.warn { fill: #3b3030; stroke: #e7aa55; stroke-width: 2; }
.line { stroke: #4bc0c8; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.muted { fill: #a8bed0; font: 22px Arial, sans-serif; }
.label { fill: #d9edf6; font: 24px Arial, sans-serif; font-weight: 700; }
.title { fill: #f3fbff; font: 32px Arial, sans-serif; font-weight: 700; }
.small { fill: #a8bed0; font: 20px Arial, sans-serif; }
.code { fill: #92e3e8; font: 22px "SFMono-Regular", Consolas, monospace; }
</style>
</defs>
<rect class="bg" width="720" height="1140" rx="28"/>
<text class="title" x="54" y="62">Один event, несколько delivery</text>
<text class="muted" x="54" y="96">Учебный contract июня 2021</text>
<rect class="accent" x="54" y="138" width="612" height="188" rx="18"/>
<text class="label" x="84" y="180">1. Producer: формирует факт</text>
<text class="code" x="84" y="220">id · source · type · subject</text>
<text class="code" x="84" y="254">schemaVersion · data</text>
<text class="small" x="84" y="294">Envelope и payload имеют разных owners.</text>
<path class="line" d="M360 326 L360 402"/>
<rect class="panel" x="54" y="418" width="612" height="154" rx="18"/>
<text class="label" x="84" y="460">2. Delivery boundary</text>
<text class="muted" x="84" y="498">Передаёт event consumer-у.</text>
<text class="small" x="84" y="534">Delivery не равна доменному факту.</text>
<path class="line" d="M360 572 L360 648"/>
<rect class="accent" x="54" y="664" width="612" height="192" rx="18"/>
<text class="label" x="84" y="706">3. Consumer: сначала проверка</text>
<text class="code" x="84" y="746">validate envelope → contract</text>
<text class="code" x="84" y="780">schema version → projection</text>
<text class="small" x="84" y="820">Unknown schema не создаёт effect.</text>
<path class="line" d="M360 856 L360 932"/>
<rect class="panel" x="54" y="948" width="612" height="142" rx="18"/>
<text class="label" x="84" y="990">4. Versioned result ledger</text>
<text class="code" x="84" y="1028">consumerId : source : id</text>
<text class="small" x="84" y="1062">resultVersion объясняет интерпретацию.</text>
<path class="line" d="M652 506 C704 506 704 796 652 796"/>
<rect class="warn" x="432" y="364" width="234" height="88" rx="16"/>
<text class="label" x="452" y="400">Duplicate / replay</text>
<text class="small" x="452" y="430">тот же source:id</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+794
View File
@@ -0,0 +1,794 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + escapeHtml(caption) + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
);
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
}
return { ...meta, contentHtml, proseLength };
}
const cloudEvents202104 = {
title: 'CloudEvents Core Specification snapshot, 16 апреля 2021 (v1.0.2-wip)',
url: 'https://raw.githubusercontent.com/cloudevents/spec/6eb8b9f4bfe92a332ad93dda56090f917e44602d/spec.md',
note: 'исторический working draft существовал к июню 2021 года и различает context attributes, event data и protocol binding. Учебный envelope ниже не является CloudEvent и не реализует binding.',
};
const avro101 = {
title: 'Apache Avro 1.10.1 Specification — Schema Resolution',
url: 'https://avro.apache.org/docs/1.10.1/spec.pdf',
note: 'официальная спецификация различает writer и reader schema и описывает resolution. Здесь она служит ориентиром для явного consumer contract, а не форматом payload или schema registry.',
};
const kafkaProducer27 = {
title: 'Apache Kafka 2.7.0 KafkaProducer API',
url: 'https://kafka.apache.org/27/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html',
note: 'версионная документация линии 2.7 предупреждает, что retry может привести к duplicate. Fixture не запускает Kafka, не использует offset, partition, transaction или producer API.',
};
const trainingBoundary = 'Все идентификаторы, даты, source, payload, результаты и правила ниже учебные. Fixture хранит состояние только в Map процесса Node: это не broker, не schema registry, не production event, не реализация CloudEvents или Kafka, не transport, не база и не измерение throughput.';
const consumerContracts = 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',
}),
'orders-projection@2': Object.freeze({
id: 'orders-projection@2',
resultVersion: '2021-06.orders-projection.2',
acceptedSchemaVersions: Object.freeze([1, 2]),
fields: Object.freeze(['orderId', 'status', 'paymentReference']),
compatibilityRule: 'maps paymentReference to null when an older v1 event did not contain it',
}),
});
export const trainingEvents = Object.freeze({
orderStatusV1: Object.freeze({
contractVersion: '2021-06',
id: 'evt-order-104-paid-01',
source: 'training://orders/order-104',
type: 'order.status.changed',
subject: 'order-104',
occurredAt: '2021-06-14T10:30:00.000Z',
schemaVersion: 1,
data: Object.freeze({
orderId: 'order-104',
status: 'paid',
}),
}),
orderStatusV2: Object.freeze({
contractVersion: '2021-06',
id: 'evt-order-104-paid-02',
source: 'training://orders/order-104',
type: 'order.status.changed',
subject: 'order-104',
occurredAt: '2021-06-14T10:31:00.000Z',
schemaVersion: 2,
data: Object.freeze({
orderId: 'order-104',
status: 'paid',
paymentReference: 'training-pay-77',
}),
}),
orderStatusV3: Object.freeze({
contractVersion: '2021-06',
id: 'evt-order-104-paid-03',
source: 'training://orders/order-104',
type: 'order.status.changed',
subject: 'order-104',
occurredAt: '2021-06-14T10:32:00.000Z',
schemaVersion: 3,
data: Object.freeze({
orderId: 'order-104',
state: 'settled',
}),
}),
});
function hasNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isTrainingOrderSource(value) {
return hasNonEmptyString(value) && value.startsWith('training://orders/');
}
/**
* Проверяется только форма нашего учебного envelope. Названия полей похожи на
* привычные event metadata намеренно: consumer должен видеть origin, type,
* subject и schema version. Но это собственный договор статьи, не сериализация
* CloudEvents и не проверка совместимости внешнего протокола.
*/
export function validateTrainingEnvelope(event) {
if (!event || typeof event !== 'object' || Array.isArray(event)) {
return { ok: false, reason: 'event-is-not-an-object' };
}
const requiredStrings = ['contractVersion', 'id', 'source', 'type', 'subject', 'occurredAt'];
for (const key of requiredStrings) {
if (!hasNonEmptyString(event[key])) {
return { ok: false, reason: 'missing-or-empty-' + key };
}
}
if (event.contractVersion !== '2021-06') {
return { ok: false, reason: 'unsupported-envelope-contract' };
}
if (!isTrainingOrderSource(event.source)) {
return { ok: false, reason: 'source-outside-training-orders-boundary' };
}
if (event.type !== 'order.status.changed') {
return { ok: false, reason: 'unsupported-event-type' };
}
if (!Number.isInteger(event.schemaVersion) || event.schemaVersion < 1) {
return { ok: false, reason: 'invalid-schema-version' };
}
if (!event.data || typeof event.data !== 'object' || Array.isArray(event.data)) {
return { ok: false, reason: 'missing-data-object' };
}
if (!hasNonEmptyString(event.data.orderId)) {
return { ok: false, reason: 'missing-order-id' };
}
if ([1, 2].includes(event.schemaVersion) && !hasNonEmptyString(event.data.status)) {
return { ok: false, reason: 'missing-stable-order-fields' };
}
if (Object.hasOwn(event.data, 'paymentReference')
&& event.data.paymentReference !== null
&& !hasNonEmptyString(event.data.paymentReference)) {
return { ok: false, reason: 'invalid-payment-reference' };
}
return {
ok: true,
envelopeKey: event.source + ':' + event.id,
stableFields: Object.freeze({
orderId: event.data.orderId,
status: event.data.status,
}),
};
}
export function projectForConsumer(event, consumerId) {
const contract = consumerContracts[consumerId];
if (!contract) {
return {
state: 'unknown-consumer-contract',
consumerId,
resultVersion: null,
effectAllowed: false,
};
}
const validation = validateTrainingEnvelope(event);
if (!validation.ok) {
return {
state: 'rejected-envelope',
consumerId: contract.id,
resultVersion: contract.resultVersion,
reason: validation.reason,
effectAllowed: false,
};
}
if (!contract.acceptedSchemaVersions.includes(event.schemaVersion)) {
return {
state: 'contract-update-required',
consumerId: contract.id,
resultVersion: contract.resultVersion,
inputSchemaVersion: event.schemaVersion,
reason: 'consumer-does-not-declare-this-schema-version',
effectAllowed: false,
};
}
const projection = {
orderId: validation.stableFields.orderId,
status: validation.stableFields.status,
};
if (contract.id === 'orders-projection@2') {
projection.paymentReference = Object.hasOwn(event.data, 'paymentReference')
? event.data.paymentReference
: null;
}
return {
state: 'ready',
consumerId: contract.id,
resultVersion: contract.resultVersion,
inputSchemaVersion: event.schemaVersion,
envelopeKey: validation.envelopeKey,
compatibilityRule: contract.compatibilityRule,
projection: Object.freeze(projection),
effectAllowed: true,
};
}
/**
* Ledger отвечает только за один учебный результат consumer contract + source + event id.
* Настоящий доменный effect может требовать иной ключ, транзакционную границу,
* outbox, идемпотентный API или manual route. Это не заменяет эти решения.
*/
export function consumeTrainingEvent(ledger, event, consumerId, deliveryKind) {
if (!(ledger instanceof Map)) {
throw new Error('training ledger must be a Map');
}
const projected = projectForConsumer(event, consumerId);
if (!projected.effectAllowed) {
return {
state: projected.state,
deliveryKind,
consumerId: projected.consumerId || consumerId,
resultVersion: projected.resultVersion,
inputSchemaVersion: projected.inputSchemaVersion || event?.schemaVersion || null,
reason: projected.reason || null,
effectWritten: false,
};
}
const ledgerKey = projected.consumerId + ':' + projected.envelopeKey;
const previous = ledger.get(ledgerKey);
if (previous) {
return {
state: 'duplicate-or-replay-suppressed',
deliveryKind,
consumerId: projected.consumerId,
resultVersion: projected.resultVersion,
inputSchemaVersion: event.schemaVersion,
ledgerKey,
firstResultVersion: previous.resultVersion,
effectWritten: false,
evidence: 'same consumer contract and source + event id already have a recorded training result',
};
}
const result = Object.freeze({
resultVersion: projected.resultVersion,
inputSchemaVersion: event.schemaVersion,
projection: projected.projection,
deliveryKind,
});
ledger.set(ledgerKey, result);
return {
state: 'effect-recorded',
deliveryKind,
consumerId: projected.consumerId,
resultVersion: projected.resultVersion,
inputSchemaVersion: event.schemaVersion,
ledgerKey,
effectWritten: true,
projection: projected.projection,
};
}
/**
* Одна детерминированная учебная история:
* - v2 добавляет необязательное поле, которое contract v1 осознанно игнорирует;
* - повторная delivery и explicit replay несут тот же source + event id;
* - contract v2 может нормализовать старый v1 к null;
* - v3 не получает тихой интерпретации, пока consumer не обновит договор.
*/
export function runEventIntegrationFixture() {
const v1 = trainingEvents.orderStatusV1;
const v2 = trainingEvents.orderStatusV2;
const v3 = trainingEvents.orderStatusV3;
const invalidEnvelope = { ...v1, source: 'https://orders.example.test/order-104' };
const v1Validation = validateTrainingEnvelope(v1);
const v2Validation = validateTrainingEnvelope(v2);
const invalidValidation = validateTrainingEnvelope(invalidEnvelope);
const projectionV1OnV2 = projectForConsumer(v2, 'orders-projection@1');
const projectionV2OnV1 = projectForConsumer(v1, 'orders-projection@2');
const unknownSchema = projectForConsumer(v3, 'orders-projection@2');
const projectionLedger = new Map();
const firstDelivery = consumeTrainingEvent(
projectionLedger,
v2,
'orders-projection@1',
'initial-delivery',
);
const ledgerSizeAfterFirstDelivery = projectionLedger.size;
const duplicateDelivery = consumeTrainingEvent(
projectionLedger,
v2,
'orders-projection@1',
'duplicate-delivery',
);
const ledgerSizeAfterDuplicateDelivery = projectionLedger.size;
const explicitReplay = consumeTrainingEvent(
projectionLedger,
v2,
'orders-projection@1',
'controlled-replay',
);
const ledgerSizeAfterExplicitReplay = projectionLedger.size;
const sameIdOtherSource = {
...v2,
source: 'training://orders/order-105',
subject: 'order-105',
data: { ...v2.data, orderId: 'order-105' },
};
const sameIdOtherSourceDelivery = consumeTrainingEvent(
projectionLedger,
sameIdOtherSource,
'orders-projection@1',
'same-id-other-source',
);
const secondConsumerLedger = new Map();
const v1ForV2Consumer = consumeTrainingEvent(
secondConsumerLedger,
v1,
'orders-projection@2',
'historical-replay',
);
const rejectedV3 = consumeTrainingEvent(
secondConsumerLedger,
v3,
'orders-projection@2',
'new-schema-delivery',
);
const assertions = {
envelopeAcceptsRequiredBoundaryFields: v1Validation.ok && v2Validation.ok,
envelopeRejectsForeignSource: !invalidValidation.ok
&& invalidValidation.reason === 'source-outside-training-orders-boundary',
additiveV2KeepsStableV1Projection: projectionV1OnV2.state === 'ready'
&& projectionV1OnV2.inputSchemaVersion === 2
&& projectionV1OnV2.projection.orderId === 'order-104'
&& !Object.hasOwn(projectionV1OnV2.projection, 'paymentReference'),
newerConsumerNormalizesOlderEvent: projectionV2OnV1.state === 'ready'
&& projectionV2OnV1.projection.paymentReference === null,
firstDeliveryWritesOneVersionedResult: firstDelivery.effectWritten
&& firstDelivery.resultVersion === '2021-06.orders-projection.1'
&& ledgerSizeAfterFirstDelivery === 1,
duplicateDoesNotWriteSecondResult: duplicateDelivery.state === 'duplicate-or-replay-suppressed'
&& !duplicateDelivery.effectWritten
&& ledgerSizeAfterDuplicateDelivery === 1,
replayPreservesEventIdentity: explicitReplay.state === 'duplicate-or-replay-suppressed'
&& explicitReplay.deliveryKind === 'controlled-replay'
&& explicitReplay.ledgerKey === firstDelivery.ledgerKey
&& ledgerSizeAfterExplicitReplay === 1,
sameIdFromOtherSourceHasItsOwnReceipt: sameIdOtherSourceDelivery.effectWritten
&& sameIdOtherSourceDelivery.ledgerKey !== firstDelivery.ledgerKey
&& projectionLedger.size === 2,
historicalReplayHasItsOwnConsumerResultVersion: v1ForV2Consumer.effectWritten
&& v1ForV2Consumer.resultVersion === '2021-06.orders-projection.2'
&& secondConsumerLedger.size === 1,
unknownSchemaNeedsExplicitContractUpdate: rejectedV3.state === 'contract-update-required'
&& !rejectedV3.effectWritten
&& rejectedV3.resultVersion === '2021-06.orders-projection.2',
fixtureNamesItsBoundary: trainingBoundary.includes('не broker')
&& trainingBoundary.includes('не schema registry'),
};
if (!Object.values(assertions).every(Boolean)) {
throw new Error('event integration training fixture violated a documented invariant');
}
return {
model: trainingBoundary,
contracts: consumerContracts,
validations: { v1Validation, v2Validation, invalidValidation },
projections: { projectionV1OnV2, projectionV2OnV1, unknownSchema },
deliveries: {
firstDelivery,
duplicateDelivery,
explicitReplay,
sameIdOtherSourceDelivery,
v1ForV2Consumer,
rejectedV3,
},
ledgers: {
projection: [...projectionLedger.entries()],
secondConsumer: [...secondConsumerLedger.entries()],
},
assertions,
};
}
const envelopeCode = [
'// Собственный учебный envelope. Это не CloudEvent.',
'const event = {',
" contractVersion: '2021-06',",
" id: 'evt-order-104-paid-02',",
" source: 'training://orders/order-104',",
" type: 'order.status.changed',",
" subject: 'order-104',",
" occurredAt: '2021-06-14T10:31:00.000Z',",
' schemaVersion: 2,',
' data: {',
" orderId: 'order-104',",
" status: 'paid',",
" paymentReference: 'training-pay-77',",
' },',
'};',
'',
'// id нужен для наблюдения и controlled replay.',
'// schemaVersion принадлежит payload contract, не transport.',
].join('\n');
const validationCode = [
'const checked = validateTrainingEnvelope(event);',
'',
'if (!checked.ok) {',
" return { state: 'manual-review', reason: checked.reason };",
'}',
'',
'// До consumer сохраняем доказательство: source, id, type, subject, schemaVersion.',
'return { state: "ready-for-declared-consumer", envelopeKey: checked.envelopeKey };',
].join('\n');
const compatibilityCode = [
'const v1ConsumerOnV2 = projectForConsumer(event, "orders-projection@1");',
'',
'// v1 читает только стабильные поля.',
'// paymentReference он намеренно не интерпретирует.',
'v1ConsumerOnV2.projection;',
"// { orderId: 'order-104', status: 'paid' }",
'',
'const v2ConsumerOnV1 = projectForConsumer(trainingEvents.orderStatusV1, "orders-projection@2");',
'// Новый consumer нормализует отсутствующее необязательное поле к null.',
].join('\n');
const deliveryCode = [
'const ledger = new Map();',
'const first = consumeTrainingEvent(ledger, event, "orders-projection@1", "initial-delivery");',
'const duplicate = consumeTrainingEvent(ledger, event, "orders-projection@1", "duplicate-delivery");',
'const replay = consumeTrainingEvent(ledger, event, "orders-projection@1", "controlled-replay");',
'',
'first.effectWritten; // true',
'duplicate.effectWritten; // false',
'replay.effectWritten; // false',
'ledger.size; // 1',
].join('\n');
const resultCode = [
'const result = consumeTrainingEvent(',
' new Map(),',
' trainingEvents.orderStatusV1,',
' "orders-projection@2",',
' "historical-replay",',
');',
'',
'result.resultVersion; // "2021-06.orders-projection.2"',
'result.inputSchemaVersion; // 1',
'result.projection.paymentReference; // null',
'',
'// Result version описывает consumer contract, не версию broker-а.',
].join('\n');
const unsupportedCode = [
'const decision = projectForConsumer(trainingEvents.orderStatusV3, "orders-projection@2");',
'',
'decision;',
'// {',
'// state: "contract-update-required",',
'// inputSchemaVersion: 3,',
'// effectAllowed: false,',
'// }',
'',
'// Не угадываем, что state: "settled" эквивалентен status: "paid".',
].join('\n');
const fixtureCode = [
'const fixture = runEventIntegrationFixture();',
'if (!Object.values(fixture.assertions).every(Boolean)) {',
" throw new Error('event contract changed without an explicit decision');",
'}',
'',
'console.log(fixture.deliveries.duplicateDelivery.state);',
"// 'duplicate-or-replay-suppressed'",
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2021-06-practice-event-driven',
title: 'Событийная интеграция: какой envelope зафиксировать до первого consumer',
categories: ['Архитектура', 'События', 'Практика'],
cover: '/assets/editorial/2021/event-topology-2021.svg',
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],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-06-mechanism-event-driven',
title: 'Эволюция схемы события: где проходит граница совместимого consumer',
categories: ['Архитектура', 'События', 'Данные'],
cover: '/assets/editorial/2021/event-schema-compatibility-2021.svg',
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.'),
dataTable(
'Направления совместимости: какую пару проверяем',
['Writer event', 'Reader contract', 'Учебный verdict', 'Причина'],
[
['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 или миграции. Контракт удобнее держать маленьким, чем потом спасать широкую схему исключениями.'),
codeBlock(compatibilityCode),
figure(
'/assets/editorial/2021/event-schema-compatibility-2021.svg',
'Вертикальная схема матрицы совместимости: 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 и что он записал.'),
codeBlock(resultCode),
dataTable(
'Что хранить рядом с результатом consumer',
['Факт', 'Зачем нужен', 'Недостаточный заменитель'],
[
['<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],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-06-field-event-driven',
title: 'Replay события: как не записать второй result и не скрыть новую схему',
categories: ['Архитектура', 'События', 'Отладка'],
cover: '/assets/editorial/2021/event-replay-diagnosis-2021.svg',
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.'),
heading('Replay сохраняет логическую идентичность'),
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 допустим'],
['delivery schema v3', 'новый event id, неизвестная schema', 'неважно', '<code>contract-update-required</code>', 'effect запрещён'],
],
),
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 имеет право его создавать.'),
codeBlock(resultCode),
figure(
'/assets/editorial/2021/event-replay-diagnosis-2021.svg',
'Вертикальное дерево диагностики 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 и передать решение владельцу операции.'),
],
[kafkaProducer27, cloudEvents202104, avro101],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
const fixture = runEventIntegrationFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('event integration fixture assertions failed');
}
process.stdout.write(JSON.stringify(fixture, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2021-06.mjs --print-revisions | --verify-fixture\n');
}
}