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

8 lines
22 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": 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) =&gt; {\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) &amp;&amp; nextAttempt &lt; 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>"
}