DarkRiDDeR15 мин

Poison message: как остановить retry и передать задачу на ручную проверку

НадёжностьОтладкаПрактика

Симптом poison message обычно слышен раньше, чем виден: один consumer постоянно пишет одинаковую ошибку, очередь не освобождается, а полезные задачи ждут за ним. Цена бесконечного retry — не только лишняя нагрузка. Сообщение теряет контекст среди повторов, оператор не знает, был ли уже создан эффект, а команда привыкает считать красный лог нормальным фоном. Если затем вручную удалить запись, исчезает единственная связь между исходной задачей и решением, которое было принято вместо неё.

В марте 2021 года я бы называл poison не «неудобное сообщение», а терминальный результат конкретного обработчика. Он появляется, когда после ограниченных проверок consumer не имеет безопасного автоматического действия. Здесь терминальный маршрут — учебная запись manual-review с id, effectKey, причиной, количеством попыток и requiredCheck. Эта запись не является dead-letter queue выбранного broker и не обещает сохранность в реальной топологии. Она нужна, чтобы отделить диагностический факт от следующего ручного решения.

Сначала классифицируем причину, а не счётчик попыток

Один и тот же текст исключения может скрывать разные причины, поэтому попытки сами по себе не дают диагноз. Временное условие — это известное ограничение, для которого есть конечная повторная проверка: например, учебная зависимость не дала ответ и контракт допускает вторую попытку через 1000 мс. Терминальный случай — consumer не может безопасно трактовать вход: неизвестная схема, пропуск последовательности, нарушенный обязательный ключ. Неизвестный класс тоже не должен автоматически считаться temporary. Пока нет доказательства, безопаснее прекратить автоматический маршрут и сохранить контекст.

Матрица решения для одной неуспешной обработки
НаблюдениеЧто проверяемАвтоматический исходЧего не заключаем
контролируемое временное условие на первой попыткепричина входит в явно описанный transient classretry с заданной задержкойчто внешняя зависимость уже здорова
неизвестная версия payloadschema не входит в контракт consumermanual review сразу или после явного правилачто вход можно безопасно преобразовать
нарушен sequenceKeyпредыдущая последовательность не подтвержденаmanual review с reason sequence-gapчто следующая задача может обогнать предыдущую
effectKey уже естьledger содержит тот же доменный эффектack duplicate без нового эффектачто исходная доставка не нуждается в расследовании
retry limit исчерпанпопытки и задержки соответствуют policyterminal record и владелец решениячто удаление записи исправляет причину

В этой таблице важен последний столбец. Терминальный маршрут не доказывает, что вход неправильный навсегда. Он говорит только, что текущий consumer не имеет доказанного автоматического шага. Это оставляет место для исправления schema, отмены доменной операции или отдельного replay после проверки. Но эти действия не должны происходить внутри того же бесконечного цикла. Иначе повтор скрывает границу ответственности: кто исправляет данные, кто разрешает повтор и кто отвечает за потенциальный повтор эффекта.

Retry имеет смысл только вместе с ограниченным backoff

В учебной policy всего две попытки и одна фиксированная задержка 1000 мс. Она нужна не для красивого числа, а чтобы fixture показывала явное состояние: задача не исчезла и не выполняется непрерывно. В реальной системе значение будет зависеть от timeout, лимитов и длины очереди. Здесь его нельзя выдавать за норму. Можно проверить только то, что процесс не повторяет один и тот же вход мгновенно и не превышает заранее записанный лимит.

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 }

Обработчик не должен сам из любого исключения строить слово temporary. Для этого нужна маленькая классификация с версиями и критериями. Если условие нельзя опознать, записываем reason как неизвестный и переносим задачу на ручную проверку. Такой выбор кажется строгим, но он дешевле потери контекста. При необходимости команда позже добавит новый transient class и отдельную fixture. Тогда изменение будет видно в диффе: появился новый известный случай, лимит и ожидаемый результат, а не просто увеличилось число повторов.

