Один оператор сравнения чуть не удалил тысячу пользователей
На днях наводил порядок в логике автопродления подписок Telegram-бота.
Казалось, задача на полчаса.
Если кратко: у пользователя заканчивается подписка, и бот проверяет дату планируемой оплаты.
Если срок подписки истёк и продления нет, то пользователь попадает в очередь на удаление. Всё просто.
Проблема обнаружилась случайно.
Я решил прогнать сценарий сразу на нескольких тестовых пользователях. Один из них только что оплатил подписку, но бот всё равно пометил его как кандидата на удаление.
Сначала я подумал, что платёж не успел записаться в базу. Проверил – платёж записался.
Потом начал искать проблему в часовом поясе. Тоже нет.
Дальше полез смотреть SQL-запросы, логи, время выполнения задач. Потратил на это больше часа и уже начал подозревать, что где-то появилась гонка между обработчиком платежей и задачей проверки подписок. Стал разбираться…
Причина оказалась намного проще: в фундаменте не стоял тот оператор сравнения.
Вместо subscription_end < now
я написал subscription_end <= now.
Из-за этого подписка была просроченной не только после истечения срока, но и ровно в момент его окончания. Если время проверки и обновления совпадало, пользователь попадал в удаление по граничному условию.
Если задача проверки запускалась в тот же момент, когда обновлялась дата окончания подписки, пользователь попадал в удаление при совпадении времени окончания и времени проверки .
В большинстве случаев этого не происходило, но иногда баг воспроизводился. Именно такие баги самые неприятные, потому что они появляются случайно.
И чем больше пользователей становится, тем чаще начинают всплывать.
В тот момент у меня была тестовая база, поэтому последствия не были бы критичными. Максимум, произошло бы несколько некорректных удалений.
Но я сразу представил, что было бы в продакшене. Если бы ошибка попала в релиз, задача удаления прошлась бы по всем пользователям, которые удовлетворяли этому условию.
И если совпали бы время запуска задачи и обновление подписок, ошибочно удаленных пользователей было бы несколько сотен или тысяч.
Что изменил:
1. Перенастроил удаление: теперь оно происходит не сразу.
Пользователь сначала получает статус "ожидает удаления", а сама операция выполняется отдельной задачей через некоторое время.
2. Настроил перепроверку актуального состояния подписки через x часов, чтобы отсечь ложные срабатывания.
Если пользователь оплатил доступ или подписка продлилась, удаление отменяется.
3. Добавил тест на этот сценарий, чтобы избежать повторения <=ошибки.
Раньше я проверял только обычные случаи.
Теперь отдельно проверяются пограничные значения времени.
Мораль:
даже очевидные условия нужно проверять на границах.
Смотришь на условие и кажется, что ошибиться невозможно. Но однажды выясняется, что была допущена элементарная ошибка, которая убила тонну времени.
Всегда нужно помнить о границе условий. Именно там чаще всего и живут самые неприятные ошибки.
Есть ощущение, что подобные баги зачастую находятся не в сложной логике, а в самых очевидных местах?
Интересно, как вы предостерегаете себя от таких стыдных ошибок?
