Archiwum Czerwonej Księgi

[Zmiana] [Pozostałe] Nowy system synchronizacji sesji

Started by pajper 11 posts last post 2 days ago

#1 Edited

Od samego początku istnienia Eltena, program w określonym takcie, który zmieniał się wielokrotnie na przestrzeni lat, odpytywał serwer o powiadomienia i aktualizował sesję jako zwykłe zapytanie HTTP. By szok migracji do Eltena 3.0 nie był aż taki i ograniczyć liczbę błędów, zachowaliśmy to zachowanie także w nowym API, a zmiana tego była planowana na Eltena 3.1.

W związku z tym jednak, że nagłym i zaskakującym mnie zainteresowaniem okazało się tworzenie programów do Eltena, w tym gier real-time, wymaga to szybszej aktualizacji. Dlatego nowe API serwerowe udostępnię w Eltenie 3.0.2.

* Serwer do wymiany danych używa ramek strumienia HTTP/2
Oznacza to, że nie aktualizuje stanu kolejnymi zapytaniami, a informacje o powiadomieniach lub zmianach są dostarczane natychmiast, bez opóźnienia
* Jako fallback przy niestabilności protokołu lub wymuszeniu HTTP/1.1 (np. z powodu proxy, ograniczonych sieci Wi-Fi), używana jest strategia long-poll
#StandWithUkraine

Shoot for the Moon. Even if you miss, you'll land among the stars.

#2

9cf8b68 Improve session updating handling, with HTTP/2 streams and long-poll
#StandWithUkraine

Shoot for the Moon. Even if you miss, you'll land among the stars.

#3

co z utratą dostępu do internetu? Kiedy klient się połączy ponownie sam?

#4

Przy braku ACK przez 30 sekund próbuje przeskoku na fallback i po 60 sekundach próbuje ponownego nawiązania łączności przez ramki H2. Jak się nie uda, pozostaje na fallbacku i za kolejne 60 sekund próbuje skoku na nowe API. Itd.
#StandWithUkraine

Shoot for the Moon. Even if you miss, you'll land among the stars.

#5

Elegancko.

#6

Jak uruchamiam eltena na nowym commicie, wczoraj niby działało, dziś się sypie potężnie. W sensie codecs twierdzi, że mnożonych jest pełno żądań, dochodzi do limit exceeded i potem już forum, wiadomości, cokolwiek bym nie zrobił wyrzuca błąd. Myślałem, że to mój gameroom robi dziwne akcje i mnoży żądania, ale robiłem testy potem na czysto
po co mi sygnatura?

#7

Pokażesz log? Bo u mnie działa na razie.
#StandWithUkraine

Shoot for the Moon. Even if you miss, you'll land among the stars.

#8

Wkleję to, co wypluł codecs, sorki, nieco przydlugie, ale idę do pracy i nie mam jak wyczyścić xd.
# Regresja po commicie 9cf8b68 — lawina żądań i „Too many requests”

Data testu: 30 sierpnia 2026
Klient: ELTEN 3.0.1, Windows x64, Ruby 4.0.6
Podejrzany commit: `9cf8b68617c1cc738b7d42d5799cce862491de8e` — „Improve session updating handling, with HTTP/2 streams and long-poll”
Commit kontrolny: `a9a96b422a59796ac8c9364aec5e8711aa912f2b` — bezpośredni rodzic `9cf8b68`, również ELTEN 3.0.1

## Streszczenie

Regresję udało się odtworzyć od świeżego uruchomienia ELTEN-a, bez otwierania forum, wiadomości ani Game Roomu.

Po commicie `9cf8b68` klient wysyła około 10 żądań `/api/v1/system/realtime-state` na sekundę przez HTTP/2. Po wyłączeniu HTTP/2 nadal wysyła około 6 żądań na sekundę.

Serwer nie realizuje żądanego `wait_ms=5000`. Poprawna odpowiedź przychodzi po około 72–100 ms, zamiast po około pięciu sekundach. Odpowiedź nie zawiera również pól `realtime_stream` ani `realtime_cursor`.

