Bot zaczął wypluwać tę samą odpowiedź dwa lub trzy razy za każdym razem, gdy użytkownik seryjnie klikał przycisk wyślij. Duplikacja pojawiała się tylko u osób piszących wystarczająco szybko, by wysłać kilka wiadomości, zanim AI zaczęła „myśleć”, i przez długi czas pozostawała niewidoczna na produkcji, ponieważ taki wzorzec występował rzadko. Przedwczesne zwolnienie blokady bazy danych – zaledwie kilka milisekund po jej zajęciu – pozostawiało rozmowę bez ochrony, co pozwalało wielu procesom na odpowiadanie na to samo zapytanie.

Dlaczego blokada zawiodła

Kod uzyskiwał blokadę w ramach pojedynczego wywołania bazy danych, a następnie natychmiast przekazywał kontrolę z powrotem do obsługi żądania. Czas życia blokady liczony był w milisekundach, co było znacznie krótsze niż czas potrzebny modelowi AI na wygenerowanie odpowiedzi. W momencie, gdy model zaczynał pracę, blokada już wygasła, więc nic nie zapobiegło drugiemu żądaniu przejęciu tego samego rekordu rozmowy i wysłaniu kolejnej odpowiedzi.

Pojawiły się dwa objawy:

  • Identyczne odpowiedzi były wysyłane jedna po drugiej.
  • Pojawiały się nieco inaczej sformułowane odpowiedzi na to samo pytanie, ponieważ każdy proces budował własny prompt na podstawie tego samego wejścia użytkownika.

Ponieważ większość użytkowników robi przerwy między wiadomościami, błąd pozostawał niezauważony. Tylko osoby szybko piszące wyzwalały warunek wyścigu (race condition), a przypadki te były rzadkie.

Półśrodek, który nie wystarczył

Pierwszą reakcją było dodanie krótkiego opóźnienia po otrzymaniu wiadomości, w nadziei na zastosowanie mechanizmu „debounce” dla szybkich wejść. Pomogło to w sytuacjach, gdy dwie wiadomości przychodziły jedna po drugiej, ale rozwiązanie zawiodło, gdy pojawiała się trzecia wiadomość, podczas gdy AI wciąż generowała tekst.

Drugi problem pojawił się, gdy timery i dane rozmowy znajdowały się w tym samym obszarze składowania (storage bucket). Gdy bot kończył przetwarzanie żądania, nadpisywał rekord timera, co w praktyce usuwało jego własny licznik odliczania. System tracił informację o tym, na które wiadomości udzielono już odpowiedzi, co otwierało drogę do dalszych duplikacji.

Budowa niezawodnej ochrony: liczniki wersji, odizolowane timery i dzierżawa (lease)

Zespół zaprojektował przepływ wokół trzech filarów:

  • Licznik wersji – każda przychodząca wiadomość zwiększa licznik przechowywany wraz z rozmową. Licznik informuje system, ile wiadomości dotarło od czasu ostatniej odpowiedzi, co ułatwia wykrywanie nowych danych wejściowych podczas generowania odpowiedzi.
  • Dedykowane okno debounce – timery znajdują się teraz w oddzielnym obszarze składowania, odizolowanym od danych rozmowy. Sztywne ograniczenie czasu trwania mechanizmu debounce zapobiega nieskończonemu blokowaniu bota przez użytkownika.
  • Dzierżawa sesji (lease) – pierwotna blokada została zastąpiona dzierżawą, która posiada wyraźny znacznik czasu wygaśnięcia. Dzierżawa jest przejmowana za pomocą operacji compare-and-swap (CAS): proces odczytuje aktualną wartość dzierżawy, zapisuje nową tylko wtedy, gdy stara wartość się zgadza, i dzięki temu uzyskuje wyłączne prawo do rozmowy. Jeśli proces ulegnie awarii, dzierżawa wygasa automatycznie, zwalniając rozmowę dla następnego obsługującego.

Jak działa nowy potok (pipeline)

  1. Nadejście wiadomości – system zwiększa licznik wersji i (re)ustawia timer debounce. Natychmiast zwraca odpowiedź do klienta, nie uruchamiając AI.
  2. Wygasnięcie timera – obsługa timera próbuje uzyskać dzierżawę. Jeśli operacja CAS zakończy się sukcesem, obsługa przechodzi dalej; w przeciwnym razie wycofuje się, wiedząc, że inny proces już posiada rozmowę.
  3. Sprawdzenie nowych danych wejściowych – obsługa porównuje aktualny licznik wersji z wartością zarejestrowaną w momencie rozpoczęcia timera. Jeśli licznik wzrósł, wszystkie oczekujące wiadomości są agregowane w jedno zapytanie.
  4. Generowanie odpowiedzi – model AI uruchamia się raz, tworząc jedną odpowiedź obejmującą wszystkie ostatnie dane wejściowe użytkownika.
  5. Końcowa weryfikacja – tuż przed wysłaniem odpowiedzi, obsługa ponownie odczytuje licznik wersji. Jeśli w trakcie generowania dotarła nowsza wiadomość, odpowiedź jest odrzucana, a proces ponownie uruchamia timer, co gwarantuje, że użytkownik nie otrzyma nieaktualnej odpowiedzi.

To podejście eliminuje duplikaty odpowiedzi, ogranicza czas, w którym rozmowa może zostać wstrzymana, i automatycznie odzyskuje sprawność po awariach procesów, ponieważ dzierżawa wygasa samoistnie.

Wnioski

Blokada, która znika przed rozpoczęciem sekcji krytycznej, nie zapewnia żadnej ochrony. Zastąpienie ulotnej blokady bazy danych wyraźną, wygasającą dzierżawą oraz odizolowanie timerów od danych rozmowy sprawia, że bot gwarantuje pojedynczą, aktualną odpowiedź, nawet gdy użytkownicy piszą z błyskawiczną prędkością. Ta sytuacja podkreśla ponadczasową lekcję: zabezpieczenia współbieżności muszą trwać dłużej niż praca, którą chronią, w przeciwnym razie stają się niewidzialnymi barierami, przez które przeciekają błędy.