Użytkownik klika przycisk. Żądanie zawiesza się. Dziesięć sekund ciszy. Klika przycisk awaryjny. Teraz dwa zadania działają w ramach tej samej intencji. Kończysz z duplikacją efektów ubocznych, podwójnymi opłatami i bałaganem w danych, który pochłonie całe Twoje popołudnie.

To nie jest błąd frontendowy. Wyłączony przycisk lub timer debounce w React nie uratują Cię. Pierwsze żądanie było już w toku. Sieć po prostu „połknęła” odpowiedź. Jeśli Twój backend traktuje każde przychodzące żądanie jako zupełnie nową instrukcję, ponowienia stają się ryzykiem. Musisz to naprawić w projekcie swojego API i schemacie bazy danych.

Rozwiązanie zaczyna się od prostego podziału strukturalnego.

Rozdziel zadania (Jobs) od prób (Attempts)

Myśl o zadaniu (job) jako o trwałym zapisie tego, czego chce użytkownik. Rejestruje ono właściciela, parametry, docelowego dostawcę i dokładną intencję. Próba (attempt) to konkretna próba realizacji tej intencji.

Wyobraź sobie drukarnię. Przekazujesz plik, a oni dają Ci bilet nr 45. Ten bilet to zadanie. Drukarnia próbuje użyć drukarki atramentowej. Zacięła się. To jest pierwsza próba. Przenoszą plik do drukarki laserowej. To jest druga próba. W całym procesie numer biletu #45 pozostaje niezmienny. Gdyby drukarnia wydawała nowy bilet dla każdej wypróbowanej drukarki, zapłaciłbyś trzy razy i otrzymałbyś trzy niechciane kopie.

Twoja baza danych powinna to odzwierciedlać. Jedna tabela przechowuje zadania. Inna tabela przechowuje próby. Wiersz zadania pozostaje stały, podczas gdy próby gromadzą się pod nim.

Ten podział daje Ci kontrolę. Daje Ci również miejsce do przypisania klucza idempotencji, który przetrwa zakłócenia sieciowe.

Wymagaj klucza idempotencji dla każdego zadania

Każde żądanie POST tworzące zadanie musi zawierać unikalny klucz idempotencji. Klucz ten należy do użytkownika, a nie do sesji. Połącz identyfikator właściciela (owner ID) z kluczem, a następnie wymuś unikalne ograniczenie (constraint) w bazie danych dla tych dwóch kolumn.

Dlaczego ograniczenie w bazie danych? Ponieważ sprawdzanie istnienia w kodzie aplikacji przed wstawieniem danych to sytuacja typu race condition, która tylko czeka na wystąpienie. Dwa identyczne żądania mogą przeniknąć przez tę samą mikrosekundową lukę. Pozwól bazie danych pełnić rolę egzekutora. Jeśli użytkownik wyśle ten sam owner ID i klucz dwa razy, drugie żądanie napotka naruszenie unikalności, a Ty zwrócisz istniejące już zadanie. Oba żądania otrzymają ten sam identyfikator zadania (job ID). Żadne duplikujące się zadanie nie zostanie uruchomione.

Bądź rygorystyczny w kwestii zakresu. Jeśli ktoś użyje tego samego klucza, ale zmieni ładunek (payload), zwróć błąd konfliktu. Klucz idempotencji musi wiązać się z dokładną intencją, a nie tylko z użytkownikiem. Ten sam klucz z innym wejściem oznacza, że klient jest zdezorientowany, a Twój system powinien to odrzucić, zamiast zgadywać.

Chroń przejścia stanów

Próba to przejście stanu, a nie nowe zadanie. Twoje API musi odmawiać tworzenia nowej próby, jeśli poprzednia próba wciąż znajduje się w stanie początkowym lub nieznanym.

Powodem są przekroczenia czasu (timeouts). Gdy żądanie do dostawcy wygaśnie, klient widzi błąd, ale proces po stronie serwera może wciąż działać. Klaster GPU może wciąż przetwarzać Twoje żądanie wnioskowania (inference). Kontener może wciąż zapisywać dane do pamięci masowej typu blob. Jeśli oznaczysz wygasłą próbę jako nieudaną i natychmiast uruchomisz drugą, ryzykujesz wystąpieniem duplikacji efektów ubocznych.

Traktuj przekroczenie czasu jako stan nieznany, a nie nieudany. Blokuj nowe próby, dopóki wcześniejsza nie osiągnie stanu końcowego lub nie zostanie jawnie anulowana przez proces poza głównym przepływem (out-of-band). To wstrzymanie jest niewygodne. Zmusza użytkownika do czekania. Zapobiega jednak chaosowi, w którym dwóch pracowników modyfikuje te same zasoby w dalszej części systemu.

Rozwiązuj wyścigi za pomocą Compare-and-Swap

Najtrudniejsze problemy pojawiają się, gdy kończy się wiele prób jednocześnie. Być może Twój system uruchomił pierwszą próbę u głównego dostawcy. Po dziesięciu sekundach ciszy uruchomił drugą próbę u dostawcy awaryjnego. Teraz obie próby zostały zakończone. Nie możesz pozwolić, aby obie zapisały swoje wyniki w tym samym wierszu zadania.

