8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 275,
|
||
"slug": "editorial-2020-05-mechanism-background-jobs",
|
||
"title": "Фоновая задача без дублей: delivery, ack и карантин",
|
||
"excerpt": "Как отделить бизнес-состояние фоновой задачи от доставки сообщения, поставить ack после результата и остановить бесконечный retry для неисправимых данных.",
|
||
"contentHtml": "<p>Пользователь запускает экспорт отчёта и получает два одинаковых файла. В другом случае worker вызывает внешний API, после чего сообщение исчезает, а результата нет. Оба симптома появляются на одной границе: приложение путает бизнес-задачу с отдельной доставкой сообщения. Цена ошибки — повторный платный вызов, дублирующий файл или письмо, потерянная работа и очередь, забитая одной неисправимой записью.</p>\n<p>Рабочая модель разделяет эти объекты. Бизнес-задача имеет стабильный <code>jobId</code>, состояние и ключ результата. Broker доставляет сообщение с собственным <code>delivery tag</code>. Worker проверяет состояние, выполняет эффект с устойчивым ключом, сохраняет результат и только потом подтверждает конкретную доставку через <code>ack</code>. Если связь оборвётся до подтверждения, broker может доставить сообщение снова. Повтор не должен создавать новый эффект для уже завершённого <code>jobId</code>.</p>\n<h2>Delivery и задача отвечают на разные вопросы</h2>\n<p>Delivery отвечает на вопрос broker: кто сейчас отвечает за эту копию сообщения? В AMQP 0-9-1 <code>delivery tag</code> уникален только внутри канала и передаётся в <code>ack</code>, <code>reject</code> или <code>nack</code>. Поэтому подтверждать доставку нужно на том же канале, на котором она пришла. При закрытии канала неподтверждённая доставка может вернуться в очередь.</p>\n<p>Задача отвечает на вопрос приложения: что попросил пользователь, на какой попытке находится операция и где лежит результат. Её состояние должно жить в базе или другом устойчивом хранилище. Нельзя использовать <code>delivery tag</code> как идентификатор задачи. При новой доставке tag относится к тому же каналу, но идентифицирует уже новую delivery; признак повторной доставки передаётся отдельно.</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><code>jobId</code></td><td>запись задачи, payload, журнал</td><td>не меняется между повторами</td><td>состояние, дедупликация и результат</td></tr><tr><td>delivery tag</td><td>канал consumer</td><td>для каждой delivery</td><td>точный <code>ack</code> или <code>nack</code> на том же канале</td></tr><tr><td><code>attempt</code></td><td>запись задачи или retry-сообщение</td><td>при разрешённой новой попытке</td><td>лимит повторов и диагностика</td></tr><tr><td><code>resultKey</code></td><td>база и хранилище результата</td><td>один раз при успехе</td><td>доказательство готового эффекта</td></tr></tbody></table></div>\n<p>Из этого разделения следует ограничение: модель даёт at-least-once delivery, а не глобальный «ровно один раз». Сообщение может прийти повторно. Поэтому эффект должен быть идемпотентным в границе задачи. Для отчёта это может быть путь <code>reports/{jobId}.csv</code> и уникальная запись результата. Для внешнего API нужен его собственный idempotency key. Локальная таблица не отменяет уже отправленный запрос в чужую систему.</p>\n<figure><img src=\"/assets/editorial/2020/background-job-retry-boundary-2020.svg\" alt=\"Схема границы повторов: worker получает delivery, сохраняет результат и только затем отправляет ack; временная ошибка попадает в ограниченный retry, а невалидная задача — в карантин\" loading=\"lazy\" /><figcaption>Повтор относится к delivery, а состояние — к <code>jobId</code>. Сначала приложение сохраняет решение, затем broker получает подтверждение или отказ.</figcaption></figure>\n<h2>Минимальный контракт задачи</h2>\n<p>До публикации сообщения приложение создаёт запись задачи и outbox-событие в одной транзакции. Outbox хранит намерение опубликовать сообщение, пока dispatcher не получит подтверждение от broker-клиента. Такое подтверждение говорит, что broker принял публикацию по своему контракту; оно не доказывает, что consumer уже выполнил бизнес-эффект. Задача может быть видна пользователю, хотя публикация ещё не завершена.</p>\n<pre><code>// Учебный псевдокод: это не готовый API RabbitMQ.\nasync function requestExport(input, db) {\n // В реальном проекте уникальность задаёт выбранная бизнес-граница.\n const jobId = stableId(input.accountId, input.idempotencyKey);\n await db.transaction(async (tx) => {\n await tx.insertJob({\n id: jobId,\n state: 'queued',\n attempt: 0,\n resultKey: null,\n payload: input.payload,\n });\n await tx.insertOutbox({\n type: 'report.export.requested',\n jobId,\n publishedAt: null,\n });\n });\n return { accepted: true, jobId };\n}</code></pre>\n<p>Пример учебный. Он показывает контракт, а не измеренную производительность и не конкретную библиотеку. <code>idempotencyKey</code> должен иметь понятную область уникальности: иначе повтор запроса и новая операция за тот же период можно случайно слить. Функция принимает намерение и возвращает <code>jobId</code>. Она не держит HTTP-соединение до окончания экспорта. Dispatcher отдельно публикует событие и отмечает <code>publishedAt</code> после подтверждения своего клиентского API.</p>\n<h2>Ack ставим после устойчивого результата</h2>\n<p>Ранний <code>ack</code> сообщает broker, что доставка обработана. Если отправить его сразу после чтения сообщения, а затем получить ошибку базы, файлового хранилища или внешнего API, broker удалит delivery, хотя бизнес-результата нет. Это путь к потере работы.</p>\n<p>Поздний <code>ack</code> оставляет другое окно. Worker может сохранить результат, а соединение оборвётся до подтверждения. Broker доставит сообщение повторно. Второй worker должен прочитать terminal state и подтвердить только новое delivery. Он не должен повторять экспорт. Чтобы два worker не выполняли эффект одновременно, нужна атомарная блокировка или lease в хранилище; одного чтения состояния недостаточно.</p>\n<pre><code>// Учебный обработчик одного delivery.\n// jobs.claim атомарно захватывает job или возвращает terminal state.\nasync function handleDelivery(delivery, jobs, broker) {\n const job = await jobs.claim(delivery.jobId);\n if (job.state === 'succeeded' || job.state === 'quarantined' || job.state === 'retry_wait') {\n await broker.ack(delivery.tag);\n return;\n }\n try {\n const resultKey = await writeReportOnce(job.id, job.payload);\n await jobs.markSucceeded(job.id, resultKey);\n await broker.ack(delivery.tag);\n } catch (error) {\n // attempt хранится в задаче, а не в AMQP delivery.\n const nextAttempt = job.attempt + 1;\n if (isTransient(error) && nextAttempt < 3) {\n // Состояние и retry-intent записываются одной транзакцией.\n await jobs.markRetryWaitWithOutbox(job.id, nextAttempt, error.code);\n } else {\n await jobs.markQuarantined(job.id, nextAttempt, error.code);\n }\n // Задержанный retry публикуется отдельным dispatcher; не requeue-им delivery сразу.\n await broker.nack(delivery.tag, { requeue: false });\n }\n}</code></pre>\n<p>Порядок в примере — часть контракта. <code>claim</code> должен атомарно исключать второй эффект или выдавать lease с понятным истечением; это проектная операция хранилища, а не гарантия RabbitMQ. Terminal state и <code>retry_wait</code> проверяются до эффекта. Результат получает детерминированный ключ. Статус успеха сохраняется до <code>ack</code>. Если <code>nack</code> потеряется после записи retry-intent, повторная delivery увидит <code>retry_wait</code> и подтвердится без нового эффекта. Для внешнего вызова нужна такая же защита на стороне API: ключ операции, уникальное ограничение или запрос статуса по прежнему ключу.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Карта диагностики одного jobId</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Есть <code>resultKey</code>, но нет <code>ack</code></td><td>Связь оборвалась после результата</td><td>Сравнить порядок <code>result_saved</code> и <code>ack_sent</code></td><td>При повторе прочитать terminal state и подтвердить только delivery</td></tr><tr><td>Два файла для одного <code>jobId</code></td><td>Случайное имя, гонка или поздняя проверка</td><td>Сопоставить имена файлов с журналом worker и lease</td><td>Использовать <code>resultKey</code>, уникальное ограничение и claim до эффекта</td></tr><tr><td><code>attempt</code> растёт с одной причиной</td><td>Постоянную ошибку отправляют в requeue</td><td>Повторить validation на сохранённом payload</td><td>Перевести задачу в <code>quarantined</code> и прекратить requeue</td></tr><tr><td>Задача долго queued</td><td>Не сработал outbox или маршрут</td><td>Проверить outbox, marker публикации и binding</td><td>Исправить dispatcher или маршрут, не менять handler вслепую</td></tr></tbody></table></div>\n<p>Проверку ведут по одному <code>jobId</code>. В журнале достаточно событий <code>received</code>, <code>result_saved</code>, <code>retry_scheduled</code>, <code>quarantined</code> и <code>ack_sent</code>. Рядом пишут attempt, признак <code>redelivered</code> и безопасную причину. Полный payload, токены и пользовательские документы в журнал не кладут.</p>\n<h2>Retry нужен не для любой ошибки</h2>\n<p>Повтор оправдан, если новое время может изменить исход: зависимость временно недоступна, сработал сетевой timeout до ответа или ожидаемая запись ещё не появилась. Но timeout после отправки запроса не доказывает, что внешний эффект не состоялся. Такой вызов повторяют только с ключом идемпотентности или после проверки статуса операции.</p>\n<p>Невалидный JSON, неизвестная версия события и отсутствующее обязательное поле повтором не исправятся. Бесконечный <code>nack(requeue=true)</code> создаёт горячий redelivery loop: RabbitMQ может быстро вернуть сообщение в очередь, и consumer будет снова брать ту же запись. Он занимает worker и прячет полезные сообщения за одной постоянной ошибкой.</p>\n<p>Для retry задают максимальное число попыток, причину последнего перехода и время следующего допуска. Задержка в этой модели создаётся retry-очередью с TTL, планировщиком или другим механизмом выбранного клиента: dispatcher публикует новую delivery после <code>notBefore</code>, а текущую delivery отклоняют с <code>requeue=false</code>. Это отличается от немедленного requeue. Попытка хранится в базе или в retry-сообщении, потому что AMQP delivery сама по себе не является счётчиком попыток.</p>\n<h2>Карантин для poison message</h2>\n<p>Poison message — сообщение, которое текущий consumer не может обработать автоматически. Worker сначала сохраняет причину и состояние <code>quarantined</code>, затем отклоняет delivery без requeue. При настроенном dead-letter exchange broker переопубликует сообщение в отдельный exchange. Без такой конфигурации оно может быть отброшено, поэтому карантин должен быть проверяемой частью инфраструктуры, а не только словом в коде. Состояние в базе и сообщение в DLX — разные следы: нужно проверить оба.</p>\n<p>Карантин не означает успех. Он означает, что автоматический путь остановился с понятной причиной. Владелец может исправить payload и переиздать задачу, обновить consumer или отменить операцию. Автоматически читать карантин обратно в основную очередь без исправления причины нельзя: loop вернётся.</p>\n<h2>Порядок действий</h2>\n<ol><li>Определить стабильный <code>jobId</code>, область уникальности idempotency key и записывать их в задачу, событие, результат и журнал.</li><li>Разделить состояния <code>queued</code>, <code>running</code>, <code>retry_wait</code>, <code>succeeded</code> и <code>quarantined</code>.</li><li>Проверить согласованное создание outbox и задачи, а также публикацию после подтверждения broker-клиента.</li><li>Поставить атомарный claim или lease и проверку terminal state до необратимого эффекта.</li><li>Сохранить результат и <code>resultKey</code> до <code>ack</code> конкретного delivery на том же канале.</li><li>Разделить временные, неопределённые и постоянные ошибки; для каждой задать проверку и лимит.</li><li>Для временной ошибки записать retry-intent, отклонить текущую delivery с <code>requeue=false</code> и проверить задержанную публикацию.</li><li>Для постоянной ошибки записать <code>quarantined</code>, отправить отказ без requeue и проверить dead-letter маршрут.</li><li>Прогнать повторную доставку после сохранённого результата и убедиться, что второй эффект не создаётся.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не делает систему ровно-однократной. Два worker могут одновременно выполнить эффект, если claim, lease или уникальное ограничение реализованы неверно. Внешний сервис может принять запрос и не вернуть ответ. Broker может иметь другую семантику подтверждений. Поэтому порядок нужно сверить с версией клиента, типом очереди и реальной политикой dead-lettering.</p>\n<p>Псевдокод выше не открывает соединение с RabbitMQ, не измеряет throughput и не является production-тестом. Он ограничен учебной иллюстрацией переходов. Интеграционная проверка должна использовать выбранный broker, несколько worker, падение после сохранения результата и до <code>ack</code>, повтор после результата, отдельный retry с задержкой и невалидный payload после лимита.</p>\n<h2>Критерий готовности</h2>\n<p>Решение готово, когда для одного заранее известного <code>jobId</code> журнал показывает устойчивый результат до первого <code>ack</code>, повторную delivery с признаком <code>redelivered</code> и отсутствие второго эффекта. Для временной ошибки видны сохранённый retry-intent, ограниченные попытки и следующий допуск. Для невалидного payload видны причина, состояние <code>quarantined</code>, отказ с <code>requeue=false</code> и подтверждённый маршрут DLX либо явно зафиксированное отбрасывание. Эти свойства должны воспроизводиться на интеграционном стенде выбранного broker, а не только в unit-тесте.</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</a> — поля consumer delivery, включая <code>delivery-tag</code> и <code>redelivered</code>, а также режимы подтверждения.</li><li><a href=\"https://www.rabbitmq.com/docs/3.13/confirms\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ 3.13: Consumer Acknowledgements and Publisher Confirms</a> — область delivery tag, ручной ack, возврат неподтверждённой delivery и риск redelivery loop.</li><li><a href=\"https://www.rabbitmq.com/docs/3.13/dlx\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ 3.13: Dead Letter Exchanges</a> — условия dead-lettering при <code>reject</code>/<code>nack</code> с <code>requeue=false</code> и настройка маршрута.</li></ul>"
|
||
}
|