← Назад к проектам

Списание есть, ответа нет

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

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

Фактура

  • Внешний симптом: списание есть, ответа нет, блокировка висит до истечения TTL (20 минут).
  • Реальная причина первого инцидента — необъявленные константы в перенесённом коде; урок про проверку: искать определения, а не вхождения подстроки.
  • Защитная сеть различает два состояния: «списано, но задача ещё у обработчика» (возврат нужен) и «задача уже отдана фоновому потоку» (возврат сделает сам поток в своём завершающем блоке).
  • Восстановление на старте: пороги по поверхностям разные — для задач самого бота 15 минут, для задач мини-приложения 30 минут, потому что сервис живёт на другом хосте и ранний подбор давал двойной возврат вместе с активным исполнителем.
  • Порог мини-приложения намеренно больше TTL блокировки (1200 секунд).
  • Баг с потерянным возвратом: начисление по одному ключу владения вместо двух; столбец допускал пустое значение, поэтому вставка не падала, а создавала партию-сироту с двумя пустыми ключами. На бою сирот оказалось ноль — баг был латентным, ремедиация не потребовалась.
  • Диагностика невидимого начисления: сравнение того, что возвращает вставка, с тем, что видят запросы баланса; после фикса случай «оба ключа пусты» пишется в лог уровня critical — молча не теряем.
  • Шесть регрессионных тестов на этот сценарий: потеря возврата, снятие блокировки, оба порога, отсутствие сирот. Весь набор на тот момент — 66 тестов.
  • Смежное правило проекта: при любой генерации сначала списание, при ошибке — возврат отдельным типом операции, чтобы история операций оставалась читаемой.