{ "index": 274, "slug": "editorial-2020-05-field-background-jobs", "title": "Фоновая задача продублировала экспорт: порядок записи, ack и карантин ошибок", "excerpt": "Разбираем два сбоя фонового worker: повторный внешний эффект после потери ack и бесконечный requeue для постоянной ошибки. Показываем порядок действий, журнал и критерий готовности.", "contentHtml": "
Пользователь запускает экспорт и через несколько минут получает два одинаковых файла. В той же очереди задача с неизвестным типом отчёта появляется снова сразу после отказа worker. Первая ошибка создаёт лишний внешний эффект: приходится выяснять, какой файл считать правильным, и чистить дубликаты. Вторая забивает worker одинаковыми исключениями. Полезные задачи ждут, а журнал растёт быстрее, чем его успевает читать дежурный инженер.
\nТезис такой: доставка сообщения и выполнение бизнес-операции — разные факты. Broker может доставить одну бизнес-задачу повторно, если не увидел подтверждение. Worker обязан сделать повтор безопасным по устойчивому ключу. Он также обязан отличать временный сбой от постоянной ошибки входных данных. Для первой ошибки нужен идемпотентный результат и правильный порядок записи. Для второй — ограничение попыток и карантин, а не безусловный requeue.
Пусть запрос создаёт задачу с jobId = export-42-2020-05. Producer публикует сообщение, а worker получает delivery с отдельным deliveryTag. Первый идентификатор относится к бизнес-операции. Второй относится к конкретной доставке на канале. Новый deliveryTag не означает новый экспорт.
Worker начинает работу, сохраняет файл и переводит задачу в succeeded. Затем соединение с broker обрывается до ack. Для broker доставка осталась неподтверждённой. После восстановления consumer получает её снова. Если обработчик каждый раз вызывает экспорт, он создаёт второй файл. Если имя строится из текущего времени, по имени файла невозможно доказать, что это повтор.
Вторая задача содержит reportKind = unknown. Worker не может обработать её ни сейчас, ни через секунду: причина находится во входных данных. Если обработчик на любое исключение отвечает nack(requeue=true), broker снова выдаёт то же сообщение. Так возникает hot loop. Повтор помогает только тогда, когда новая попытка может изменить исход.
Подтверждение не является сигналом «код вошёл в функцию». Для ручного ack оно должно означать, что consumer выполнил работу, за которую берёт ответственность. Если ack отправить до записи результата, сбой после ack может потерять работу. Если записать результат, но не сделать повтор безопасным, сбой до ack создаст дубль. Поэтому сначала фиксируют бизнес-результат, затем подтверждают delivery.
\nОдного порядка недостаточно. Повтор должен найти тот же объект по jobId, прочитать terminal state и вернуть уже сохранённый результат. Внешний ключ результата тоже должен быть детерминированным: например, reports/export-42-2020-05.csv. Это защищает путь к файлу, но не любой внешний эффект. Email-провайдер или другой HTTP-сервис должен поддерживать собственный idempotency key либо получать вызов через отдельный надёжный протокол.
Ниже — учебный псевдокод. Он показывает границу ответственности, но не является готовым клиентом RabbitMQ, не задаёт транзакционный API базы и не сообщает показатели production-нагрузки.
\nasync function handle(delivery) {\n const { jobId, reportKind } = delivery.payload;\n const job = await jobs.lockById(jobId);\n\n if (job.state === 'succeeded') {\n await delivery.ack();\n return job.resultKey;\n }\n\n if (!isKnownReport(reportKind)) {\n await jobs.markQuarantined(jobId, {\n reason: 'unknown_report_kind',\n payloadVersion: delivery.payload.version,\n });\n await delivery.nack({ requeue: false });\n return;\n }\n\n const resultKey = `reports/${jobId}.csv`;\n await exportReport({ reportKind, resultKey });\n await jobs.markSucceeded(jobId, resultKey);\n await delivery.ack();\n}\nПроверка terminal state стоит до внешнего действия. Блокировка или другой механизм защиты строки должен охватывать чтение состояния и резервирование работы. В реальной системе нужно решить, что происходит при падении между записью файла и markSucceeded. Обычно результат пишут во временный объект, затем атомарно публикуют его по детерминированному ключу и сохраняют состояние. Конкретный storage может потребовать иной протокол.
Ветвь постоянной ошибки не вызывает экспорт и не отправляет сообщение обратно в основную очередь. Она сохраняет причину и версию входных данных до отрицательного подтверждения. Затем transport направляет сообщение в настроенный dead-letter route либо в другой согласованный карантин. Если такого маршрута нет, requeue=false не создаёт архив для разбора автоматически: сообщение может быть отброшено в зависимости от конфигурации.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Для одного jobId появились два файла | Результат создаётся по случайному имени или проверка состояния стоит после эффекта | Сопоставить jobId, resultKey, порядок result_saved, succeeded и ack_sent | Читать terminal state до экспорта и сохранять результат по детерминированному ключу |
| Повторная доставка приходит после успешной записи | Соединение оборвалось до ack | Сверить журнал broker, delivery tag и запись задачи | Обработать повтор как тот же jobId, не запускать внешний эффект снова |
| attempt растёт с одной и той же причиной | Постоянную ошибку приняли за временную | Сравнить payload и код причины на соседних попытках | Сохранить причину, перевести задачу в quarantined, отключить requeue |
| После nack сообщение исчезло | Для очереди не настроен dead-letter маршрут или сообщение не должно храниться | Проверить policy, binding, routing key и журнал публикации | Настроить проверяемый карантин либо явно принять потерю как контракт |
| Задача долго стоит в очереди | Не сработал outbox, producer публикует не туда или worker не читает binding | Найти запись job, marker публикации и факт первой доставки | Проверить dispatcher и маршрут до handler; не менять handler без delivery |
Таблица задаёт порядок расследования. Сначала привяжите наблюдение к одному jobId. Не начинайте с увеличения timeout или числа retry. Эти изменения не исправят случайное имя результата, неизвестный reportKind или неверный binding.
Ручное подтверждение помогает выбрать границу ответственности, но не превращает распределённую систему в exactly-once механизм. Между записью результата и ack остаётся окно сбоя. Между отправкой ack и получением его broker тоже есть сеть. Система с повторной доставкой обычно даёт at-least-once обработку: сообщение могут обработать снова, поэтому handler должен быть идемпотентным.
\nПризнак redelivered полезен для диагностики, но он не заменяет бизнес-ключ. При повторной публикации похожее сообщение может выглядеть как новая доставка. Бизнес-правило должно опираться на jobId, уникальный ключ результата и состояние операции. Если worker выполняют несколько экземпляров, lock и уникальное ограничение должны защищать одну и ту же область.
Для временной ошибки используйте ограниченный retry с задержкой. Временной может быть недоступность хранилища, краткий сетевой отказ или ещё не созданная зависимая запись. Сохраняйте attempt, код причины и время следующей попытки. После лимита не отправляйте сообщение в бесконечный цикл. Оставьте его в карантине, чтобы инженер мог исправить данные или код и принять решение о повторном запуске.
jobId. Соберите запись задачи, payload, outbox, delivery и журнал worker в одной временной шкале.result_saved, terminal state и ack_sent. Не запускайте экспорт повторно, пока не проверили сохранённый результат.Описанный алгоритм не говорит, какая база или очередь нужна проекту. Row lock защищает только выбранную запись и не заменяет уникальное ограничение во внешнем хранилище. Состояние в базе и файл в object storage не откатываются одной транзакцией. Если процесс остановился после загрузки файла, нужна проверка существующего ключа и политика очистки незавершённых объектов.
\nЛимит retry нельзя выбрать одинаковым для всех задач. Слишком малый лимит превращает краткий сбой в карантин. Слишком большой растягивает задержку и маскирует постоянную ошибку. Backoff и лимит должны учитывать стоимость работы, срок жизни входных данных и допустимую задержку пользователя.
\nУчебный код не доказывает поведение конкретной версии broker, client library или policy. Проверяйте ack, redelivery, nack, dead-lettering и восстановление соединения на той конфигурации, которую действительно запускает сервис. Не называйте результат готовым, если проверили только in-memory переходы.
\nРешение готово, когда два независимых сценария проходят на выбранном окружении. После сбоя между сохранением результата и ack повторная доставка того же jobId не создаёт второй внешний эффект и возвращает один проверяемый resultKey. После постоянной ошибки payload задача достигает quarantined не позднее заданного лимита, причина сохраняется, а сообщение не возвращается в основную очередь. В журнале можно восстановить порядок событий без догадок.