function classifyTrainingFailure(input) {
  if (input.code === 'dependency-not-inspectedy') 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 не выбирается
Вертикальная диагностическая схема poison message: сообщение проходит validation, известная временная причина получает ограниченный retry, duplicate сверяется с effect ledger, а неизвестная или терминальная причина идёт в manual-review record с тремя допустимыми решениями
Диагностика не угадывает исправление: она сохраняет причину и останавливает автоматический цикл там, где правило обработки закончилось.

Terminal record должен быть пригоден для решения

Запись для manual route не обязана содержать всё, что есть в логах. Ей нужны данные, без которых нельзя безопасно выбрать действие. messageId связывает запись с исходным намерением. effectKey нужен, чтобы проверить риск duplicate. terminalReason объясняет, почему consumer остановился. attempts показывает, какая policy уже была применена. requiredCheck запрещает оператору нажать replay, не разобрав вход или эффект. Добавьте минимальный payload reference только там, где политика данных это разрешает.

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 сама и не удаляет исходный контекст.

Важно сохранить terminal record до ручного действия. Если сначала удалить сообщение, а потом открыть задачу, в ней часто окажется только пересказ: «похоже, была старая схема». Если сначала записать контекст, можно спокойно проверить два независимых вопроса. Первый — должен ли этот доменный эффект существовать вообще. Второй — может ли текущий consumer выполнить его без второго эффекта. Это различие экономит время на ручном разборе и не заставляет повторять обработку только ради того, чтобы снова увидеть текст ошибки.

Ручной маршрут — не скрытая кнопка replay

У оператора должно быть немного явных действий. Отмена закрывает запись и фиксирует, что эффект не нужен. Исправление входа создаёт новый контролируемый запуск с тем же или новым ключом по правилам домена. Replay после проверки сохраняет effectKey, чтобы ledger по-прежнему смог подавить повтор. Если в карточке нет этих условий, ручной маршрут быстро превращается в интерфейс «попробовать ещё раз», а poison message возвращается туда же без нового факта.

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');

Функция выше специально не выполняет effect и не ставит сообщение в реальную очередь. Она показывает только выбор состояния. В production такой выбор надо соединить с правами, аудитом и транзакционной границей конкретного приложения. В статье эти механизмы не моделируются. Но даже в маленьком сервисе полезно закрепить правило: replay возможен только после того, как человек указал, что именно было исправлено и почему второе выполнение не создаст новый доменный результат.

Один message id, две учебные ветви

В runQueueFixture() используется один неизменяемый msg-order-417. Для ясности он проходит две взаимоисключающие ветви, а не одну невозможную историю. В recovery branch первая доставка получает контролируемый retry, вторая записывает эффект, а третья — duplicate — видит тот же effectKey и не пишет его второй раз. В poison branch тот же логический id после ограниченной попытки получает terminal reason training-schema-not-supported и manual record. Вторая ветвь намеренно оставляет свой ledger пустым.

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']

Такое разделение важно для честности примера. В реальном broker конкретное сообщение не может одновременно быть успешно подтверждено и уйти в terminal route как одна и та же история. Fixture сравнивает два исхода, чтобы проверять два инварианта в одном маленьком модуле: duplicate не создаёт второй эффект, а непонятный вход не зацикливается. Модель не заменяет интеграционные тесты и не называет статистику повторов. Она только делает возможными изменения без потери уже выбранных границ.

Маршрут диагностики poison message

  1. Зафиксировать message id, effectKey, попытку, причину и время наблюдения до ручного удаления или нового replay.
  2. Проверить ledger: был ли уже создан доменный эффект для этого ключа. Duplicate не должен автоматически создавать второй эффект.
  3. Классифицировать причину как известную temporary, terminal или unknown. Не превращать unknown в retry по умолчанию.
  4. Для temporary применить только записанный лимит и backoff. После исчерпания policy не продолжать цикл без нового правила.
  5. Для terminal или unknown сформировать manual record с reason, attempts и requiredCheck, а затем остановить автоматический маршрут.
  6. Выбрать ручное действие: отменить эффект, исправить вход, либо подготовить контролируемый replay с проверкой effectKey и порядка.
  7. После решения добавить или изменить fixture, чтобы следующий такой случай имел проверяемый автоматический ответ, а не только новый текст в логе.

Исторические источники и пределы уверенности

В первичной спецификации AMQP 0-9 есть redelivered и рекомендация считать многократно непроверенное сообщение непригодным для обработки и переносить его в dead letter queue. В обзоре RabbitMQ 3.8, доступном задолго до марта 2021 года, отдельно упомянут poison message и delivery limit. Kafka 2.7 документирует, что retry способен открыть путь к duplicate. Эти материалы полезны как историческая рамка. Они не означают, что любой dead-letter path сохраняет запись, или что одинаковый лимит подходит для каждой задачи.

В данном пакете не запускались реальный broker, сеть, база, consumer SDK, browser, CI или production build. Нет внешнего payload, персональных данных, измерений очереди или настоящего manual UI. Поэтому текст не выдаёт учебный terminal record за operational procedure. Следующий шаг — проверить, где выбранный broker хранит attempts и acknowledgement, как выглядит фактический dead-letter route и можно ли рядом с вашим внешним эффектом сделать проверку effectKey. Только после этого policy можно считать кандидатом на реализацию.

Проверяемые источники

  • AMQP 0-9: спецификация basic.deliver, basic.ack и redelivered — первичная спецификация 2008 года: у delivery есть признак повторной доставки, а подтверждение относится к доставленному сообщению; учебная модель ниже не реализует протокол
  • RabbitMQ 3.8 Release Overview — 11 ноября 2019 года — версия и обзор были доступны к марту 2021 года; источник упоминает delivery limit для poison message, но не является описанием данного учебного маршрута
  • Apache Kafka 2.7.X: versioned documentation — версионная документация линии 2.7, доступной в марте 2021 года; терминология доставки приводится только для разграничения обязательств, не как claim об этой fixture
  • Apache Kafka 2.7.0 KafkaProducer API — официальный API линии 2.7 предупреждает, что retries могут открыть путь к duplicate; это не настройка и не запуск Kafka в статье