Blog

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

El 28 de septiembre, unos 17 minutos después de una publicación, los dos servidores de producción de la API se quedaron sin memoria a la vez, 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 le dio a la API el doble de memoria. La causa real apareció más tarde ese mismo día: abrir un chat concreto hacía que la API pidiera 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 130–140 MiB, así que el límite parecía tener mucho margen.

Ambos contenedores llegaron a ese límite a la vez y se cayeron con «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 ambos servidores fallaron al mismo tiempo, no quedaba adónde 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 espacio

La hora apuntaba a la publicación. Tras una publicación, los turnos del agente que se interrumpieron 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 ambos servidores ese mismo día y lo confirmamos en la configuración de despliegue, para que la siguiente publicación no devolviera silenciosamente el valor de 384. Un arreglo hecho a mano en un servidor que la siguiente publicación deshace es una forma habitual de que la misma caída vuelva.

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 antiguo límite de memoria de producción. La copia tumbó la API por sí sola, sin ninguna publicación de por medio.

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 son poca cosa. En este, los 30 mensajes 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 por las transcripciones de los subagentes: los agentes auxiliares que el agente principal lanza para trabajar en paralelo en una parte de una tarea, cuyo trabajo completo se guarda junto a la respuesta.

Construir esa única respuesta consumía más heap del que tenía la API. Y cada vez que se abría o refrescaba el proyecto, la petición se repetía. 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 cantidad que, por casualidad, era pequeño para los chats normales.

La solución: páginas limitadas por tamaño además de por número

Una página de historial ahora tiene 30 mensajes o unos 4 MB de ellos, lo que se alcance 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 almacenados 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. Recorriendo de los más nuevos a los más antiguos, 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 del tamaño que sea. Una página que no devolviera nada dejaría el chat atascado, 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 lugar con la misma forma. Cuando detienes 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

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

  • La primera página ocupa 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 a partir de 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 siguiente empieza en el siguiente.

Lo que aprendimos

  • Un recuento 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. La publicación fue un desencadenante real de trabajo extra, y más memoria era útil. Pero la caída se reprodujo sin publicación, y sin reproducirla nos habríamos quedado en el aumento de memoria.
  • Dos servidores caen juntos cuando reciben la misma petición. La redundancia protege contra la caída de un servidor. No protege contra una petición que tumba cualquier servidor al que llegue, sobre todo una que el usuario repetirá naturalmente refrescando.
  • Arréglalo en la configuración, no solo en la máquina. El aumento de memoria se incluyó en la configuración de despliegue ese mismo día, así que la siguiente publicación 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 de creación de apps con IA · Herramientas gratuitas para crear apps con IA · English

English አማርኛ العربية Български বাংলা Bosanski Català Čeština Dansk Deutsch Ελληνικά Español 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 简体中文 繁體中文(香港) 繁體中文(台灣)