Перейти к содержанию

RabbitMQ: идемпотентный эффект и квитанция inbox

Повтор возможен после потерянного confirm, потерянного ack и падения между публикацией retry-копии и завершением исходной доставки. Теперь защитим предметный эффект. Результат урока — локальная транзакция, в которой обновление каталога и квитанция события записываются вместе. Повтор известного конверта возвращает сохранённый результат, а повтор идентификатора с другим телом становится конфликтом.

Полная функция apply_event находится в store.py. SQLite, дубликаты и аварии автор не запускал. Руководство RabbitMQ по надёжности прямо рассматривает возможность дубликатов; договор локальной квитанции и редакций ниже относится к нашему проекту «Доставка».

Проверка должна жить рядом с эффектом

Простейшая идея — хранить eventId в множестве Python и пропускать повтор. После перезапуска такое множество исчезает. Даже при сохранении отдельно остаётся промежуток: эффект уже выполнен, но eventId ещё не записан. Падение в этом месте приведёт к повторному эффекту. Поэтому квитанция должна иметь общую атомарную границу с изменением предметного состояния.

У каждого consumer собственная SQLite-база. В ней таблица inbox имеет первичный ключ event_id, fingerprint полного конверта и результат обработки. Catalog также хранит projection по article_id, audit — конверты в таблице audit. Одна транзакция включает проверку квитанции, изменение эффекта и вставку inbox. Ack выполняется только после commit этой транзакции.

Полный набор таблиц consumer приведён в init_consumer. Ключевой фрагмент обработки известного события выглядит так; digest уже вычислен каноническим способом:

db.execute('BEGIN IMMEDIATE')
known = db.execute(
    'SELECT fingerprint,result FROM inbox WHERE event_id=?',
    (event['eventId'],)
).fetchone()
if known:
    if known[0] != digest:
        raise PermanentError('eventId reused with another body')
    db.commit()
    return 'duplicate:' + known[1]

В этом листинге SQL — часть исходного файла, а не операция, выполненная автором. BEGIN IMMEDIATE занимает роль сериализации локальной записи до чтения квитанции. Вся функция открывает соединение, завершает commit либо rollback и закрывает его. Скрытого общего соединения между callback разных процессов нет.

Идентификатор нельзя переиспользовать для другого факта

Само наличие eventId в inbox недостаточно. Если издатель случайно изменил title, сохранив прежний eventId, простое «уже видели» скрывает нарушение договора. Fingerprint связывает идентичность с каноническим содержимым. Совпадение ключа при другом fingerprint вызывает PermanentError и сохраняется в очереди отказов.

Канонизация сортирует JSON-ключи и фиксирует разделители, поэтому перестановка полей не создаёт другой факт. Порядок массивов и значения строк при этом остаются содержательными, если появятся в договоре. Это не универсальное понимание семантического равенства: два разных написания заголовка считаются разными данными, даже если редактор считает смысл близким.

Для аудита одна и та же запись eventId не добавляется второй раз. Первое событие сохраняет конверт и inbox в одной транзакции. Повтор возвращает duplicate:recorded. Такой результат означает отсутствие нового локального эффекта, но не отсутствие второй доставки. Журнал consumer может содержать несколько строк одного факта, и это не противоречит идемпотентной записи.

Новое событие может быть старой редакцией

Дедупликация по eventId защищает повтор одного факта. Но две разные редакции имеют разные eventId. Событие revision 2 может обработаться раньше revision 1, особенно при независимых попытках и восстановлении процессов. Если projection безусловно принимает последнее пришедшее сообщение, старое событие перезапишет новое состояние.

Поэтому catalog сравнивает event.revision с текущей revision материала. Больший номер обновляет проекцию. Меньший отмечается как stale, без обратного изменения. Равный номер сравнивает fingerprint предметного состояния: одинаковое содержание получает same-state, разное — постоянный конфликт. Квитанция записывается и для осознанно проигнорированного старого события.

Для material.withdrawn состояние содержит published:false. Заголовок и адрес остаются частью сохранённой редакции, чтобы договор события был одинаковым. Withdrawn revision 2, полученное раньше published revision 1, не должно снова сделать материал видимым при обработке старого факта. Именно монотонная revision защищает этот сценарий, а не occurredAt.

Ожидаемая последовательность результата:

published revision 1 → updated
тот же eventId и тело → duplicate:updated
withdrawn revision 2 → updated, published=false
другое событие revision 1 → stale
прежний eventId с другим title → PermanentError

Это предметные ожидания будущего наблюдения, не выполненный журнал. Количество транспортных повторов не утверждается. Важно различить три идентичности: eventId для факта, articleId для материала и revision для порядка его состояния. Ни delivery_tag, ни номер attempt не заменяют их.

Квитанция и эффект фиксируются последними строками транзакции

После изменения projection или audit функция вставляет inbox и делает commit. Если исключение возникает раньше, выполняется rollback. Это сохраняет связь: либо известен результат и сохранён предметный эффект, либо событие остаётся незавершённым для следующей попытки. При неизвестном исходе commit после потери процесса повторная функция прочитает SQLite и обнаружит квитанцию, если она сохранилась.

Такая схема зависит от локальной транзакционной базы и её реальных условий хранения. Она не обещает спасение при потере всех файлов .state. Резервирование, файловая система и аппаратные отказы находятся за пределами самого уникального ключа. В заключительном уроке отметим, что локальный пример не заменяет эксплуатационный договор базы.

Если эффект находится в другой системе, общая SQLite-транзакция его не включает. Например, отправку письма нельзя сделать атомарной с inbox только расположением двух вызовов рядом. Нужен идемпотентный внешний API, предметный ключ получателя либо другой согласованный протокол. Нельзя перенести наш результат на произвольный HTTP-эффект и объявить его exactly-once.

Срок хранения квитанций влияет на повтор

Если удалить inbox слишком рано, старое сообщение снова выглядит новым. Для projection сравнение revision ещё может предотвратить обратное изменение, но для audit или счётчика это не универсальная защита. Срок хранения зависит от возможного времени повторной доставки, ручного восстановления и поведения издателя. В учебном коде автоматического очищения inbox нет.

Очередь отказов также способна содержать очень старый конверт. Перед ручным повтором сопровождающий должен понимать, сохраняется ли соответствующая квитанция и допустим ли эффект. Поэтому retention inbox и dead-очереди нельзя выбирать независимо как случайные параметры диска. Они определяют границы предметного восстановления.

Для будущего упражнения повторите неизменный sample-event и затем представьте конфликт того же UUID с другим title. Объясните, почему первый случай завершается обычным ack после чтения квитанции, а второй требует расследования. Затем отправьте revision 2 withdrawn раньше revision 1. Далее разберём восстановление соединения и порядок, сохранив уже полученную защиту локального состояния.

Оглавление серии