Files
progcode/editorial/agent-rewrites/275.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
19 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: кто сейчас отвечает за эту копию сообщения? Его жизненный цикл заканчивается на <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) =&gt; {\\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 &lt; 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>"
}