РАЗБОР РЕАЛЬНОЙ ОШИБКИ
Как мы остановили дубли уведомлений из почты
Владелец выполнял одну операцию, а затем получал несколько одинаковых сообщений о ней. Почтовая система не создавала несколько разных писем: одно событие повторно попадало в обработку через параллельные каналы и повторные попытки. Мы перенесли дедупликацию в начало процесса и сделали отправку уведомления отдельной защищённой операцией.
Как выглядела проблема
Система принимала письма, определяла тип операции и отправляла владельцу короткое сообщение. Входящие данные поступали двумя способами: почтовый вебхук сообщал о новом событии, а периодический опрос через IMAP подбирал то, что могло быть пропущено.
Оба канала были нужны. Вебхук давал быструю реакцию, а опрос страховал систему от временного сбоя. Но они независимо создавали задачи. Кроме того, очередь повторяла задачу, если обработчик не успевал подтвердить завершение. В результате одно письмо могло пройти по такой цепочке:
письмо получено
→ вебхук создал задачу
→ IMAP-опрос создал ещё одну задачу
→ первая задача отправила уведомление
→ подтверждение задержалось
→ очередь повторила первую задачу
→ владелец получил одинаковые сообщения
Проверка статуса «письмо уже прочитано» не помогала. Флаг мог измениться после обработки, не успеть синхронизироваться или сброситься при перемещении письма. Статус почтового ящика описывает письмо, но не доказывает, что конкретное действие уже выполнено.
Почему нельзя просто отключить повторы
Повторная доставка сама по себе не является ошибкой. Очереди и внешние сервисы часто гарантируют доставку как минимум один раз: событие не должно потеряться, поэтому при неопределённом результате оно приходит снова.
Если полностью запретить повторные попытки, временный сетевой сбой приведёт к пропущенному письму. Правильная цель — принимать повтор безопасно. Для этого операция должна быть идемпотентной: повторный запуск с тем же ключом возвращает прежний результат и не создаёт второе уведомление.
Дедупликация должна защищать действие, а не только место, где событие впервые появилось.
Шаг 1. Разделите доставку и логическую операцию
Сначала мы перестали считать каждую запись из очереди новым письмом. У системы появились два уровня идентичности:
delivery_key— конкретная доставка события через вебхук, IMAP или повтор очереди;message_key— само письмо, независимо от способа доставки;operation_key— действие над письмом, например отправка уведомления владельцу.
Для message_key лучше использовать стабильный идентификатор провайдера. Если он недоступен, подходит заголовок Message-ID вместе с идентификатором подключённого ящика. Один Message-ID без ящика использовать опасно: разные подключения могут видеть одну пересланную или общую почту.
message_key = sha256(
mailbox_id + ":" + normalize(message_id)
)
operation_key = sha256(
message_key + ":notify_owner:v1"
)
Версия в конце ключа полезна, если смысл операции изменится. Например, notify_owner:v2 можно обработать отдельно, не выдавая старую запись за результат новой логики.
Шаг 2. Добавьте резервный отпечаток
У некоторых писем Message-ID отсутствует или сформирован некорректно. Для них можно вычислить хеш из стабильных полей:
fallback_key = sha256(
mailbox_id + "\n" +
normalize(from_address) + "\n" +
normalize(subject) + "\n" +
normalize(sent_at) + "\n" +
sha256(canonical_body)
)
Перед вычислением удалите меняющиеся служебные заголовки, приведите адреса к одному регистру и нормализуйте переносы строк. Не включайте время получения письма: при повторной доставке оно будет другим.
Резервный отпечаток — компромисс. Два действительно разных автоматических письма могут иметь одинаковые тему, отправителя, время и тело. Поэтому сначала используйте идентификатор провайдера или Message-ID, а содержимое — только как запасной вариант.
Шаг 3. Зафиксируйте уникальность в базе
Проверка вида «сначала выполнить SELECT, затем INSERT» не защищает от параллельных обработчиков. Два worker могут одновременно не найти запись и оба отправить сообщение. Уникальность должна обеспечиваться базой данных.
CREATE TABLE notification_operations (
operation_key TEXT PRIMARY KEY,
message_key TEXT NOT NULL,
status TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
external_message_id TEXT,
last_error TEXT
);
Перед отправкой обработчик пытается занять операцию:
INSERT INTO notification_operations (
operation_key,
message_key,
status,
owner_id,
created_at
)
VALUES (
:operation_key,
:message_key,
'processing',
:owner_id,
CURRENT_TIMESTAMP
)
ON CONFLICT (operation_key) DO NOTHING
RETURNING operation_key;
Если команда вернула строку, текущий обработчик получил право продолжить. Если строка не вернулась, операция уже зарегистрирована. При статусе completed задачу можно завершить без отправки; при processing — отложить и проверить позже.
Шаг 4. Не оставляйте разрыв между базой и отправкой
Даже уникальный ключ не решает последний сложный случай: сообщение отправлено, но процесс завершился до записи статуса completed. Следующий обработчик увидит незавершённую операцию и может отправить уведомление снова.
Если канал уведомлений поддерживает собственный ключ идемпотентности, передавайте ему operation_key. Если нет, используйте паттерн outbox: в одной транзакции сохраните результат обработки письма и команду на отправку.
BEGIN;
INSERT INTO processed_messages (message_key, processed_at)
VALUES (:message_key, CURRENT_TIMESTAMP)
ON CONFLICT (message_key) DO NOTHING;
INSERT INTO notification_outbox (
operation_key,
recipient,
payload,
status
)
VALUES (
:operation_key,
:recipient,
:payload,
'pending'
)
ON CONFLICT (operation_key) DO NOTHING;
COMMIT;
Отдельный отправитель читает таблицу notification_outbox. Он не создаёт новые команды, а только доставляет уже зарегистрированные. Это не делает внешний канал автоматически безопасным, но сужает неопределённый участок до одного контролируемого места.
Шаг 5. Ограничьте время незавершённой операции
Статус processing нельзя блокировать навсегда: worker может аварийно завершиться до отправки. Добавьте срок владения задачей — lease — и номер попытки.
processing:
lease_seconds: 120
max_attempts: 4
retry:
backoff_seconds: [5, 20, 60]
retry_on:
- connection_reset
- temporary_unavailable
- rate_limit
stop_on:
- invalid_recipient
- authentication_error
- invalid_payload
Значения приведены как пример структуры. Срок должен быть больше обычного времени обработки, но достаточно коротким для восстановления после сбоя. Перед повторным захватом проверяйте, не сохранён ли внешний идентификатор уже отправленного сообщения.
Как проверить дедупликацию
Проверять нужно не только итоговый чат владельца, но и состояние на каждой границе. Для теста используйте отдельный ящик и тестовый канал уведомлений.
- Передайте одно письмо через вебхук и IMAP. Должна появиться одна логическая операция уведомления.
- Дважды отправьте один и тот же вебхук с одинаковым идентификатором события. Вторая доставка не должна создавать новую операцию.
- Запустите два worker одновременно с одинаковым
operation_key. Право на отправку должен получить только один. - Остановите обработчик после регистрации операции, но до отправки. После истечения lease задача должна безопасно продолжиться.
- Сымитируйте временную ошибку до принятия сообщения внешним каналом. Повтор должен сохранить прежний
operation_key. - Сымитируйте неизвестный результат: канал принял сообщение, но ответ потерялся. Система должна попытаться сверить внешний идентификатор, а не слепо отправлять копию.
- Отправьте два разных письма с одинаковыми темой и текстом. Они не должны склеиться, если у них разные стабильные идентификаторы.
Критерий успеха — повторные доставки видны в журнале, но для одного operation_key
Что обычно ломается
Дедупликацию ставят после отправки. К этому моменту необратимое действие уже выполнено. Ключ нужно регистрировать до обращения к внешнему каналу.
Используют только IMAP UID. Такой идентификатор относится к конкретной папке и может измениться после копирования или перемещения письма.
Ключ строят только из темы. Регулярные отчёты и чеки часто имеют одинаковые темы. Так система начнёт терять настоящие события.
Каждая повторная попытка получает новый ключ. Поле attempt должно меняться, а operation_key — оставаться прежним.
Запись о дубле удаляют. Повторная доставка после удаления снова выглядит новой. Храните ключи не меньше максимального периода, в течение которого источник может повторить событие.
Статус processing считают окончательным. Без lease одна авария навсегда заблокирует уведомление.
Ограничения подхода
Локальная база не может точно узнать, принял ли внешний канал сообщение, если соединение оборвалось в момент ответа. Полную защиту в этом случае даёт только поддержка ключа идемпотентности или поиск отправленного сообщения на стороне канала.
Резервный хеш содержимого может ошибочно объединить похожие письма. Слишком короткий срок хранения ключей пропустит поздний повтор, а слишком длинный увеличит объём таблицы. Наконец, дедупликация не исправляет неверную классификацию: она гарантирует одно выполнение выбранной операции, но не доказывает, что операция выбрана правильно.
Главное изменение было архитектурным: входящее письмо, доставка события и уведомление владельцу перестали считаться одним и тем же объектом. Теперь любой канал может повторно доставить событие, но уникальный ключ операции, ограничение базы и контролируемая отправка не позволяют одному письму превратиться в несколько одинаковых сообщений.