From 406e60bf4492d61361ff000abc3293fa07a629bc Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 11:27:15 +0300 Subject: [PATCH] revise May 2020 background job articles --- editorial/production/README.md | 2 +- editorial/reviews/2020-05-draft.md | 133 ++++++ web/data/editorial-revisions.mjs | 2 + .../2020/background-job-diagnosis-2020.svg | 53 +++ .../2020/background-job-lifecycle-2020.svg | 58 +++ .../background-job-retry-boundary-2020.svg | 55 +++ web/scripts/upgrade-2020-05.mjs | 415 ++++++++++++++++++ 7 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2020-05-draft.md create mode 100644 web/public/assets/editorial/2020/background-job-diagnosis-2020.svg create mode 100644 web/public/assets/editorial/2020/background-job-lifecycle-2020.svg create mode 100644 web/public/assets/editorial/2020/background-job-retry-boundary-2020.svg create mode 100644 web/scripts/upgrade-2020-05.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index fde89d5..1c87b4f 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 82 из 358 созданных материалов. Остальные 276 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 85 из 358 созданных материалов. Остальные 273 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2020-05-draft.md b/editorial/reviews/2020-05-draft.md new file mode 100644 index 0000000..cb38463 --- /dev/null +++ b/editorial/reviews/2020-05-draft.md @@ -0,0 +1,133 @@ +# Май 2020 — тройное ревью автономного пакета P27 «Фоновые задачи» + +Статус: **принят в publication registry 31 июля 2026 года**. Он включает +ровно три revision для стабильных slug; registry сохраняет дату и автора +базового архива: + +- editorial-2020-05-practice-background-jobs; +- editorial-2020-05-mechanism-background-jobs; +- editorial-2020-05-field-background-jobs. + +В module export нет полей date и author; +articles.json, standard, очередь и package config не +перезаписывались. При вызове --print-revisions stdout +содержит только JSON, а --verify-fixture запускает отдельную +детерминированную учебную фикстуру. + +## Проход 1. Факты, состояния и границы механизма — пройдено + +| Утверждение | Первичный или официальный источник | Редакторская граница | +| --- | --- | --- | +| Delivery tag относится к доставке, а `basic.ack` и `basic.reject` относятся к этой доставке | [AMQP 0-9-1 specification](https://www.rabbitmq.com/resources/specs/amqp-xml-doc0-9-1.pdf) | Тексты не используют delivery tag как вечный идентификатор задачи и не приписывают абстрактному псевдокоду API конкретной Node-библиотеки. | +| Неподтверждённая ручная delivery возвращается после закрытия канала/соединения; requeue может образовать дорогой loop | [RabbitMQ: Consumer Acknowledgements and Publisher Confirms](https://www.rabbitmq.com/docs/3.13/confirms) | `redelivered` используется как сигнал в журнале, но идемпотентность опирается на `jobId` и terminal state, а не на один флаг. | +| Publisher/consumer неопределённость допускает duplicate, поэтому consumer должен быть идемпотентен либо делать deduplication | [RabbitMQ: Reliability guide](https://www.rabbitmq.com/docs/reliability) | Материалы утверждают только at-least-once контракт одной задачи; exactly-once не обещается. | +| `reject`/`nack` без requeue может направить сообщение в dead-letter route при настроенном DLX | [RabbitMQ: Dead Letter Exchanges](https://www.rabbitmq.com/docs/next/dlx) | Карантин описан как проверяемый маршрут конкретной конфигурации. Не заявлено, что он существует без настройки или одинаково работает у любого broker. | + +Внутри статей разделены четыре факта, которые часто смешивают: запись +намерения в базе, outbox-публикация, delivery worker-у и сохранённый бизнес- +результат. Для одного jobId описаны состояния +queued, running, retry_wait, +succeeded и quarantined. Ack следует после +сохранения resultKey и terminal state. Повтор до этого момента +не считается дефектом: повторный worker должен увидеть завершённую задачу и +подтвердить новое delivery без второго effect. + +Проверена и намеренно оставлена граница внешнего side effect. Детерминированный +путь reports/{jobId}.csv демонстрирует идемпотентность одного +экспорта; он не делает идемпотентными email, платёж, сторонний API или любое +другое необратимое действие. Для него нужен собственный стабильный ключ и +контракт принимающей стороны. Outbox также не выдан за распределённую +транзакцию с broker: он оставляет приложению восстанавливаемую запись на +публикацию, а реальный confirm зависит от выбранного клиента и стенда. + +Историческая граница сохранена: это М3 / май 2020. Автор уже связывает backend +и delivery — состояние задачи, outbox, worker, retry и журнал — но не +приписывает себе платформу observability, современную оркестрацию или +универсальную реализацию задержанных очередей. Современная официальная +документация использована для сверки смысла протокольных операций, а не как +заявление о конкретно запущенной инфраструктуре мая 2020 года. + +Вердикт прохода: **пройден**. Механизм и его технологические допущения +разделены; учебная фикстура не выдана за реальный broker. + +## Проход 2. Редактура, глубина и голос М3 — пройдено + +| Ревизия | Проблема и цена в первых двух абзацах | Практический вопрос | Объём по publication draft gate | +| --- | --- | --- | --- | +| Практика | Экспорт исчезает или создаёт дубль; цена — потерянная работа, неверный статус и ручной разбор | Как зафиксировать намерение, outbox, result и ack в одном коротком контракте | **9 385** знаков | +| Механизм | Команда смешивает job и delivery; цена — ранний ack, дубль effect или бесконечный requeue | Почему ack ставится после результата и как один `jobId` переживает повтор | **10 550** знаков | +| Полевой разбор | Один экспорт дублируется, невалидная задача крутится; цена — лишний effect, перегруженный worker и скрытая причина | Как расследовать один `jobId` и остановить poison message | **10 484** знака | + +- Во всех трёх текстах первый абзац называет конкретный симптом и его цену, а + второй очерчивает учебную границу. Далее выдержан маршрут «симптом → причина + → проверка → действие → ограничение», без общей техлид-риторики. +- У каждой статьи есть не менее пяти смысловых разделов, таблица с + caption/thead, рисунок с самостоятельными + alt и figcaption, код или JSON-журнал, + нумерованный маршрут и четыре первичных/официальных источника. +- Практика объясняет outbox и порядок результата/ack. Механизм отделяет + delivery tag от jobId, вводит retry-политику и quarantine. + Полевой текст не притворяется реальным incident: все ID, журналы и результат + маркированы как анонимизированная фикстура. +- Никакой текст не предлагает «повторить всё» или «увеличить timeout» без + классификации. Временная, неопределённая внешняя и постоянная ошибка имеют + разные проверки и разные следующие действия. + +Отдельный редакторский контроль подтвердил, что исходные названия заметно +конкретизированы: в заголовках и excerpts есть job, worker, ack, retry, +идемпотентность и poison message, а не яркий общий заголовок с коротким +содержимым. Диапазон 5 000–15 000 соблюдён собственным guard модуля без +таблиц, code blocks и figures; строгий draft gate дополнительно измерил +видимое тело с этими артефактами. + +Вердикт прохода: **пройден**. Глубина достигнута через разбор границ и +проверяемый маршрут, голос остаётся прагматичным уровнем М3. + +## Проход 3. Визуал, фикстура и выпусковой preflight — пройдено в пределах пакета + +- background-job-lifecycle-2020.svg показывает вертикальный + путь от записи job/outbox к worker, сохранённому result, ack, retry и + quarantine/DLX. +- background-job-retry-boundary-2020.svg отделяет delivery tag + от terminal state и показывает, что временный retry ограничен, а постоянная + ошибка останавливается без requeue. +- background-job-diagnosis-2020.svg ведёт расследование по одному + jobId через запись задачи, outbox, журнал worker, resultKey и + карантин. +- Все схемы имеют title, desc, + role="img", вертикальный viewBox, короткие подписи и контрастные + карточки. В них нет JavaScript, foreignObject, внешних URL или + raster data URI. +- Независимый mobile preflight отрендерил каждый SVG через Sharp в PNG шириной + 375 px и просмотрел результат. На первом рисунке длинная последняя подпись + была развёрнута в две строки, на втором короткая tag-подпись была сокращена; + после правки обрезания, наложения и горизонтального выхода в SVG не осталось. + Это статическая проверка visual asset, не browser-run и не тест screen reader. +- Основной редактор отдельно просмотрел финальные raster-версии на 375 px и + подтвердил вывод. После подключения registry strict audit и production build + пройдены; они не подменяют настоящий broker-интеграционный тест. + +Учебная фикстура --verify-fixture выполнила две цепочки в памяти: +временный отказ → повторная delivery → result → ack и три неуспеха → +quarantined. Она вернула три истинных условия: ackAfterResult, +poisonStopped и retryWasRedelivered. Фикстура не +подключалась к RabbitMQ, не проверяла AMQP frames, DLX policy, сеть, несколько +consumer, скорость или production workload. + +### Фактически выполненные проверки + +| Проверка | Команда | Реальный результат | +| --- | --- | --- | +| Синтаксис модуля | node --check scripts/upgrade-2020-05.mjs из web/ | PASS, код 0 | +| Import-safe export и draft gate | npm run audit:draft -- scripts/upgrade-2020-05.mjs из web/ | PASS: 9 385 / 10 550 / 10 484 знаков body; три slug, таблицы, figures, code, routes, sources и assets найдены | +| Учебная фикстура состояний | node scripts/upgrade-2020-05.mjs --verify-fixture из web/ | PASS: `ackAfterResult`, `poisonStopped`, `retryWasRedelivered` — true | +| XML трёх схем | xmllint --noout public/assets/editorial/2020/background-job-lifecycle-2020.svg public/assets/editorial/2020/background-job-retry-boundary-2020.svg public/assets/editorial/2020/background-job-diagnosis-2020.svg из web/ | PASS, код 0 | +| Mobile visual preflight | Sharp render трёх SVG в PNG шириной 375 px и ручной просмотр | PASS после точечной правки двух коротких подписей; нет clipping, наложения или horizontal overflow внутри SVG | +| Strict audit после подключения registry | PASS: 9 385 / 10 550 / 10 484 знаков; 2 / 2 / 2 table и 1 / 2 / 2 code example | +| npm run build | PASS, code 0, 374 статические страницы | +| Scope/self-review | PASS: в revision нет date/author, а articles.json не перезаписан | + +Выпусковой вердикт: **тройное ревью пройдено, пакет принят к публикации**. +Registry заменяет только редакционные поля по стабильному slug. Реальный +broker, DLX policy, browser и assistive-tech не запускались и не выданы за +результат in-memory fixture или статической сборки. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 0d930b6..f4ed96d 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -23,6 +23,7 @@ import { revisions as january2020Revisions } from '../scripts/upgrade-2020-01.mj import { revisions as february2020Revisions } from '../scripts/upgrade-2020-02.mjs'; import { revisions as march2020Revisions } from '../scripts/upgrade-2020-03.mjs'; import { revisions as april2020Revisions } from '../scripts/upgrade-2020-04.mjs'; +import { revisions as may2020Revisions } from '../scripts/upgrade-2020-05.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -51,4 +52,5 @@ export const editorialRevisions = [ ...february2020Revisions, ...march2020Revisions, ...april2020Revisions, + ...may2020Revisions, ]; diff --git a/web/public/assets/editorial/2020/background-job-diagnosis-2020.svg b/web/public/assets/editorial/2020/background-job-diagnosis-2020.svg new file mode 100644 index 0000000..8eaa7e0 --- /dev/null +++ b/web/public/assets/editorial/2020/background-job-diagnosis-2020.svg @@ -0,0 +1,53 @@ + + Маршрут диагностики фоновой задачи + Вертикальная схема проводит расследование по одному jobId через запись задачи, outbox и публикацию, журнал worker, сохранённый результат либо карантинную очередь. + + + + + + Диагностика по jobId + Не перезапускать effect, пока не известна граница + + + + 1 + Запись задачи + state, attempt, lastError, resultKey + + + + + 2 + Outbox и публикация + Есть ли message до worker? + + + + + 3 + Журнал worker + received → result → ack + Сравнить порядок и attempt + + + + + 4 + resultKey найден + Повторный delivery только ACK + + + + + 5 + Постоянная ошибка + quarantined + DLX-разбор + diff --git a/web/public/assets/editorial/2020/background-job-lifecycle-2020.svg b/web/public/assets/editorial/2020/background-job-lifecycle-2020.svg new file mode 100644 index 0000000..bb1a98d --- /dev/null +++ b/web/public/assets/editorial/2020/background-job-lifecycle-2020.svg @@ -0,0 +1,58 @@ + + Жизненный цикл фоновой задачи + Вертикальная схема показывает запись задачи и outbox, публикацию, обработку worker-ом, сохранение результата перед ack, повтор при временной ошибке и карантин постоянной ошибки. + + + + + + Жизненный цикл одной задачи + jobId связывает запись, delivery и результат + + + + HTTP + Записать job и outbox + Ответ: принято, jobId известен + + + + + DISPATCH + Опубликовать jobId + Только затем отметить outbox + + + + + WORKER + Прочитать job по jobId + Delivery ещё не подтверждён + + + + + УСПЕХ + Сохранить result + succeeded + Только после этого отправить ACK + Повтор увидит terminal state + + + + + RETRY + Временная ошибка: retry_wait + Ограниченный повтор вернётся к worker + + + + Постоянная ошибка или лимит + → quarantine / DLX + diff --git a/web/public/assets/editorial/2020/background-job-retry-boundary-2020.svg b/web/public/assets/editorial/2020/background-job-retry-boundary-2020.svg new file mode 100644 index 0000000..4f704f0 --- /dev/null +++ b/web/public/assets/editorial/2020/background-job-retry-boundary-2020.svg @@ -0,0 +1,55 @@ + + Граница повторов и подтверждения фоновой задачи + Вертикальная схема показывает, что worker сохраняет результат до подтверждения доставки, ограничивает временный повтор и отправляет постоянную ошибку в карантин без бесконечного requeue. + + + + + + Граница retry и ack + delivery может повториться; jobId остаётся тем же + + + + DELIVERY + Получить tag и jobId + Ack ещё не отправлен + + + + + CHECK + Terminal state уже есть? + Да: не делать effect второй раз + Нет: начать одну попытку + + + + + RESULT + resultKey + succeeded сохранены + Только затем ACK этого tag + Порядок защищает от ранней потери + + + + + RETRY + Временная ошибка, attempt < 3 + retry_wait → задержанный повтор + + + + + STOP + Невалидно или лимит + quarantined → nack без requeue + Дальше отдельный DLX-разбор + diff --git a/web/scripts/upgrade-2020-05.mjs b/web/scripts/upgrade-2020-05.mjs new file mode 100644 index 0000000..192b878 --- /dev/null +++ b/web/scripts/upgrade-2020-05.mjs @@ -0,0 +1,415 @@ +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(lines.join('\n')) + '
'; +} + +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 visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function proseText(html) { + return visibleText( + html + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+      .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(/
[\s\S]*?<\/div>/g, ''), + ); +} + +function createRevision(meta, bodyParts, sources) { + const bodyHtml = bodyParts.join('\n'); + const proseLength = proseText(bodyHtml).length; + + if (proseLength < 5000 || proseLength > 15000) { + throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength); + } + + if (sources.length < 2) { + throw new Error(meta.slug + ': at least two primary or official sources are required'); + } + + return { + ...meta, + contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'), + proseLength, + }; +} + +const amqpSpec = { + title: 'AMQP 0-9-1 specification: basic.ack и basic.reject', + url: 'https://www.rabbitmq.com/resources/specs/amqp-xml-doc0-9-1.pdf', + note: 'первичная спецификация: delivery tag адресует доставку, а basic.reject с requeue управляет возвратом или отказом от сообщения', +}; + +const rabbitAcknowledgements = { + title: 'RabbitMQ: Consumer Acknowledgements and Publisher Confirms', + url: 'https://www.rabbitmq.com/docs/3.13/confirms', + note: 'официальное описание ручного ack, автоматического requeue не подтверждённой доставки и риска немедленного redelivery loop', +}; + +const rabbitReliability = { + title: 'RabbitMQ: Reliability guide', + url: 'https://www.rabbitmq.com/docs/reliability', + note: 'официальная граница между подтверждением доставки и обработкой, а также необходимость идемпотентного consumer при повторной доставке', +}; + +const rabbitDlx = { + title: 'RabbitMQ: Dead Letter Exchanges', + url: 'https://www.rabbitmq.com/docs/next/dlx', + note: 'официальное описание маршрутизации отклонённого сообщения в отдельный exchange и причин dead-lettering', +}; + +const enqueueExample = [ + '// Учебный псевдокод: одна транзакция приложения, не клиент RabbitMQ.', + 'async function requestExport(input, db) {', + ' const jobId = makeStableId(input.accountId, input.period);', + '', + ' await db.transaction(async (tx) => {', + ' await tx.insertJob({', + " id: jobId, state: 'queued', attempt: 0, resultKey: null,", + ' });', + ' await tx.insertOutbox({', + " type: 'report.export.requested', jobId: jobId, publishedAt: null,", + ' });', + ' });', + '', + ' return { accepted: true, jobId: jobId };', + '}', + '', + '// Отдельный dispatcher читает неопубликованный outbox.', + '// Он отмечает publishedAt только после подтверждения выбранного broker client.', +].join('\n'); + +const workerExample = [ + '// Учебный обработчик одного delivery. transport API намеренно абстрактный.', + 'async function handleDelivery(delivery, jobs, broker) {', + ' const job = await jobs.findForUpdate(delivery.jobId);', + '', + " if (job.state === 'succeeded' || job.state === 'quarantined') {", + ' await broker.ack(delivery.tag);', + ' return;', + ' }', + '', + ' try {', + " await jobs.markRunning(job.id, delivery.attempt);", + ' const resultKey = await writeReportOnce(job.id, job.payload);', + ' await jobs.markSucceeded(job.id, resultKey);', + ' await broker.ack(delivery.tag); // только после durable result + state', + ' } catch (error) {', + ' const nextAttempt = delivery.attempt + 1;', + ' await jobs.markRetryOrQuarantine(job.id, nextAttempt, error.code);', + ' await broker.nack(delivery.tag, { requeue: nextAttempt < 3 });', + ' }', + '}', +].join('\n'); + +const journalExample = [ + '{"event":"received","jobId":"export-42-2020-05","attempt":1,"redelivered":false}', + '{"event":"retry_scheduled","jobId":"export-42-2020-05","attempt":1,"reason":"upstream_timeout"}', + '{"event":"received","jobId":"export-42-2020-05","attempt":2,"redelivered":true}', + '{"event":"result_saved","jobId":"export-42-2020-05","resultKey":"reports/export-42-2020-05.csv"}', + '{"event":"ack_sent","jobId":"export-42-2020-05","attempt":2}', +].join('\n'); + +const poisonJournalExample = [ + '{"event":"received","jobId":"export-43-2020-05","attempt":3,"redelivered":true}', + '{"event":"validation_failed","jobId":"export-43-2020-05","reason":"unknown_report_kind"}', + '{"event":"quarantined","jobId":"export-43-2020-05","queue":"jobs.quarantine"}', + '{"event":"nack_sent","jobId":"export-43-2020-05","requeue":false}', +].join('\n'); + +function runStateFixture() { + const journal = []; + const retryJob = { id: 'export-42-2020-05', state: 'queued', attempt: 0, resultKey: null }; + const poisonJob = { id: 'export-43-2020-05', state: 'queued', attempt: 0, resultKey: null }; + + function deliver(job, outcome) { + job.attempt += 1; + journal.push({ event: 'received', jobId: job.id, attempt: job.attempt, redelivered: job.attempt > 1 }); + + if (outcome === 'success') { + job.resultKey = 'reports/' + job.id + '.csv'; + job.state = 'succeeded'; + journal.push({ event: 'result_saved', jobId: job.id, resultKey: job.resultKey }); + journal.push({ event: 'ack_sent', jobId: job.id, attempt: job.attempt }); + return; + } + + if (outcome === 'invalid' || job.attempt >= 3) { + job.state = 'quarantined'; + journal.push({ event: 'quarantined', jobId: job.id, attempt: job.attempt, requeue: false }); + return; + } + + job.state = 'retry_wait'; + journal.push({ event: 'retry_scheduled', jobId: job.id, attempt: job.attempt, requeue: true }); + } + + deliver(retryJob, 'transient'); + deliver(retryJob, 'success'); + deliver(poisonJob, 'transient'); + deliver(poisonJob, 'transient'); + deliver(poisonJob, 'invalid'); + + return { + fixture: 'in-memory-job-state-machine', + retryJob: retryJob, + poisonJob: poisonJob, + journal: journal, + assertions: { + ackAfterResult: retryJob.state === 'succeeded' && retryJob.resultKey !== null, + poisonStopped: poisonJob.state === 'quarantined' && poisonJob.attempt === 3, + retryWasRedelivered: journal.some((entry) => entry.jobId === retryJob.id && entry.redelivered === true), + }, + }; +} + +const practiceArticle = createRevision( + { + slug: 'editorial-2020-05-practice-background-jobs', + title: 'Фоновые задачи: сначала фиксируем намерение, потом запускаем worker', + categories: ['Backend', 'Очереди', 'Практика'], + cover: '/assets/editorial/2020/background-job-lifecycle-2020.svg', + excerpt: 'Долгий экспорт не должен жить внутри HTTP-запроса. Собираем маленький контракт: запись задачи, публикация, обработка, сохранённый результат и ack в последнюю очередь.', + readingMinutes: 14, + }, + [ + paragraph('Симптом выглядит как «экспорт иногда исчезает». Пользователь нажал кнопку, HTTP-ответ вернул 202, но через десять минут нет ни файла, ни понятного статуса. Иногда всё хуже: worker успел записать отчёт, упал перед ack, а повтор создал второй файл или дважды отправил письмо. Цена не в самой минуте ожидания. Поддержка не может ответить, принята ли работа, разработчик не отличает потерю сообщения от дубля, а следующий срочный фикс добавляет ещё один таймаут вместо контракта.'), + paragraph('В мае 2020 я бы не начинал с «фоновой платформы». Достаточна узкая связка delivery и backend: HTTP принимает намерение, приложение сохраняет состояние задачи, отдельный dispatcher публикует сообщение, worker делает работу, а очередь получает подтверждение только после сохранённого результата. Это учебная схема для одной операции report.export. Она не доказывает работу конкретного RabbitMQ-кластера и не обещает exactly-once; её задача — сделать каждую точку потери или повтора видимой.'), + heading('У задачи есть свой владелец состояния'), + paragraph('Сообщение в очереди не должно быть единственным местом, где живёт смысл работы. Брокер знает о delivery, но не обязан знать, создан ли CSV, обновлена ли строка в базе и что увидит пользователь. Поэтому у операции есть запись приложения с постоянным jobId, входными параметрами, числом попыток, текущим состоянием, ключом результата и короткой причиной последней неудачи. Один и тот же jobId проходит HTTP, outbox, message, worker и журнал. Это не трассировка всей системы, а минимальная нить для одного расследования.'), + paragraph('Нормальный ответ HTTP — не «готово», а «принято»: { accepted: true, jobId }. Клиент затем спрашивает статус именно этой записи или получает уведомление привычным для продукта способом. Если запись не создана, нет принятой задачи. Если она создана, но сообщение ещё не опубликовано, это отдельное наблюдаемое состояние, а не повод сказать пользователю, что worker уже начал работу.'), + dataTable( + 'Контракт одной фоновой задачи: состояние не прячется внутри очереди', + ['Слой', 'Что хранит', 'Что считается доказательством', 'Чего не обещает'], + [ + ['HTTP', 'jobId и ответ 202', 'в транзакции появилась запись задачи', 'что обработка уже завершена'], + ['База приложения', 'state, attempt, resultKey, lastError', 'задача читается по jobId', 'что сообщение дошло до broker'], + ['Outbox', 'событие публикации и признак отправки', 'есть элемент, который можно дочитать после рестарта', 'атомарный commit с внешним broker'], + ['Очередь', 'delivery выбранному consumer', 'ручной ack или nack по delivery tag', 'идемпотентность бизнес-операции'], + ['Worker', 'результат и переход состояния', 'resultKey сохранён до ack', 'безопасность внешнего побочного эффекта без ключа'], + ], + ), + paragraph('Такой список убирает опасное сокращение «задача в очереди». У нас есть как минимум запись намерения, запись на публикацию, delivery и бизнес-результат. Они могут находиться в разных состояниях одновременно. Дежурный не должен гадать по отсутствию файла: он открывает строку задачи, смотрит state, attempt и lastError, после чего знает, на какой границе продолжать проверку.'), + figure('/assets/editorial/2020/background-job-lifecycle-2020.svg', 'Вертикальная схема жизненного цикла фоновой задачи: HTTP сохраняет задачу и outbox, dispatcher публикует сообщение, worker сохраняет результат, затем отправляет ack; ошибочная задача уходит в повтор или карантин', 'Жизненный цикл показывает четыре разных факта: намерение записано, сообщение опубликовано, результат сохранён, delivery подтверждён. Ack стоит последним, потому что не заменяет бизнес-результат.'), + heading('Разрыв между записью и публикацией надо назвать'), + paragraph('Наивная последовательность «сначала записали заявку, потом отправили сообщение» ломается при падении между двумя строками. В базе уже есть queued, а delivery нет. Обратный порядок не лучше: worker может получить сообщение и не найти ещё незафиксированную запись. Распределённую транзакцию между базой и broker я здесь не предлагаю: для небольшого сервиса она быстро становится дороже самой задачи. Вместо этого полезнее в одной транзакции приложения сохранить и задачу, и outbox-запись.'), + paragraph('После commit отдельный короткий dispatcher ищет outbox без publishedAt, передаёт минимальное сообщение { jobId, type, version } выбранному клиенту broker и отмечает публикацию только по контракту этого клиента. Если процесс умер до такой отметки, dispatcher попробует снова. Отсюда следует неприятный, но здоровый вывод: consumer обязан выдержать duplicate. Повтор публикации — не дефект, который можно «выключить» флагом; это цена за возможность восстановиться после неясного обрыва.'), + codeBlock(enqueueExample.split('\n')), + paragraph('В примере нет SQL-схемы реального проекта и нет вызова библиотеки RabbitMQ. Важно другое: jobId создаётся до публикации, а outbox живёт рядом с бизнес-записью. В результате можно отдельно проверить, почему dispatcher не двигает запись: отсутствует ли соединение, неверен ли маршрут, не получено ли ожидаемое подтверждение или просто нет самого outbox-события. Это намного полезнее, чем повторно запускать весь экспорт по кнопке.'), + heading('Ack подтверждает delivery, а не желание верить в успех'), + paragraph('В AMQP delivery tag относится к конкретной доставке на конкретном канале, а не к вечному идентификатору задачи. Ручной ack говорит broker, что consumer принял ответственность за это delivery; после него broker может удалить сообщение. Поэтому ack до записи результата опасен: процесс может упасть после подтверждения, а нужная работа исчезнет из очереди. Ack после результата допускает обратное окно: результат есть, а ack не успел уйти. Тогда сообщение будет доставлено повторно. Это ожидаемый сценарий, не исключение.'), + paragraph('Проверка порядка должна быть предельно приземлённой. До ack в хранилище уже есть state = succeeded и устойчивый resultKey. При повторной доставке worker читает эту запись, не запускает экспорт снова и подтверждает только повторное delivery. Если результат создаётся во внешнем сервисе, например объектном хранилище или email-шлюзе, одного флага succeeded недостаточно: нужен стабильный ключ объекта или ключ идемпотентности на стороне такого вызова. В этой статье мы ограничиваемся одной задачей и явно не выдаём этот принцип за универсальную гарантию всех сторонних систем.'), + heading('Повтор — это отдельное состояние, не бесконечный requeue'), + paragraph('У временной ошибки есть диагностируемая причина: короткий сбой зависимости, временный лимит или неготовый вход. Для неё можно сохранить retry_wait, номер попытки и код причины, а затем вернуть работу через заданную задержку и ограниченный маршрут. У невалидного входа причина другая: новый delivery не исправит неизвестный тип отчёта или отсутствующий обязательный параметр. Если каждое такое сообщение немедленно отправлять с requeue: true, worker будет тратить CPU на один и тот же отказ, а полезные задачи окажутся позади него.'), + dataTable( + 'Минимальная retry-политика для учебного экспорта', + ['Наблюдение', 'Состояние задачи', 'Действие с delivery', 'Что остаётся для разбора'], + [ + ['Сохранён CSV и resultKey', 'succeeded', 'ack', 'jobId, ключ результата, попытка'], + ['Короткий timeout зависимости, попытка 1–2', 'retry_wait', 'вернуть в задержанный retry-маршрут', 'код ошибки и следующая попытка'], + ['Невалидный payload или попытка 3', 'quarantined', 'nack/reject без requeue; DLX при настроенном маршруте', 'payload-версия, причина, jobId, delivery metadata'], + ['Повтор delivery после сбоя до ack', 'succeeded уже есть', 'ack без нового экспорта', 'признак redelivered и прежний resultKey'], + ], + ), + paragraph('Poison message здесь не мистическая категория broker. Это сообщение, которое снова и снова не может пройти известный consumer-контракт. Его путь должен заканчиваться в отдельной карантинной очереди или другой управляемой поверхности, а не возвращаться в тот же hot loop. Карантин не означает «удалить и забыть»: для записи должны остаться jobId, версия payload, причина, число попыток и понятный владелец решения — исправить данные, исправить worker или осознанно отменить работу.'), + heading('Маршрут проверки до первого реального запуска'), + orderedList([ + 'Записать для операции постоянный jobId, разрешённые состояния и условие, после которого пользователь может увидеть результат.', + 'В одной транзакции приложения создать job и outbox; после имитации рестарта проверить, что непросланный outbox всё ещё читается.', + 'На учебной фикстуре прогнать временную ошибку: первая попытка переходит в retry_wait, вторая сохраняет результат, и только затем появляется ack_sent.', + 'Отдельно прогнать невалидный payload или лимит попыток: запись становится quarantined, а маршруту не разрешён немедленный requeue.', + 'Повторить delivery для уже succeeded job и убедиться, что результат не создаётся второй раз, а worker подтверждает новое delivery.', + 'До подключения broker выбрать конкретный клиентский API, проверить его publisher-confirm и DLX-настройки на стенде; этот текст не подменяет такой прогон.', + ]), + heading('Граница этой практики'), + paragraph('Здесь нет настоящего очередного сервера, зарегистрированного consumer или production-лога. В модуле пакета есть только детерминированная in-memory фикстура состояний: она доказывает, что авторский переход «повтор → успех» отправляет ack после result и что третий неуспех переводит другую задачу в карантин. Она не доказывает поведение сети, задержку broker, порядок в нескольких worker и конфигурацию dead-letter exchange. Эти свойства должны быть проверены отдельным стендом с тем broker и клиентом, которые выбрал проект.'), + ], + [amqpSpec, rabbitAcknowledgements, rabbitReliability, rabbitDlx], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2020-05-mechanism-background-jobs', + title: 'Под капотом фоновой задачи: delivery, ack, повтор и карантин', + categories: ['Backend', 'Очереди', 'Механизмы'], + cover: '/assets/editorial/2020/background-job-retry-boundary-2020.svg', + excerpt: 'Разделяем бизнес-состояние задачи и одно delivery: где ставить ack, почему повтор нормален, как сделать одну задачу идемпотентной и остановить poison message.', + readingMinutes: 15, + }, + [ + paragraph('Симптом механической ошибки обычно звучит так: «мы же уже обработали сообщение, почему оно пришло ещё раз?» Или наоборот: worker вызвал API, после чего задача пропала без результата. Цена обоих случаев одна — команда смешала два разных объекта: бизнес-операцию и delivery от broker. Первый живёт столько, сколько нужен отчёт или запись; второе живёт до ack/nack на конкретном канале. Пока между ними нет явного перехода, дубль кажется аварией, а ранний ack кажется оптимизацией.'), + paragraph('В мае 2020 полезнее освоить небольшой автомат, чем рисовать сложную оркестрацию. У операции есть jobId, у каждой доставки — свой tag и признак redelivery, а worker делает четыре действия в фиксированном порядке: получить delivery, захватить состояние задачи, сохранить исход работы, сообщить broker результат обработки. Этот порядок не даёт «ровно один раз» на всём мире. Он даёт проверяемую at-least-once модель, в которой повтор не обязан повторять эффект одной уже завершённой задачи.'), + heading('Delivery и задача отвечают на разные вопросы'), + paragraph('Delivery отвечает на вопрос broker: кто сейчас несёт ответственность за эту копию сообщения? Для ручного подтверждения ответ заканчивается ack(tag) или отрицательным ответом. Если соединение consumer закрылось до ack, broker может вернуть непроверенную доставку и позже отдать её тому же или другому worker. Задача отвечает на вопрос приложения: что пользователь попросил, на какой попытке это находится и где результат. Её нельзя определить только по тому, есть ли сообщение в очереди.'), + paragraph('Из этой разницы получается рабочее правило. Не используем delivery tag как jobId: tag привязан к каналу и меняется при следующей доставке. В payload кладём короткий стабильный идентификатор и версию контракта, а детали входа храним у владельца задачи либо подписываем настолько ясно, чтобы worker мог проверить их версию. Когда приходит redelivery, worker ищет тот же jobId, а не пытается угадать, была ли такая строка по совпадению времени или имени файла.'), + dataTable( + 'Два идентификатора и две ответственности', + ['Сущность', 'Живёт где', 'Меняется когда', 'Правильное применение'], + [ + ['jobId', 'в базе приложения, payload и журнале', 'не меняется между повторами', 'идемпотентность, статус, результат, поддержка'], + ['delivery tag', 'в канале broker/client', 'при каждом новом delivery', 'ровно один ack/nack конкретной доставки'], + ['attempt', 'в записи задачи или явно в повторном сообщении', 'при принятом решении о повторе', 'лимит retry и отчёт о причине'], + ['resultKey', 'в устойчивом storage/БД', 'один раз при успехе', 'доказательство, что effect уже готов до ack'], + ], + ), + paragraph('Префикс «at least once» относится к доставке, не к тому, что пользователь увидит два отчёта. Повтор возможен и после publisher-side неопределённости, и после consumer-side падения. Поэтому нельзя надеяться, что флаг redelivered всё решит: он полезен для журнала и приоритета проверки, но не заменяет запись состояния. Безопасное решение на уровне одной задачи — выбирать один стабильный эффект: например, файл всегда пишется в reports/{jobId}.csv, а строка результата хранит этот же ключ.'), + figure('/assets/editorial/2020/background-job-retry-boundary-2020.svg', 'Вертикальная схема границы повторов: worker получает delivery, сохраняет результат и только затем ack; временная ошибка идёт в ограниченный retry, а невалидная задача после лимита оказывается в карантине без бесконечного requeue', 'Повтор относится к delivery, а состояние — к jobId. На каждом пути сначала сохраняется решение приложения, затем broker получает ack или nack.'), + heading('Ack ставим после устойчивого бизнес-результата'), + paragraph('Ручной ack — это не запись в лог «worker начал работу». Это граница, после которой broker вправе удалить delivery. Если handler отправил ack сразу после получения, то сбой в SQL, файловом хранилище или внешнем API оставит ложный след: очередь считает задачу завершённой, приложение — нет. Если ack ставится после результата, возможен другой порядок: результат уже сохранён, а сеть закрылась. Следующее delivery должно обнаружить завершённую задачу и не делать effect второй раз.'), + paragraph('Здесь важно назвать настоящий порядок устойчивости. Внутри приложения markSucceeded и resultKey должны быть сохранены одной согласованной операцией или в таком порядке, который можно восстановить после рестарта. Для внешнего вызова нужен собственный ключ: запись файла по детерминированному пути, уникальный ключ отправки или API-контракт, который принимает idempotency key. Нельзя сначала сделать необратимый эффект, а потом надеяться, что локальная таблица спасёт от дубля. Таблица лишь даёт worker решение при следующем delivery.'), + codeBlock(workerExample.split('\n')), + paragraph('Это не интерфейс конкретной Node-библиотеки. Он намеренно показывает точки, которые нельзя переставлять: terminal state проверяется до действия; попытка фиксируется до повторного маршрута; success пишется до ack; карантин фиксируется до nack без requeue. Реальный API может называть методы иначе и по-разному задавать задержку. До внедрения надо сверить эти места с версией выбранного client и с тем, настроен ли у очереди путь dead-lettering.'), + heading('Повтор классифицируем до того, как вернуть сообщение'), + paragraph('Не всякая ошибка заслуживает retry. Timeout до получения ответа иногда временный, но он не доказывает, что внешняя операция не состоялась; здесь особенно нужен ключ идемпотентности. Ошибка в payload, неизвестная версия сообщения или нарушенное обязательное поле повтором не исправится. Ошибка локальной валидации должна сразу закончить processing как карантин или осознанная отмена, а не навсегда держать одно delivery на голове очереди.'), + dataTable( + 'Решение для ошибки одного delivery', + ['Класс', 'Пример симптома', 'Проверка перед решением', 'Следующее действие'], + [ + ['Успех', 'resultKey уже сохранён', 'состояние terminal и эффект читается по jobId', 'ack; при дубле — только ack'], + ['Временная', 'короткий timeout до ответного байта', 'записаны attempt и причина; есть бюджет меньше лимита', 'перевести job в retry_wait, вернуть через ограниченный маршрут'], + ['Неопределённая внешняя', 'таймаут после отправки запроса', 'проверить внешний ключ или статус по jobId', 'не создавать второй эффект; повторять только через идемпотентный контракт'], + ['Постоянная', 'payload не проходит version/validation', 'причина воспроизводится на тех же данных', 'quarantine и nack/reject без requeue'], + ], + ), + paragraph('Задержка между попытками тоже часть контракта. В 2020 году для одного сервиса можно обойтись простой retry-очередью с TTL или расписанием, которое уже умеет конкретная библиотека; не обязательно строить платформу. Но у каждой задачи должны быть фиксированные максимум попыток, причина последнего перехода и время следующего допуска. Формула exponential backoff не спасает, если неизвестно, какая именно попытка уже была и кто вернёт сообщение из задержки.'), + heading('Poison message — это работа, которая больше не должна горячо крутиться'), + paragraph('В AMQP basic.reject с requeue=false не обозначает «успех». Оно заканчивает обработку данного delivery; при заранее настроенном dead-letter exchange broker направляет сообщение на отдельный маршрут, иначе оно может быть отброшено. Это требует явной проектной договорённости: куда попадёт карантин, кто посмотрит его, как сопоставить payload с записью задачи и как защитить чувствительные поля от попадания в журнал.'), + paragraph('Не стоит строить логику на бесконечном nack(requeue=true). RabbitMQ прямо предупреждает, что consumer, который все время возвращает delivery, может создать затратный redelivery loop. Лимит попыток в записи задачи плюс отдельный результат quarantined разрывают этот цикл с понятной ценой: часть работы не завершена автоматически, зато полезные сообщения продолжают получать worker, а человек получает конкретную причину вместо бесконечного шума.'), + heading('Короткий журнал заменяет догадку о порядке'), + paragraph('Для первой версии не нужна отдельная observability-платформа. Достаточно, чтобы каждый переход писал один и тот же набор: event, jobId, attempt, признак redelivered, причина и при успехе resultKey. Тогда вопрос «был ли ack до результата?» проверяется порядком пяти строк, а не памятью того, кто разбирает сбой. Нельзя класть в такой журнал полный payload, токены или документ пользователя: для связи достаточно идентификатора и безопасной классификации ошибки.'), + codeBlock(journalExample.split('\n')), + paragraph('Это пример ожидаемого учебного журнала, не снятый log реального worker. Первая строка показывает начало первой попытки, вторая — сохранённое решение о повторе. Вторая доставка имеет тот же jobId, но уже другой delivery и redelivered: true. Только после строки result_saved возникает ack_sent. Если эти две строки поменялись местами, расследование закончено: worker подтверждает работу до того, как может доказать её эффект.'), + heading('Маршрут проверки автомата'), + orderedList([ + 'Составить таблицу состояний для одного jobId: queued, running, retry_wait, succeeded и quarantined; убрать неявное «вроде выполняется».', + 'Взять один payload и показать два delivery с разными tags; убедиться, что оба ищут одну и ту же запись задачи.', + 'Смоделировать падение после result_saved до ack. Повтор должен только подтвердить новое delivery, а не записать второй результат.', + 'Смоделировать временную ошибку до лимита и проверить, что attempt и причина сохранены до постановки retry.', + 'Смоделировать невалидный payload либо третий отказ: задача становится quarantined, а немедленный requeue не разрешён.', + 'На отдельном стенде выбранного broker проверить реальную семантику ack/nack, redelivery, policy DLX и задержки; учебный автомат этого не заменяет.', + ]), + heading('Граница модели'), + paragraph('Этот материал не утверждает, что любая очередь предоставляет одинаковый delivery count, delayed retry или безопасный dead-letter путь. В нём использованы понятия AMQP 0-9-1 и документация RabbitMQ как проверяемый ориентир; конкретная конфигурация зависит от версии broker, типа очереди и client library. Внутри пакета выполнена только детерминированная фикстура переходов в памяти. Она не открывает соединение с RabbitMQ, не измеряет throughput, не запускает конкурирующих worker и не заменяет интеграционный тест проекта.'), + ], + [amqpSpec, rabbitAcknowledgements, rabbitReliability, rabbitDlx], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2020-05-field-background-jobs', + title: 'Разбор: почему экспорт отчёта повторился и как остановить poison message', + categories: ['Backend', 'Очереди', 'Разбор'], + cover: '/assets/editorial/2020/background-job-diagnosis-2020.svg', + excerpt: 'Учебный разбор двух путей: результат создан до потерянного ack и невалидная задача крутится в requeue. Собираем журнал, меняем порядок и вводим карантин.', + readingMinutes: 14, + }, + [ + paragraph('Симптом в учебном разборе такой: пользователь запрашивает экспорт, а через несколько минут видит два одинаковых файла. Одновременно другая заявка с неизвестным типом отчёта снова и снова появляется у worker, занимая очередь. Цена двойная. Первый сбой создаёт лишний внешний эффект и спор, какая копия верная; второй забирает время worker и скрывает полезные задачи под повторяющейся ошибкой. Фраза «очередь доставила дважды» описывает факт, но ещё не называет место, где принято неверное решение.'), + paragraph('Разберу не production-инцидент, а анонимизированную in-memory фикстуру мая 2020 года. В ней нет реального broker, файлового хранилища, user data или измеренной нагрузки. Зато есть две детерминированные цепочки с одним jobId каждая: временный отказ между сохранением результата и ack, а также невалидный payload после лимита попыток. Цель — показать практическое расследование: симптом → причина → проверка → действие, а не рассказать историю успеха постфактум.'), + heading('Сначала отделяем факт результата от факта delivery'), + paragraph('Первый экспорт имеет jobId = export-42-2020-05. Worker получил delivery, записал CSV по устойчивому ключу и пометил задачу как succeeded. Затем соединение до broker оборвалось до ack. У broker остаётся непроверенное delivery, поэтому следующий worker получает ту же бизнес-задачу повторно. Если handler относится к любому received message как к новому, он снова вызывает экспорт и пишет второй файл с новым случайным именем. Это не исправляется большим timeout: проблема в том, что idempotency check находится после эффекта или отсутствует.'), + paragraph('Вторая заявка — export-43-2020-05 — содержит неизвестный reportKind. Worker ловит ошибку, делает nack(requeue=true) и тут же получает ту же доставку снова. Никакая пауза не сделает неизвестный тип валидным. Пока задача не имеет состояния quarantined и ограничителя попыток, очередь по сути работает как генератор одинаковых ошибок. Здесь цена уже операционная: журнал растёт, полезная работа ждёт, а владелец данных не получает короткий список того, что нужно исправить.'), + codeBlock(poisonJournalExample.split('\n')), + paragraph('Строки выше — синтетический журнал фикстуры, не вывод запущенного RabbitMQ consumer. Они важны именно порядком. У poison-задачи третья попытка ещё фиксирует вход и причину, затем приложение сохраняет quarantined, и только после этого выбранному transport посылается отрицательный ответ без requeue. В реальном AMQP дальнейшая судьба зависит от настроенного dead-letter exchange: без маршрута сообщение может быть отброшено. Поэтому «карантин» обязан существовать не только как слово в коде, но и как проверяемая конфигурация выбранного окружения.'), + dataTable( + 'Карта расследования: какой факт исключает какую гипотезу', + ['Наблюдение', 'Причина, которую проверяем', 'Минимальное доказательство', 'Действие'], + [ + ['Есть resultKey, но нет ack_sent', 'сбой произошёл в узком окне после результата', 'job.state = succeeded раньше следующего received', 'при повторе не создавать результат, подтвердить новое delivery'], + ['Два файла для одного jobId', 'внешний эффект не имеет стабильного ключа либо check сделан поздно', 'сопоставить имена файлов и порядок journal', 'путь результата построить из jobId, terminal state читать до export'], + ['attempt растёт, причина одна и та же', 'постоянный payload повторно requeue', 'validation_failed повторяется на равных входных данных', 'пометить quarantined и направить в DLX/разбор'], + ['queued долго без received', 'outbox не опубликован либо worker не читает маршрут', 'есть job/outbox, но нет publish marker и journal delivery', 'проверить dispatcher, binding и конкретный broker client'], + ], + ), + paragraph('Эта таблица не заменяет доступ к очереди. Она задаёт порядок вопросов до изменения кода. Если уже есть resultKey, не нужно стартовать новый экспорт «на всякий случай». Если причина unknown_report_kind воспроизводится из сохранённой версии payload, не нужно увеличивать retry. Если у задачи нет received, бесполезно рассматривать handler: сперва ищем outbox, публикацию и маршрут. Каждый шаг привязывает действие к одному наблюдаемому факту.'), + figure('/assets/editorial/2020/background-job-diagnosis-2020.svg', 'Вертикальная схема диагностики фоновой задачи: по jobId проверяют запись задачи, outbox и сообщение, затем журнал worker, сохранённый resultKey и при постоянной ошибке карантинную очередь', 'Разбор идёт не по названию компонента, а по пути одного jobId. Это уменьшает риск перезапустить эффект, когда проблема находится до worker или после результата.'), + heading('Причина первого дубля: случайное имя и ack не на той стороне'), + paragraph('Плохой вариант handler выглядит почти естественно: он берёт сообщение, сразу начинает генерацию, формирует имя из текущего времени, отправляет ack и только затем пытается отметить успех. В нём две точки неопределённости. Во-первых, повтору нечем доказать, что прежняя генерация уже завершилась: название файла другое, а состояние ещё не terminal. Во-вторых, ack может добраться до broker раньше записи статуса. При сбое получаем либо потерянную работу, либо повтор без защиты.'), + paragraph('Исправление на уровне одной задачи не требует общего дедупликатора. Worker сначала читает строку по jobId с блокировкой, проверяет terminal states и резервирует попытку. Результат записывается по детерминированному ключу reports/{jobId}.csv. После записи в этом же бизнес-шаге сохраняются resultKey и succeeded. Если после этого broker повторно доставит сообщение, handler видит terminal state, не пишет файл заново и только завершает текущий delivery. Для email или внешнего API нужно отдельно убедиться, что принимающая сторона поддерживает такой ключ; путь файла не решает чужой side effect.'), + dataTable( + 'Изменение порядка для повторной доставки', + ['Старая последовательность', 'Риск', 'Новая последовательность', 'Проверяемый результат'], + [ + ['receive → generate random file → ack → save state', 'дубль или потеря при падении между шагами', 'receive → read job → save deterministic result + succeeded → ack', 'повтор видит succeeded и не создаёт второй файл'], + ['catch → nack(requeue=true) всегда', 'горячий цикл на невалидном payload', 'classify → retry_wait или quarantined → nack по решению', 'attempt ограничен, причина остаётся рядом с jobId'], + ['искать ошибку по времени', 'непонятно, к какой попытке относится строка', 'писать jobId, attempt, event, reason', 'одна цепочка читается без догадки о совпадении'], + ], + ), + paragraph('Здесь нет обещания, что SQL-блокировка сделает worker глобально одиночным. Она лишь защищает запись задачи в границе выбранной базы. Конкурирующие worker, внешнее хранилище и сеть всё равно требуют проверяемого контракта. Поэтому практический критерий короче: каждый новый delivery для уже succeeded обязан завершиться без нового результата. Если это нельзя проверить, слово «идемпотентность» в код-ревью пока ничего не означает.'), + heading('Причина hot loop: постоянную ошибку приняли за временную'), + paragraph('Повтор нужен, когда новое время может изменить исход: зависимость была недоступна, лимит снят, ожидаемая запись ещё не появилась. Но unknown_report_kind не зависит от времени. Для такого случая worker должен назвать ошибку постоянной, сохранить её в записи и завершить автоматический путь. В AMQP отрицательный ответ без requeue может направить сообщение в DLX, если проект это настроил. Отдельный маршрут делает ошибку предметом разбора, а не бесконечным consumer workload.'), + paragraph('Карантин не стоит использовать как корзину для всех исключений. Сначала сохраняем тип причины и версию payload: это отделяет неисправимые данные от дефекта worker после обновления. Затем владелец решает: поправить данные и переиздать новую задачу с новым или тем же бизнес-ключом, починить consumer и вручную вернуть сообщение через контролируемый маршрут, либо отменить операцию. Автоматическое чтение карантина обратно в основную очередь без исправления причины снова создаёт тот же loop, только с более длинным названием.'), + heading('Фикстура как маленький регрессионный контракт'), + paragraph('В модуле ревизий есть команда node scripts/upgrade-2020-05.mjs --verify-fixture. Она не эмулирует AMQP frames. Она детерминированно создаёт две записи в памяти: первая переживает transient ошибку, получает повторную доставку, сохраняет result и ack; вторая после третьего неуспеха переходит в quarantined. Вывод — JSON-журнал и три булевых условия. Такой тест полезен тем, что не позволяет незаметно переставить result_saved и ack_sent в учебном алгоритме.'), + codeBlock(journalExample.split('\n')), + paragraph('Фикстура не даёт ложной уверенности в broker. У неё нет TCP-соединения, реального delivery tag, политики DLX, нескольких consumer или диска. Но она отделяет две логические проверки, которые можно выполнить без инфраструктуры: для retry есть новая попытка с тем же jobId, а успешный путь пишет result до ack; poison-путь обрывает requeue на известном пределе. После выбора библиотеки эту же пару сценариев нужно повторить на интеграционном стенде и сравнить реальные журналы с ожидаемыми переходами.'), + heading('Маршрут разбора перед исправлением'), + orderedList([ + 'Взять один конкретный jobId и собрать рядом запись задачи, outbox, журнал worker, ключ результата и информацию о current attempt.', + 'Проверить, в каком порядке появились result_saved, succeeded и ack_sent; не делать новый экспорт, пока это не ясно.', + 'Для повтора сравнить jobId, а не delivery tag: новый tag не означает новую бизнес-операцию.', + 'Классифицировать последнюю ошибку как временную, неопределённую внешнюю или постоянную; записать основание рядом с attempt.', + 'Для постоянной ошибки остановить requeue, перевести job в quarantined и проверить, что выбранная DLX/карантинная поверхность действительно принимает сообщение.', + 'После изменения прогнать in-memory фикстуру, затем отдельный broker-интеграционный сценарий с падением до ack; в этом пакете выполнен только первый шаг.', + ]), + heading('Граница полевого разбора'), + paragraph('Все идентификаторы, причины и строки журнала здесь придуманы для проверки переходов. Нет реального файла, заказчика, очереди, RabbitMQ policy, production-config или browser-действия. Тексты опираются на спецификацию AMQP и официальную документацию RabbitMQ, чтобы не выдумывать смысл ack, reject и redelivery, но не выдают современную документацию за снимок конкретной инфраструктуры мая 2020 года. Автор этого периода умеет провести узкое backend/delivery расследование и оставить route для стенда; он ещё не заявляет готовую платформу наблюдаемости или сложную оркестрацию.'), + ], + [amqpSpec, rabbitAcknowledgements, rabbitReliability, rabbitDlx], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions)); +} else if (process.argv.includes('--verify-fixture')) { + const fixture = runStateFixture(); + if (!Object.values(fixture.assertions).every(Boolean)) { + throw new Error('State fixture assertions failed'); + } + process.stdout.write(JSON.stringify(fixture, null, 2) + '\n'); +}