diff --git a/editorial/agent-rewrites/246.json b/editorial/agent-rewrites/246.json index b3a046a..8257543 100644 --- a/editorial/agent-rewrites/246.json +++ b/editorial/agent-rewrites/246.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-03-practice-queues", "title": "Очередь задач без иллюзий: повтор, порядок и право на эффект", "excerpt": "Двойная отправка и застрявшие сообщения начинаются не с выбора брокера, а с неявного контракта. Разбираем идентификаторы, локальный порядок, ограниченный retry, защиту эффекта и ручной маршрут.", - "contentHtml": "
Письмо ушло дважды. Изменение статуса пришло раньше предыдущего. Сообщение с новой схемой возвращается к одному и тому же worker-у. Эти симптомы появляются после первого успешного запуска очереди, когда команда уже умеет принимать работу, но ещё не договорилась, что считать выполнением.
\nЦена ошибки — повторный платёж, второй файл, неверный статус или потерянная задача. Ещё дороже ручной разбор без ответа на три вопроса: какой эффект уже создан, кому принадлежит порядок и почему сообщение нельзя обработать автоматически.
\nОчередь отделяет того, кто создаёт задачу, от того, кто выполняет действие. Producer записывает намерение. Consumer читает его позже и создаёт побочный эффект: отправляет письмо, меняет запись, вызывает внешний API или формирует файл. Между этими моментами может оборваться процесс. Consumer может успеть создать эффект, но не успеть подтвердить доставку. Брокер тогда вправе выдать ту же задачу снова.
\nПоэтому сообщение нужно связывать не только с payload. В конверте должны быть наблюдаемый id, ключ конкретного эффекта effectKey, ключ объекта, внутри которого важен порядок, и версия схемы. Эти поля отвечают на разные вопросы. Один случайный UUID не заменяет все четыре правила.
| Поле или правило | Вопрос | Проверка | Действие при нарушении |
|---|---|---|---|
id | Как найти историю этой задачи? | Значение непустое и не меняется при повторной доставке | Остановить обработку и сохранить вход |
effectKey | Какой эффект нельзя создать второй раз? | Поискать ключ в ledger до внешнего действия | Подавить duplicate или передать на разбор |
sequenceKey и номер | В каком порядке применяются изменения? | Сравнить с последним принятым номером этого объекта | Отложить gap или остановить конфликт |
| retry policy | Какая ошибка действительно временная? | Сопоставить причину с известным классом и лимитом | Сделать ограниченный retry или завершить автоматический путь |
| manual route | Куда попадёт непонятный вход? | Есть причина, попытки и следующий обязательный check | Сохранить запись, не удалять сообщение молча |
id нужен для расследования. effectKey защищает результат. sequenceKey ограничивает область порядка. Retry управляет временем, но не исправляет неправильные данные. Если смешать эти роли, логика начинает принимать решение по полю, которое для него не предназначено.
Возьмём учебную задачу о напоминании по счёту. Значения ниже вымышлены. Функции работают только с объектами в памяти Node.js. Они не подключаются к RabbitMQ, Kafka, базе или внешнему API. Их задача — сделать границы решения видимыми.
\nconst message = {\n id: 'msg-order-417',\n kind: 'invoice.reminder',\n effectKey: 'invoice-417:reminder',\n sequenceKey: 'invoice-417',\n sequence: 4,\n payload: { invoiceId: '417', schema: '2021-03' }\n};\nConsumer сначала проверяет форму сообщения и версию payload. Затем он проверяет порядок, если домен его требует. После этого он ищет effectKey в ledger. Только если ключ отсутствует, обработчик получает право создать эффект и записать подтверждение. В рабочей системе запись ledger и изменение локального состояния должны иметь согласованную транзакционную границу. Внешний вызов всё равно требует отдельного правила неопределённого ответа.
function decide(message, state) {\n if (!message?.id || !message.effectKey) {\n return { status: 'manual-review', reason: 'missing-identity' };\n }\n\n if (message.payload?.schema !== '2021-03') {\n return { status: 'manual-review', reason: 'unsupported-schema' };\n }\n\n const last = state.lastSequence[message.sequenceKey] ?? 0;\n if (message.sequence <= last) {\n return state.effects.has(message.effectKey)\n ? { status: 'duplicate-suppressed' }\n : { status: 'stale-or-conflicting' };\n }\n\n if (message.sequence > last + 1) {\n return { status: 'manual-review', reason: 'sequence-gap' };\n }\n\n if (state.effects.has(message.effectKey)) {\n return { status: 'duplicate-suppressed' };\n }\n\n return { status: 'apply-and-record' };\n}\nФункция не говорит, что делать с внешним сервисом. Она только разделяет исходы. Повтор с уже записанным эффектом не запускает второе действие. Пропуск номера не разрешает обработать более новое изменение поверх неизвестного состояния. Старая запись не возвращает объект назад. Неизвестная схема не становится временной ошибкой из-за удобства.
\nДве независимые задачи можно выполнять параллельно. Но изменения одного счёта или заказа часто имеют порядок. Задача с номером 12 может зависеть от результата 11. Это не означает, что нужно остановить всю очередь. Правило действует внутри sequenceKey. Worker может обрабатывать другие ключи, пока сообщение с gap ждёт пропущенную запись или ручного решения.
Не называйте порядок гарантированным только потому, что брокер хранит сообщения последовательно. При нескольких consumer-ах доставка и завершение обработки могут пересечься. Даже один consumer может потерять подтверждение после побочного эффекта. Отдельно проверяйте порядок доставки, порядок применения и порядок записи результата. Это три разных свойства.
\nВременная причина имеет наблюдаемое условие и предел ожидания. Например, учебная зависимость не ответила на первом вызове, а контракт допускает одну повторную попытку через 1000 мс. Это не доказательство восстановления сервиса и не универсальная настройка. Это только ограниченная ветка модели.
\nНеподдерживаемая схема, пропущенная последовательность и отсутствующий ключ не становятся временными от повторения. Если классификация не уверена, сохраните сообщение для проверки. Бесконечный requeue создаёт шум, удерживает worker и скрывает полезные задачи.
\nfunction nextAttempt(attempt, reason) {\n const temporary = reason === 'dependency-not-ready';\n if (!temporary) return { status: 'manual-review', reason };\n if (attempt >= 2) return { status: 'manual-review', reason: 'retry-limit' };\n return { status: 'retry', delayMs: 1000 };\n}\nЛимит выбирают по времени ожидания зависимости, допустимой нагрузке и цене ручного разбора. Число из примера нельзя переносить в рабочую систему без этих проверок. После исчерпания лимита автоматический маршрут заканчивается. Новое решение должно появиться явно: исправить вход, отменить эффект или подготовить контролируемый replay.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Эффект появился дважды | Подтверждение потерялось после действия | Сверить effectKey, ledger и время эффекта | Подавить duplicate; отдельно расследовать неопределённый первый ответ |
| Номер 12 пришёл до 11 | Gap или параллельное завершение | Найти 11, deferred-запись и владельца порядка | Не применять 12; сохранить контекст до восстановления порядка |
| Одна ошибка повторяется | Terminal input ошибочно признан временным | Проверить класс причины и счётчик попыток | Остановить цикл; создать manual record |
| Сообщение исчезло | Ack отправлен до записи результата | Сопоставить момент ack с записью эффекта | Исправить порядок подтверждения; восстановить задачу из журнала |
| Старая задача меняет новое состояние | Нет проверки версии или sequence | Сверить последний номер объекта и id сообщения | Отклонить stale input; не перезаписывать состояние назад |
Самый неприятный случай не выглядит как обычная ошибка. Consumer вызвал внешний API. API мог принять запрос, но ответ пропал по сети. Consumer не знает результата и не должен автоматически считать его неуспешным. Повтор без ключа создаст второй эффект. Ack без проверки может потерять задачу. У этой ситуации должен быть отдельный статус, например effect-result-unknown, и способ проверить внешний факт.
Ledger помогает только там, где он действительно связан с эффектом. Локальная запись «мы собирались отправить письмо» не доказывает, что письмо принял внешний провайдер. Для денег, доступа и других дорогих действий нужен provider id, ответ владельца эффекта или ручное решение с журналом. Не подменяйте отсутствие ответа успехом.
\nid, effectKey, sequenceKey, номер, payload version, попытку и время.Идемпотентный ключ не делает два независимых сервиса одной транзакцией. Ack не гарантирует, что внешний эффект завершён. Версия не гарантирует доставку следующего события. Dead-letter маршрут не заменяет владельца решения. Гарантия «exactly once» от транспорта не означает, что прикладной эффект невозможно повторить. Эти свойства нужно проверять на границах конкретной системы.
\nПример выше учебный. Он не запускает брокер, базу, сеть или внешний сервис и не сообщает измерений нагрузки. Его можно использовать только для проверки формы контракта: duplicate не создаёт второй эффект, gap не применяется молча, stale input не откатывает состояние, а непонятное сообщение получает конечный маршрут.
\nКонтракт готов к реализации, когда для одной задачи можно показать цепочку «сообщение → решение consumer-а → запись эффекта → подтверждение» и ответить, что происходит при обрыве после каждого шага. Повторная доставка с тем же effectKey не создаёт новый результат. Gap сохраняется и не меняет состояние. Ошибка вне временного класса прекращает retry. По этим четырём проверкам решение можно обсуждать с владельцем домена и выбирать конкретный broker.
redelivered у доставки.На тестовом стенде инженер запустил worker для очереди напоминаний и сразу увидел три разных симптома: письмо ушло дважды, новый статус пришёл раньше старого, а сообщение с новой схемой снова досталось тому же worker-у. Это учебная сцена, но набор ошибок реален для любой системы, где приём задачи и побочный эффект разделены во времени.
\nСначала он проверил только размер очереди и доступность процесса. После этого повторил чтение и обнаружил цену такой проверки: второй платёж, лишний файл, неверное состояние записи или потерянная задача. Одной метрики «очередь не пустая» недостаточно. Нужно знать, какой эффект уже создан, кому принадлежит порядок и почему конкретное сообщение нельзя обрабатывать автоматически.
\nПредставим короткий запуск на Redis Streams. Producer добавляет задачу в поток, consumer читает её через группу и после обработки отправляет XACK. Если процесс завершился после внешнего действия, но до подтверждения, запись останется в Pending Entries List (PEL) — списке выданных, но ещё не подтверждённых сообщений. Новый consumer сможет подобрать её для повторной обработки. Поэтому XACK — не защита от повторного эффекта, а отметка о завершённой работе.
# Redis 5+; команды выполняются в redis-cli\nXADD jobs * type reminder invoice_id 417\nXGROUP CREATE jobs reminders $ MKSTREAM\nXREADGROUP GROUP reminders worker-1 COUNT 1 BLOCK 5000 STREAMS jobs >\n# Подставьте ID из ответа XREADGROUP\nXACK jobs reminders <message-id>\nЗнак > в XREADGROUP просит новые записи группы. В реальном ответе Redis будет ID сообщения; его и передают в XACK. Для восстановления после падения нужно отдельно читать собственные pending-записи и проверять, не был ли внешний эффект создан до сбоя. Эти команды демонстрируют маршрут сообщения, но не делают отправку напоминания идемпотентной.
Producer записывает намерение, consumer читает его позже и создаёт побочный эффект: отправляет письмо, меняет запись, вызывает внешний API или формирует файл. Между этими моментами может оборваться процесс. Consumer может успеть создать эффект, но не успеть подтвердить обработку. Брокер или поток тогда вправе выдать ту же задачу снова.
\nПоэтому сообщение нужно связывать не только с payload. В конверте должны быть наблюдаемый id, ключ конкретного эффекта effectKey, ключ объекта, внутри которого важен порядок, и версия схемы. Эти значения отвечают на разные вопросы. Один UUID не заменяет весь контракт, а транспортный ID не обязан совпадать с ключом бизнес-эффекта.
| Поле или правило | Вопрос | Проверка | Действие при нарушении |
|---|---|---|---|
id | Как найти историю этой задачи? | Значение непустое и не меняется при повторной доставке | Остановить обработку и сохранить вход |
effectKey | Какой эффект нельзя создать второй раз? | Поискать ключ в журнале эффектов до внешнего действия | Подавить повтор или передать на разбор |
sequenceKey и номер | В каком порядке применяются изменения? | Сравнить с последним принятым номером этого объекта | Отложить пропуск или остановить конфликт |
| retry policy | Какая ошибка действительно временная? | Сопоставить причину с известным классом и лимитом | Сделать ограниченный повтор или завершить автоматический путь |
| manual route | Куда попадёт непонятный вход? | Есть причина, попытки и следующий обязательный шаг | Сохранить запись, не удалять сообщение молча |
id нужен для расследования. effectKey защищает результат. sequenceKey ограничивает область порядка. Политика retry управляет временем, но не исправляет неправильные данные. Если смешать эти роли, логика начинает принимать решение по полю, которое для него не предназначено.
Возьмём учебную задачу о напоминании по счёту. Значения вымышлены. Функции работают только с объектами в памяти Node.js: они не подключаются к RabbitMQ, Kafka, Redis, базе или внешнему API. Их задача — сделать границы решения видимыми.
\nconst message = {\n id: 'msg-order-417',\n kind: 'invoice.reminder',\n effectKey: 'invoice-417:reminder',\n sequenceKey: 'invoice-417',\n sequence: 4,\n payload: { invoiceId: '417', schema: '2021-03' }\n};\n\nconst state = {\n lastSequence: { 'invoice-417': 3 },\n effects: new Set()\n};\nВ этом состоянии номер 3 уже принят, поэтому сообщение с номером 4 можно рассматривать как следующий шаг. Если начать с пустого состояния, тот же пример корректно попадёт в ветку sequence-gap: функция не угадывает, что произошло с номерами 1–3.
Consumer сначала проверяет форму сообщения и версию payload. Затем проверяет порядок, если домен его требует. После этого ищет effectKey в журнале эффектов. Только если ключ отсутствует, обработчик получает право создать эффект и записать подтверждение. В рабочей системе запись журнала и изменение локального состояния должны иметь согласованную транзакционную границу. Внешний вызов всё равно требует отдельного правила неопределённого ответа.
function decide(message, state) {\n if (\n !message?.id\n || !message.effectKey\n || !message.sequenceKey\n || !Number.isInteger(message.sequence)\n ) {\n return { status: 'manual-review', reason: 'invalid-contract' };\n }\n\n if (message.payload?.schema !== '2021-03') {\n return { status: 'manual-review', reason: 'unsupported-schema' };\n }\n\n const last = state.lastSequence[message.sequenceKey] ?? 0;\n if (message.sequence <= last) {\n return state.effects.has(message.effectKey)\n ? { status: 'duplicate-suppressed' }\n : { status: 'stale-or-conflicting' };\n }\n\n if (message.sequence > last + 1) {\n return { status: 'manual-review', reason: 'sequence-gap' };\n }\n\n if (state.effects.has(message.effectKey)) {\n return { status: 'duplicate-suppressed' };\n }\n\n return { status: 'apply-and-record' };\n}\n\nconsole.log(decide(message, state));\n// { status: 'apply-and-record' }\nФункция разделяет исходы, но не вызывает внешний сервис. Повтор с уже записанным эффектом не запускает второе действие. Пропуск номера не разрешает обработать более новое изменение поверх неизвестного состояния. Старая запись не возвращает объект назад. Неизвестная схема не становится временной ошибкой из-за удобства.
\nДве независимые задачи можно выполнять параллельно. Но изменения одного счёта или заказа часто имеют порядок. Задача с номером 12 может зависеть от результата 11. Это не означает, что нужно остановить всю очередь. Правило действует внутри sequenceKey. Worker может обрабатывать другие ключи, пока сообщение с gap ждёт пропущенную запись или ручного решения.
Не называйте порядок гарантированным только потому, что брокер хранит сообщения последовательно. При нескольких consumer-ах доставка и завершение обработки могут пересечься. Даже один consumer может потерять подтверждение после побочного эффекта. Отдельно проверяйте порядок доставки, порядок применения и порядок записи результата. Это три разных свойства.
\nВременная причина имеет наблюдаемое условие и предел ожидания. Например, учебная зависимость не ответила на первом вызове, а контракт допускает одну повторную попытку через 1000 мс. Это не доказательство восстановления сервиса и не универсальная настройка. Это только ограниченная ветка модели.
\nНеподдерживаемая схема, пропущенная последовательность и отсутствующий ключ не становятся временными от повторения. Если классификация не уверена, сохраните сообщение для проверки. Бесконечный requeue создаёт шум, удерживает worker и скрывает полезные задачи.
\nfunction nextAttempt(completedAttempts, reason) {\n const temporary = reason === 'dependency-not-ready';\n\n if (!temporary) {\n return { status: 'manual-review', reason };\n }\n\n if (completedAttempts >= 1) {\n return { status: 'manual-review', reason: 'retry-limit' };\n }\n\n return { status: 'retry', delayMs: 1000 };\n}\n\nconsole.log(nextAttempt(0, 'dependency-not-ready'));\n// { status: 'retry', delayMs: 1000 }\nconsole.log(nextAttempt(1, 'dependency-not-ready'));\n// { status: 'manual-review', reason: 'retry-limit' }\nЗдесь completedAttempts — число уже завершившихся неуспешных попыток. Лимит выбирают по времени ожидания зависимости, допустимой нагрузке и цене ручного разбора. Число из примера нельзя переносить в рабочую систему без этих проверок. После исчерпания лимита автоматический маршрут заканчивается. Новое решение должно появиться явно: исправить вход, отменить эффект или подготовить контролируемый replay.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Эффект появился дважды | Подтверждение потерялось после действия | Сверить effectKey, журнал и время эффекта | Подавить повтор; отдельно расследовать неопределённый первый ответ |
| Номер 12 пришёл до 11 | Пропуск или параллельное завершение | Найти 11, отложенную запись и владельца порядка | Не применять 12; сохранить контекст до восстановления порядка |
| Одна ошибка повторяется | Терминальный вход ошибочно признан временным | Проверить класс причины и счётчик попыток | Остановить цикл; создать запись для ручного разбора |
| Сообщение исчезло | Ack отправлен до записи результата | Сопоставить момент ack с записью эффекта | Исправить порядок подтверждения; восстановить задачу из журнала |
| Старая задача меняет новое состояние | Нет проверки версии или sequence | Сверить последний номер объекта и id сообщения | Отклонить устаревший вход; не перезаписывать состояние назад |
Самый неприятный случай не выглядит как обычная ошибка. Consumer вызвал внешний API. API мог принять запрос, но ответ пропал по сети. Consumer не знает результата и не должен автоматически считать его неуспешным. Повтор без ключа создаст второй эффект. Ack без проверки может потерять задачу. У этой ситуации должен быть отдельный статус, например effect-result-unknown, и способ проверить внешний факт.
Журнал помогает только там, где он действительно связан с эффектом. Локальная запись «мы собирались отправить письмо» не доказывает, что письмо принял внешний провайдер. Для денег, доступа и других дорогих действий нужен идентификатор провайдера, ответ владельца эффекта или ручное решение с журналом. Не подменяйте отсутствие ответа успехом.
\nid, effectKey, sequenceKey, номер, версию payload, попытку и время.Идемпотентный ключ не делает два независимых сервиса одной транзакцией. Ack не гарантирует, что внешний эффект завершён. Версия не гарантирует доставку следующего события. Dead-letter маршрут не заменяет владельца решения. Даже если транспорт предоставляет exactly-once для своей записи, это не означает, что внешний прикладной эффект невозможно повторить: для другой системы нужна её кооперация и собственный ключ.
\nПример выше учебный. Он не запускает брокер, базу, сеть или внешний сервис и не сообщает измерений нагрузки. Redis-команды показывают PEL и подтверждение, а JavaScript — только решение до внешнего действия. Фрагменты можно использовать для проверки формы контракта: повтор не создаёт второй эффект, gap не применяется молча, устаревший вход не откатывает состояние, а непонятное сообщение получает конечный маршрут.
\nКонтракт готов к реализации, когда для одной задачи можно показать цепочку «сообщение → решение consumer-а → запись эффекта → подтверждение» и ответить, что происходит при обрыве после каждого шага. Повторная доставка с тем же effectKey не создаёт новый результат. Gap сохраняется и не меняет состояние. Ошибка вне временного класса прекращает retry. По этим четырём проверкам решение можно обсуждать с владельцем домена и выбирать конкретный брокер.
>, повторного чтения pending-записей и связи с XACK.redelivered у доставки и правилами подтверждения.