Мы отмечали уведомление доставленным до отправки. Поэтому ошибки выглядели как успех

На этой неделе я полез проверять уведомления в одном из своих сервисов. Обычная рутина: заявки приходят, менеджеры получают сообщения, в журнале напротив каждой попытки стоит аккуратное «доставлено».

Всё зелёное. Жалоб почти нет. Можно идти дальше.

Проблема была только в том, что часть сообщений никто не получал.

Сервис автоматизирует работу с обращениями: следит, чтобы вопрос покупателя не завис, напоминает менеджеру и помогает не потерять диалог. Такие системы особенно приятно показывать на схеме. Покупатель написал — событие принято — уведомление отправлено — менеджер ответил. Четыре прямоугольника, три стрелки, никакой драмы.

В реальности между «принято» и «отправлено» живёт маленький кусок кода, который способен испортить всю картину.

Галочка появлялась слишком рано

Мы защищались от дублей. Если фоновая задача запускается повторно, менеджеру нельзя послать одно и то же сообщение ещё раз. Поэтому перед отправкой система создавала запись: такое уведомление уже обработано.

Звучит разумно. Я сам много раз использовал этот приём.

Только запись сразу получала смысл «доставлено». Не «взято в работу», не «пытаемся отправить», а фактически «всё готово». И лишь после этого код обращался к Telegram.

Если Telegram отвечал ошибкой, сеть моргала или база на секунду становилась недоступной, происходило неприятное: человек ничего не получал, а система уже считала задачу выполненной. Следующий запуск видел готовую запись и честно пропускал повтор.

Мы построили защиту от дублей, которая заодно защищала нас от исправления собственных ошибок.

Самое неприятное — внешне всё работало. Процесс запускался по расписанию. Очередь разгребалась. Счётчик необработанных событий не рос. В журнале были записи. Если смотреть на работу механизмов, поводов для тревоги не было.

Если смотреть на телефон менеджера — сообщения не было.

Почему я не нашёл это сразу

Сначала я проверял не тот конец цепочки. Убедился, что событие создано. Потом — что фоновая задача его забрала. Потом — что функция отправки была вызвана. Каждая проверка отвечала «да».

Это типичная ловушка автоматизации: мы проверяем собственные намерения вместо чужого результата.

Вызвать функцию отправки — не значит отправить сообщение. Получить успешный ответ от промежуточного сервиса — не значит показать текст человеку. Поставить строке статус «готово» — вообще ничего не значит, если этот статус появился до необратимого действия.

Я потратил больше времени не на исправление, а на то, чтобы перестать верить красивой внутренней картине. Помогло простое сравнение: взял успешный случай и проблемный, разложил оба по шагам и посмотрел, в какой момент расходятся факты.

В успешном случае после попытки отправки был ответ Telegram, а затем менялся статус. В проблемном статус уже стоял заранее. Дальше любая ошибка превращалась в тихий пропуск.

Как пришлось переделать

Теперь у уведомления не два состояния, а нормальная жизнь: ожидает отправки, отправлено, ошибка. Сначала создаётся запись «ожидает». Потом идёт реальная попытка. Только подтверждённая доставка переводит её в «отправлено».

Ошибка тоже сохраняется, а не растворяется в журнале. Такой элемент можно забрать повторно. Если отправка зависла посередине, сторож через разумное время считает попытку протухшей и возвращает её в работу.

Но тут появилась следующая грабля.

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

Ещё пришлось пройти дальше по цепочке. Недостаточно исправить одну функцию: другие задачи уже привыкли к старому значению «готово» и принимали его за доказательство доставки. Некоторые освобождали период отчёта, некоторые глушили повтор, некоторые отвечали внешней системе успехом. Корень был один, но последствия разъехались по разным местам.

Что в итоге оказалось главным

Я начинал аудит с вопроса: «Где у нас может упасть отправка?» Это был слабый вопрос. Упасть может что угодно, и все ошибки заранее не перечислить.

Правильный вопрос звучит иначе: «Какой факт даёт нам право сказать, что человек получил сообщение?»

После него архитектура становится намного проще. Статус ставится после факта. Повтор остаётся возможным. Зависшая попытка не считается вечным успехом. Мониторинг смотрит не на пульс процесса, а на конечный результат.

Этот баг не был сложным. В нём не было загадочной нагрузки, редкой гонки или необычной технологии. Одна галочка стояла на несколько строк раньше, чем должна.

Но именно такие ошибки я теперь боюсь больше всего. Когда система падает с красным экраном, её хотя бы идут чинить. Когда она уверенно говорит «доставлено», а человек молча ничего не получает, можно месяцами улучшать продукт, которого в критический момент как будто нет.

Мой вывод неприятный, но полезный: слово «успех» в базе данных должно быть не обещанием кода, а квитанцией из внешнего мира. Всё остальное — просто хорошо оформленное намерение.