Descartar cuatro oportunidades seguidas en el tablero de licitaciones tumbaba la página con un 502 Bad Gateway, el error que sale cuando el servidor dejó de responder. El equipo revisaba la tanda del día entre cliente y cliente, botaba las tres primeras convocatorias y en la cuarta se quedaba mirando el error hasta que el proceso volviera solo. Tres clics de trabajo y uno de contemplación.
El 502 llegaba al cuarto clic
El monitor revisa las convocatorias del Estado y las deja en un tablero donde el equipo las mueve entre pendiente, en curso, presentada y ganada o perdida. De cada tanda, la mayoría no tiene nada que ver con cámaras ni con fotografía, así que descartar es la operación más frecuente del sistema y era justo la que lo mataba.
El primer sospechoso fue el túnel: meses antes tuvimos 502 y 530 aleatorios por un túnel de Cloudflare compartido. Pero este 502 aparecía siempre al tercer o cuarto clic, y el registro del gestor de procesos mostraba un reinicio por memoria cada vez.
Un cuaderno que se reescribía entero
Cambiar el estado de una tarjeta leía los 130 MB a memoria, convertía ese texto en objetos, modificaba un campo de un registro, volvía a convertir los 21,880 a texto y escribía el archivo completo otra vez. Todo eso corría síncrono, así que solo la reescritura del archivo dejaba a Node bloqueado entre 3 y 5 segundos, sin atender nada más. Era como volver a copiar a mano el cuaderno de ingresos del taller cada vez que tachas una línea, cuando lo que necesitábamos era el archivador de fichas: sacas una, la corriges, la devuelves.
El pico de memoria por escritura llegaba a unos 800 MB, porque durante un instante conviven el texto leído, los objetos en memoria y el texto nuevo. Ese pico pasaba el techo del gestor de procesos, que reiniciaba el servicio, y de ahí salía el 502 en pantalla.
Todo en un JSON
Borrar con un clic
Copiar 130 MB por clic
Copiar 130 MB por clic
Ninguna optimización dentro del JSON servía
Podíamos convertir el texto más rápido, escribir por partes o mandar la escritura a un hilo aparte. Ninguna de las tres toca el problema, que está en el modelo de almacenamiento: para cambiar una línea había que reescribir las 21,880, y el archivo crece cada día con las convocatorias nuevas. Guardar todo en un archivo de texto fue mi idea y aguantó meses, que es la forma más cara de equivocarse.
Columnas para filtrar, una columna de datos para el resto
Migramos cuatro tablas a SQLite en modo WAL (write-ahead log, que deja leer mientras otro proceso escribe): oportunidades, propuestas, comentarios y corridas del scraper. El esquema es híbrido a propósito: unas pocas columnas indexadas para lo que el tablero filtra y ordena, y el objeto completo guardado como JSON en una columna de datos. Cada convocatoria quedó como una ficha propia y agregar un campo nuevo sigue costando casi nada.
El scraper y el portal abren el mismo archivo, cada uno con su conexión. La estrategia de concurrencia es WAL más un busy timeout de 5 segundos: si el archivo está ocupado, el otro proceso espera en vez de fallar. No pusimos un bloqueo en la aplicación porque a este volumen de escrituras no hace falta.
Configuración, tokens de sesión e historial de notificaciones siguen en archivos JSON planos: son chicos, se escriben poco y conviene abrirlos a mano. No migramos nada que no tuviera un problema medido.
Las altas y actualizaciones del scraper ahora entran en una sola transacción y la tanda bajó de unos 10 s a cerca de 1 s. El reproceso que vuelve a calificar los registros contra las palabras clave, 30 segundos pegados a la CPU, salió del arranque: corre cuando las palabras clave cambian, no cada vez que el servicio levanta.
0.6 ms y diez borrados en 11 ms
Un cambio de estado completo, medido del clic a la respuesta, bajó de entre 4,000 y 6,000 ms a 0.6 ms. Diez borrados seguidos, que antes tumbaban el proceso, toman 11 ms en total. El equipo dejó de avisarme que la página estaba muerta.
Dos procesos escribiendo el mismo archivo funciona a este volumen. Si sumamos un tercer servicio que escriba seguido, el busy timeout va a aparecer en los registros y toca repensar el reparto.
Mover de sitio 21,880 registros en producción da miedo, así que ahora escribo el script de migración, el respaldo con fecha y el plan de rollback antes de tocar el almacenamiento. Y cuando algo está lento pregunto primero si está guardado de la forma equivocada. El cuarto clic hoy es igual de aburrido que los tres primeros.
