8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 274,
|
||
"slug": "editorial-2020-05-field-background-jobs",
|
||
"title": "Разбор: почему экспорт отчёта повторился и как остановить poison message",
|
||
"excerpt": "Учебный разбор двух путей: результат создан до потерянного ack и невалидная задача крутится в requeue. Собираем журнал, меняем порядок и вводим карантин.",
|
||
"contentHtml": "<p>Симптом в учебном разборе такой: пользователь запрашивает экспорт, а через несколько минут видит два одинаковых файла. Одновременно другая заявка с неизвестным типом отчёта снова и снова появляется у worker, занимая очередь. Цена двойная. Первый сбой создаёт лишний внешний эффект и спор, какая копия верная; второй забирает время worker и скрывает полезные задачи под повторяющейся ошибкой. Фраза «очередь доставила дважды» описывает факт, но ещё не называет место, где принято неверное решение.</p>\n<p>Разберу не production-инцидент, а анонимизированную in-memory фикстуру мая 2020 года. В ней нет реального broker, файлового хранилища, user data или измеренной нагрузки. Зато есть две детерминированные цепочки с одним <code>jobId</code> каждая: временный отказ между сохранением результата и ack, а также невалидный payload после лимита попыток. Цель — показать практическое расследование: симптом → причина → проверка → действие, а не рассказать историю успеха постфактум.</p>\n<h2>Сначала отделяем факт результата от факта delivery</h2>\n<p>Первый экспорт имеет <code>jobId = export-42-2020-05</code>. Worker получил delivery, записал CSV по устойчивому ключу и пометил задачу как <code>succeeded</code>. Затем соединение до broker оборвалось до ack. У broker остаётся непроверенное delivery, поэтому следующий worker получает ту же бизнес-задачу повторно. Если handler относится к любому received message как к новому, он снова вызывает экспорт и пишет второй файл с новым случайным именем. Это не исправляется большим timeout: проблема в том, что idempotency check находится после эффекта или отсутствует.</p>\n<p>Вторая заявка — <code>export-43-2020-05</code> — содержит неизвестный <code>reportKind</code>. Worker ловит ошибку, делает <code>nack(requeue=true)</code> и тут же получает ту же доставку снова. Никакая пауза не сделает неизвестный тип валидным. Пока задача не имеет состояния <code>quarantined</code> и ограничителя попыток, очередь по сути работает как генератор одинаковых ошибок. Здесь цена уже операционная: журнал растёт, полезная работа ждёт, а владелец данных не получает короткий список того, что нужно исправить.</p>\n<pre><code>{"event":"received","jobId":"export-43-2020-05","attempt":3,"redelivered":true}\n{"event":"validation_failed","jobId":"export-43-2020-05","reason":"unknown_report_kind"}\n{"event":"quarantined","jobId":"export-43-2020-05","queue":"jobs.quarantine"}\n{"event":"nack_sent","jobId":"export-43-2020-05","requeue":false}</code></pre>\n<p>Строки выше — синтетический журнал фикстуры, не вывод запущенного RabbitMQ consumer. Они важны именно порядком. У poison-задачи третья попытка ещё фиксирует вход и причину, затем приложение сохраняет <code>quarantined</code>, и только после этого выбранному transport посылается отрицательный ответ без requeue. В реальном AMQP дальнейшая судьба зависит от настроенного dead-letter exchange: без маршрута сообщение может быть отброшено. Поэтому «карантин» обязан существовать не только как слово в коде, но и как проверяемая конфигурация выбранного окружения.</p>\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>Есть resultKey, но нет ack_sent</td><td>сбой произошёл в узком окне после результата</td><td>job.state = succeeded раньше следующего received</td><td>при повторе не создавать результат, подтвердить новое delivery</td></tr><tr><td>Два файла для одного jobId</td><td>внешний эффект не имеет стабильного ключа либо check сделан поздно</td><td>сопоставить имена файлов и порядок journal</td><td>путь результата построить из jobId, terminal state читать до export</td></tr><tr><td>attempt растёт, причина одна и та же</td><td>постоянный payload повторно requeue</td><td>validation_failed повторяется на равных входных данных</td><td>пометить quarantined и направить в DLX/разбор</td></tr><tr><td>queued долго без received</td><td>outbox не опубликован либо worker не читает маршрут</td><td>есть job/outbox, но нет publish marker и journal delivery</td><td>проверить dispatcher, binding и конкретный broker client</td></tr></tbody></table></div>\n<p>Эта таблица не заменяет доступ к очереди. Она задаёт порядок вопросов до изменения кода. Если уже есть <code>resultKey</code>, не нужно стартовать новый экспорт «на всякий случай». Если причина <code>unknown_report_kind</code> воспроизводится из сохранённой версии payload, не нужно увеличивать retry. Если у задачи нет <code>received</code>, бесполезно рассматривать handler: сперва ищем outbox, публикацию и маршрут. Каждый шаг привязывает действие к одному наблюдаемому факту.</p>\n<figure><img src=\"/assets/editorial/2020/background-job-diagnosis-2020.svg\" alt=\"Вертикальная схема диагностики фоновой задачи: по jobId проверяют запись задачи, outbox и сообщение, затем журнал worker, сохранённый resultKey и при постоянной ошибке карантинную очередь\" loading=\"lazy\" /><figcaption>Разбор идёт не по названию компонента, а по пути одного jobId. Это уменьшает риск перезапустить эффект, когда проблема находится до worker или после результата.</figcaption></figure>\n<h2>Причина первого дубля: случайное имя и ack не на той стороне</h2>\n<p>Плохой вариант handler выглядит почти естественно: он берёт сообщение, сразу начинает генерацию, формирует имя из текущего времени, отправляет ack и только затем пытается отметить успех. В нём две точки неопределённости. Во-первых, повтору нечем доказать, что прежняя генерация уже завершилась: название файла другое, а состояние ещё не terminal. Во-вторых, ack может добраться до broker раньше записи статуса. При сбое получаем либо потерянную работу, либо повтор без защиты.</p>\n<p>Исправление на уровне одной задачи не требует общего дедупликатора. Worker сначала читает строку по <code>jobId</code> с блокировкой, проверяет terminal states и резервирует попытку. Результат записывается по детерминированному ключу <code>reports/{jobId}.csv</code>. После записи в этом же бизнес-шаге сохраняются <code>resultKey</code> и <code>succeeded</code>. Если после этого broker повторно доставит сообщение, handler видит terminal state, не пишет файл заново и только завершает текущий delivery. Для email или внешнего API нужно отдельно убедиться, что принимающая сторона поддерживает такой ключ; путь файла не решает чужой side effect.</p>\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>receive → generate random file → ack → save state</td><td>дубль или потеря при падении между шагами</td><td>receive → read job → save deterministic result + succeeded → ack</td><td>повтор видит succeeded и не создаёт второй файл</td></tr><tr><td>catch → nack(requeue=true) всегда</td><td>горячий цикл на невалидном payload</td><td>classify → retry_wait или quarantined → nack по решению</td><td>attempt ограничен, причина остаётся рядом с jobId</td></tr><tr><td>искать ошибку по времени</td><td>непонятно, к какой попытке относится строка</td><td>писать jobId, attempt, event, reason</td><td>одна цепочка читается без догадки о совпадении</td></tr></tbody></table></div>\n<p>Здесь нет обещания, что SQL-блокировка сделает worker глобально одиночным. Она лишь защищает запись задачи в границе выбранной базы. Конкурирующие worker, внешнее хранилище и сеть всё равно требуют проверяемого контракта. Поэтому практический критерий короче: каждый новый delivery для уже <code>succeeded</code> обязан завершиться без нового результата. Если это нельзя проверить, слово «идемпотентность» в код-ревью пока ничего не означает.</p>\n<h2>Причина hot loop: постоянную ошибку приняли за временную</h2>\n<p>Повтор нужен, когда новое время может изменить исход: зависимость была недоступна, лимит снят, ожидаемая запись ещё не появилась. Но <code>unknown_report_kind</code> не зависит от времени. Для такого случая worker должен назвать ошибку постоянной, сохранить её в записи и завершить автоматический путь. В AMQP отрицательный ответ без requeue может направить сообщение в DLX, если проект это настроил. Отдельный маршрут делает ошибку предметом разбора, а не бесконечным consumer workload.</p>\n<p>Карантин не стоит использовать как корзину для всех исключений. Сначала сохраняем тип причины и версию payload: это отделяет неисправимые данные от дефекта worker после обновления. Затем владелец решает: поправить данные и переиздать новую задачу с новым или тем же бизнес-ключом, починить consumer и вручную вернуть сообщение через контролируемый маршрут, либо отменить операцию. Автоматическое чтение карантина обратно в основную очередь без исправления причины снова создаёт тот же loop, только с более длинным названием.</p>\n<h2>Фикстура как маленький регрессионный контракт</h2>\n<p>В модуле ревизий есть команда <code>node scripts/upgrade-2020-05.mjs --verify-fixture</code>. Она не эмулирует AMQP frames. Она детерминированно создаёт две записи в памяти: первая переживает transient ошибку, получает повторную доставку, сохраняет result и ack; вторая после третьего неуспеха переходит в <code>quarantined</code>. Вывод — JSON-журнал и три булевых условия. Такой тест полезен тем, что не позволяет незаметно переставить <code>result_saved</code> и <code>ack_sent</code> в учебном алгоритме.</p>\n<pre><code>{"event":"received","jobId":"export-42-2020-05","attempt":1,"redelivered":false}\n{"event":"retry_scheduled","jobId":"export-42-2020-05","attempt":1,"reason":"upstream_timeout"}\n{"event":"received","jobId":"export-42-2020-05","attempt":2,"redelivered":true}\n{"event":"result_saved","jobId":"export-42-2020-05","resultKey":"reports/export-42-2020-05.csv"}\n{"event":"ack_sent","jobId":"export-42-2020-05","attempt":2}</code></pre>\n<p>Фикстура не даёт ложной уверенности в broker. У неё нет TCP-соединения, реального delivery tag, политики DLX, нескольких consumer или диска. Но она отделяет две логические проверки, которые можно выполнить без инфраструктуры: для retry есть новая попытка с тем же jobId, а успешный путь пишет result до ack; poison-путь обрывает requeue на известном пределе. После выбора библиотеки эту же пару сценариев нужно повторить на интеграционном стенде и сравнить реальные журналы с ожидаемыми переходами.</p>\n<h2>Маршрут разбора перед исправлением</h2>\n<ol><li>Взять один конкретный jobId и собрать рядом запись задачи, outbox, журнал worker, ключ результата и информацию о current attempt.</li><li>Проверить, в каком порядке появились result_saved, succeeded и ack_sent; не делать новый экспорт, пока это не ясно.</li><li>Для повтора сравнить jobId, а не delivery tag: новый tag не означает новую бизнес-операцию.</li><li>Классифицировать последнюю ошибку как временную, неопределённую внешнюю или постоянную; записать основание рядом с attempt.</li><li>Для постоянной ошибки остановить requeue, перевести job в quarantined и проверить, что выбранная DLX/карантинная поверхность действительно принимает сообщение.</li><li>После изменения прогнать in-memory фикстуру, затем отдельный broker-интеграционный сценарий с падением до ack; в этом пакете выполнен только первый шаг.</li></ol>\n<h2>Граница полевого разбора</h2>\n<p>Все идентификаторы, причины и строки журнала здесь придуманы для проверки переходов. Нет реального файла, заказчика, очереди, RabbitMQ policy, production-config или browser-действия. Тексты опираются на спецификацию AMQP и официальную документацию RabbitMQ, чтобы не выдумывать смысл ack, reject и redelivery, но не выдают современную документацию за снимок конкретной инфраструктуры мая 2020 года. Автор этого периода умеет провести узкое backend/delivery расследование и оставить route для стенда; он ещё не заявляет готовую платформу наблюдаемости или сложную оркестрацию.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rabbitmq.com/resources/specs/amqp-xml-doc0-9-1.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">AMQP 0-9-1 specification: basic.ack и basic.reject</a> — первичная спецификация: delivery tag адресует доставку, а basic.reject с requeue управляет возвратом или отказом от сообщения</li><li><a href=\"https://www.rabbitmq.com/docs/confirms\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Consumer Acknowledgements and Publisher Confirms</a> — официальное описание ручного ack, автоматического requeue не подтверждённой доставки и риска немедленного redelivery loop</li><li><a href=\"https://www.rabbitmq.com/docs/reliability\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Reliability guide</a> — официальная граница между подтверждением доставки и обработкой, а также необходимость идемпотентного consumer при повторной доставке</li><li><a href=\"https://www.rabbitmq.com/docs/dlx\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Dead Letter Exchanges</a> — официальное описание маршрутизации отклонённого сообщения в отдельный exchange и причин dead-lettering</li></ul>"
|
||
}
|