8 lines
19 KiB
JSON
8 lines
19 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: кто сейчас отвечает за эту копию сообщения? Его жизненный цикл заканчивается на <code>ack</code>, <code>reject</code> или закрытии канала. При закрытии канала неподтверждённая доставка может вернуться в очередь.</p>\n<p>Задача отвечает на вопрос приложения: что попросил пользователь, на какой попытке находится операция и где лежит результат. Её состояние должно жить в базе или другом устойчивом хранилище. Нельзя использовать delivery tag как идентификатор задачи. Tag относится к каналу и может измениться при следующей доставке той же бизнес-операции.</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>при новой доставке</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-клиента. Такой порядок закрывает отдельную дыру: задача уже видна пользователю, но процесс публикации ещё не завершён.</p>\n<pre><code>// Учебный псевдокод: это не готовый API RabbitMQ.\\nasync function requestExport(input, db) {\\n const jobId = stableId(input.accountId, input.period);\\n await db.transaction(async (tx) => {\\n await tx.insertJob({ id: jobId, state: 'queued', attempt: 0, resultKey: null });\\n await tx.insertOutbox({ type: 'report.export.requested', jobId, publishedAt: null });\\n });\\n return { accepted: true, jobId };\\n}</code></pre>\n<p>Пример учебный. Он показывает контракт, а не измеренную производительность и не конкретную библиотеку. Функция принимает намерение и возвращает <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. Он не должен повторять экспорт.</p>\n<pre><code>// Учебный обработчик одного delivery.\\nasync function handleDelivery(delivery, jobs, broker) {\\n const job = await jobs.findForUpdate(delivery.jobId);\\n if (job.state === 'succeeded' || job.state === 'quarantined') {\\n await broker.ack(delivery.tag);\\n return;\\n }\\n try {\\n await jobs.markRunning(job.id, delivery.attempt);\\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 const nextAttempt = delivery.attempt + 1;\\n await jobs.markRetryOrQuarantine(job.id, nextAttempt, error.code);\\n await broker.nack(delivery.tag, { requeue: nextAttempt < 3 });\\n }\\n}</code></pre>\n<p>Порядок в примере — часть контракта. Состояние <code>succeeded</code> проверяется до эффекта. Результат получает детерминированный ключ. Статус успеха сохраняется до <code>ack</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</td><td>Использовать <code>resultKey</code> и проверять статус до эффекта</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>. Рядом пишут попытку, признак redelivery и безопасную причину. Полный payload, токены и пользовательские документы в журнал не кладут.</p>\n<h2>Retry нужен не для любой ошибки</h2>\n<p>Повтор оправдан, если новое время может изменить исход: зависимость временно недоступна, сработал сетевой timeout до ответа или ожидаемая запись ещё не появилась. Но timeout после отправки запроса не доказывает, что внешний эффект не состоялся. Такой вызов повторяют только с ключом идемпотентности или после проверки статуса операции.</p>\n<p>Невалидный JSON, неизвестная версия события и отсутствующее обязательное поле повтором не исправятся. Бесконечный <code>nack(requeue=true)</code> создаёт горячий redelivery loop. Он занимает worker и прячет полезные сообщения за одной постоянной ошибкой.</p>\n<p>Для retry задают максимальное число попыток, причину последнего перехода и время следующего допуска. Задержка может использовать retry-очередь с TTL или другой механизм выбранного клиента. Важно, чтобы worker знал текущую попытку и не возвращал неисправимую запись в основной маршрут без изменения причины.</p>\n<h2>Карантин для poison message</h2>\n<p>Poison message — сообщение, которое текущий consumer не может обработать автоматически. Worker сначала сохраняет причину и состояние <code>quarantined</code>, затем отклоняет delivery без requeue. При настроенном dead-letter exchange broker направит сообщение на отдельный маршрут. Без такой конфигурации оно может быть отброшено, поэтому карантин должен быть проверяемой частью инфраструктуры, а не только словом в коде.</p>\n<p>Карантин не означает успех. Он означает, что автоматический путь остановился с понятной причиной. Владелец может исправить payload и переиздать задачу, обновить consumer или отменить операцию. Автоматически читать карантин обратно в основную очередь без исправления причины нельзя: loop вернётся.</p>\n<h2>Порядок действий</h2>\n<ol><li>Определить стабильный <code>jobId</code> и записывать его в задачу, событие, результат и журнал.</li><li>Разделить состояния <code>queued</code>, <code>running</code>, <code>retry_wait</code>, <code>succeeded</code> и <code>quarantined</code>.</li><li>Проверить согласованное создание outbox и задачи, а также публикацию после подтверждения broker-клиента.</li><li>Поставить проверку terminal state до необратимого эффекта.</li><li>Сохранить результат и <code>resultKey</code> до <code>ack</code> конкретного delivery.</li><li>Разделить временные, неопределённые и постоянные ошибки; для каждой задать проверку и лимит.</li><li>Для постоянной ошибки записать <code>quarantined</code>, отправить отказ без requeue и проверить dead-letter маршрут.</li><li>Прогнать повторную доставку после сохранённого результата и убедиться, что второй эффект не создаётся.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не делает систему ровно-однократной. Два worker могут одновременно увидеть незахваченную задачу, если хранилище не даёт блокировку или уникальное ограничение. Внешний сервис может принять запрос и не вернуть ответ. Broker может иметь другую семантику подтверждений. Поэтому порядок нужно сверить с версией клиента, типом очереди и реальной политикой dead-lettering.</p>\n<p>Псевдокод выше не открывает соединение с RabbitMQ, не измеряет throughput и не является production-тестом. Он ограничен учебной иллюстрацией переходов. Интеграционная проверка должна использовать выбранный broker, несколько worker, падение до <code>ack</code>, повтор после результата и невалидный payload после лимита.</p>\n<h2>Критерий готовности</h2>\n<p>Решение готово, когда для одного заранее известного <code>jobId</code> журнал показывает устойчивый результат до первого <code>ack</code>, повторную доставку с новым tag и отсутствие второго эффекта. Для временной ошибки видны ограниченные попытки и следующий допуск. Для невалидного payload видны причина, состояние <code>quarantined</code> и отсутствие немедленного requeue. Эти свойства должны воспроизводиться на интеграционном стенде выбранного 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> — формат delivery tag и операции подтверждения или отклонения.</li><li><a href=\"https://www.rabbitmq.com/docs/3.13/confirms\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Consumer Acknowledgements and Publisher Confirms</a> — ручной ack, возврат неподтверждённой доставки и риск redelivery loop.</li><li><a href=\"https://www.rabbitmq.com/docs/next/dlx\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Dead Letter Exchanges</a> — условия маршрутизации отклонённых сообщений в отдельный exchange.</li></ul>"
|
||
}
|