connection-pooling
notatki (01)
Od lenistwa przy nauce n8n do stress-testów aplikacji w Zigu
Kontekst
Zaczęło się od potrzeby: przelotka powiadomień z n8n na Coolify do ntfy1. Problem - nie umiałem sensownie zapisać workflow n8n do gita (i nie chciało mi się poświęcać na to więcej czasu), więc zamiast dalej z tym walczyć, napisałem to sam, od zera, w Zigu2.
I tak powstał ntfy.zig - mały relay: przyjmuje webhooki (póki co Coolify i GitHub Actions/Deployments), przerabia je na tytuł/wiadomość/priorytet i forwarduje do ntfy.
Relację z vibe-codingiem na przestrzeni miesięcy można streścić do typowej adnotacji pod klientem llm, “korzystaj, ale sprawdzaj” - stąd też fajnie było wykorzystać doświadczenie testerskie i zrobić stress testy Locustem.
Setup
Jeden katalog stress/ w repo: fake endpoint ntfy symulujący opóźnienia/fail rate/timeouty, Locust mieszający ruch z obu kanałów webhooków (Coolify + GitHub, z celowo złym podpisem HMAC - sekretem, którym GitHub podpisuje payload webhooka, żeby odbiorca mógł zweryfikować, że request faktycznie pochodzi od niego - do testowania odrzucenia 401) i skrypt spinający całość, wszystko w jednym środowisku uv na free-threaded Pythonie 3.14t. Pełne techniczne detale, w tym dlaczego free-threaded akurat tu ma sens, są w PR #14.
Pierwszy przebieg - coś jest nie tak
Przy 20 użytkownikach wszystko ładnie: mediana 5-6ms. Podbiłem do 300 użytkowników żeby znaleźć sufit i dostałem:
OSError(49, "Can't assign requested address")
Oho, problemy. OSError 49 to wyczerpanie puli efemerycznych portów - i to zanim CPU zdążyło coś poczuć.
Przyczyna i fix
Powód: każdy pojedynczy forward do ntfy tworzył nowego klienta HTTP od zera i od razu go zamykał. Czyli nowe połączenie TCP (a dla https - nowy handshake TLS) na każdy webhook. Zero connection poolingu3 - stąd tak szybkie wyczerpanie portów.
Fix: jeden http.Client budowany raz przy starcie i współdzielony między wszystkimi requestami4, zamiast tworzenia nowego na każdy forward. Połączenia TCP zaczynają się zatem reużywać, a nie mnożyć w nieskończoność.
Weryfikacja
150 użytkowników, ~550 req/s. lsof na procesie ntfy.zig pokazał 11 stałych połączeń do fake ntfy zamiast jednego nowego na request. Zero błędów wyczerpania portów.
| Metryka | Przed | Po |
|---|---|---|
| Połączeń TCP przy 150 VU / ~550 req/s | ~1 na request | ~11 stałych |
| Throughput (lokalnie) | ~70 req/s (wait_time-bound) | ~550 req/s |
Wniosek
Stress test nie musiał być wyszukany, żeby złapać realny bug - wystarczyło podbić liczbę użytkowników wystarczająco wysoko, żeby zderzyć się z limitem portów zamiast z limitem CPU. Dziwny to zresztą świat, w którym można napisać i przetestować pod obciążeniem aplikację w języku, którego się w ogóle nie zna5.
Przy okazji: cała aplikacja w spoczynku zajmuje około 2MB RAM-u - więc obok tego, że działa poprawnie, jest też całkiem sympatyczna dla zasobów.
Footnotes
-
Sympatyczny i dość minimalistyczny selfhosted serwer do powiadomień push. ↩
-
Tak w skrócie wygląda moje podejście do życia. W wielu aspektach. ↩
-
Mechanizm, w którym raz otwarte połączenie zostaje utrzymane i reużywane dla kolejnych requestów, zamiast być zamykane i otwierane od nowa za każdym razem. ↩
-
context7 często ma nieaktualną dokumentację dla tak egzotycznych bibliotek jak świeża stdlib Ziga - przez co Sonnet 5, pracując nad nowym
std.http, sympatycznie nawigował wprost po kodzie źródłowym biblioteki, zamiast polegać na (nieaktualnej) dokumentacji. ↩ -
I model najwyraźniej też, ale on dla odmiany ma znacznie więcej cierpliwości do czytania kodu bibliotek. ↩