Po przekroczeniu limitów serwer zwraca HTTP 429 wraz z `Retry-After: 1`, ale klient nadal odpytuje w tym samym tempie. Najpierw uruchamia się limit przeznaczony dla realtime, a następnie ogólny limit uwierzytelnionej sesji. W rezultacie forum, wiadomości i inne funkcje raz działają, a raz zwracają „Too many requests”.

W wersji kontrolnej sprzed commita mechanizm wykonuje około 0,47 żądania na sekundę, zgodnie z lokalnym odstępem dwóch sekund. Forum i wiadomości działają wtedy stabilnie.

## Odtworzenie od zera

1. Zamknięto wszystkie działające kopie ELTEN-a.
2. Uruchomiono jedną kopię ELTEN-a 3.0.1 z commitem `9cf8b68`.
3. Nie otwierano forum, wiadomości ani Game Roomu.
4. Zmierzono wewnętrzny licznik żądań `NotificationService`.
5. Wykonano pojedyncze, kontrolowane próby endpointów realtime, forum i wiadomości.
6. Następnie przeprowadzono identyczny test na bezpośrednio wcześniejszym commicie ELTEN-a 3.0.1.

## Wyniki wersji z commitem 9cf8b68

Klient został uruchomiony o 08:21:52.

O 08:23:05 licznik żądań wzrósł z 660 do 690 w ciągu trzech sekund. Daje to dokładnie 10 żądań na sekundę.

W tym samym momencie `/api/v1/system/realtime-state` zwrócił:

- HTTP 429;
- kod `rate_limits.exceeded`;
- profil `realtime`;
- regułę `realtime_session_10s`;
- zakres `session`;
- `Retry-After: 1`.

Stan klienta w tym momencie:

- `stream_supported` było ustawione na `false`;
- strumień realtime nie był połączony;
- klient nie próbował otwierać strumienia;
- liczba awarii strumienia wynosiła zero;
- termin następnego żądania, `next_request_at`, pozostawał w przeszłości;
- po zakończeniu każdego żądania następne było wysyłane natychmiast.

## Dlaczego właściwy strumień realtime się nie uruchamia?

Long-poll jest tutaj mechanizmem awaryjnym. Docelowo klient powinien przejść na stały strumień HTTP/2.

Negocjacja wygląda następująco:

1. Klient wysyła `stream_capability=1`, informując serwer, że obsługuje strumień.
2. Serwer powinien odpowiedzieć `realtime_stream=1`.
3. Dopiero wtedy klient próbuje otworzyć `/api/v1/system/realtime-stream`.

W rzeczywistej odpowiedzi serwera nie było jednak ani `realtime_stream`, ani `realtime_cursor`.

W konsekwencji klient ustawił `stream_supported = false` i w ogóle nie próbował otwierać właściwego strumienia. Potwierdza to również licznik błędów strumienia równy zero — strumień nie uległ awarii, ponieważ nigdy nie został uruchomiony.

Nie da się z samego klienta rozstrzygnąć, dlaczego backend nie reklamuje strumienia. Możliwe przyczyny to między innymi:

- niewdrożona obsługa po stronie serwera;
- wyłączona funkcja;
- niezgodna wersja kontraktu klient–serwer;
- niepełne wdrożenie zmian backendowych.

Można natomiast jednoznacznie potwierdzić, że klient nie uruchamia strumienia właśnie dlatego, że odpowiedź serwera nie zawiera wymaganej informacji o jego obsłudze.

## Sprawdzenie zachowania long-polla

Aby sprawdzić zachowanie serwera bez aktywnego limitu, automatyczne odpytywanie zostało tymczasowo zatrzymane wyłącznie w pamięci procesu.

Przez 12 sekund licznik żądań nie zmienił się ani razu.

Po wygaśnięciu limitu wysłano jedno żądanie z:

- `wait_ms=5000`;
- `stream_capability=1`.

Serwer odpowiedział:

- HTTP 200;
- po 0,0718 sekundy;
- bez `realtime_stream`;
- bez `realtime_cursor`.

Odpowiedź przyszła więc po około 72 ms, a nie po pięciu sekundach.

