Como um chat pesado podia derrubar a nossa API
A 28 de setembro, cerca de 17 minutos depois de uma publicação, os dois servidores de API de produção ficaram sem memória no mesmo instante, reiniciaram, e durante cerca de cinco minutos o balanceador de carga não teve nenhum servidor saudável para encaminhar tráfego. A nossa primeira correção deu à API o dobro da memória. A causa real apareceu mais tarde nesse dia: abrir um chat específico pedia à API para carregar 53 MB de mensagens num só pedido. O histórico de chats passou a carregar em páginas limitadas tanto pelo tamanho como pelo número, e esse chat abre agora com uma primeira página de 5 MB em vez de crashar.
O que caiu
A nossa API corre como um contentor em cada um de dois servidores, atrás de um balanceador de carga. Cada contentor tinha um limite de memória de 384 MiB. Em repouso usa 130–140 MiB, por isso o limite parecia ter muita folga.
Os dois contentores atingiram o limite ao mesmo tempo e crasharam com «JavaScript heap out of memory». O heap é a memória que um processo Node.js usa para os objetos com que trabalha. Como os dois servidores falharam em simultâneo, já não havia para onde encaminhar os pedidos. É o pior tipo de avaria para uma configuração com dois servidores: a redundância só ajuda quando as falhas não acontecem ao mesmo tempo.
A primeira correção: mais folga
O timing apontava para a publicação. Depois de uma versão, os turnos do agente que a publicação interrompeu são retomados e voltam a guardar as suas transcrições, e essa rajada de trabalho parecia a fonte óbvia do pico de memória.
Por isso, a primeira alteração subiu o limite da API de 384 MiB para 768 MiB. Aplicámo-la ao vivo nos dois servidores no mesmo dia, e comprometemo-la na configuração de deploy para que a publicação seguinte não repusesse discretamente os 384. Uma correção feita à mão num servidor que a publicação seguinte desfaz é uma forma comum de a mesma avaria voltar.
Mais memória era uma margem de segurança razoável. Não era uma explicação.
A causa real: um pedido, 71 MB
Mais tarde nesse dia reproduzimos o crash numa cópia de um chat, usando o antigo limite de memória de produção. A cópia derrubou a API por si só, sem qualquer publicação envolvida.
Quando abre um projeto, a aplicação web pede à API o histórico do chat. O endpoint devolvia as 30 mensagens mais recentes, inteiras. Para a maioria dos chats, 30 mensagens são pouco. Para este, as 30 mensagens mais recentes ocupavam 53 MB na base de dados e uma resposta de 71 MB. Várias respostas do agente tinham 4–10 MB cada, sobretudo as transcrições de sub-agentes: os agentes auxiliares que o agente principal lança para trabalhar em paralelo numa parte da tarefa, cujo trabalho completo fica guardado com a resposta.
Construir essa resposta exigia mais heap do que a API tinha. E cada abertura ou refresh desse projeto voltava a enviar o pedido. Um único chat podia derrubar qualquer servidor que o recebesse, e como a página podia ser recarregada, podia atingir os dois.
«30 mensagens» nunca tinha sido um limite de tamanho real. Era um limite de contagem que, por acaso, era pequeno para chats normais.
A correção: páginas limitadas pelo tamanho e pelo número
Uma página de histórico passa a ter 30 mensagens ou cerca de 4 MB delas, o que vier primeiro.
Funciona em dois passos:
- Pedir primeiro os tamanhos. A API pede à base de dados os IDs e os tamanhos guardados das mensagens mais recentes, sem os conteúdos. Essa consulta é barata, independentemente do tamanho das mensagens.
- Carregar apenas o que cabe. Percorrendo da mais recente para a mais antiga, acrescenta mensagens até que a seguinte faça a página ultrapassar 4 MB. Só essas linhas são depois carregadas por completo.
Duas regras mantêm isto seguro:
- A mensagem mais recente entra sempre na página, seja qual for o seu tamanho. Uma página que devolvesse nada deixaria o chat bloqueado, sem forma de voltar atrás.
- Nada é cortado. Nenhuma mensagem é truncada ou descartada. O resto do chat carrega através do controlo «Ver mensagens anteriores», que as mensagens antigas já usavam, uma página de cada vez.
Encontrámos mais um sítio com a mesma forma. Quando interrompe um turno, a API verifica as últimas respostas para ver se alguma está vazia e deve ser limpa. Fazia isso carregando oito mensagens completas. Num chat com respostas de vários megabytes, é o mesmo problema numa forma mais pequena. Agora pergunta à base de dados uma questão de sim ou não — esta mensagem tem algum conteúdo? — sem carregar o próprio conteúdo.
Como verificámos
Na cópia do chat, a correr com o antigo limite de 384 MiB:
- A primeira página tem 5 MB. Antes, o mesmo pedido derrubava a API.
- Cinco separadores do browser a abrir o chat em simultâneo atingem um pico de 154 MB de memória.
- Percorrer o chat inteiro até ao início devolve as 190 mensagens, cada uma exatamente uma vez, por ordem.
A lógica de escolha de páginas também tem testes unitários, incluindo um construído a partir dos tamanhos reais das mensagens mais recentes desse chat. A primeira página devolve uma mensagem, indica que há mais, e a página seguinte começa na mensagem a seguir.
O que tirámos disto
- Um número não é um limite de tamanho. Qualquer endpoint que devolva «as N mais recentes» de algo que pode crescer sem limite também precisa de um orçamento em bytes. Os dados acabam sempre por encontrar a lacuna.
- O suspeito óbvio nem sempre é a causa. A publicação foi um gatilho real de trabalho extra, e mais memória valia a pena ter. Mas o crash reproduziu-se sem publicação, e sem reproduzir o problema teríamos parado no aumento de memória.
- Dois servidores falham juntos quando recebem o mesmo pedido. A redundância protege contra um servidor que morre. Não protege contra um pedido que derruba qualquer servidor que o receba, sobretudo um que um utilizador repetirá naturalmente ao fazer refresh.
- Corrija na configuração, não apenas na máquina. O aumento de memória foi para a configuração de deploy no mesmo dia, para que a publicação seguinte o mantivesse.
Esta é a nossa segunda história recente de memória ligada ao output do agente. A primeira foi uma etiqueta de estado que colocou 690 MB de strings em fila. Se usa sub-agentes na Mythex, pode ler como funcionam na documentação.