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

Почему блокировка не сработала

Код захватывал блокировку одним вызовом к базе данных, а затем сразу передавал управление обработчику запроса. Время жизни блокировки измерялось миллисекундами, что было гораздо меньше времени, необходимого модели ИИ для генерации ответа. К тому моменту, когда модель приступала к работе, блокировка уже исчезала, поэтому ничто не мешало второму запросу захватить ту же запись диалога и выдать еще один ответ.

Появились два симптома:

  • Отправлялись идентичные ответы один за другим.
  • На один и тот же вопрос приходили слегка перефразированные ответы, так как каждый процесс формировал свой собственный промпт на основе одних и тех же пользовательских данных.

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

Попытка исправить проблему «на скорую руку», которая не сработала

Первым решением было добавление небольшой задержки после получения сообщения в надежде «дебаунснуть» (debounce) быстрый ввод. Это помогало, когда два сообщения приходили почти одновременно, но схема разваливалась, если появлялось третье сообщение, пока ИИ все еще генерировал текст.

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

Создание надежной защиты: счетчики версий, изолированные таймеры и лиз

Команда переработала процесс, опираясь на три столпа:

  • Счетчик версий — каждое входящее сообщение увеличивает счетчик, хранящийся вместе с диалогом. Счетчик сообщает системе, сколько сообщений поступило с момента последнего ответа, что позволяет легко обнаруживать новый ввод во время генерации ответа.
  • Выделенное окно дебаунса — теперь таймеры живут в отдельной области хранения, изолированной от полезной нагрузки диалога. Жесткое ограничение длительности дебаунса не позволяет пользователю бесконечно тормозить работу бота.
  • Сессионный лиз (session lease) — исходная блокировка заменена на лиз, который имеет явную метку времени истечения срока действия. Лиз запрашивается с помощью операции compare-and-swap (CAS): процесс считывает текущее значение лиза, записывает новое только в том случае, если старое значение совпадает, и тем самым получает исключительные права на диалог. Если процесс аварийно завершается, срок действия лиза истекает автоматически, освобождая диалог для следующего обработчика.

Как работает новый конвейер

  1. Прибытие сообщения — система увеличивает счетчик версий и (пере)устанавливает таймер дебаунса. Она немедленно возвращает ответ клиенту, не запуская ИИ.
  2. Истечение таймера — обработчик таймера пытается захватить лиз. Если операция CAS проходит успешно, обработчик продолжает работу; в противном случае он делает отступ, понимая, что диалог уже занят другим процессом.
  3. Проверка нового ввода — обработчик сравнивает текущий счетчик версий со значением, которое он записал при запуске таймера. Если счетчик увеличился, он объединяет ожидающие сообщения в один промпт.
  4. Генерация ответа — модель ИИ запускается один раз, создавая один ответ, охватывающий все последние сообщения пользователя.
  5. Финальная проверка — непосредственно перед отправкой ответа обработчик снова считывает счетчик версий. Если во время генерации пришло новое сообщение, ответ отбрасывается, а процесс перезапускает таймер, гарантируя, что пользователь не получит устаревший ответ.

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

Вывод

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