Dowodzi to, że backend obsługujący tę sesję zachowuje się dla tego endpointu jak wcześniejszy endpoint migawkowy. Nie utrzymuje żądania przez podany czas long-polla.

## Wpływ na forum i wiadomości

Po ponownym uruchomieniu automatycznego odpytywania:

- o 08:25:12 odrzucone zostały jednocześnie odczyt forum i odczyt listy rozmów;
- w kolejnych próbach forum było odrzucane stale;
- wiadomości działały naprzemiennie — jedna próba przechodziła, kolejna była odrzucana;
- licznik automatycznych odpytań wzrósł z 1709 do 3104 między 08:25:12 a 08:27:44.

Bezpośrednie próby wykonane o 08:28:35 zwróciły zarówno dla forum, jak i wiadomości:

- HTTP 429;
- kod `rate_limits.exceeded`;
- profil `authenticated_api`;
- regułę `authenticated_session_5m`;
- zakres `session`;
- `Retry-After: 1`.

To wyjaśnia obserwowane zachowanie, w którym pierwsze żądanie może przejść, kolejne zwraca błąd, a następne znowu działa.

Zwykłe operacje współdzielą limit uwierzytelnionej sesji, który jest stale obciążany przez mechanizm realtime. W zależności od chwilowego stanu okna limitu pojedyncze żądanie może zostać przepuszczone lub odrzucone.

## Próba bez HTTP/2

HTTP/2 został tymczasowo wyłączony wyłącznie w pamięci uruchomionego klienta.

Wyniki:

- worker wykonał 18 żądań w trzy sekundy;
- daje to 6 żądań na sekundę;
- po dwunastu sekundach ciszy pojedynczy long-poll HTTP/1.1 zakończył się sukcesem po 0,1004 sekundy;
- odpowiedź nadal nie zawierała informacji o obsłudze strumienia.

Wyłączenie HTTP/2 zmniejsza tempo, ponieważ HTTP/1.1 ma większy narzut, ale nie usuwa przyczyny błędu.

Problem występuje we wspólnej logice planowania kolejnego odpytywania, a nie w samym transporcie HTTP/2.

## Kontrola A/B na poprzednim ELTEN-ie 3.0.1

Uruchomiono ten sam profil, na tym samym komputerze i serwerze, używając bezpośredniego rodzica commita, czyli `a9a96b4`.

Była to nadal wersja ELTEN 3.0.1, a nie wcześniejszy ELTEN 3.0.

Wyniki:

- licznik żądań wzrósł o 7 w ciągu 15 sekund;
- daje to około 0,467 żądania na sekundę;
- lokalny `refresh_interval` wynosił dwie sekundy;
- cztery kolejne odczyty struktury forum zakończyły się poprawnie;
- cztery kolejne odczyty listy wiadomości zakończyły się poprawnie;
- nie wystąpił limit.

Test A/B izoluje regresję do zmian wprowadzonych przez `9cf8b68`.

Nie jest to efekt:

- Game Roomu;
- serwera MCP;
- uruchomienia drugiej kopii programu;
- profilu użytkownika;
- samego HTTP/2.

## Łańcuch przyczynowy w kodzie

Przed commitem `request_status` zawsze ustawiał lokalny termin następnego żądania:

```ruby
@next_request_at = started_at + refresh_interval
```

Commit `9cf8b68` usunął to zabezpieczenie i zaczął polegać na tym, że serwer przetrzyma żądanie przez `wait_ms=5000`.

Obecny przebieg wygląda więc następująco:

1. Klient wysyła żądanie realtime z `wait_ms=5000`.
2. Serwer nie utrzymuje żądania i natychmiast zwraca odpowiedź.
3. Odpowiedź nie reklamuje obsługi strumienia.
4. Klient pozostaje w trybie long-poll.
5. Worker wykonuje swoją pętlę co 0,1 sekundy.
6. Po odebraniu odpowiedzi żądanie zostaje usunięte z listy trwających żądań.
7. `next_request_at` nadal wskazuje czas w przeszłości.
8. Worker natychmiast wysyła następne żądanie.
9. Powstaje około 10 żądań na sekundę.