Użyj logiki compare-and-swap. Dodaj numer wersji do wiersza zadania. Gdy próba się kończy, wykonuje aktualizację z warunkami:

  • Aktualna wersja musi zgadzać się z tą, którą próba odczytała na początku.
  • Żadna inna próba nie mogła już wcześniej zająć miejsca na wynik.
  • Jeśli oba warunki są spełnione, zapisz wynik i zwiększ wersję.

W terminologii SQL wygląda to jak instrukcja update z klauzulą WHERE id = $1 AND version = $2 AND completed_by IS NULL. Jeśli aktualizacja zwróci zero wierszy, oznacza to, że inna próba już wygrała. Spóźnione żądanie musi zostać zignorowane. Odrzuć jego wynik. Nie łącz go. Nie dopisuj. Po prostu go usuń. Spóźniony wynik, który nadpisuje wcześniejszego zwycięzcę, to uszkodzenie danych, a jedynym bezpiecznym ruchem jest jego odrzucenie.

To rozwiązuje problem zakończenia w odwrotnej kolejności w sposób uporządkowany. Próba A wychodzi jako pierwsza, ale wraca po trzydziestu sekundach. Próba B wychodzi jako druga, ale wraca po pięciu sekundach. Próba B wygrywa operację compare-and-swap. Aktualizacja próby A nie dotyka żadnego wiersza. Twój system loguje wyścig, ignoruje nieaktualny payload i idzie dalej.

Testuj punkty krytyczne

Nie wyłapiesz tych błędów podczas testowania ścieżki szczęśliwej (happy-path). Twój zestaw testowy musi celować w pęknięcia.

  • Symuluj dwukrotne kliknięcie. Dwa jednoczesne żądania POST z tym samym kluczem idempotencji muszą zwrócić identyczne identyfikatory zadań (job IDs).
  • Wyślij ten sam klucz z niezgodnymi danymi wejściowymi. Spodziewaj się odpowiedzi o konflikcie. System nie może po cichu zwrócić istniejącego zadania, jeśli parametry się różnią.
  • Wywołaj przekroczenie czasu oczekiwania (timeout). Zweryfikuj, czy zadanie trafia w stan nieznany (unknown state), a nie w stan błędu (failed state), oraz czy system blokuje dalsze próby, dopóki nie zostanie wyjaśniona niejednoznaczność.
  • Wymuś zakończenie dwóch prób w odwrotnej kolejności. Potwierdź, że ta, która wróci jako druga, przegrywa, nawet jeśli ta, która wyszła jako pierwsza, była oficjalnym głównym dostawcą (primary provider).

Te testy to nie luksusowe przypadki brzegowe. To kontrakt, jaki Twoje API zawiera z resztą systemu.

Waliduj intencję dostawcy przed przełączeniem awaryjnym (failover)

Jeśli korzystasz z konfiguracji wielodostawcy, możesz ulec pokusie traktowania różnych modeli AI jako wymiennych slotów. Dzielą one tę samą ścieżkę kodu, ten sam klient HTTP i ten sam schemat JSON. To nie oznacza jednak, że zachowują się tak samo.

Jeden model może wygenerować halucynację w postaci klucza na najwyższym poziomie. Inny może zignorować formatowanie Twojego system promptu. Walidacja schematu wyłapuje błędy składniowe, ale przepuści odpowiedź, której Twoja logika biznesowa nie będzie w stanie zinterpretować. Dostawca może zwrócić poprawny JSON, który po prostu wykona niewłaściwą czynność na Twoim szablonie promptu.

Uruchamiaj testy specyficzne dla dostawcy, zanim pozwolisz na automatyczne przełączanie modeli. Potwierdź, że model zapasowy (fallback) faktycznie przestrzega struktury wyjściowej przy niskiej temperaturze (low temperature). Zweryfikuj, czy Twój prompt jest poprawnie renderowany przez tokenizer danego dostawcy. Przetestuj pełną drogę (round trip) z rzeczywistymi danymi wejściowymi. Automatyczny failover jest bezpieczny tylko wtedy, gdy udowodnisz, że model zapasowy współdzieli ten sam kontrakt operacyjny.

Zachowaj jedno zadanie na jedną intencję

Ścieżki fallback są dobre. Niekontrolowane mnożenie się mechanizmów fallback to błąd. Każda warstwa Twojego stosu musi ocenić, czy widziała już dokładnie to samo zadanie. Load balancer, handler API, baza danych i worker muszą wszystkie respektować tę samą tożsamość.

Zbuduj swój system tak, aby ponowienia (retries) i mechanizmy fallback pojawiały się jako nowe próby w ramach jednego, stabilnego zadania. Zablokuj zadanie za pomocą klucza idempotencji opartego na bazie danych. Chroń przejścia. Doprowadź do wyścigu między próbami. Pozwól wygrać dokładnie jednej. W ten sposób zapobiegniesz sytuacji, w której jedno kliknięcie użytkownika zamieni się w cały weekend sprzątania danych.