This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function dataTable(caption, headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bodyText(content) {
|
||||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
|
||||
function createRevision(meta, bodyParts, sources) {
|
||||
if (sources.length < 2) {
|
||||
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
|
||||
}
|
||||
|
||||
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
|
||||
const proseLength = bodyText(contentHtml).length;
|
||||
|
||||
if (proseLength < 5000 || proseLength > 15000) {
|
||||
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
|
||||
}
|
||||
|
||||
return { ...meta, contentHtml, proseLength };
|
||||
}
|
||||
|
||||
const 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(
|
||||
'Минимальный контракт задачи: поле должно отвечать на конкретный вопрос',
|
||||
['Поле или правило', 'Владелец', 'Инвариант', 'Проверка перед действием'],
|
||||
[
|
||||
['<code>id</code>', 'создатель задачи', 'одна логическая задача имеет один наблюдаемый id', 'id не пустой и сохраняется во всех учебных ветвях'],
|
||||
['<code>effectKey</code>', 'обработчик и ledger', 'один ключ даёт не более одного записанного эффекта', 'сначала lookup в ledger, затем запись или suppress duplicate'],
|
||||
['<code>sequenceKey</code>', 'доменный владелец порядка', 'порядок проверяется только внутри одного ключа', 'следующий номер следует за предыдущим или задача уходит в manual route'],
|
||||
['retry policy', 'контракт обработки', 'повтор ограничен числом попыток и задержкой', 'временный класс ошибки соответствует известному условию'],
|
||||
['manual route', 'оператор и владелец домена', 'терминальный случай не исчезает и не зацикливается', 'есть причина, число попыток и допустимые следующие действия'],
|
||||
],
|
||||
),
|
||||
paragraph('Эта таблица не требует отдельной платформы. Её можно положить рядом с функцией обработчика и с тестом контракта. Главное — не смешивать роли. <code>id</code> позволяет найти историю. <code>effectKey</code> защищает конкретный эффект. <code>sequenceKey</code> ограничивает порядок. Попытка использовать один случайный идентификатор для всех трёх задач обычно скрывает важные вопросы: что делать при новой версии входа, можно ли повторить операцию после исправления и относится ли порядок к одному объекту или к очереди целиком.'),
|
||||
heading('Конверт сообщения описывает границу до payload'),
|
||||
paragraph('Payload отвечает за данные доменной операции. Конверт отвечает за то, как эту операцию вести через границу обработки. В учебном конверте есть <code>id</code>, <code>kind</code>, <code>effectKey</code>, <code>sequenceKey</code> и минимальная версия схемы. Этого мало для любого приложения, но достаточно, чтобы прочитать запись о повторе: это тот же логический запрос или новый; он должен создавать тот же эффект или другой; порядок нужен именно для этого счёта или его здесь вообще нет.'),
|
||||
codeBlock(envelopeCode),
|
||||
paragraph('Схема не должна быть декоративной строкой. Если consumer не знает, как трактовать payload, он не должен угадывать поле и продолжать работу с частично понятным объектом. В нашем учебном примере неизвестная версия даёт <code>manual-review</code>. Такой исход не говорит, что запись испорчена навсегда. Он говорит ровно одно: текущий обработчик не имеет безопасного правила для этого входа. Исправление может оказаться в 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('В коде <code>nextTrainingDecision</code> принимает классификацию извне. Это намеренно. Функция не может по строке исключения честно узнать, временная ли причина в любой системе. Отдельная граница отвечает за классификацию и обязана вернуть понятный вид причины. Когда этого правила нет, лучше записать неуверенный случай в ручной маршрут, чем назвать каждую ошибку temporary и ждать, пока ограничение на попытки само скроет проблему.'),
|
||||
heading('Ledger принадлежит эффекту, а не слову retry'),
|
||||
paragraph('Есть неприятный промежуток: эффект уже записан, но consumer не знает, дошло ли его подтверждение. Следующая доставка может быть тем же логическим сообщением. Если код сначала отправит письмо или изменит запись, а затем попробует понять, был ли такой эффект раньше, duplicate уже случится. Поэтому проверка ключа эффекта должна идти перед созданием эффекта, а результат этой проверки должен позволять завершить повтор без второго действия.'),
|
||||
codeBlock(ledgerCode),
|
||||
paragraph('В реальном проекте ledger может быть строкой в той же транзакции, уникальным индексом или другим доменным механизмом. Этот выбор зависит от базы, внешнего API и границы транзакции. Учебная Map ничего такого не реализует. Она показывает единственный инвариант: второй вызов с тем же <code>effectKey</code> не добавляет новую запись. Нельзя называть это гарантией доставки. Это защита конкретного доменного эффекта при условии, что реальная запись ledger и сам эффект имеют согласованную границу.'),
|
||||
heading('Маршрут до первого запуска'),
|
||||
orderedList([
|
||||
'Назвать один тип задачи и один внешний эффект. Не объединять отправку письма, обновление баланса и перестройку индекса в один общий контракт.',
|
||||
'Добавить в конверт стабильный <code>id</code>, отдельный <code>effectKey</code> и <code>sequenceKey</code> только при реальной необходимости порядка.',
|
||||
'Сформулировать допустимые исходы 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('Слова <code>at-most-once</code>, <code>at-least-once</code> и <code>exactly-once</code> часто попадают в решение раньше контракта. Для локального дизайна полезнее разложить их на наблюдаемые обязательства. Мы можем договориться, что consumer допускает повтор одной логической задачи; что эффект с одинаковым <code>effectKey</code> записывается один раз; что порядок проверяется только по <code>sequenceKey</code>; что неизвестная ошибка не повторяется бесконечно. Ни одно из этих предложений не превращает абстрактную модель в характеристику сети, диска или выбранного продукта.'),
|
||||
dataTable(
|
||||
'Словарь договора: каждое слово привязано к своей границе',
|
||||
['Термин в обсуждении', 'Что фиксируем в учебном контракте', 'Что остаётся за границей', 'Проверяемый признак'],
|
||||
[
|
||||
['delivery', 'один запуск consumer над message id', 'дошло ли сообщение по сети и как хранит его broker', 'в fixture видны delivery 1, 2 и 3'],
|
||||
['duplicate', 'повторный запуск того же id после неопределённого результата', 'почему именно появился повтор в конкретном transport', 'ledger возвращает <code>duplicate-effect-suppressed</code>'],
|
||||
['effect', 'доменная запись, привязанная к effectKey', 'атомарность между базой и внешней системой', 'в ledger остаётся одна строка ключа'],
|
||||
['order', 'проверка последовательности для одного sequenceKey', 'общий порядок всех задач', 'gap ведёт к manual review, а не к догадке'],
|
||||
['terminal route', 'автоматический маршрут остановлен с контекстом', 'решение оператора и последующая интеграция', 'есть reason, attempts и requiredCheck'],
|
||||
],
|
||||
),
|
||||
paragraph('Такой словарь снимает ложный выбор между «настроить гарантию» и «ничего не делать». У команды появляется ряд маленьких вопросов. Когда можно подтвердить обработку? Где лежит ключ эффекта? Что происходит, если внешний вызов завершился, а запись о нём нет? Для какой сущности порядок обязателен? Какой случай не имеет права автоматически возвращаться в работу? Ответы могут оказаться разными даже внутри одного сервиса. Это нормально: граница договора определяется риском операции, а не названием очереди.'),
|
||||
heading('Эффект и подтверждение нельзя менять местами'),
|
||||
paragraph('Представим учебный второй запуск. Первая попытка получила временное условие и была отложена на 1000 мс. Вторая дошла до записи эффекта и сразу после этого потеряла знание о результате подтверждения. Третья доставка выглядит как duplicate. Если обработчик не смотрит в ledger, он повторит эффект. Если он смотрит в ledger до эффекта, он видит существующий ключ и может завершить текущую доставку без нового доменного действия. Эта последовательность не доказывает, что повтор обязательно случится; она показывает, почему код обязан быть готов к нему.'),
|
||||
codeBlock(ledgerCode),
|
||||
paragraph('Важна последовательность, а не название функции. Сначала проверить <code>effectKey</code>. Если ключ найден, не создавать второй эффект. Если ключа нет, попытаться записать эффект и ключ в одной подходящей для проекта границе. После этого принять решение о подтверждении текущей доставки. Чем дальше друг от друга эти действия, тем больше сценариев неопределённости. Внешний 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 имеет <code>duplicate: true</code> и результат <code>ack-after-ledger-check</code>. Это не показатель реального флага выбранного протокола, а документированный исход модели.'),
|
||||
paragraph('Полезный контрпример: не хранить один общий список message id без связи с эффектом. Если один logical id законно создаёт несколько различных эффектов, глобальный список помешает работе. Если два разных message id представляют одно и то же намерение, список id не остановит duplicate. Поэтому ключ выбирают у доменного эффекта: <code>invoice-417:reminder</code> в учебном примере означает именно одно напоминание для конкретного счёта. Это решение не универсально; название и состав ключа должен подтвердить владелец домена.'),
|
||||
codeBlock(fixtureCode),
|
||||
paragraph('Фикстура проверяет десять инвариантов: один message id в обеих учебных ветвях, контролируемую задержку, отсутствие эффекта на retry, единственную запись ledger, suppress duplicate, terminal решение по duplicate, пустой ledger в poison path, manual route, границу решения оператора и разделение двух ветвей. Она не измеряет retry клиента и не показывает протокольный acknowledgement. Её ценность в том, что при редактуре или доработке нельзя тихо поменять правило на «повтор создаёт новый эффект».'),
|
||||
heading('Порядок всегда имеет владельца и ключ'),
|
||||
paragraph('Вопрос порядка часто появляется поздно: сначала consumer обработал несколько задач параллельно, затем доменная модель требует, чтобы статус не вернулся назад. Здесь недостаточно сказать «сделаем один worker». Один worker замедлит всё, но не объяснит, что происходит после перезапуска или между разными ключами. Нужен <code>sequenceKey</code>, правило следующего номера и исход для gap. Для несвязанных задач правило может отсутствовать; искусственный порядок там превращает обработку в очередь ожидания без пользы.'),
|
||||
codeBlock(sequenceCode),
|
||||
paragraph('Этот пример не реализует partition или блокировку. Он показывает форму инварианта: для <code>invoice-417</code> можно принять номер только после предыдущего. Если номер пропущен, consumer не придумывает порядок и не раздувает retry. Он формирует terminal record с причиной <code>sequence-gap</code>. Дальше владелец домена смотрит на происхождение входа: задача пришла раньше, потеряна запись о предыдущем шаге или последовательность вообще неправильно определена. Это уже другое расследование, не алгоритм повторной доставки.'),
|
||||
heading('Backoff регулирует попытки, а не правду'),
|
||||
paragraph('Задержка перед повтором нужна, чтобы не превращать временный сбой в плотный цикл. Но backoff не делает ошибку временной и не восстанавливает порядок. В учебной policy две задержки заданы явно: 1000 и 4000 мс. Они не вычисляются из случайного времени и поэтому fixture повторяема. В реальном проекте значения выбирают по договору зависимости, допустимому ожиданию и наблюдаемой нагрузке. До такого выбора надо разделить хотя бы две причины: контролируемое временное условие и вход, который consumer не умеет обработать.'),
|
||||
codeBlock(retryCode),
|
||||
paragraph('Если retry уже исчерпан, терминальный путь должен быть видимым, а не состоять из удаления сообщения. Ручная запись несёт id, effectKey, причину, попытки и то, какую проверку ожидают от оператора. Это не бюрократия. Без <code>effectKey</code> оператор не знает, может ли 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 не имеет безопасного автоматического действия. Здесь терминальный маршрут — учебная запись <code>manual-review</code> с 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 <code>sequence-gap</code>', 'что следующая задача может обогнать предыдущую'],
|
||||
['effectKey уже есть', 'ledger содержит тот же доменный эффект', 'ack duplicate без нового эффекта', 'что исходная доставка не нуждается в расследовании'],
|
||||
['retry limit исчерпан', 'попытки и задержки соответствуют policy', 'terminal record и владелец решения', 'что удаление записи исправляет причину'],
|
||||
],
|
||||
),
|
||||
paragraph('В этой таблице важен последний столбец. Терминальный маршрут не доказывает, что вход неправильный навсегда. Он говорит только, что текущий consumer не имеет доказанного автоматического шага. Это оставляет место для исправления schema, отмены доменной операции или отдельного replay после проверки. Но эти действия не должны происходить внутри того же бесконечного цикла. Иначе повтор скрывает границу ответственности: кто исправляет данные, кто разрешает повтор и кто отвечает за потенциальный повтор эффекта.'),
|
||||
heading('Retry имеет смысл только вместе с ограниченным backoff'),
|
||||
paragraph('В учебной policy всего две попытки и одна фиксированная задержка 1000 мс. Она нужна не для красивого числа, а чтобы fixture показывала явное состояние: задача не исчезла и не выполняется непрерывно. В реальной системе значение будет зависеть от timeout, лимитов и длины очереди. Здесь его нельзя выдавать за норму. Можно проверить только то, что процесс не повторяет один и тот же вход мгновенно и не превышает заранее записанный лимит.'),
|
||||
codeBlock(retryCode),
|
||||
paragraph('Обработчик не должен сам из любого исключения строить слово <code>temporary</code>. Для этого нужна маленькая классификация с версиями и критериями. Если условие нельзя опознать, записываем 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 не обязана содержать всё, что есть в логах. Ей нужны данные, без которых нельзя безопасно выбрать действие. <code>messageId</code> связывает запись с исходным намерением. <code>effectKey</code> нужен, чтобы проверить риск duplicate. <code>terminalReason</code> объясняет, почему consumer остановился. <code>attempts</code> показывает, какая policy уже была применена. <code>requiredCheck</code> запрещает оператору нажать 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('В <code>runQueueFixture()</code> используется один неизменяемый <code>msg-order-417</code>. Для ясности он проходит две взаимоисключающие ветви, а не одну невозможную историю. В recovery branch первая доставка получает контролируемый retry, вторая записывает эффект, а третья — duplicate — видит тот же effectKey и не пишет его второй раз. В poison branch тот же логический id после ограниченной попытки получает terminal reason <code>training-schema-not-supported</code> и 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');
|
||||
}
|
||||
Reference in New Issue
Block a user