Симптом выглядит как спор с логами: обработчик записал эффект, но затем то же сообщение пришло снова. Если второй запуск создаёт ещё одно письмо, списание или запись, команда начинает искать ошибку в очереди. Цена такой реакции выше дубля. Можно настроить повтор иначе и всё равно оставить ту же дыру между эффектом и подтверждением. Пока не названа граница, где эффект считается записанным, любой термин о delivery скрывает главный вопрос: что делать с повторной работой над тем же намерением.
Для марта 2021 года полезно говорить скромнее. Подтверждение доставки — это решение по конкретной доставке сообщения. Идемпотентность — свойство операции с определённым ключом эффекта. Порядок — инвариант доменного ключа. Эти вещи могут взаимодействовать, но не становятся одним свойством после выбора broker. Ниже — детерминированная state machine в памяти. Она не соединяется с AMQP или Kafka и не заявляет, что даёт exactly-once. Её задача — сделать видимыми точки, где появляется duplicate и где он должен быть остановлен.
Сначала называем, о какой гарантии идёт речь
Слова at-most-once, at-least-once и exactly-once часто попадают в решение раньше контракта. Для локального дизайна полезнее разложить их на наблюдаемые обязательства. Мы можем договориться, что consumer допускает повтор одной логической задачи; что эффект с одинаковым effectKey записывается один раз; что порядок проверяется только по sequenceKey; что неизвестная ошибка не повторяется бесконечно. Ни одно из этих предложений не превращает абстрактную модель в характеристику сети, диска или выбранного продукта.
| Термин в обсуждении | Что фиксируем в учебном контракте | Что остаётся за границей | Проверяемый признак |
|---|---|---|---|
| delivery | один запуск consumer над message id | дошло ли сообщение по сети и как хранит его broker | в fixture видны delivery 1, 2 и 3 |
| duplicate | повторный запуск того же id после неопределённого результата | почему именно появился повтор в конкретном transport | ledger возвращает duplicate-effect-suppressed |
| effect | доменная запись, привязанная к effectKey | атомарность между базой и внешней системой | в ledger остаётся одна строка ключа |
| order | проверка последовательности для одного sequenceKey | общий порядок всех задач | gap ведёт к manual review, а не к догадке |
| terminal route | автоматический маршрут остановлен с контекстом | решение оператора и последующая интеграция | есть reason, attempts и requiredCheck |
Такой словарь снимает ложный выбор между «настроить гарантию» и «ничего не делать». У команды появляется ряд маленьких вопросов. Когда можно подтвердить обработку? Где лежит ключ эффекта? Что происходит, если внешний вызов завершился, а запись о нём нет? Для какой сущности порядок обязателен? Какой случай не имеет права автоматически возвращаться в работу? Ответы могут оказаться разными даже внутри одного сервиса. Это нормально: граница договора определяется риском операции, а не названием очереди.
Эффект и подтверждение нельзя менять местами
Представим учебный второй запуск. Первая попытка получила временное условие и была отложена на 1000 мс. Вторая дошла до записи эффекта и сразу после этого потеряла знание о результате подтверждения. Третья доставка выглядит как duplicate. Если обработчик не смотрит в ledger, он повторит эффект. Если он смотрит в ledger до эффекта, он видит существующий ключ и может завершить текущую доставку без нового доменного действия. Эта последовательность не доказывает, что повтор обязательно случится; она показывает, почему код обязан быть готов к нему.
function recordEffectOnce(ledger, message) {
if (ledger.has(message.effectKey)) {
return { state: 'duplicate-effect-suppressed' };
}
ledger.set(message.effectKey, { messageId: message.id });
return { state: 'effect-recorded' };
}
// Подтверждение доставки принимается только после решения по ledger.
Важна последовательность, а не название функции. Сначала проверить effectKey. Если ключ найден, не создавать второй эффект. Если ключа нет, попытаться записать эффект и ключ в одной подходящей для проекта границе. После этого принять решение о подтверждении текущей доставки. Чем дальше друг от друга эти действия, тем больше сценариев неопределённости. Внешний HTTP-вызов особенно важен: Map из фикстуры не способна откатить письмо или платёж. Там нужен отдельный контракт идемпотентного ключа на стороне внешней границы либо ручный маршрут.
function handleTrainingDelivery(ledger, message) {
const effect = recordEffectOnce(ledger, message);
if (effect.state === 'duplicate-effect-suppressed') {
return { delivery: 'ack', effect: 'not-repeated' };
}
return { delivery: 'ack', effect: 'recorded-once-in-training-ledger' };
}
// В production эта функция должна получить реальную границу хранения.
Duplicate — не повод терять исходную причину
Когда ledger подавил повтор, обработка ещё не закончила объяснение. Нужно сохранить, что повтор был и на каком участке он обнаружен. Иначе через месяц останется только одна строка эффекта, но пропадёт сигнал, что граница подтверждения или восстановление consumer требуют отдельной проверки. В учебной fixture третий delivery имеет duplicate: true и результат ack-after-ledger-check. Это не показатель реального флага выбранного протокола, а документированный исход модели.
Полезный контрпример: не хранить один общий список message id без связи с эффектом. Если один logical id законно создаёт несколько различных эффектов, глобальный список помешает работе. Если два разных message id представляют одно и то же намерение, список id не остановит duplicate. Поэтому ключ выбирают у доменного эффекта: invoice-417:reminder в учебном примере означает именно одно напоминание для конкретного счёта. Это решение не универсально; название и состав ключа должен подтвердить владелец домена.
const fixture = runQueueFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('training queue contract failed');
}
console.log(fixture.recovery.map((item) => item.outcome));
// ['controlled-retry', 'effect-written-receipt-unknown', 'ack-after-ledger-check']
Фикстура проверяет десять инвариантов: один message id в обеих учебных ветвях, контролируемую задержку, отсутствие эффекта на retry, единственную запись ledger, suppress duplicate, terminal решение по duplicate, пустой ledger в poison path, manual route, границу решения оператора и разделение двух ветвей. Она не измеряет retry клиента и не показывает протокольный acknowledgement. Её ценность в том, что при редактуре или доработке нельзя тихо поменять правило на «повтор создаёт новый эффект».
Порядок всегда имеет владельца и ключ
Вопрос порядка часто появляется поздно: сначала consumer обработал несколько задач параллельно, затем доменная модель требует, чтобы статус не вернулся назад. Здесь недостаточно сказать «сделаем один worker». Один worker замедлит всё, но не объяснит, что происходит после перезапуска или между разными ключами. Нужен sequenceKey, правило следующего номера и исход для gap. Для несвязанных задач правило может отсутствовать; искусственный порядок там превращает обработку в очередь ожидания без пользы.
const lane = new Map();
function acceptInSequence(message) {
const previous = lane.get(message.sequenceKey) || 0;
if (message.sequence !== previous + 1) {
return { state: 'manual-review', reason: 'sequence-gap' };
}
lane.set(message.sequenceKey, message.sequence);
return { state: 'ready' };
}
// Это локальный контракт одного ключа, не обещание глобального порядка.
Этот пример не реализует partition или блокировку. Он показывает форму инварианта: для invoice-417 можно принять номер только после предыдущего. Если номер пропущен, consumer не придумывает порядок и не раздувает retry. Он формирует terminal record с причиной sequence-gap. Дальше владелец домена смотрит на происхождение входа: задача пришла раньше, потеряна запись о предыдущем шаге или последовательность вообще неправильно определена. Это уже другое расследование, не алгоритм повторной доставки.
Backoff регулирует попытки, а не правду
Задержка перед повтором нужна, чтобы не превращать временный сбой в плотный цикл. Но backoff не делает ошибку временной и не восстанавливает порядок. В учебной policy две задержки заданы явно: 1000 и 4000 мс. Они не вычисляются из случайного времени и поэтому fixture повторяема. В реальном проекте значения выбирают по договору зависимости, допустимому ожиданию и наблюдаемой нагрузке. До такого выбора надо разделить хотя бы две причины: контролируемое временное условие и вход, который consumer не умеет обработать.
const retryPolicy = { maxAttempts: 2, delaysMs: [1000] };
function nextTrainingDecision(attempt, failureKind) {
if (failureKind === 'temporary' && attempt < retryPolicy.maxAttempts) {
return { state: 'retry', afterMs: retryPolicy.delaysMs[attempt - 1] };
}
return { state: 'manual-review', reason: failureKind };
}
nextTrainingDecision(1, 'temporary');
// { state: 'retry', afterMs: 1000 }
Если retry уже исчерпан, терминальный путь должен быть видимым, а не состоять из удаления сообщения. Ручная запись несёт id, effectKey, причину, попытки и то, какую проверку ожидают от оператора. Это не бюрократия. Без effectKey оператор не знает, может ли replay создать второе действие. Без причины неясно, исправлять ли вход или зависимость. Без requiredCheck любой повтор становится случайным запуском того же consumer.
Маршрут проектирования delivery-контракта
- Нарисовать одну доставку отдельно от доменного эффекта. Указать, где consumer получает вход и где появляется запись или внешний вызов.
- Выбрать effectKey вместе с владельцем доменной операции и добавить проверку duplicate до выполнения эффекта.
- Зафиксировать, что подтверждение текущей доставки возможно только после решения по ledger. Не называть это универсальной гарантией.
- Определить sequenceKey только для объектов, где обратный порядок действительно опасен, и описать путь при gap.
- Составить короткий список известных временных причин и ограниченный retry/backoff. Не включать в него неизвестную схему и нарушение инварианта.
- Создать terminal record для manual route. В нём должны быть исходный id, effectKey, attempts, причина и допустимые действия.
- Проверить весь путь controlled fixture, затем отдельно на конкретной версии broker, базе и внешних зависимостях проекта.
Источники помогают не подменять границы
Спецификация AMQP 0-9 отделяет delivery и acknowledgement, а также содержит признак redelivered. Kafka 2.7 в своей versioned documentation отдельно обсуждает семантику доставки и последствия retry. Эти факты полезны, потому что не дают свести обработку к слову «очередь». Но в статье нет вывода о реальном конфиге RabbitMQ или Kafka: учебные имена, Map и события fixture не соответствуют API какого-либо продукта. Переносить нужно вопросы к контракту, а не код из примера.
Здесь не запускались broker, HTTP, база, внешнее API, browser, CI или production build. Нет измерений throughput, времени восстановления или потери данных. Следующий шаг после чтения — показать выбранную транзакционную границу и ключ эффекта на маленькой интеграции. Если эту границу невозможно обеспечить, надо сократить автоматическое действие и направить сомнительные случаи в ручной маршрут, а не назвать задачу solved из-за одного успешного запуска.
Проверяемые источники
- AMQP 0-9: спецификация basic.deliver, basic.ack и redelivered — первичная спецификация 2008 года: у delivery есть признак повторной доставки, а подтверждение относится к доставленному сообщению; учебная модель ниже не реализует протокол
- Apache Kafka 2.7.X: versioned documentation — версионная документация линии 2.7, доступной в марте 2021 года; терминология доставки приводится только для разграничения обязательств, не как claim об этой fixture
- Apache Kafka 2.7.0 KafkaProducer API — официальный API линии 2.7 предупреждает, что retries могут открыть путь к duplicate; это не настройка и не запуск Kafka в статье
- RabbitMQ 3.8 Release Overview — 11 ноября 2019 года — версия и обзор были доступны к марту 2021 года; источник упоминает delivery limit для poison message, но не является описанием данного учебного маршрута