Como um único chat pesado poderia derrubar nossa API
Em 28 de setembro, cerca de 17 minutos depois de uma publicação de versão, os dois servidores de API de produção ficaram sem memória no mesmo instante, reiniciaram e, por cerca de cinco minutos, o balanceador de carga não tinha nenhum servidor saudável para enviar tráfego. Nossa primeira correção deu à API o dobro de memória. A causa real apareceu mais tarde naquele dia: abrir um chat específico fazia a API carregar 53 MB de mensagens em uma única requisição. O histórico do chat agora é carregado em páginas limitadas por tamanho, além de por quantidade, e esse chat abre com uma primeira página de 5 MB em vez de travar.
O que caiu
Nossa API roda como um contêiner em cada um de dois servidores, atrás de um balanceador de carga. Cada contêiner tinha um limite de memória de 384 MiB. Em repouso, usa de 130 a 140 MiB, então o limite parecia ter muita folga.
Os dois contêineres atingiram esse limite juntos e travaram com “JavaScript heap out of memory”. A heap é a memória que um processo Node.js usa para os objetos com os quais trabalha. Como os dois servidores falharam ao mesmo tempo, não sobrou para onde rotear as requisições. É o pior tipo de queda para uma configuração com dois servidores: a redundância só ajuda quando as falhas não acontecem simultaneamente.
A primeira correção: mais espaço
O momento apontava para a publicação. Depois de uma versão, os turnos do agente que a publicação interrompeu são retomados e salvam suas transcrições de novo, e essa rajada de trabalho parecia a fonte óbvia do pico de memória.
Por isso, a primeira mudança elevou o limite da API de 384 MiB para 768 MiB. Aplicamos ao vivo nos dois servidores no mesmo dia e registramos a mudança na configuração de publicação, para que a próxima publicação não voltasse silenciosamente para 384. Uma correção feita manualmente em um servidor que a próxima publicação desfaz é uma forma comum de a mesma queda voltar.
Mais memória era uma margem de segurança razoável. Não era uma explicação.
A causa real: uma requisição, 71 MB
Mais tarde naquele dia, reproduzimos a falha em uma cópia de um chat, usando o antigo limite de memória de produção. A cópia derrubou a API sozinha, sem nenhuma publicação envolvida.
Ao abrir um projeto, o app web pede à API o histórico do chat. O endpoint retornava as 30 mensagens mais recentes, inteiras. Para a maioria dos chats, 30 mensagens são pouco. Para este, as 30 mensagens mais recentes somavam 53 MB no banco e uma resposta de 71 MB. Várias respostas do agente tinham de 4 a 10 MB cada, sobretudo as transcrições dos sub-agentes: os agentes auxiliares que o agente principal inicia para trabalhar em paralelo em parte de uma tarefa, cujo trabalho completo fica salvo junto com a resposta.
Montar essa única resposta consumia mais heap do que a API tinha. E cada abertura ou atualização daquele projeto enviava a requisição de novo. 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 real de tamanho. Era um limite de quantidade que por acaso era pequeno o bastante para chats normais.
A correção: páginas limitadas por tamanho e por quantidade
Uma página de histórico agora tem 30 mensagens ou cerca de 4 MB delas, o que ocorrer primeiro.
Funciona em duas etapas:
- Pedir os tamanhos primeiro. A API pede ao banco os IDs e os tamanhos armazenados das mensagens mais recentes, sem o conteúdo. Essa consulta é barata, não importa o tamanho das mensagens.
- Carregar só o que cabe. Indo da mais nova para a mais antiga, ela adiciona mensagens até que a próxima ultrapassasse 4 MB. Só então essas linhas são carregadas por completo.
Duas regras mantêm isso seguro:
- A mensagem mais recente sempre entra na página, qualquer que seja o tamanho. Uma página vazia deixaria o chat travado, sem como voltar no histórico.
- Nada é cortado. Nenhuma mensagem é truncada ou descartada. O restante do chat é carregado pelo controle “Role para cima para mensagens anteriores”, que as mensagens antigas já usavam, uma página de cada vez.
Encontramos mais um ponto com o mesmo padrão. Quando você interrompe um turno, a API verifica as últimas respostas para ver se alguma está vazia e precisa ser limpa. Para isso, carregava oito mensagens inteiras. Em um chat com respostas de vários megabytes, isso é o mesmo problema em forma menor. Agora ela faz uma pergunta de sim ou não ao banco (esta mensagem tem algum conteúdo?) sem carregar o conteúdo em si.
Como verificamos
Na cópia do chat, rodando com o antigo limite de 384 MiB:
- A primeira página tem 5 MB. Antes, a mesma requisição derrubava a API.
- Cinco abas do navegador abrindo o chat ao mesmo tempo chegam a um pico de 154 MB de memória.
- Rolar o chat inteiro para trás retorna todas as 190 mensagens, cada uma exatamente uma vez, em ordem.
A lógica de escolha de páginas também tem testes unitários, incluindo um feito com os tamanhos reais das mensagens mais recentes daquele chat. A primeira página volta com uma mensagem, indica que há mais, e a próxima página começa na seguinte.
O que tiramos disso
- Uma quantidade não é um limite de tamanho. Qualquer endpoint que retorne “as N mais recentes” de algo que pode crescer sem limite também precisa de um orçamento em bytes. Os dados acabam encontrando 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. Mas a falha se reproduziu sem nenhuma publicação, e sem reproduzi-la teríamos parado no aumento de memória.
- Dois servidores falham juntos quando recebem a mesma requisição. A redundância protege contra um servidor que morre. Não protege contra uma requisição que derruba qualquer servidor que ela alcance, sobretudo uma que o usuário vai repetir naturalmente ao atualizar a página.
- Corrija na configuração, não só na máquina. O aumento de memória foi para a configuração de publicação no mesmo dia, então a próxima publicação o manteve.
Esta é a nossa segunda história recente de memória envolvendo a saída do agente. A primeira foi um rótulo de status que enfileirou 690 MB de strings. Se você usa sub-agentes no Mythex, pode ler sobre como funcionam na documentação.