diff --git a/editorial/agent-rewrites/275.json b/editorial/agent-rewrites/275.json index 1e4061b..4a926c6 100644 --- a/editorial/agent-rewrites/275.json +++ b/editorial/agent-rewrites/275.json @@ -3,5 +3,5 @@ "slug": "editorial-2020-05-mechanism-background-jobs", "title": "Фоновая задача без дублей: delivery, ack и карантин", "excerpt": "Как отделить бизнес-состояние фоновой задачи от доставки сообщения, поставить ack после результата и остановить бесконечный retry для неисправимых данных.", - "contentHtml": "
Пользователь запускает экспорт отчёта и получает два одинаковых файла. В другом случае worker вызывает внешний API, после чего сообщение исчезает, а результата нет. Оба симптома появляются на одной границе: приложение путает бизнес-задачу с отдельной доставкой сообщения. Цена ошибки — повторный платный вызов, дублирующий файл или письмо, потерянная работа и очередь, забитая одной неисправимой записью.
\nРабочая модель разделяет эти объекты. Бизнес-задача имеет стабильный jobId, состояние и ключ результата. Broker доставляет сообщение с собственным delivery tag. Worker проверяет состояние, выполняет эффект с устойчивым ключом, сохраняет результат и только потом подтверждает конкретную доставку через ack. Если связь оборвётся до подтверждения, broker может доставить сообщение снова. Повтор не должен создавать новый эффект для уже завершённого jobId.
Delivery отвечает на вопрос broker: кто сейчас отвечает за эту копию сообщения? Его жизненный цикл заканчивается на ack, reject или закрытии канала. При закрытии канала неподтверждённая доставка может вернуться в очередь.
Задача отвечает на вопрос приложения: что попросил пользователь, на какой попытке находится операция и где лежит результат. Её состояние должно жить в базе или другом устойчивом хранилище. Нельзя использовать delivery tag как идентификатор задачи. Tag относится к каналу и может измениться при следующей доставке той же бизнес-операции.
\n| Объект | Где живёт | Когда меняется | Для чего нужен |
|---|---|---|---|
jobId | запись задачи, payload, журнал | не меняется между повторами | состояние, дедупликация и результат |
| delivery tag | канал consumer | при новой доставке | точный ack или nack |
attempt | запись задачи или retry-сообщение | при разрешённой новой попытке | лимит повторов и диагностика |
resultKey | база и хранилище результата | один раз при успехе | доказательство готового эффекта |
Из этого разделения следует ограничение: модель даёт at-least-once delivery, а не глобальный «ровно один раз». Сообщение может прийти повторно. Поэтому эффект должен быть идемпотентным в границе задачи. Для отчёта это может быть путь reports/{jobId}.csv и уникальная запись результата. Для внешнего API нужен его собственный idempotency key. Локальная таблица не отменяет уже отправленный запрос в чужую систему.
jobId. Сначала приложение сохраняет решение, затем broker получает подтверждение или отказ.До публикации сообщения приложение создаёт запись задачи и outbox-событие в одной транзакции. Outbox хранит намерение опубликовать сообщение, пока dispatcher не получит подтверждение от выбранного broker-клиента. Такой порядок закрывает отдельную дыру: задача уже видна пользователю, но процесс публикации ещё не завершён.
\n// Учебный псевдокод: это не готовый 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}\nПример учебный. Он показывает контракт, а не измеренную производительность и не конкретную библиотеку. Функция принимает намерение и возвращает jobId. Она не держит HTTP-соединение до окончания экспорта. Dispatcher отдельно публикует событие и отмечает publishedAt после подтверждения своего клиентского API.
Ранний ack сообщает broker, что доставка обработана. Если отправить его сразу после чтения сообщения, а затем получить ошибку базы, файлового хранилища или внешнего API, broker удалит delivery, хотя бизнес-результата нет. Это путь к потере работы.
Поздний ack оставляет другое окно. Worker может сохранить результат, а соединение оборвётся до подтверждения. Broker доставит сообщение повторно. Второй worker должен прочитать terminal state и завершить только новое delivery. Он не должен повторять экспорт.
// Учебный обработчик одного 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}\nПорядок в примере — часть контракта. Состояние succeeded проверяется до эффекта. Результат получает детерминированный ключ. Статус успеха сохраняется до ack. Для внешнего вызова нужна такая же защита на стороне API: ключ операции, уникальное ограничение или запрос статуса по прежнему ключу.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Есть resultKey, но нет ack | Связь оборвалась после результата | Сравнить порядок result_saved и ack_sent | При повторе прочитать terminal state и подтвердить только delivery |
Два файла для одного jobId | Случайное имя или поздняя проверка | Сопоставить имена файлов с журналом worker | Использовать resultKey и проверять статус до эффекта |
attempt растёт с одной причиной | Постоянную ошибку отправляют в requeue | Повторить validation на сохранённом payload | Перевести задачу в quarantined и прекратить requeue |
| Задача долго queued | Не сработал outbox или маршрут | Проверить outbox, marker публикации и binding | Исправить dispatcher или маршрут, не менять handler вслепую |
Проверку ведут по одному jobId. В журнале достаточно событий received, result_saved, retry_scheduled, quarantined и ack_sent. Рядом пишут попытку, признак redelivery и безопасную причину. Полный payload, токены и пользовательские документы в журнал не кладут.
Повтор оправдан, если новое время может изменить исход: зависимость временно недоступна, сработал сетевой timeout до ответа или ожидаемая запись ещё не появилась. Но timeout после отправки запроса не доказывает, что внешний эффект не состоялся. Такой вызов повторяют только с ключом идемпотентности или после проверки статуса операции.
\nНевалидный JSON, неизвестная версия события и отсутствующее обязательное поле повтором не исправятся. Бесконечный nack(requeue=true) создаёт горячий redelivery loop. Он занимает worker и прячет полезные сообщения за одной постоянной ошибкой.
Для retry задают максимальное число попыток, причину последнего перехода и время следующего допуска. Задержка может использовать retry-очередь с TTL или другой механизм выбранного клиента. Важно, чтобы worker знал текущую попытку и не возвращал неисправимую запись в основной маршрут без изменения причины.
\nPoison message — сообщение, которое текущий consumer не может обработать автоматически. Worker сначала сохраняет причину и состояние quarantined, затем отклоняет delivery без requeue. При настроенном dead-letter exchange broker направит сообщение на отдельный маршрут. Без такой конфигурации оно может быть отброшено, поэтому карантин должен быть проверяемой частью инфраструктуры, а не только словом в коде.
Карантин не означает успех. Он означает, что автоматический путь остановился с понятной причиной. Владелец может исправить payload и переиздать задачу, обновить consumer или отменить операцию. Автоматически читать карантин обратно в основную очередь без исправления причины нельзя: loop вернётся.
\njobId и записывать его в задачу, событие, результат и журнал.queued, running, retry_wait, succeeded и quarantined.resultKey до ack конкретного delivery.quarantined, отправить отказ без requeue и проверить dead-letter маршрут.Эта схема не делает систему ровно-однократной. Два worker могут одновременно увидеть незахваченную задачу, если хранилище не даёт блокировку или уникальное ограничение. Внешний сервис может принять запрос и не вернуть ответ. Broker может иметь другую семантику подтверждений. Поэтому порядок нужно сверить с версией клиента, типом очереди и реальной политикой dead-lettering.
\nПсевдокод выше не открывает соединение с RabbitMQ, не измеряет throughput и не является production-тестом. Он ограничен учебной иллюстрацией переходов. Интеграционная проверка должна использовать выбранный broker, несколько worker, падение до ack, повтор после результата и невалидный payload после лимита.
Решение готово, когда для одного заранее известного jobId журнал показывает устойчивый результат до первого ack, повторную доставку с новым tag и отсутствие второго эффекта. Для временной ошибки видны ограниченные попытки и следующий допуск. Для невалидного payload видны причина, состояние quarantined и отсутствие немедленного requeue. Эти свойства должны воспроизводиться на интеграционном стенде выбранного broker, а не только в unit-тесте.
Пользователь запускает экспорт отчёта и получает два одинаковых файла. В другом случае worker вызывает внешний API, после чего сообщение исчезает, а результата нет. Оба симптома появляются на одной границе: приложение путает бизнес-задачу с отдельной доставкой сообщения. Цена ошибки — повторный платный вызов, дублирующий файл или письмо, потерянная работа и очередь, забитая одной неисправимой записью.
\nРабочая модель разделяет эти объекты. Бизнес-задача имеет стабильный jobId, состояние и ключ результата. Broker доставляет сообщение с собственным delivery tag. Worker проверяет состояние, выполняет эффект с устойчивым ключом, сохраняет результат и только потом подтверждает конкретную доставку через ack. Если связь оборвётся до подтверждения, broker может доставить сообщение снова. Повтор не должен создавать новый эффект для уже завершённого jobId.
Delivery отвечает на вопрос broker: кто сейчас отвечает за эту копию сообщения? В AMQP 0-9-1 delivery tag уникален только внутри канала и передаётся в ack, reject или nack. Поэтому подтверждать доставку нужно на том же канале, на котором она пришла. При закрытии канала неподтверждённая доставка может вернуться в очередь.
Задача отвечает на вопрос приложения: что попросил пользователь, на какой попытке находится операция и где лежит результат. Её состояние должно жить в базе или другом устойчивом хранилище. Нельзя использовать delivery tag как идентификатор задачи. При новой доставке tag относится к тому же каналу, но идентифицирует уже новую delivery; признак повторной доставки передаётся отдельно.
| Объект | Где живёт | Когда меняется | Для чего нужен |
|---|---|---|---|
jobId | запись задачи, payload, журнал | не меняется между повторами | состояние, дедупликация и результат |
| delivery tag | канал consumer | для каждой delivery | точный ack или nack на том же канале |
attempt | запись задачи или retry-сообщение | при разрешённой новой попытке | лимит повторов и диагностика |
resultKey | база и хранилище результата | один раз при успехе | доказательство готового эффекта |
Из этого разделения следует ограничение: модель даёт at-least-once delivery, а не глобальный «ровно один раз». Сообщение может прийти повторно. Поэтому эффект должен быть идемпотентным в границе задачи. Для отчёта это может быть путь reports/{jobId}.csv и уникальная запись результата. Для внешнего API нужен его собственный idempotency key. Локальная таблица не отменяет уже отправленный запрос в чужую систему.
jobId. Сначала приложение сохраняет решение, затем broker получает подтверждение или отказ.До публикации сообщения приложение создаёт запись задачи и outbox-событие в одной транзакции. Outbox хранит намерение опубликовать сообщение, пока dispatcher не получит подтверждение от broker-клиента. Такое подтверждение говорит, что broker принял публикацию по своему контракту; оно не доказывает, что consumer уже выполнил бизнес-эффект. Задача может быть видна пользователю, хотя публикация ещё не завершена.
\n// Учебный псевдокод: это не готовый 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}\nПример учебный. Он показывает контракт, а не измеренную производительность и не конкретную библиотеку. idempotencyKey должен иметь понятную область уникальности: иначе повтор запроса и новая операция за тот же период можно случайно слить. Функция принимает намерение и возвращает jobId. Она не держит HTTP-соединение до окончания экспорта. Dispatcher отдельно публикует событие и отмечает publishedAt после подтверждения своего клиентского API.
Ранний ack сообщает broker, что доставка обработана. Если отправить его сразу после чтения сообщения, а затем получить ошибку базы, файлового хранилища или внешнего API, broker удалит delivery, хотя бизнес-результата нет. Это путь к потере работы.
Поздний ack оставляет другое окно. Worker может сохранить результат, а соединение оборвётся до подтверждения. Broker доставит сообщение повторно. Второй worker должен прочитать terminal state и подтвердить только новое delivery. Он не должен повторять экспорт. Чтобы два worker не выполняли эффект одновременно, нужна атомарная блокировка или lease в хранилище; одного чтения состояния недостаточно.
// Учебный обработчик одного 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}\nПорядок в примере — часть контракта. claim должен атомарно исключать второй эффект или выдавать lease с понятным истечением; это проектная операция хранилища, а не гарантия RabbitMQ. Terminal state и retry_wait проверяются до эффекта. Результат получает детерминированный ключ. Статус успеха сохраняется до ack. Если nack потеряется после записи retry-intent, повторная delivery увидит retry_wait и подтвердится без нового эффекта. Для внешнего вызова нужна такая же защита на стороне API: ключ операции, уникальное ограничение или запрос статуса по прежнему ключу.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Есть resultKey, но нет ack | Связь оборвалась после результата | Сравнить порядок result_saved и ack_sent | При повторе прочитать terminal state и подтвердить только delivery |
Два файла для одного jobId | Случайное имя, гонка или поздняя проверка | Сопоставить имена файлов с журналом worker и lease | Использовать resultKey, уникальное ограничение и claim до эффекта |
attempt растёт с одной причиной | Постоянную ошибку отправляют в requeue | Повторить validation на сохранённом payload | Перевести задачу в quarantined и прекратить requeue |
| Задача долго queued | Не сработал outbox или маршрут | Проверить outbox, marker публикации и binding | Исправить dispatcher или маршрут, не менять handler вслепую |
Проверку ведут по одному jobId. В журнале достаточно событий received, result_saved, retry_scheduled, quarantined и ack_sent. Рядом пишут attempt, признак redelivered и безопасную причину. Полный payload, токены и пользовательские документы в журнал не кладут.
Повтор оправдан, если новое время может изменить исход: зависимость временно недоступна, сработал сетевой timeout до ответа или ожидаемая запись ещё не появилась. Но timeout после отправки запроса не доказывает, что внешний эффект не состоялся. Такой вызов повторяют только с ключом идемпотентности или после проверки статуса операции.
\nНевалидный JSON, неизвестная версия события и отсутствующее обязательное поле повтором не исправятся. Бесконечный nack(requeue=true) создаёт горячий redelivery loop: RabbitMQ может быстро вернуть сообщение в очередь, и consumer будет снова брать ту же запись. Он занимает worker и прячет полезные сообщения за одной постоянной ошибкой.
Для retry задают максимальное число попыток, причину последнего перехода и время следующего допуска. Задержка в этой модели создаётся retry-очередью с TTL, планировщиком или другим механизмом выбранного клиента: dispatcher публикует новую delivery после notBefore, а текущую delivery отклоняют с requeue=false. Это отличается от немедленного requeue. Попытка хранится в базе или в retry-сообщении, потому что AMQP delivery сама по себе не является счётчиком попыток.
Poison message — сообщение, которое текущий consumer не может обработать автоматически. Worker сначала сохраняет причину и состояние quarantined, затем отклоняет delivery без requeue. При настроенном dead-letter exchange broker переопубликует сообщение в отдельный exchange. Без такой конфигурации оно может быть отброшено, поэтому карантин должен быть проверяемой частью инфраструктуры, а не только словом в коде. Состояние в базе и сообщение в DLX — разные следы: нужно проверить оба.
Карантин не означает успех. Он означает, что автоматический путь остановился с понятной причиной. Владелец может исправить payload и переиздать задачу, обновить consumer или отменить операцию. Автоматически читать карантин обратно в основную очередь без исправления причины нельзя: loop вернётся.
\njobId, область уникальности idempotency key и записывать их в задачу, событие, результат и журнал.queued, running, retry_wait, succeeded и quarantined.resultKey до ack конкретного delivery на том же канале.requeue=false и проверить задержанную публикацию.quarantined, отправить отказ без requeue и проверить dead-letter маршрут.Эта схема не делает систему ровно-однократной. Два worker могут одновременно выполнить эффект, если claim, lease или уникальное ограничение реализованы неверно. Внешний сервис может принять запрос и не вернуть ответ. Broker может иметь другую семантику подтверждений. Поэтому порядок нужно сверить с версией клиента, типом очереди и реальной политикой dead-lettering.
\nПсевдокод выше не открывает соединение с RabbitMQ, не измеряет throughput и не является production-тестом. Он ограничен учебной иллюстрацией переходов. Интеграционная проверка должна использовать выбранный broker, несколько worker, падение после сохранения результата и до ack, повтор после результата, отдельный retry с задержкой и невалидный payload после лимита.
Решение готово, когда для одного заранее известного jobId журнал показывает устойчивый результат до первого ack, повторную delivery с признаком redelivered и отсутствие второго эффекта. Для временной ошибки видны сохранённый retry-intent, ограниченные попытки и следующий допуск. Для невалидного payload видны причина, состояние quarantined, отказ с requeue=false и подтверждённый маршрут DLX либо явно зафиксированное отбрасывание. Эти свойства должны воспроизводиться на интеграционном стенде выбранного broker, а не только в unit-тесте.
delivery-tag и redelivered, а также режимы подтверждения.reject/nack с requeue=false и настройка маршрута.