From 8cdf52e111ff521dd8668d174dce57c6cd03c71f Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 12:18:44 +0300 Subject: [PATCH] revise March 2021 queue articles --- editorial/production/README.md | 2 +- editorial/reviews/2021-03-draft.md | 162 +++++ web/data/editorial-revisions.mjs | 2 + .../2021/queue-delivery-guarantees-2021.svg | 77 +++ .../2021/queue-message-lifecycle-2021.svg | 84 +++ .../2021/queue-poison-diagnosis-2021.svg | 81 +++ web/scripts/upgrade-2021-03.mjs | 570 ++++++++++++++++++ 7 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2021-03-draft.md create mode 100644 web/public/assets/editorial/2021/queue-delivery-guarantees-2021.svg create mode 100644 web/public/assets/editorial/2021/queue-message-lifecycle-2021.svg create mode 100644 web/public/assets/editorial/2021/queue-poison-diagnosis-2021.svg create mode 100644 web/scripts/upgrade-2021-03.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index c26f8a8..a416704 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 112 из 358 созданных материалов. Остальные 246 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 115 из 358 созданных материалов. Остальные 243 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2021-03-draft.md b/editorial/reviews/2021-03-draft.md new file mode 100644 index 0000000..d50e655 --- /dev/null +++ b/editorial/reviews/2021-03-draft.md @@ -0,0 +1,162 @@ +# Автономное тройное ревью П37 · март 2021 · «Очереди задач» + +Статус: **принят независимым редактором в выпусковой набор**. Пакет содержит +ровно три revision для стабильных slug: + +- editorial-2021-03-practice-queues; +- editorial-2021-03-mechanism-queues; +- editorial-2021-03-field-queues. + +Созданы только пять разрешённых файлов П37: + +- web/scripts/upgrade-2021-03.mjs; +- web/public/assets/editorial/2021/queue-message-lifecycle-2021.svg; +- web/public/assets/editorial/2021/queue-delivery-guarantees-2021.svg; +- web/public/assets/editorial/2021/queue-poison-diagnosis-2021.svg; +- этот документ. + +Revision-модуль экспортирует только изменяемые редакционные поля. В нём нет +date, author или подключения registry. +articles.json, README, очередь, package config и Git не +изменялись. Команда --print-revisions печатает JSON из +import-safe export; --verify-fixture выполняет контролируемую +проверку только в памяти. + +## Проход 1. Факты и историческая рамка — пройдено + +| Утверждение | Первичный или официальный источник | Проверенная граница | +| --- | --- | --- | +| AMQP 0-9 описывает delivery, отдельное acknowledgement и флаг redelivered; в руководстве для implementer есть рекомендация учитывать повторные доставки и переносить непригодный вход в dead letter queue | [AMQP 0-9 specification](https://www.rabbitmq.com/resources/specs/amqp-xml-doc0-9.pdf) | Статья использует это только как словарь доставки и повтора. Учебная state machine не реализует AMQP, channel, ack API или настоящий dead-letter route. | +| RabbitMQ 3.8 был выпущен 11 ноября 2019 года; обзор этой линии упоминает poison-message feature и configurable delivery limit для quorum queues | [RabbitMQ 3.8 Release Overview](https://www.rabbitmq.com/blog/2019/11/11/rabbitmq-3-8-release-overview) | Линия существовала к марту 2021 года. В тексте нет переноса её топологии, названия policy или обещания сохранности terminal path на произвольный broker. | +| Версионная документация Kafka 2.7 разделяет delivery semantics, а API producer предупреждает, что retry может открыть duplicate | [Kafka 2.7 documentation](https://kafka.apache.org/27/documentation/#semantics), [KafkaProducer 2.7.0 API](https://kafka.apache.org/27/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html) | Термины используются для разграничения delivery и domain effect. Модуль не запускает Kafka и не заявляет exactly-once или реальный delivery guarantee. | + +Весь пример создан в runQueueFixture(). Он берёт один +неизменяемый учебный message id msg-order-417 и показывает две +явно взаимоисключающие ветви, а не одну невозможную историю: + +- **recovery**: временное условие → controlled retry через 1000 мс → один + записанный effect → duplicate после неизвестного статуса receipt → + suppress duplicate и решение по этой delivery; +- **poison**: отдельный учебный исход того же logical id → временно + недоступная учебная проверка schema → ограниченная попытка → terminal reason + training-schema-not-supported → + manual-review с requiredCheck; +- **ledger**: recovery ledger хранит один effectKey, poison + ledger остаётся пустым. Поэтому terminal ветвь не выдаётся за уже + совершённый эффект. + +Это сознательно узкая модель. Она не моделирует transport, persistence, +consumer group, broker acknowledgement, реальную DLQ, внешнее API, базу, +транзакцию, сетевой сбой или права оператора. Ровно поэтому статья не называет +свою Map настройкой RabbitMQ/Kafka/SQS и не делает claim о delivery guarantee +за пределами инвариантов fixture. + +Вердикт прохода: **пройден**. Исторические ссылки привязаны к источникам, +которые существовали к марту 2021 года; современный язык платформы не +подменяет границы учебного договора. + +## Проход 2. Редактура, глубина и голос М4 — пройдено + +| Revision | Симптом и цена в первых двух абзацах | Главный вопрос | Объём основного текста | +| --- | --- | --- | --- | +| Практика | Дубликат, нарушенный порядок и неподходящая схема появляются после первого consumer; цена — неизвестный сделанный effect и потерянный ручной контекст | Как записать контракт задачи до выбора consumer | **10 248** знака body | +| Механизм | Effect уже записан, но та же логическая задача приходит снова; цена — второй effect и неверный поиск «ошибки очереди» | Где проходит граница delivery, ledger и порядка | **11 280** знаков body | +| Полевой разбор | Один consumer бесконечно повторяет одинаковую ошибку; цена — заблокированная работа, шум и ручное удаление без контекста | Когда остановить retry и как передать решение человеку | **10 901** знака body | + +- Каждый текст сохраняет последовательность «симптом → причина → проверка → + действие». Вводные не делают общих заявлений о важности очередей. +- В каждом revision есть не менее пяти смысловых разделов, таблица с + caption/thead, figure с содержательным + alt/figcaption, два и более технических примера, + нумерованный маршрут и четыре официальные ссылки. +- М4 марта 2021 года проявляется в явных инвариантах и границах владельца: + effectKey принадлежит эффекту, sequenceKey — + доменному порядку, retry policy — договору обработки, manual record — + владельцу следующего решения. Тон короткий и технический, без риторики + зрелой event-platform. +- Текст не обещает exactly-once. Понятия delivery, duplicate, effect и order + разделены, а Map названа учебной моделью, а не транзакционным решением. +- Отдельно вычитаны два опасных смешения: duplicate не отождествляется с + причиной повторной доставки, а terminal route не отождествляется с удалением + сообщения или завершённым ручным расследованием. + +Вердикт прохода: **пройден**. Длина находится в диапазоне 5 000–15 000 +знаков; новые навыки автора следуют из предыдущих материалов о delivery, +ретраях, логах и диагностике, но не приписывают ему несуществующий опыт +эксплуатации большой платформы. + +## Проход 3. Визуал, fixture и preflight — пройдено в пределах пакета + +- queue-message-lifecycle-2021.svg показывает полный учебный + маршрут от конверта до ledger и manual route. После первого мобильного + рендера сокращены длинные подписи delivery decision и retry, чтобы текст не + обрезался на ширине 375 px. +- queue-delivery-guarantees-2021.svg отделяет запуск consumer, + effect ledger, suppress duplicate, решение delivery и terminal path. +- queue-poison-diagnosis-2021.svg показывает дерево + classification: известная временная причина идёт в ограниченный retry, + terminal/unknown — в manual review; длинная подпись operator decision также + сокращена после 375 px проверки. +- Во всех трёх SVG есть title, desc и + role="img"; вертикальный viewBox, контрастные карточки и + исходный текст не мельче 21 px. Статическая проверка не нашла + script, foreignObject, внешние URL или raster + data URI. +- Sharp отрендерил финальные SVG в PNG шириной 375 px. Результаты просмотрены + вручную: заголовки, стрелки, карточки и нижние подписи читаются; clipping, + overlap и горизонтальный overflow внутри схем не обнаружены. Это проверка + статичного рендера, не browser-run и не проверка screen reader. + +### Фактически выполненные проверки + +Финальное состояние пакета проверено 31 июля 2026 года: + +
cd web && node --check scripts/upgrade-2021-03.mjs
+cd web && npm run audit:draft -- scripts/upgrade-2021-03.mjs
+cd web && node scripts/upgrade-2021-03.mjs --verify-fixture
+cd web && xmllint --noout \
+  public/assets/editorial/2021/queue-message-lifecycle-2021.svg \
+  public/assets/editorial/2021/queue-delivery-guarantees-2021.svg \
+  public/assets/editorial/2021/queue-poison-diagnosis-2021.svg
+ +| Проверка | Реальный результат | +| --- | --- | +| node --check | PASS, code 0 | +| Import-safe export и draft gate | PASS: **10 248 / 11 280 / 10 901** знака body; для трёх slug найдены sections, table, figure, code, route, sources и локальные assets | +| In-memory fixture | PASS: десять assertions истинны — один logical id, controlled retry, одна запись effect ledger, suppress duplicate, terminal manual path и явное разделение ветвей | +| xmllint --noout | PASS, все три SVG — корректный XML | +| SVG safety scan | PASS: не найдены script, foreignObject, внешние asset URL или raster data URI | +| Sharp mobile preflight | PASS: три финальных PNG шириной 375 px просмотрены вручную; нет clipping, overlap или horizontal overflow внутри схем | +| Scope/self-review | PASS: созданы только пять разрешённых файлов; registry, articles.json, README, очередь, package config и Git не менялись | + +npm run audit:draft завершилась с code 0. npm вывел старые +предупреждения о пользовательских store-dir, cache-dir +и public-hoist-pattern; эти конфигурации не относятся к П37 и не +изменялись пакетом. + +Не запускались: strict audit после подключения к registry, production build, +browser, screen reader, реальный broker, SDK, HTTP, база, внешнее API, CI, +deployment и публикация. Эти операции намеренно оставлены интегратору, потому +что автономная партия не подключает registry и не меняет Git. + +## Независимая интеграционная приёмка + +Основной редактор 31 июля 2026 года подключил три revision к +web/data/editorial-revisions.mjs, не меняя базовый +articles.json, даты или автора архивных записей. В registry стало +106 revision. AMQP 0-9, RabbitMQ 3.8 и Kafka 2.7 source material сверены +независимо: они поддерживают границы redelivery, poison/dead-letter route и +возможности duplicate, но не превращают Map из статьи в реальный broker или +end-to-end guarantee. + +| Проверка после интеграции | Реальный результат | +| --- | --- | +| Строгий audit трёх slug | PASS: 10 248 / 11 280 / 10 901 знака; у каждой статьи есть figure, table и code examples | +| Production build | PASS: Next.js собрал 374 статические страницы | +| Независимый mobile visual review | PASS: основной редактор повторно просмотрел три SVG после Sharp-рендера в 375 px; clipping, overlap и overflow не обнаружены | + +Ни этот отчёт, ни интеграция не утверждают запуск broker, SDK, HTTP, базы, +external API, browser или assistive technology. + +Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной +публикационной операцией; Git остаётся источником её фактической записи. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index cf1cd94..22fc254 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -33,6 +33,7 @@ import { revisions as november2020Revisions } from '../scripts/upgrade-2020-11.m import { revisions as december2020Revisions } from '../scripts/upgrade-2020-12.mjs'; import { revisions as january2021Revisions } from '../scripts/upgrade-2021-01.mjs'; import { revisions as february2021Revisions } from '../scripts/upgrade-2021-02.mjs'; +import { revisions as march2021Revisions } from '../scripts/upgrade-2021-03.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -71,4 +72,5 @@ export const editorialRevisions = [ ...december2020Revisions, ...january2021Revisions, ...february2021Revisions, + ...march2021Revisions, ]; diff --git a/web/public/assets/editorial/2021/queue-delivery-guarantees-2021.svg b/web/public/assets/editorial/2021/queue-delivery-guarantees-2021.svg new file mode 100644 index 0000000..26def12 --- /dev/null +++ b/web/public/assets/editorial/2021/queue-delivery-guarantees-2021.svg @@ -0,0 +1,77 @@ + + Границы delivery, effect ledger и manual route + Вертикальная схема показывает, что delivery относится к запуску consumer, effect ledger относится к доменному результату, а manual route останавливает автоматический путь; duplicate не создаёт второй эффект. + + + + + + + + Границы delivery-контракта + одно слово — одна ответственность + + + A · DELIVERY + Consumer получил message id + это запуск обработки, не доменный эффект + возможно повторное появление того же id + + + + B · EFFECT KEY + Ledger ищет effectKey + нет ключа → записать один эффект + есть ключ → принять duplicate без нового эффекта + + + + новый эффект + duplicate + + + C · RESULT + Effect записан + одна доменная запись + ключ хранит связь с intent + + + C · SAME KEY + Suppress + без нового + effect + + + + + D · DECISION + Решить текущую delivery + ack только после ответа ledger + это не claim о протоколе или transport + + + + E · TERMINAL PATH + Unknown / terminal → manual review + reason · attempts · required check + + + + ГРАНИЦА УВЕРЕННОСТИ + Fixture проверяет договор, не брокер + сеть, storage и внешний эффект проверяются отдельно + diff --git a/web/public/assets/editorial/2021/queue-message-lifecycle-2021.svg b/web/public/assets/editorial/2021/queue-message-lifecycle-2021.svg new file mode 100644 index 0000000..7f33832 --- /dev/null +++ b/web/public/assets/editorial/2021/queue-message-lifecycle-2021.svg @@ -0,0 +1,84 @@ + + Жизненный цикл учебного сообщения очереди + Вертикальная схема: producer создаёт конверт, consumer валидирует вход, временная причина получает ограниченный retry, effect ledger подавляет duplicate, а терминальная причина идёт в ручной маршрут. + + + + + + + + Жизненный цикл задачи + учебный контракт, не схема конкретного broker + + + 1 · СОЗДАТЬ + Конверт сообщения + id · effectKey · sequenceKey · schema + + + + 2 · ПРОВЕРИТЬ + Consumer читает контракт + вход понятен и допустим? + + + + 3 · КЛАССИФИЦИРОВАТЬ + Выбрать один исход + ready · temporary · terminal / unknown + + + + ready + terminal + + + 4 · EFFECT LEDGER + Проверить effectKey + новый → записать эффект + duplicate → не повторять + + + 4 · STOP + Manual + reason + attempts + + + + + + 5 · DELIVERY DECISION + Подтвердить запуск + после проверки ledger + не второй effect + + + 5 · OPERATOR + Проверить + исправить вход, + отменить, replay + + + + + + ОГРАНИЧЕНИЕ + Retry: известная временная причина + лимит и backoff заранее заданы + иначе — manual route + diff --git a/web/public/assets/editorial/2021/queue-poison-diagnosis-2021.svg b/web/public/assets/editorial/2021/queue-poison-diagnosis-2021.svg new file mode 100644 index 0000000..db5ecc4 --- /dev/null +++ b/web/public/assets/editorial/2021/queue-poison-diagnosis-2021.svg @@ -0,0 +1,81 @@ + + Диагностика poison message и ручной маршрут + Вертикальное дерево решений: сначала проверяется effect ledger, затем классифицируется причина; только известная временная причина получает ограниченный retry, а terminal и unknown случаи сохраняются для manual review. + + + + + + + + Poison message: путь решения + остановить цикл, сохранить контекст + + + 1 · FAILURE + Consumer не завершил задачу + сохранить id, effectKey, attempt, reason + + + + 2 · EFFECT LEDGER + Эффект уже записан? + да → не повторять effect; решить delivery + нет → перейти к классификации причины + + + + duplicate + новый effect + + + 3 · ACK + Suppress duplicate + нового эффекта нет + + + 3 · CLASSIFY + Причина? + known / terminal + + + + 4 · TEMPORARY + Controlled retry + limit + backoff + + + + 5 · EXHAUSTED + Manual review + no more retry + + + terminal or unknown: + не угадывать retry + + + + 6 · OPERATOR DECISION + Исправить вход / отменить / replay + проверить effectKey перед повтором + record хранит reason, attempts и effectKey + + + Диагностика, не конфигурация DLQ. + diff --git a/web/scripts/upgrade-2021-03.mjs b/web/scripts/upgrade-2021-03.mjs new file mode 100644 index 0000000..d05f677 --- /dev/null +++ b/web/scripts/upgrade-2021-03.mjs @@ -0,0 +1,570 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sourceList(items) { + return ''; +} + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +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 amqp091 = { + title: 'AMQP 0-9: спецификация basic.deliver, basic.ack и redelivered', + url: 'https://www.rabbitmq.com/resources/specs/amqp-xml-doc0-9.pdf', + note: 'первичная спецификация 2008 года: у delivery есть признак повторной доставки, а подтверждение относится к доставленному сообщению; учебная модель ниже не реализует протокол', +}; + +const rabbitMq38 = { + title: 'RabbitMQ 3.8 Release Overview — 11 ноября 2019 года', + url: 'https://www.rabbitmq.com/blog/2019/11/11/rabbitmq-3-8-release-overview', + note: 'версия и обзор были доступны к марту 2021 года; источник упоминает delivery limit для poison message, но не является описанием данного учебного маршрута', +}; + +const kafka27 = { + title: 'Apache Kafka 2.7.X: versioned documentation', + url: 'https://kafka.apache.org/27/documentation/#semantics', + note: 'версионная документация линии 2.7, доступной в марте 2021 года; терминология доставки приводится только для разграничения обязательств, не как claim об этой fixture', +}; + +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: 'официальный API линии 2.7 предупреждает, что retries могут открыть путь к duplicate; это не настройка и не запуск Kafka в статье', +}; + +export const queueTrainingMessage = Object.freeze({ + id: 'msg-order-417', + kind: 'invoice.reminder', + effectKey: 'invoice-417:reminder', + sequenceKey: 'invoice-417', + payload: Object.freeze({ invoiceId: '417', schema: '2021-03' }), +}); + +const retryPolicy = Object.freeze({ + maxAttempts: 2, + delaysMs: Object.freeze([1000]), +}); + +function assertTrainingMessage(message) { + if (!message || typeof message.id !== 'string' || typeof message.effectKey !== 'string') { + throw new Error('training message must have id and effectKey'); + } + if (typeof message.sequenceKey !== 'string' || !message.payload || typeof message.payload.schema !== 'string') { + throw new Error('training message must have sequenceKey and payload schema'); + } +} + +function recordEffectOnce(ledger, message, deliveredAt) { + if (ledger.has(message.effectKey)) { + return { + state: 'duplicate-effect-suppressed', + effectKey: message.effectKey, + deliveredAt, + effectWritten: false, + }; + } + + ledger.set(message.effectKey, { messageId: message.id, deliveredAt }); + return { + state: 'effect-recorded', + effectKey: message.effectKey, + deliveredAt, + effectWritten: true, + }; +} + +function controlledBackoff(attempt) { + if (!Number.isInteger(attempt) || attempt < 1 || attempt > retryPolicy.delaysMs.length) { + throw new Error('controlled retry attempt is outside the training policy'); + } + return retryPolicy.delaysMs[attempt - 1]; +} + +/** + * Две взаимоисключающие учебные ветви для одного неизменяемого message id. + * Это state machine в памяти, а не клиент, сервер, transport или обещание + * конкретного broker. Recovery показывает duplicate после неизвестного статуса + * подтверждения; poison показывает ручной маршрут для другого исхода обработки. + */ +export function runQueueFixture() { + const message = queueTrainingMessage; + assertTrainingMessage(message); + + const recoveryLedger = new Map(); + const firstDelay = controlledBackoff(1); + const recoveryFirst = { + delivery: 1, + messageId: message.id, + atMs: 0, + outcome: 'controlled-retry', + reason: 'training-temporary-dependency', + retryAfterMs: firstDelay, + effectLedgerSize: recoveryLedger.size, + }; + const recoveryWrite = recordEffectOnce(recoveryLedger, message, firstDelay); + const recoverySecond = { + delivery: 2, + messageId: message.id, + atMs: firstDelay, + outcome: 'effect-written-receipt-unknown', + reason: 'training-interruption-after-effect', + effect: recoveryWrite, + }; + const recoveryDuplicate = recordEffectOnce(recoveryLedger, message, firstDelay); + const recoveryThird = { + delivery: 3, + messageId: message.id, + atMs: firstDelay, + duplicate: true, + outcome: 'ack-after-ledger-check', + effect: recoveryDuplicate, + }; + + const poisonLedger = new Map(); + const poisonFirstDelay = controlledBackoff(1); + const poisonFirst = { + delivery: 1, + messageId: message.id, + atMs: 0, + outcome: 'controlled-retry', + reason: 'training-schema-lookup-not-ready', + retryAfterMs: poisonFirstDelay, + }; + const manualRecord = { + route: 'manual-review', + messageId: message.id, + effectKey: message.effectKey, + terminalReason: 'training-schema-not-supported', + attempts: 2, + requiredCheck: 'operator chooses corrected input, cancellation, or explicit replay', + }; + const poisonSecond = { + delivery: 2, + messageId: message.id, + atMs: poisonFirstDelay, + outcome: 'dead-letter-manual-route', + manualRecord, + }; + + const assertions = { + oneLogicalMessageUsed: recoveryFirst.messageId === message.id + && recoveryThird.messageId === message.id + && poisonSecond.messageId === message.id, + retryDelayIsControlled: recoveryFirst.retryAfterMs === 1000 && poisonFirst.retryAfterMs === 1000, + retryHasNoEffect: recoveryFirst.effectLedgerSize === 0 && recoveryFirst.outcome === 'controlled-retry', + effectLedgerWritesOnce: recoveryWrite.effectWritten && recoveryLedger.size === 1, + duplicateDoesNotWriteSecondEffect: recoveryDuplicate.state === 'duplicate-effect-suppressed' + && !recoveryDuplicate.effectWritten && recoveryLedger.size === 1, + duplicateReceivesDeliveryDecision: recoveryThird.outcome === 'ack-after-ledger-check', + poisonLeavesNoEffect: poisonLedger.size === 0, + poisonHasTerminalManualRoute: poisonSecond.outcome === 'dead-letter-manual-route' + && manualRecord.route === 'manual-review' && manualRecord.attempts === retryPolicy.maxAttempts, + manualRecordKeepsDecisionBoundary: manualRecord.requiredCheck.includes('operator') + && manualRecord.terminalReason === 'training-schema-not-supported', + branchesAreExplicitlySeparate: recoverySecond.outcome === 'effect-written-receipt-unknown' + && poisonFirst.reason === 'training-schema-lookup-not-ready', + }; + + if (!Object.values(assertions).every(Boolean)) { + throw new Error('queue training fixture violated a documented invariant'); + } + + return { + model: 'controlled in-memory message state machine; not a broker implementation', + retryPolicy, + message, + recovery: [recoveryFirst, recoverySecond, recoveryThird], + poison: [poisonFirst, poisonSecond], + ledgers: { + recovery: [...recoveryLedger.entries()], + poison: [...poisonLedger.entries()], + }, + assertions, + }; +} + +const envelopeCode = [ + '// Минимальный контракт сообщения. Значения учебные.', + 'const message = {', + " id: 'msg-order-417',", + " kind: 'invoice.reminder',", + " effectKey: 'invoice-417:reminder',", + " sequenceKey: 'invoice-417',", + " payload: { invoiceId: '417', schema: '2021-03' },", + '};', + '', + '// id отвечает за наблюдение, effectKey — за внешний эффект.', + '// sequenceKey нужен только там, где порядок действительно является инвариантом.', +].join('\n'); + +const contractCode = [ + 'function validateTrainingMessage(message) {', + " if (!message.id) throw new Error('message id is required');", + " if (!message.effectKey) throw new Error('effect key is required');", + " if (!message.payload || message.payload.schema !== '2021-03') {", + " return { state: 'manual-review', reason: 'schema-not-supported' };", + ' }', + " return { state: 'ready', sequenceKey: message.sequenceKey };", + '}', +].join('\n'); + +const retryCode = [ + 'const retryPolicy = { maxAttempts: 2, delaysMs: [1000] };', + '', + 'function nextTrainingDecision(attempt, failureKind) {', + " if (failureKind === 'temporary' && attempt < retryPolicy.maxAttempts) {", + " return { state: 'retry', afterMs: retryPolicy.delaysMs[attempt - 1] };", + ' }', + " return { state: 'manual-review', reason: failureKind };", + '}', + '', + "nextTrainingDecision(1, 'temporary');", + "// { state: 'retry', afterMs: 1000 }", +].join('\n'); + +const ledgerCode = [ + 'function recordEffectOnce(ledger, message) {', + ' if (ledger.has(message.effectKey)) {', + " return { state: 'duplicate-effect-suppressed' };", + ' }', + ' ledger.set(message.effectKey, { messageId: message.id });', + " return { state: 'effect-recorded' };", + '}', + '', + '// Подтверждение доставки принимается только после решения по ledger.', +].join('\n'); + +const sequenceCode = [ + 'const lane = new Map();', + '', + 'function acceptInSequence(message) {', + ' const previous = lane.get(message.sequenceKey) || 0;', + ' if (message.sequence !== previous + 1) {', + " return { state: 'manual-review', reason: 'sequence-gap' };", + ' }', + ' lane.set(message.sequenceKey, message.sequence);', + " return { state: 'ready' };", + '}', + '', + '// Это локальный контракт одного ключа, не обещание глобального порядка.', +].join('\n'); + +const manualRecordCode = [ + 'const manualRecord = {', + " route: 'manual-review',", + " messageId: 'msg-order-417',", + " effectKey: 'invoice-417:reminder',", + " terminalReason: 'schema-not-supported',", + ' attempts: 2,', + " requiredCheck: 'correct input, cancel, or explicit replay',", + '};', + '', + '// Запись не запускает replay сама и не удаляет исходный контекст.', +].join('\n'); + +const operatorDecisionCode = [ + 'function decideManualRecord(record, action) {', + " if (action === 'cancel') return { state: 'closed', effectWritten: false };", + " if (action === 'replay-after-fix') {", + " return { state: 'ready-for-new-attempt', preservesEffectKey: true };", + ' }', + " return { state: 'waiting-for-evidence' };", + '}', + '', + "decideManualRecord(manualRecord, 'replay-after-fix');", +].join('\n'); + +const fixtureCode = [ + 'const fixture = runQueueFixture();', + 'if (!Object.values(fixture.assertions).every(Boolean)) {', + " throw new Error('training queue contract failed');", + '}', + '', + 'console.log(fixture.recovery.map((item) => item.outcome));', + "// ['controlled-retry', 'effect-written-receipt-unknown', 'ack-after-ledger-check']", +].join('\n'); + +const practiceArticle = createRevision( + { + slug: 'editorial-2021-03-practice-queues', + title: 'Очереди задач: какой контракт написать до первого consumer', + categories: ['Надёжность', 'Backend', 'Практика'], + cover: '/assets/editorial/2021/queue-message-lifecycle-2021.svg', + excerpt: 'Очередь не задаёт сама порядок, защиту от дубликата и судьбу ошибочного сообщения. Разбираем маленький контракт: идентификатор, ключ эффекта, повтор, терминальный маршрут и ручная проверка.', + readingMinutes: 15, + }, + [ + paragraph('Симптом появляется после первого успешного consumer: напоминание ушло дважды, соседнее изменение пришло раньше предыдущего, а запись с неподходящей схемой снова возвращается в обработку. Цена не в самом факте очереди. Цена в том, что команда не может сказать, какой эффект уже сделан, кому принадлежит порядок и где остановить сообщение, которое нельзя обработать. Тогда повтор становится случайной попыткой, а ручной разбор начинается без исходного контекста.'), + paragraph('В марте 2021 года я бы не начинал с обещания, что один broker решит доставку. Сначала нужен контракт на одну логическую задачу. Он определяет, что наблюдаем как сообщение, что считаем внешним эффектом, когда допускаем повтор и какой исход переводит задачу в ручной маршрут. Ниже все значения учебные и живут в памяти Node. Это не RabbitMQ, Kafka, SQS, реальный consumer или замер нагрузки. Модель нужна, чтобы проверить границы решения до подключения инфраструктуры.'), + heading('Очередь отделяет выполнение, но не отменяет договор'), + paragraph('Очередь полезна, когда создатель задачи не должен ждать работу обработчика. Но она добавляет границу между тем, кто сформировал вход, и тем, кто создаёт эффект. По эту сторону границы есть запись о намерении. По другую — отправленное письмо, изменённый статус, созданный файл или вызов соседнего API. Если эти два состояния не связаны явным ключом, повторная доставка легко превращается в повторный эффект. Поэтому первый вопрос не «какой consumer написать», а «какой результат он имеет право создавать и как мы увидим, что результат уже был». '), + paragraph('Порядок тоже нельзя приписывать очереди одним словом. Для части задач порядок не важен: две независимые очистки можно выполнить в любом порядке. Для другой части есть один объект, например счёт или заказ, и изменение с номером 12 нельзя применять до номера 11. Это локальный инвариант одного ключа, а не глобальная очередь для всей системы. У такого инварианта должен быть владелец: функция, которая хранит последнюю принятую последовательность, или явный маршрут на ручную проверку при пропуске.'), + dataTable( + 'Минимальный контракт задачи: поле должно отвечать на конкретный вопрос', + ['Поле или правило', 'Владелец', 'Инвариант', 'Проверка перед действием'], + [ + ['id', 'создатель задачи', 'одна логическая задача имеет один наблюдаемый id', 'id не пустой и сохраняется во всех учебных ветвях'], + ['effectKey', 'обработчик и ledger', 'один ключ даёт не более одного записанного эффекта', 'сначала lookup в ledger, затем запись или suppress duplicate'], + ['sequenceKey', 'доменный владелец порядка', 'порядок проверяется только внутри одного ключа', 'следующий номер следует за предыдущим или задача уходит в manual route'], + ['retry policy', 'контракт обработки', 'повтор ограничен числом попыток и задержкой', 'временный класс ошибки соответствует известному условию'], + ['manual route', 'оператор и владелец домена', 'терминальный случай не исчезает и не зацикливается', 'есть причина, число попыток и допустимые следующие действия'], + ], + ), + paragraph('Эта таблица не требует отдельной платформы. Её можно положить рядом с функцией обработчика и с тестом контракта. Главное — не смешивать роли. id позволяет найти историю. effectKey защищает конкретный эффект. sequenceKey ограничивает порядок. Попытка использовать один случайный идентификатор для всех трёх задач обычно скрывает важные вопросы: что делать при новой версии входа, можно ли повторить операцию после исправления и относится ли порядок к одному объекту или к очереди целиком.'), + heading('Конверт сообщения описывает границу до payload'), + paragraph('Payload отвечает за данные доменной операции. Конверт отвечает за то, как эту операцию вести через границу обработки. В учебном конверте есть id, kind, effectKey, sequenceKey и минимальная версия схемы. Этого мало для любого приложения, но достаточно, чтобы прочитать запись о повторе: это тот же логический запрос или новый; он должен создавать тот же эффект или другой; порядок нужен именно для этого счёта или его здесь вообще нет.'), + codeBlock(envelopeCode), + paragraph('Схема не должна быть декоративной строкой. Если consumer не знает, как трактовать payload, он не должен угадывать поле и продолжать работу с частично понятным объектом. В нашем учебном примере неизвестная версия даёт manual-review. Такой исход не говорит, что запись испорчена навсегда. Он говорит ровно одно: текущий обработчик не имеет безопасного правила для этого входа. Исправление может оказаться в producer, в миграции схемы или в новом consumer, но это отдельное решение с новой проверкой.'), + codeBlock(contractCode), + figure( + '/assets/editorial/2021/queue-message-lifecycle-2021.svg', + 'Вертикальная схема жизненного цикла учебного сообщения: producer создаёт конверт, consumer валидирует его, временная ошибка идёт в ограниченный retry, effect ledger подавляет duplicate, а терминальная ошибка сохраняет запись для manual review', + 'Жизненный цикл учебной задачи: retry — один из исходов, а не бесконечная ветка по умолчанию.', + ), + heading('Повтор исправляет только временное условие'), + paragraph('Повтор полезен, когда причина уже названа временной и есть проверяемый предел. Например, в учебной модели зависимость не ответила на первый контролируемый вызов, поэтому задача ждёт 1000 мс и возвращается на вторую попытку. Эта задержка не доказывает, что в реальной системе зависимость восстановится, и не является рекомендацией для любой нагрузки. Она делает другое: ограничивает состояние модели. После заданного числа попыток вопрос меняется с «когда повторить» на «какой человек или код должен принять решение дальше». '), + paragraph('Неподходящая схема, нарушение последовательности или отсутствие обязательного ключа не становятся временными только потому, что их неудобно разбирать. Если классификатор не уверен, безопаснее завершить автоматический маршрут и сохранить контекст. Иначе очередь превращается в тихий цикл: одно сообщение занимает worker, создаёт одинаковые логи и мешает увидеть новые задачи. Число попыток — проектное решение; его нельзя брать из чужой статьи без связи с типом сбоя, временем ожидания и ценой ручной проверки.'), + codeBlock(retryCode), + paragraph('В коде nextTrainingDecision принимает классификацию извне. Это намеренно. Функция не может по строке исключения честно узнать, временная ли причина в любой системе. Отдельная граница отвечает за классификацию и обязана вернуть понятный вид причины. Когда этого правила нет, лучше записать неуверенный случай в ручной маршрут, чем назвать каждую ошибку temporary и ждать, пока ограничение на попытки само скроет проблему.'), + heading('Ledger принадлежит эффекту, а не слову retry'), + paragraph('Есть неприятный промежуток: эффект уже записан, но consumer не знает, дошло ли его подтверждение. Следующая доставка может быть тем же логическим сообщением. Если код сначала отправит письмо или изменит запись, а затем попробует понять, был ли такой эффект раньше, duplicate уже случится. Поэтому проверка ключа эффекта должна идти перед созданием эффекта, а результат этой проверки должен позволять завершить повтор без второго действия.'), + codeBlock(ledgerCode), + paragraph('В реальном проекте ledger может быть строкой в той же транзакции, уникальным индексом или другим доменным механизмом. Этот выбор зависит от базы, внешнего API и границы транзакции. Учебная Map ничего такого не реализует. Она показывает единственный инвариант: второй вызов с тем же effectKey не добавляет новую запись. Нельзя называть это гарантией доставки. Это защита конкретного доменного эффекта при условии, что реальная запись ledger и сам эффект имеют согласованную границу.'), + heading('Маршрут до первого запуска'), + orderedList([ + 'Назвать один тип задачи и один внешний эффект. Не объединять отправку письма, обновление баланса и перестройку индекса в один общий контракт.', + 'Добавить в конверт стабильный id, отдельный effectKey и sequenceKey только при реальной необходимости порядка.', + 'Сформулировать допустимые исходы consumer: успешно записан эффект, duplicate подавлен, controlled retry, manual review. Для каждого указать владельца.', + 'Записать retry policy с числом попыток, задержкой и одним классом временной причины. Все неясные случаи оставить вне автоматического повтора.', + 'Выбрать место ledger рядом с реальным эффектом и проверить, что duplicate не может создать вторую запись или второй вызов.', + 'Сохранить terminal record с id, effectKey, причиной, попытками и требуемым ручным решением. Не заменять её строкой в логе без владельца.', + 'Запустить контролируемую fixture, затем отдельно проверить выбранный broker, хранилище и внешний API в среде проекта.', + ]), + heading('Историческая рамка и границы примера'), + paragraph('К марту 2021 года AMQP 0-9 уже содержал отдельный признак повторной доставки и подтверждение доставки. В обзоре RabbitMQ 3.8, выпущенном в ноябре 2019 года, уже описывался delivery limit для poison message. Документация Kafka 2.7 тоже разводит сценарии доставки и последствия retry. Эти источники помогают выбрать вопросы, но не дают один переносимый конфиг. У каждого продукта свои версии, подтверждения, топология и граница между обработкой и внешним эффектом.'), + paragraph('Фикстура этой статьи не открывает сеть, не публикует сообщение, не запускает broker и не создаёт реальный delivery guarantee. В ней один неизменяемый message id показан в двух взаимоисключающих учебных ветвях: recovery объясняет duplicate после неизвестного статуса подтверждения, poison объясняет terminal manual route. Это не одна фактическая история доставки. Следующий проверяемый шаг — взять свой тип задачи, написать такой же контракт и прогнать его на тестовой интеграции с версиями конкретного стека.'), + ], + [amqp091, rabbitMq38, kafka27, kafkaProducer27], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2021-03-mechanism-queues', + title: 'Очереди задач: как duplicate появляется после уже записанного эффекта', + categories: ['Надёжность', 'Архитектура', 'Backend'], + cover: '/assets/editorial/2021/queue-delivery-guarantees-2021.svg', + excerpt: 'Разбираем границы delivery: подтверждение относится к доставке, а идемпотентность — к доменному эффекту. Отдельно фиксируем порядок, retry и ручной исход без обещаний exactly-once.', + readingMinutes: 16, + }, + [ + paragraph('Симптом выглядит как спор с логами: обработчик записал эффект, но затем то же сообщение пришло снова. Если второй запуск создаёт ещё одно письмо, списание или запись, команда начинает искать ошибку в очереди. Цена такой реакции выше дубля. Можно настроить повтор иначе и всё равно оставить ту же дыру между эффектом и подтверждением. Пока не названа граница, где эффект считается записанным, любой термин о delivery скрывает главный вопрос: что делать с повторной работой над тем же намерением.'), + paragraph('Для марта 2021 года полезно говорить скромнее. Подтверждение доставки — это решение по конкретной доставке сообщения. Идемпотентность — свойство операции с определённым ключом эффекта. Порядок — инвариант доменного ключа. Эти вещи могут взаимодействовать, но не становятся одним свойством после выбора broker. Ниже — детерминированная state machine в памяти. Она не соединяется с AMQP или Kafka и не заявляет, что даёт exactly-once. Её задача — сделать видимыми точки, где появляется duplicate и где он должен быть остановлен.'), + heading('Сначала называем, о какой гарантии идёт речь'), + paragraph('Слова at-most-once, at-least-once и exactly-once часто попадают в решение раньше контракта. Для локального дизайна полезнее разложить их на наблюдаемые обязательства. Мы можем договориться, что consumer допускает повтор одной логической задачи; что эффект с одинаковым effectKey записывается один раз; что порядок проверяется только по sequenceKey; что неизвестная ошибка не повторяется бесконечно. Ни одно из этих предложений не превращает абстрактную модель в характеристику сети, диска или выбранного продукта.'), + dataTable( + 'Словарь договора: каждое слово привязано к своей границе', + ['Термин в обсуждении', 'Что фиксируем в учебном контракте', 'Что остаётся за границей', 'Проверяемый признак'], + [ + ['delivery', 'один запуск consumer над message id', 'дошло ли сообщение по сети и как хранит его broker', 'в fixture видны delivery 1, 2 и 3'], + ['duplicate', 'повторный запуск того же id после неопределённого результата', 'почему именно появился повтор в конкретном transport', 'ledger возвращает duplicate-effect-suppressed'], + ['effect', 'доменная запись, привязанная к effectKey', 'атомарность между базой и внешней системой', 'в ledger остаётся одна строка ключа'], + ['order', 'проверка последовательности для одного sequenceKey', 'общий порядок всех задач', 'gap ведёт к manual review, а не к догадке'], + ['terminal route', 'автоматический маршрут остановлен с контекстом', 'решение оператора и последующая интеграция', 'есть reason, attempts и requiredCheck'], + ], + ), + paragraph('Такой словарь снимает ложный выбор между «настроить гарантию» и «ничего не делать». У команды появляется ряд маленьких вопросов. Когда можно подтвердить обработку? Где лежит ключ эффекта? Что происходит, если внешний вызов завершился, а запись о нём нет? Для какой сущности порядок обязателен? Какой случай не имеет права автоматически возвращаться в работу? Ответы могут оказаться разными даже внутри одного сервиса. Это нормально: граница договора определяется риском операции, а не названием очереди.'), + heading('Эффект и подтверждение нельзя менять местами'), + paragraph('Представим учебный второй запуск. Первая попытка получила временное условие и была отложена на 1000 мс. Вторая дошла до записи эффекта и сразу после этого потеряла знание о результате подтверждения. Третья доставка выглядит как duplicate. Если обработчик не смотрит в ledger, он повторит эффект. Если он смотрит в ledger до эффекта, он видит существующий ключ и может завершить текущую доставку без нового доменного действия. Эта последовательность не доказывает, что повтор обязательно случится; она показывает, почему код обязан быть готов к нему.'), + codeBlock(ledgerCode), + paragraph('Важна последовательность, а не название функции. Сначала проверить effectKey. Если ключ найден, не создавать второй эффект. Если ключа нет, попытаться записать эффект и ключ в одной подходящей для проекта границе. После этого принять решение о подтверждении текущей доставки. Чем дальше друг от друга эти действия, тем больше сценариев неопределённости. Внешний HTTP-вызов особенно важен: Map из фикстуры не способна откатить письмо или платёж. Там нужен отдельный контракт идемпотентного ключа на стороне внешней границы либо ручный маршрут.'), + codeBlock([ + 'function handleTrainingDelivery(ledger, message) {', + ' const effect = recordEffectOnce(ledger, message);', + " if (effect.state === 'duplicate-effect-suppressed') {", + " return { delivery: 'ack', effect: 'not-repeated' };", + ' }', + " return { delivery: 'ack', effect: 'recorded-once-in-training-ledger' };", + '}', + '', + '// В production эта функция должна получить реальную границу хранения.', + ].join('\n')), + figure( + '/assets/editorial/2021/queue-delivery-guarantees-2021.svg', + 'Вертикальная схема границ доставки: message id проходит через обработчик, effectKey проверяется в ledger, затем выбирается решение по текущей доставке; отдельная ветка показывает duplicate без второго эффекта и terminal manual route', + 'Подтверждение относится к текущей доставке; ledger отвечает за повтор одного доменного эффекта.', + ), + heading('Duplicate — не повод терять исходную причину'), + paragraph('Когда ledger подавил повтор, обработка ещё не закончила объяснение. Нужно сохранить, что повтор был и на каком участке он обнаружен. Иначе через месяц останется только одна строка эффекта, но пропадёт сигнал, что граница подтверждения или восстановление consumer требуют отдельной проверки. В учебной fixture третий delivery имеет duplicate: true и результат ack-after-ledger-check. Это не показатель реального флага выбранного протокола, а документированный исход модели.'), + paragraph('Полезный контрпример: не хранить один общий список message id без связи с эффектом. Если один logical id законно создаёт несколько различных эффектов, глобальный список помешает работе. Если два разных message id представляют одно и то же намерение, список id не остановит duplicate. Поэтому ключ выбирают у доменного эффекта: invoice-417:reminder в учебном примере означает именно одно напоминание для конкретного счёта. Это решение не универсально; название и состав ключа должен подтвердить владелец домена.'), + codeBlock(fixtureCode), + paragraph('Фикстура проверяет десять инвариантов: один message id в обеих учебных ветвях, контролируемую задержку, отсутствие эффекта на retry, единственную запись ledger, suppress duplicate, terminal решение по duplicate, пустой ledger в poison path, manual route, границу решения оператора и разделение двух ветвей. Она не измеряет retry клиента и не показывает протокольный acknowledgement. Её ценность в том, что при редактуре или доработке нельзя тихо поменять правило на «повтор создаёт новый эффект».'), + heading('Порядок всегда имеет владельца и ключ'), + paragraph('Вопрос порядка часто появляется поздно: сначала consumer обработал несколько задач параллельно, затем доменная модель требует, чтобы статус не вернулся назад. Здесь недостаточно сказать «сделаем один worker». Один worker замедлит всё, но не объяснит, что происходит после перезапуска или между разными ключами. Нужен sequenceKey, правило следующего номера и исход для gap. Для несвязанных задач правило может отсутствовать; искусственный порядок там превращает обработку в очередь ожидания без пользы.'), + codeBlock(sequenceCode), + paragraph('Этот пример не реализует partition или блокировку. Он показывает форму инварианта: для invoice-417 можно принять номер только после предыдущего. Если номер пропущен, consumer не придумывает порядок и не раздувает retry. Он формирует terminal record с причиной sequence-gap. Дальше владелец домена смотрит на происхождение входа: задача пришла раньше, потеряна запись о предыдущем шаге или последовательность вообще неправильно определена. Это уже другое расследование, не алгоритм повторной доставки.'), + heading('Backoff регулирует попытки, а не правду'), + paragraph('Задержка перед повтором нужна, чтобы не превращать временный сбой в плотный цикл. Но backoff не делает ошибку временной и не восстанавливает порядок. В учебной policy две задержки заданы явно: 1000 и 4000 мс. Они не вычисляются из случайного времени и поэтому fixture повторяема. В реальном проекте значения выбирают по договору зависимости, допустимому ожиданию и наблюдаемой нагрузке. До такого выбора надо разделить хотя бы две причины: контролируемое временное условие и вход, который consumer не умеет обработать.'), + codeBlock(retryCode), + paragraph('Если retry уже исчерпан, терминальный путь должен быть видимым, а не состоять из удаления сообщения. Ручная запись несёт id, effectKey, причину, попытки и то, какую проверку ожидают от оператора. Это не бюрократия. Без effectKey оператор не знает, может ли replay создать второе действие. Без причины неясно, исправлять ли вход или зависимость. Без requiredCheck любой повтор становится случайным запуском того же consumer.'), + heading('Маршрут проектирования delivery-контракта'), + orderedList([ + 'Нарисовать одну доставку отдельно от доменного эффекта. Указать, где consumer получает вход и где появляется запись или внешний вызов.', + 'Выбрать effectKey вместе с владельцем доменной операции и добавить проверку duplicate до выполнения эффекта.', + 'Зафиксировать, что подтверждение текущей доставки возможно только после решения по ledger. Не называть это универсальной гарантией.', + 'Определить sequenceKey только для объектов, где обратный порядок действительно опасен, и описать путь при gap.', + 'Составить короткий список известных временных причин и ограниченный retry/backoff. Не включать в него неизвестную схему и нарушение инварианта.', + 'Создать terminal record для manual route. В нём должны быть исходный id, effectKey, attempts, причина и допустимые действия.', + 'Проверить весь путь controlled fixture, затем отдельно на конкретной версии broker, базе и внешних зависимостях проекта.', + ]), + heading('Источники помогают не подменять границы'), + paragraph('Спецификация AMQP 0-9 отделяет delivery и acknowledgement, а также содержит признак redelivered. Kafka 2.7 в своей versioned documentation отдельно обсуждает семантику доставки и последствия retry. Эти факты полезны, потому что не дают свести обработку к слову «очередь». Но в статье нет вывода о реальном конфиге RabbitMQ или Kafka: учебные имена, Map и события fixture не соответствуют API какого-либо продукта. Переносить нужно вопросы к контракту, а не код из примера.'), + paragraph('Здесь не запускались broker, HTTP, база, внешнее API, browser, CI или production build. Нет измерений throughput, времени восстановления или потери данных. Следующий шаг после чтения — показать выбранную транзакционную границу и ключ эффекта на маленькой интеграции. Если эту границу невозможно обеспечить, надо сократить автоматическое действие и направить сомнительные случаи в ручной маршрут, а не назвать задачу solved из-за одного успешного запуска.'), + ], + [amqp091, kafka27, kafkaProducer27, rabbitMq38], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2021-03-field-queues', + title: 'Poison message: как остановить retry и передать задачу на ручную проверку', + categories: ['Надёжность', 'Отладка', 'Практика'], + cover: '/assets/editorial/2021/queue-poison-diagnosis-2021.svg', + excerpt: 'Poison message — не название любой ошибки. Для неё нужны причина, лимит попыток, сохранённый контекст и явное ручное решение: исправить вход, отменить эффект или запустить новый контролируемый replay.', + readingMinutes: 15, + }, + [ + paragraph('Симптом poison message обычно слышен раньше, чем виден: один consumer постоянно пишет одинаковую ошибку, очередь не освобождается, а полезные задачи ждут за ним. Цена бесконечного retry — не только лишняя нагрузка. Сообщение теряет контекст среди повторов, оператор не знает, был ли уже создан эффект, а команда привыкает считать красный лог нормальным фоном. Если затем вручную удалить запись, исчезает единственная связь между исходной задачей и решением, которое было принято вместо неё.'), + paragraph('В марте 2021 года я бы называл poison не «неудобное сообщение», а терминальный результат конкретного обработчика. Он появляется, когда после ограниченных проверок consumer не имеет безопасного автоматического действия. Здесь терминальный маршрут — учебная запись manual-review с id, effectKey, причиной, количеством попыток и requiredCheck. Эта запись не является dead-letter queue выбранного broker и не обещает сохранность в реальной топологии. Она нужна, чтобы отделить диагностический факт от следующего ручного решения.'), + heading('Сначала классифицируем причину, а не счётчик попыток'), + paragraph('Один и тот же текст исключения может скрывать разные причины, поэтому попытки сами по себе не дают диагноз. Временное условие — это известное ограничение, для которого есть конечная повторная проверка: например, учебная зависимость не дала ответ и контракт допускает вторую попытку через 1000 мс. Терминальный случай — consumer не может безопасно трактовать вход: неизвестная схема, пропуск последовательности, нарушенный обязательный ключ. Неизвестный класс тоже не должен автоматически считаться temporary. Пока нет доказательства, безопаснее прекратить автоматический маршрут и сохранить контекст.'), + dataTable( + 'Матрица решения для одной неуспешной обработки', + ['Наблюдение', 'Что проверяем', 'Автоматический исход', 'Чего не заключаем'], + [ + ['контролируемое временное условие на первой попытке', 'причина входит в явно описанный transient class', 'retry с заданной задержкой', 'что внешняя зависимость уже здорова'], + ['неизвестная версия payload', 'schema не входит в контракт consumer', 'manual review сразу или после явного правила', 'что вход можно безопасно преобразовать'], + ['нарушен sequenceKey', 'предыдущая последовательность не подтверждена', 'manual review с reason sequence-gap', 'что следующая задача может обогнать предыдущую'], + ['effectKey уже есть', 'ledger содержит тот же доменный эффект', 'ack duplicate без нового эффекта', 'что исходная доставка не нуждается в расследовании'], + ['retry limit исчерпан', 'попытки и задержки соответствуют policy', 'terminal record и владелец решения', 'что удаление записи исправляет причину'], + ], + ), + paragraph('В этой таблице важен последний столбец. Терминальный маршрут не доказывает, что вход неправильный навсегда. Он говорит только, что текущий consumer не имеет доказанного автоматического шага. Это оставляет место для исправления schema, отмены доменной операции или отдельного replay после проверки. Но эти действия не должны происходить внутри того же бесконечного цикла. Иначе повтор скрывает границу ответственности: кто исправляет данные, кто разрешает повтор и кто отвечает за потенциальный повтор эффекта.'), + heading('Retry имеет смысл только вместе с ограниченным backoff'), + paragraph('В учебной policy всего две попытки и одна фиксированная задержка 1000 мс. Она нужна не для красивого числа, а чтобы fixture показывала явное состояние: задача не исчезла и не выполняется непрерывно. В реальной системе значение будет зависеть от timeout, лимитов и длины очереди. Здесь его нельзя выдавать за норму. Можно проверить только то, что процесс не повторяет один и тот же вход мгновенно и не превышает заранее записанный лимит.'), + codeBlock(retryCode), + paragraph('Обработчик не должен сам из любого исключения строить слово temporary. Для этого нужна маленькая классификация с версиями и критериями. Если условие нельзя опознать, записываем reason как неизвестный и переносим задачу на ручную проверку. Такой выбор кажется строгим, но он дешевле потери контекста. При необходимости команда позже добавит новый transient class и отдельную fixture. Тогда изменение будет видно в диффе: появился новый известный случай, лимит и ожидаемый результат, а не просто увеличилось число повторов.'), + codeBlock([ + 'function classifyTrainingFailure(input) {', + " if (input.code === 'dependency-not-ready') return 'temporary';", + " if (input.code === 'schema-not-supported') return 'terminal';", + " if (input.code === 'sequence-gap') return 'terminal';", + " return 'unknown';", + '}', + '', + "const kind = classifyTrainingFailure({ code: 'schema-not-supported' });", + "// kind === 'terminal'; автоматический retry не выбирается", + ].join('\n')), + figure( + '/assets/editorial/2021/queue-poison-diagnosis-2021.svg', + 'Вертикальная диагностическая схема poison message: сообщение проходит validation, известная временная причина получает ограниченный retry, duplicate сверяется с effect ledger, а неизвестная или терминальная причина идёт в manual-review record с тремя допустимыми решениями', + 'Диагностика не угадывает исправление: она сохраняет причину и останавливает автоматический цикл там, где правило обработки закончилось.', + ), + heading('Terminal record должен быть пригоден для решения'), + paragraph('Запись для manual route не обязана содержать всё, что есть в логах. Ей нужны данные, без которых нельзя безопасно выбрать действие. messageId связывает запись с исходным намерением. effectKey нужен, чтобы проверить риск duplicate. terminalReason объясняет, почему consumer остановился. attempts показывает, какая policy уже была применена. requiredCheck запрещает оператору нажать replay, не разобрав вход или эффект. Добавьте минимальный payload reference только там, где политика данных это разрешает.'), + codeBlock(manualRecordCode), + paragraph('Важно сохранить terminal record до ручного действия. Если сначала удалить сообщение, а потом открыть задачу, в ней часто окажется только пересказ: «похоже, была старая схема». Если сначала записать контекст, можно спокойно проверить два независимых вопроса. Первый — должен ли этот доменный эффект существовать вообще. Второй — может ли текущий consumer выполнить его без второго эффекта. Это различие экономит время на ручном разборе и не заставляет повторять обработку только ради того, чтобы снова увидеть текст ошибки.'), + heading('Ручной маршрут — не скрытая кнопка replay'), + paragraph('У оператора должно быть немного явных действий. Отмена закрывает запись и фиксирует, что эффект не нужен. Исправление входа создаёт новый контролируемый запуск с тем же или новым ключом по правилам домена. Replay после проверки сохраняет effectKey, чтобы ledger по-прежнему смог подавить повтор. Если в карточке нет этих условий, ручной маршрут быстро превращается в интерфейс «попробовать ещё раз», а poison message возвращается туда же без нового факта.'), + codeBlock(operatorDecisionCode), + paragraph('Функция выше специально не выполняет effect и не ставит сообщение в реальную очередь. Она показывает только выбор состояния. В production такой выбор надо соединить с правами, аудитом и транзакционной границей конкретного приложения. В статье эти механизмы не моделируются. Но даже в маленьком сервисе полезно закрепить правило: replay возможен только после того, как человек указал, что именно было исправлено и почему второе выполнение не создаст новый доменный результат.'), + heading('Один message id, две учебные ветви'), + paragraph('В runQueueFixture() используется один неизменяемый msg-order-417. Для ясности он проходит две взаимоисключающие ветви, а не одну невозможную историю. В recovery branch первая доставка получает контролируемый retry, вторая записывает эффект, а третья — duplicate — видит тот же effectKey и не пишет его второй раз. В poison branch тот же логический id после ограниченной попытки получает terminal reason training-schema-not-supported и manual record. Вторая ветвь намеренно оставляет свой ledger пустым.'), + codeBlock(fixtureCode), + paragraph('Такое разделение важно для честности примера. В реальном broker конкретное сообщение не может одновременно быть успешно подтверждено и уйти в terminal route как одна и та же история. Fixture сравнивает два исхода, чтобы проверять два инварианта в одном маленьком модуле: duplicate не создаёт второй эффект, а непонятный вход не зацикливается. Модель не заменяет интеграционные тесты и не называет статистику повторов. Она только делает возможными изменения без потери уже выбранных границ.'), + heading('Маршрут диагностики poison message'), + orderedList([ + 'Зафиксировать message id, effectKey, попытку, причину и время наблюдения до ручного удаления или нового replay.', + 'Проверить ledger: был ли уже создан доменный эффект для этого ключа. Duplicate не должен автоматически создавать второй эффект.', + 'Классифицировать причину как известную temporary, terminal или unknown. Не превращать unknown в retry по умолчанию.', + 'Для temporary применить только записанный лимит и backoff. После исчерпания policy не продолжать цикл без нового правила.', + 'Для terminal или unknown сформировать manual record с reason, attempts и requiredCheck, а затем остановить автоматический маршрут.', + 'Выбрать ручное действие: отменить эффект, исправить вход, либо подготовить контролируемый replay с проверкой effectKey и порядка.', + 'После решения добавить или изменить fixture, чтобы следующий такой случай имел проверяемый автоматический ответ, а не только новый текст в логе.', + ]), + heading('Исторические источники и пределы уверенности'), + paragraph('В первичной спецификации AMQP 0-9 есть redelivered и рекомендация считать многократно непроверенное сообщение непригодным для обработки и переносить его в dead letter queue. В обзоре RabbitMQ 3.8, доступном задолго до марта 2021 года, отдельно упомянут poison message и delivery limit. Kafka 2.7 документирует, что retry способен открыть путь к duplicate. Эти материалы полезны как историческая рамка. Они не означают, что любой dead-letter path сохраняет запись, или что одинаковый лимит подходит для каждой задачи.'), + paragraph('В данном пакете не запускались реальный broker, сеть, база, consumer SDK, browser, CI или production build. Нет внешнего payload, персональных данных, измерений очереди или настоящего manual UI. Поэтому текст не выдаёт учебный terminal record за operational procedure. Следующий шаг — проверить, где выбранный broker хранит attempts и acknowledgement, как выглядит фактический dead-letter route и можно ли рядом с вашим внешним эффектом сделать проверку effectKey. Только после этого policy можно считать кандидатом на реализацию.'), + ], + [amqp091, rabbitMq38, kafka27, kafkaProducer27], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle] + .map(({ proseLength, ...revision }) => revision); + +if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions)); +} else if (process.argv.includes('--verify-fixture')) { + const fixture = runQueueFixture(); + process.stdout.write(JSON.stringify(fixture, null, 2) + '\n'); +}