Blog

Cómo un chat pesado podía tumbar nuestra API

El 28 de septiembre, unos 17 minutos después de una publicación, los dos servidores de API de producción se quedaron sin memoria en el mismo momento, se reiniciaron, y durante unos cinco minutos el balanceador de carga no tuvo ningún servidor sano al que enviar tráfico. Nuestro primer arreglo dio a la API el doble de memoria. La causa real apareció más tarde ese día: abrir un chat concreto pedía a la API cargar 53 MB de mensajes en una sola petición. El historial del chat ahora se carga en páginas limitadas por tamaño además de por número, y ese chat se abre con una primera página de 5 MB en lugar de caerse.

Qué se cayó

Nuestra API se ejecuta como un contenedor en cada uno de dos servidores, detrás de un balanceador de carga. Cada contenedor tenía un límite de memoria de 384 MiB. En reposo usa entre 130 y 140 MiB, así que el límite parecía tener mucho margen.

Ambos contenedores alcanzaron ese límite a la vez y se cayeron con el mensaje «JavaScript heap out of memory». El heap es la memoria que usa un proceso de Node.js para los objetos con los que trabaja. Como los dos servidores fallaron al mismo tiempo, no quedaba ningún sitio al que enrutar las peticiones. Es el peor tipo de caída para una configuración de dos servidores: la redundancia solo ayuda cuando los fallos no ocurren a la vez.

El primer arreglo: más margen

La hora apuntaba a la publicación. Tras una publicación, los turnos del agente que el despliegue interrumpió se reanudan y vuelven a guardar sus transcripciones, y esa ráfaga de trabajo parecía la fuente obvia de un pico de memoria.

Así que el primer cambio subió el límite de la API de 384 MiB a 768 MiB. Lo aplicamos en directo en los dos servidores el mismo día, y lo guardamos en la configuración de despliegue para que la siguiente publicación no devolviera el límite a 384 sin avisar. Un arreglo hecho a mano en un servidor que el siguiente despliegue deshace es una forma habitual de que vuelva la misma caída.

Más memoria era un margen de seguridad razonable. No era una explicación.

La causa real: una petición, 71 MB

Más tarde ese día reprodujimos la caída en una copia de un chat, usando el límite de memoria antiguo de producción. La copia tumbó la API por sí sola, sin que interviniera ningún despliegue.

Cuando abres un proyecto, la aplicación web pide a la API el historial del chat. El endpoint devolvía los 30 mensajes más recientes, completos. En la mayoría de los chats, 30 mensajes es poco. En este, los 30 más recientes ocupaban 53 MB en la base de datos y una respuesta de 71 MB. Varias respuestas del agente pesaban entre 4 y 10 MB cada una, sobre todo las transcripciones de subagentes: los agentes auxiliares que el agente principal inicia para trabajar en paralelo en parte de una tarea, cuyo trabajo completo se guarda junto con la respuesta.

Construir esa única respuesta necesitaba más heap del que tenía la API. Y cada apertura o recarga de ese proyecto volvía a enviar la petición. Un solo chat podía tumbar el servidor que le tocara, y como la página podía recargarse, podía alcanzar a los dos.

«30 mensajes» nunca había sido un límite real de tamaño. Era un límite de número que, por casualidad, era pequeño para los chats normales.

El arreglo: páginas limitadas por tamaño además de por número

Una página de historial ahora contiene 30 mensajes o unos 4 MB de ellos, lo que se cumpla primero.

Funciona en dos pasos:

  1. Pedir primero los tamaños. La API pide a la base de datos los IDs y los tamaños guardados de los mensajes más recientes, sin su contenido. Esa consulta es barata sin importar lo grandes que sean los mensajes.
  2. Cargar solo lo que cabe. Yendo de más reciente a más antiguo, añade mensajes hasta que el siguiente haría que la página superara los 4 MB. Solo entonces se cargan completas esas filas.

Dos reglas mantienen esto seguro:

  • El mensaje más reciente siempre entra en la página, sea cual sea su tamaño. Una página que no devolviera nada dejaría el chat bloqueado, sin forma de volver atrás.
  • Nada se corta. Ningún mensaje se trunca ni se descarta. El resto del chat se carga mediante el control «Desplázate hacia arriba para ver mensajes anteriores» que ya usaban los mensajes antiguos, una página cada vez.

Encontramos otro sitio con la misma forma. Al detener un turno, la API revisa las últimas respuestas para ver si alguna está vacía y debe limpiarse. Lo hacía cargando ocho mensajes completos. En un chat con respuestas de varios megas, era el mismo problema en versión pequeña. Ahora le hace a la base de datos una pregunta de sí o no (¿este mensaje tiene algún contenido?) sin cargar el contenido en sí.

Cómo lo comprobamos

En la copia del chat, ejecutándose con el límite antiguo de 384 MiB:

  • La primera página pesa 5 MB. Antes, la misma petición tumbaba la API.
  • Cinco pestañas del navegador abriendo el chat a la vez alcanzan un pico de 154 MB de memoria.
  • Desplazarse hacia atrás por todo el chat devuelve los 190 mensajes, cada uno exactamente una vez, en orden.

La lógica de selección de páginas también tiene pruebas unitarias, incluida una construida con los tamaños reales de los mensajes más recientes de ese chat. La primera página devuelve un mensaje, indica que hay más, y la página siguiente empieza en el mensaje posterior a ese.

Qué aprendimos

  • Un número no es un límite de tamaño. Cualquier endpoint que devuelva «los N más recientes» de algo que puede crecer sin límite necesita también un presupuesto en bytes. Los datos acaban encontrando el hueco.
  • El sospechoso obvio no siempre es la causa. El despliegue fue un desencadenante real de trabajo extra, y más memoria era útil. Pero la caída se reprodujo sin ningún despliegue, y sin reproducirla nos habríamos quedado en el aumento de memoria.
  • Dos servidores fallan juntos si reciben la misma petición. La redundancia protege de que un servidor muera. No protege de una petición que tumba cualquier servidor al que llegue, sobre todo si el usuario la repite de forma natural recargando la página.
  • Arréglalo en la configuración, no solo en la máquina. El aumento de memoria se guardó en la configuración de despliegue el mismo día, así que el siguiente despliegue lo mantuvo.

Esta es nuestra segunda historia reciente de memoria relacionada con la salida del agente. La primera fue una etiqueta de estado que encoló 690 MB de cadenas. Si usas subagentes en Mythex, puedes leer cómo funcionan en la documentación.

Mythex · Pricing · Crea lo que quieras con IA · Plantillas de sitios web y apps · Mythex comparado · Glosario para crear apps con IA · Herramientas gratis para crear apps con IA · English

English አማርኛ العربية Български বাংলা Bosanski Català Čeština Dansk Deutsch Ελληνικά Español (España) Eesti فارسی Suomi Français Français (Canada) ગુજરાતી हिन्दी Hrvatski Magyar Հայերեն Bahasa Indonesia Íslenska Italiano 日本語 ქართული Қазақ тілі ಕನ್ನಡ 한국어 Lietuvių Latviešu Македонски മലയാളം Монгол मराठी Bahasa Melayu မြန်မာ Norsk bokmål Nederlands ਪੰਜਾਬੀ Polski Português (Brasil) Português (Portugal) Română Русский Slovenčina Slovenščina Soomaali Shqip Srpski Svenska Kiswahili தமிழ் తెలుగు ไทย Tagalog Türkçe Українська اردو Tiếng Việt 简体中文 繁體中文(香港) 繁體中文(台灣)