Dodatkowy problem występuje podczas obsługi odpowiedzi HTTP 429.

Serwer zwraca prawidłowy JSON w rodzaju:

```json
{
"success": false,
"error": {
"code": "rate_limits.exceeded",
"message": "Too many requests"
}
}
```

Taki obiekt nadal jest Hashem. Funkcja `status_response_data` zwraca cały payload dla każdej odpowiedzi innej niż poprawne:

```json
{
"success": true,
"data": {}
}
```

Następnie `handle_status_response` uznaje każdy Hash za prawidłowe dane stanu.

W efekcie:

- `success: false` nie jest rozpoznawane jako błąd;
- nie uruchamia się `schedule_poll_error_retry`;
- klient nie wykonuje backoffu;
- nadal wysyła kolejne żądania.

Jest jeszcze problem z nagłówkami HTTP.

Żądanie statusu przekazuje do warstwy HTTP tablicę:

```ruby
[key, request_id]
```

Warstwa HTTP zapisuje nagłówki odpowiedzi tylko wtedy, gdy przekazane `data` jest Hashem.

W rezultacie nagłówek `Retry-After: 1` nie trafia do `NotificationService`, więc ten mechanizm nie może go wykorzystać nawet po dodaniu odpowiedniej obsługi kodu 429.

## Najważniejsze miejsca w kodzie

- `src/eapi/notifications.rb:207` — pętla workera wykonywana co 0,1 sekundy;
- `src/eapi/notifications.rb:237` — wysłanie następnego żądania, gdy termin minął i nie ma żądania w toku;
- `src/eapi/notifications.rb:365` — `request_status`, bez ustawienia przyszłego `next_request_at`;
- `src/eapi/notifications.rb:386` — wysyłanie `wait_ms=5000`;
- `src/eapi/notifications.rb:664` — obsługa odpowiedzi;
- `src/eapi/notifications.rb:695` — retry uruchamiany jedynie dla rozpoznanych błędów transportu lub parsowania;
- `src/eapi/notifications.rb:1222` — odpowiedź błędu pozostaje zwykłym Hashem;
- `src/eapi/http.rb:119` — nagłówki odpowiedzi są dołączane tylko wtedy, gdy `data` jest Hashem.

## Wniosek

Commit zakłada kompatybilny backend long-poll/stream, którego bieżąca odpowiedź produkcyjna nie potwierdza.

Problem ma dwie warstwy:

1. Właściwy strumień realtime nie jest uruchamiany, ponieważ serwer nie reklamuje jego obsługi.
2. Awaryjny long-poll nie ma bezpiecznego ograniczenia tempa, gdy serwer ignoruje `wait_ms`.

Niezależnie od planowanego wdrożenia lub poprawienia serwera klient nie ma obecnie bezpiecznej obsługi starszej albo niekompatybilnej odpowiedzi.

Nie rozpoznaje też odpowiedzi `success: false` jako błędu wymagającego odczekania.

## Co warto zweryfikować w poprawce

- przywrócenie lokalnego minimalnego odstępu pomiędzy żądaniami fallbacku;
- jawne rozpoznawanie `success: false`;
- obsługę HTTP 429;
- respektowanie `Retry-After`;
- bezpieczny backoff przy kolejnych błędach;
- uruchamianie strumienia wyłącznie po jednoznacznej reklamie jego obsługi;
- test zgodności z backendem, który ignoruje `wait_ms`;
- test zgodności z backendem, który nie zwraca `realtime_stream` ani `realtime_cursor`;
- sprawdzenie po stronie serwera, dlaczego bieżąca odpowiedź nie reklamuje strumienia realtime.

W trakcie diagnostyki nie zmieniano plików źródłowych i nie wdrażano żadnej poprawki.
po co mi sygnatura?

#9

Mała serwerowa regresja, już łatam.
#StandWithUkraine

Shoot for the Moon. Even if you miss, you'll land among the stars.

#10

Naprawione.
#StandWithUkraine

Shoot for the Moon. Even if you miss, you'll land among the stars.

#11

chyba działczy, więc archiwizuję
Happy hacking