Hoe één zware chat onze API omver kon halen
Op 28 september, ongeveer 17 minuten na een release, liepen beide productie-API-servers tegelijk vol geheugen, startten opnieuw op, en voor ongeveer vijf minuten had de load balancer geen gezonde server om verkeer naartoe te sturen. Onze eerste fix gaf de API twee keer zoveel geheugen. De echte oorzaak kwam die dag later naar boven: het openen van één bepaalde chat vroeg de API om 53 MB aan berichten in één verzoek te laden. Chatgeschiedenis wordt nu in pagina's geladen die zowel op grootte als op aantal zijn begrensd, en die chat opent met een eerste pagina van 5 MB in plaats van een crash.
Wat er uitviel
Onze API draait als container op elk van twee servers achter een load balancer. Elke container had een geheugenlimiet van 384 MiB. In rust gebruikt hij 130–140 MiB, dus de limiet leek ruim genoeg.
Beide containers bereikten die limiet tegelijk en crashten met “JavaScript heap out of memory”. De heap is het geheugen dat een Node.js-proces gebruikt voor de objecten waarmee het werkt. Omdat beide servers tegelijk faalden, was er nergens meer heen om verzoeken te routeren. Dat is de ergste soort storing voor een opzet met twee servers: redundantie helpt alleen als de fouten niet tegelijk optreden.
De eerste fix: meer ruimte
De timing wees naar de deploy. Na een release worden agentbeurten die de deploy onderbrak, hervat en slaan hun transcripten opnieuw op, en die golf van werk leek de voor de hand liggende bron van een geheugenpiek.
Dus hebben we de geheugenlimiet van de API verhoogd van 384 MiB naar 768 MiB. We pasten het live toe op beide servers op dezelfde dag, en legden het vast in de deployconfiguratie, zodat de volgende deploy 384 niet stilletjes terugzet. Een fix die met de hand op een server is gemaakt en die de volgende deploy ongedaan maakt, is een veelvoorkomende manier waarop dezelfde storing terugkomt.
Meer geheugen was een redelijke veiligheidsmarge. Het was geen verklaring.
De echte oorzaak: één verzoek, 71 MB
Later die dag hebben we de crash op een kopie van één chat gereproduceerd, met de oude geheugenlimiet van productie. De kopie crashte de API op zichzelf, zonder deploy erbij.
Wanneer je een project opent, vraagt de webapp de API om de geschiedenis van de chat. Het eindpunt gaf de nieuwste 30 berichten, compleet. Voor de meeste chats is 30 berichten klein. Voor deze ene chat waren de nieuwste 30 berichten 53 MB in de database en een antwoord van 71 MB. Een aantal antwoorden van de agent was 4–10 MB elk, vooral de transcripten van subagents: de helpers die de hoofdagent start om parallel aan een deel van een taak te werken, en wiens volledige werk bij het antwoord wordt opgeslagen.
Het opbouwen van dat ene antwoord kostte meer heap dan de API had. En elke keer dat je dat project opende of ververste, werd het verzoek opnieuw verstuurd. Eén chat kon elke server die hij trof omverhalen, en omdat de pagina opnieuw geladen kon worden, kon hij beide bereiken.
“30 berichten” was nooit echt een limiet op grootte geweest. Het was een limiet op aantal die toevallig klein was voor gewone chats.
De oplossing: pagina's begrensd op grootte én op aantal
Een pagina geschiedenis is nu 30 berichten of ongeveer 4 MB ervan, wat het eerst komt.
Het werkt in twee stappen:
- Vraag eerst om de groottes. De API vraagt de database om de ID's en opgeslagen groottes van de nieuwste berichten, zonder hun inhoud. Die query is goedkoop, hoe groot de berichten ook zijn.
- Laad alleen wat past. Van nieuw naar oud lopend voegt hij berichten toe tot het volgende bericht de pagina over 4 MB zou duwen. Alleen die rijen worden dan volledig geladen.
Twee regels houden dit veilig:
- Het nieuwste bericht komt altijd op de pagina, hoe groot het ook is. Een pagina die niets teruggaf zou de chat vastzetten, zonder manier om terug te scrollen.
- Niets wordt afgekapt. Geen enkel bericht wordt ingekort of weggelaten. De rest van de chat laadt via de bediening “Scroll up for earlier messages” die oudere berichten al gebruikten, één pagina tegelijk.
We vonden nog een plek met dezelfde vorm. Wanneer je een beurt stopt, controleert de API de laatste paar antwoorden om te zien of er lege zijn die opgeruimd moeten worden. Dat deed hij door acht volledige berichten te laden. In een chat met antwoorden van meerdere megabytes is dat hetzelfde probleem in klein formaat. Hij stelt de database nu een ja-of-nee-vraag, heeft dit bericht enige inhoud?, zonder de inhoud zelf te laden.
Hoe we het hebben gecontroleerd
Op de kopie van de chat, met de oude limiet van 384 MiB:
- De eerste pagina is 5 MB. Voorheen crashte hetzelfde verzoek de API.
- Vijf browsertabbladen die de chat tegelijk openen, pieken op 154 MB geheugen.
- Het volledig terugscrollen door de chat geeft alle 190 berichten terug, elk precies één keer, in volgorde.
De logica voor het kiezen van pagina's heeft ook unit-tests, waaronder één gebouwd uit de echte groottes van de nieuwste berichten van die chat. De eerste pagina komt terug met één bericht, zegt dat er meer zijn, en de volgende pagina begint bij het bericht daarna.
Wat we hieruit hebben geleerd
- Een aantal is geen groottelimiet. Elk eindpunt dat “de nieuwste N” van iets teruggeeft dat zonder grens kan groeien, heeft ook een bytebudget nodig. De data vindt de opening uiteindelijk.
- Het voor de hand liggende vermoeden is niet altijd de oorzaak. De deploy was echt een aanleiding voor extra werk, en meer geheugen was het waard. Maar de crash reproduceerde zich zonder deploy, en zonder die reproductie waren we bij de geheugenverhoging gestopt.
- Twee servers falen samen als ze hetzelfde verzoek krijgen. Redundantie beschermt tegen één server die uitvalt. Het beschermt niet tegen een verzoek dat elke server die hij bereikt doodt, zeker niet als een gebruiker het vanzelf opnieuw probeert door te verversen.
- Los het op in de configuratie, niet alleen op de machine. De geheugenverhoging is dezelfde dag in de deployconfiguratie gezet, zodat de volgende deploy hem behield.
Dit is ons tweede recente geheugenverhaal rond de uitvoer van de agent. Het eerste was een statuslabel dat 690 MB aan strings in de wachtrij zette. Als je subagents op Mythex gebruikt, kun je in de documentatie lezen hoe ze werken.