Files
progcode/editorial/agent-rewrites/244.json
T

8 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 244,
"slug": "editorial-2021-03-field-queues",
"title": "Poison message в очереди: как остановить retry и сохранить задачу",
"excerpt": "Consumer повторяет одно сообщение, очередь шумит, а команда не знает, был ли уже создан эффект. Разбираем конечный retry, идемпотентный ключ, порядок обработки и ручной маршрут для poison message.",
"contentHtml": "<p>Consumer получает одно и то же сообщение, пишет одинаковую ошибку и возвращает его в очередь. При строгом порядке или неудачном requeue полезные задачи могут ждать за ним, нагрузка растёт, а оператор видит только новый красный лог. Если worker успел создать внешний эффект до сбоя, повтор может отправить письмо, списать деньги или открыть заявку ещё раз. Если сообщение удалить, исчезнет контекст, по которому можно понять, что произошло.</p>\n<p>Цена ошибки складывается из двух частей. Бесконечный retry расходует ресурсы и маскирует неисправный вход. Без проверки эффекта повтор создаёт дубликат. Поэтому poison message — не любое сообщение с ошибкой. Это сообщение, для которого текущий consumer исчерпал доказанные автоматические действия и должен остановиться, сохранив данные для решения.</p>\n<h2>Главный тезис</h2>\n<p>Очередь не исправляет обработку. Она только отделяет приём работы от её выполнения. Consumer должен явно различать временный сбой, непригодный вход, уже выполненный эффект и неизвестную причину. Для временного сбоя подходит ограниченный retry. Для остальных ветвей нужен сохранённый контекст, а иногда — ручная проверка. Подтверждать доставку можно после того, как обработчик выполнил нужное действие или записал безопасное состояние.</p>\n<p>В этой статье используется учебная модель. Она не подключается к RabbitMQ, Kafka, базе, сети или внешнему API. Имена <code>msg-order-417</code>, <code>invoice-417:reminder</code> и задержка 1000 мс нужны для объяснения инвариантов. Они не являются настройками production и не дают измерений пропускной способности.</p>\n<h2>Как возникает poison message</h2>\n<p>Сначала broker передаёт delivery consumer-у. Обработчик проверяет payload, читает доменные данные и выполняет эффект. Затем он подтверждает обработку. Если процесс падает до подтверждения, broker может доставить сообщение снова. Это полезно при временном сбое, но опасно, если причина лежит в самом payload или если эффект уже произошёл, а подтверждение потерялось.</p>\n<p>Один счётчик попыток не даёт диагноза. Ошибка таймаута может исчезнуть после короткой задержки. Неизвестная версия схемы не станет корректной от десяти повторов. Пропущенный sequence key может требовать ожидания предыдущего сообщения, а может указывать на потерянное состояние. Неправильный подход выглядит так: любое исключение превращают в <code>retry</code>, а после роста очереди увеличивают лимит. Так система дольше повторяет тот же неверный шаг.</p>\n<p>У обработки должны быть две независимые проверки. Первая отвечает, можно ли сейчас трактовать вход. Вторая отвечает, был ли уже создан доменный эффект. Только после них выбирают retry, подтверждение дубликата или ручной маршрут.</p>\n<h2>Минимальный контракт сообщения</h2>\n<p>Для безопасного разбора нужны поля с разной ответственностью. <code>messageId</code> связывает все попытки с логическим сообщением, но не обозначает конкретную доставку: RabbitMQ, например, использует отдельный delivery tag, а Kafka — позицию в partition. <code>effectKey</code> обозначает доменный эффект и помогает подавить повтор. <code>sequenceKey</code> задаёт объект, для которого важен порядок. Версия payload позволяет отличить известную схему от входа, который consumer не умеет читать.</p>\n<pre><code>const message = {\n messageId: 'msg-order-417',\n effectKey: 'invoice-417:reminder',\n sequenceKey: 'invoice-417',\n sequenceNumber: 7,\n payload: { schema: '2021-03', invoiceId: '417' },\n};\n\nconst retryPolicy = {\n maxAttempts: 2, // всего две попытки: initial и один retry\n retryDelaysMs: [1000], // задержка перед второй попыткой\n};\n\nfunction recordEffectOnce(ledger, effectKey) {\n if (ledger.has(effectKey)) return 'duplicate-effect-suppressed';\n ledger.add(effectKey); // в production нужна атомарная запись с UNIQUE effectKey\n return 'effect-recorded';\n}</code></pre>\n<p>Этот код — учебный пример в памяти. <code>Set</code> не заменяет транзакционный ledger, а последовательность <code>has</code> и <code>add</code> сама по себе не защищает от двух параллельных consumer-ов. В настоящем сервисе ключ должен проверяться и фиксироваться атомарно в хранилище с гарантией, соответствующей доменному эффекту. Для письма может хватить уникального ключа операции. Для платежа потребуются правила провайдера, статус операции и отдельная сверка. Нельзя переносить этот фрагмент в production без определения владельца состояния и границы записи.</p>\n<h2>Классификация перед retry</h2>\n<p>Классификация должна быть маленькой и явной. Известный временный класс получает конечную политику. Известная терминальная причина останавливает автоматический маршрут. Неизвестная причина не становится временной по умолчанию. Отдельный класс <code>defer</code> нужен для ситуации, когда условие может исчезнуть само, но ждать бесконечно нельзя.</p>\n<pre><code>function classifyFailure(error) {\n if (error.code === 'dependency-not-ready') return 'temporary';\n if (error.code === 'schema-not-supported') return 'terminal';\n if (error.code === 'sequence-gap') return 'defer';\n return 'unknown';\n}\n\nconst kind = classifyFailure({ code: 'schema-not-supported' });\n// kind === 'terminal': новый retry не выбирается</code></pre>\n<p>Ограниченный backoff нужен для временного класса, а не для успокоения метрики. В учебной policy две попытки и одна задержка; это две попытки обработки, а не два дополнительных retry. <code>sequence-gap</code> выделен отдельно: если предыдущее событие ещё может прийти, запись надо отложить до ограниченного срока. После истечения этого срока gap становится ручным случаем, а не поводом бесконечно requeue-ить сообщение. В реальном проекте лимит зависит от timeout зависимости, времени жизни данных, пропускной способности и цены повторного эффекта. Эти значения надо записать рядом с контрактом и проверить на отрицательном пути: зависимость не отвечает, попытки заканчиваются, сообщение не остаётся в бесконечном цикле.</p>\n<h2>Симптомы и действия</h2>\n<div class='table-scroll'><table><caption>Диагностика одной неуспешной обработки</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Одна ошибка повторяется с коротким интервалом</td><td>Неограниченный retry или requeue</td><td>Сравнить messageId, attempts и интервал между доставками</td><td>Ограничить попытки и добавить backoff</td></tr><tr><td>Payload не читается текущим consumer</td><td>Неизвестная версия схемы</td><td>Сверить schema с поддержанными версиями</td><td>Создать manual record и остановить цикл</td></tr><tr><td>Эффект уже есть, подтверждение потеряно</td><td>Сбой между эффектом и ack или offset commit</td><td>Проверить ledger по effectKey</td><td>Подтвердить duplicate без нового эффекта</td></tr><tr><td>Следующее сообщение нельзя выполнить по порядку</td><td>Пропущен sequenceKey или номер</td><td>Проверить предыдущее событие и срок ожидания</td><td>Отложить gap; после срока передать на manual review</td></tr><tr><td>Лимит попыток исчерпан</td><td>Policy не получила успешный исход</td><td>Проверить attempts, reason и применённые задержки</td><td>Сохранить terminal record с владельцем решения</td></tr></tbody></table></div>\n<p>Последняя колонка не обещает, что причина устранена. Она фиксирует следующий безопасный шаг. Manual review не равно dead-letter queue конкретного broker. Это логическая запись или маршрут, который должен хранить ссылку на исходное сообщение, ключ эффекта, причину, число попыток и обязательную проверку перед replay.</p>\n<figure><img src='/assets/editorial/2021/queue-poison-diagnosis-2021.svg' alt='Диагностический маршрут poison message: validation, ограниченный retry, проверка дубликата и manual review' loading='lazy' /><figcaption>Автоматический маршрут заканчивается там, где правило обработки больше не доказано.</figcaption></figure>\n<h2>Terminal record сохраняет контекст</h2>\n<p>Записывайте terminal record до удаления доставки и до ручного replay. Минимальный набор полей связывает решение с исходным входом: <code>messageId</code>, <code>effectKey</code>, <code>terminalReason</code>, <code>attempts</code>, время наблюдения и <code>requiredCheck</code>. Для отложенной последовательности добавьте срок ожидания и ожидаемый номер. Ссылку на payload храните только в пределах политики данных. Секреты и полные персональные данные не должны попадать в свободный текст ошибки.</p>\n<pre><code>const manualRecord = {\n messageId: message.messageId,\n effectKey: message.effectKey,\n terminalReason: 'schema-not-supported',\n attempts: 2,\n requiredCheck: 'confirm-schema-or-cancel-effect',\n state: 'manual-review',\n};</code></pre>\n<p>Сначала нужно надёжно записать этот объект, затем подтвердить или удалить текущую доставку по контракту выбранного broker. Если запись не удалась, подтверждение нельзя использовать как замену сохранённому состоянию: иначе оператор потеряет причину и вход.</p>\n<p>Ручной маршрут должен иметь три разных результата. Отмена фиксирует, что эффект не нужен. Исправление входа создаёт новый контролируемый запуск по правилам домена. Replay разрешён только после проверки, что эффект не был создан, или после явного решения, как избежать второго эффекта. Кнопка «попробовать ещё раз» без этих условий лишь переносит poison message обратно в цикл.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать <code>messageId</code>, <code>effectKey</code>, <code>sequenceKey</code>, номер попытки, причину и время до удаления сообщения или нового replay.</li><li>Проверить ledger или другой разрешённый источник эффекта. Если ключ уже обработан, не выполнять доменное действие второй раз.</li><li>Сопоставить причину с явной классификацией: temporary, defer, terminal или unknown. Не считать unknown временным без доказательства.</li><li>Для temporary применить только записанные лимит и backoff. После лимита остановить автоматический цикл.</li><li>Для defer ждать только до записанного срока и проверять ожидаемое предыдущее событие. После срока создать manual record.</li><li>Для terminal и unknown создать manual record с причиной, контекстом и обязательной проверкой.</li><li>Выбрать одно ручное решение: отменить эффект, исправить вход или подготовить controlled replay с проверкой ключа и порядка.</li><li>Добавить тест или наблюдение, которое отличает эту ветвь от уже известных случаев. Не подменять проверку новым сообщением в логе.</li></ol>\n<h2>Ack и offset — не одно и то же</h2>\n<p>Одинаковое слово «подтверждение» скрывает разные контракты. В RabbitMQ consumer отправляет <code>ack</code> для конкретного delivery tag; неподтверждённая доставка при закрытии канала может быть выдана снова. При отрицательном подтверждении с <code>requeue=false</code> сообщение попадёт в настроенный dead-letter exchange или будет отброшено. В Kafka consumer управляет позицией в partition и сохраняет offset. Если обработать запись, а затем упасть до commit, перезапущенный consumer прочитает её снова. Источник повтора разный, но проверка доменного эффекта нужна в обоих случаях.</p>\n<p>Из этого следует граница статьи: подтверждение сообщает брокеру, что текущая доставка или позиция обработаны, но не делает внешний API частью той же транзакции. Для записи в Kafka-топик Kafka описывает транзакционный путь, где offset и результат можно связать транзакцией. Для письма, платежа или другой внешней системы всё равно нужна её собственная идемпотентность и сверка.</p>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Подтверждение после записи эффекта не делает всю систему exactly-once. Между хранилищем и внешним API всё ещё может быть сбой. Ledger может быть недоступен. Внешняя система может принять запрос и не вернуть ответ. Поэтому для каждого эффекта нужна собственная стратегия: идемпотентный ключ провайдера, reconciliation, статусная модель или ручная сверка. Queue policy не выбирает её автоматически.</p>\n<p>Порядок тоже имеет цену. Если сообщения одной сущности должны выполняться последовательно, параллельные consumer-ы могут ускорить независимые задачи, но не должны незаметно обгонять друг друга. Если broker requeue-ит сообщение без ограничения, один poison может блокировать полезную работу или создавать шум. Если dead-letter route не настроен, reject с отключённым requeue может удалить сообщение. Проверяйте фактический контракт выбранного broker, а не переносите поведение из учебной модели.</p>\n<p>Эта статья не описывает production-инцидент и не утверждает, что приведённая policy достаточна для платежей, уведомлений или биллинга. Учебный код показывает только четыре инварианта: retry конечен, gap не ждут бесконечно, duplicate не создаёт второй эффект, а непонятный вход получает сохранённый ручной маршрут. Реальную готовность надо доказывать интеграционным тестом, проверкой прав и наблюдением за фактической очередью.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Решение готово к ограниченному внедрению, когда для одной тестовой доставки можно показать полный след: исходный идентификатор, конкретную позицию или delivery tag, причину, номер попытки, применённую задержку, проверку effectKey и итоговое состояние. На gap фиксируется срок ожидания. На временном сбое появляется не более заданного числа повторов. На неизвестной схеме создаётся manual record. На повторной доставке после успешного эффекта новый эффект не создаётся. При каждом исходе оператор видит, кто и почему может выполнить следующий шаг.</p>\n<p>Если хотя бы один из этих фактов нельзя получить из лога, хранилища или теста, автоматический маршрут ещё не доказан. Остановите расширение retry, восстановите контекст и сначала уточните границу ответственности.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.rabbitmq.com/docs/confirms' target='_blank' rel='noopener noreferrer'>RabbitMQ 4.3: Consumer Acknowledgements and Publisher Confirms</a> — официальный контракт подтверждений, повторной доставки и requeue.</li><li><a href='https://www.rabbitmq.com/docs/dlx' target='_blank' rel='noopener noreferrer'>RabbitMQ 4.3: Dead Letter Exchanges</a> — официальное описание dead-letter маршрута и условий, при которых сообщение переходит в него.</li><li><a href='https://kafka.apache.org/40/design/design/#message-delivery-semantics' target='_blank' rel='noopener noreferrer'>Apache Kafka 4.0: Message Delivery Semantics</a> — официальное описание at-most-once, at-least-once, повторной доставки и границ exactly-once.</li></ul>"
}