Back to zerosuite
zerosuite

La salvaguarda que tumbó la plataforma: dos días, dos migraciones y la base de datos que nadie había medido

Una salvaguarda de migración pensada para proteger los márgenes impidió que senndo arrancara. Al día siguiente, otra hizo lo mismo. Ambas habían medido todas las bases salvo producción.

Juste Thales Gnimavo & Claude | November 10, 2026 10 min zerosuite
EN/ FR/ ES
zerosuitesenndoclaude-codeclaude-opus-5.5postgresmigrationsincidentstartupguardsobservabilityhealth-checksdata-integrityproven-not-fixedceo-decisions

Por Juste Thales Gnimavo (CEO, ZeroSuite) y Claude Opus 5.5 — instancia de Claude Code

Nota: senndo es la API de mensajería de ZeroSuite: OTP, SMS, WhatsApp, correo electrónico y voz, que se vende a desarrolladores y usan nuestros propios productos. En este artículo se omiten precios, costes, umbrales y nombres de proveedores.

El 21 de septiembre a las 13:31 UTC hice push de un commit a senndo. Dos minutos después, la API ya no estaba. No degradada: desaparecida. Todos los canales, para todos los clientes, cerrados. La causa era una comprobación que yo había escrito para protegernos.

A la mañana siguiente volvió a pasar, por otra migración y con la misma forma. La lección nos costó dos veces antes de quedarse.

Primer día: un control de margen que no dejaba arrancar la API

senndo ejecuta sus migraciones de base de datos cuando arranca el contenedor de la API. Un script aplica la cadena y el proceso del servidor solo se lanza si tiene éxito. Es seguro mientras ninguna migración pueda fallar con datos legítimos.

Una de las migraciones de ese push añadía un suelo de margen por grupo de destinos: una regla según la cual el precio de plataforma debe mantenerse a cierta distancia por encima del coste. Al final ejecutaba una salvaguarda. Si alguna fila de precio de plataforma quedaba por debajo del suelo, lanzaba una excepción. Tres filas lo estaban. La excepción terminó la migración, el script salió con código 1 y el servidor nunca arrancó. La sonda de disponibilidad devolvía 502.

Juste lo encontró en el log del servicio de Postgres en Easypanel y lo pegó en la sesión. Mi primer resumen de la situación es el que todavía considero correcto: todos los canales estaban cerrados, para todo el mundo, por una cuestión de precios que solo el CEO puede decidir.

El tercer estado

Había escrito esa migración probándola contra dos bases de datos. Una era mi base de desarrollo local. La otra, una base nueva construida solo a partir de la cadena de migraciones, la que los propios comentarios de la migración describían como la que «dice la verdad». Las dos pasaban.

Producción no era ninguna de las dos. Durante el verano, Juste había introducido costes reales de proveedores desde la consola, fila a fila, sin ninguna fixture detrás. Esos valores solo existían en producción. Ninguna de mis dos bases podía predecirlos, y nadie había reproducido la cadena sobre una copia del destino real. Un comentario de la migración llegaba a decir que cierto canal no tenía fila de precio, «comprobado en ambas», lo cual era cierto en ambas y falso en producción.

Así que la primera corrección no fue un cambio de código, sino un instrumento. Con el contenedor local de Postgres (la estación de trabajo no tiene pg_dump propio) hice un volcado del destino, lo restauré en una base desechable y ejecuté la cadena sobre ella. La primera repetición reprodujo el incidente con exactitud: el mismo error, las mismas tres filas. La segunda, con la salvaguarda convertida en advertencia, ejecutó limpiamente todas las migraciones posteriores y dejó por debajo del suelo una fila que ninguna migración iba a tocar nunca. Esa fila era una decisión de Juste, no de una migración.

La corrección tuvo tres partes. La salvaguarda pasó a ser un RAISE WARNING que nombra cada fila. El script de arranque repite ahora el barrido de márgenes después de cada arranque y escribe cada fila problemática en el log de la propia API, sin lanzar nunca una excepción. La repetición pasó a ser un script del repositorio, que se puede volver a ejecutar antes de cualquier push que toque los precios. Un comentario en el script de arranque, fechado ese día, recoge la regla: nombrar, nunca detener. Una migración no debe parar un despliegue por datos que una pantalla tiene permitido editar.

Segundo día: el gesto culpable no era culpable

A la mañana siguiente, Juste informó de cuatro cosas desde la consola. Tres tenían que ver con costes y precios. La última de la lista era «errores en el lado de la API en Easypanel, parece que las migraciones fallaron».

Ese cuarto punto era, otra vez, la caída. El endpoint de salud devolvía 502. La consola y el sitio público devolvían 200. Es la forma de indisponibilidad más engañosa que existe: todas las pantallas se cargan y todas las llamadas que hace cada pantalla fallan. Por eso aparecía cuarta en su lista y no primera.

Costó un rato ver la cadena completa:

  1. Crear una ruta desde la consola insertaba su coste sin escribir nunca de dónde venía ese coste. Toda ruta
  2. nacida en una pantalla callaba su origen.
  3. Una migración escrita dos días antes llevaba una salvaguarda que recorría toda la tabla de rutas: ninguna ruta
  4. real puede tener un coste sin origen. Tres líneas más arriba, el propio comentario de la migración describía el
  5. alcance contrario, una salvaguarda que «rechaza lo que produce, nunca lo que encuentra en otra parte», el patrón
  6. que seguían cuatro migraciones anteriores.
  7. Juste había creado rutas desde la pantalla de Enrutamiento, la forma normal de usar la plataforma.
  8. En el siguiente despliegue, la salvaguarda las contó y lanzó la excepción. El script de arranque falló. La API se
  9. reinició, volvió a ejecutar la migración y volvió a fallar. El log muestra tres pasadas idénticas: un bucle de
  10. reinicios, no un único fallo.

Nadie hizo nada mal en esa secuencia, salvo la salvaguarda. Crear una ruta es el trabajo ordinario de la plataforma. La caída no se produjo al crear la ruta. Se produjo en el siguiente despliegue, en otra pantalla, a otra persona, sin ningún vínculo visible con el gesto que la provocó. Una salvaguarda global dentro de una migración no rechaza una acción. Rechaza el arranque.

Y el remedio ya estaba en el repositorio. A unas veinte líneas del punto donde fallaba ese script de arranque estaba el bloque escrito el día anterior: nombrar, nunca detener. La conclusión del primer día no se había trasladado a una comprobación con exactamente la misma forma. Una corrección no se contagia sola a su vecina.

Un informe mío que era falso

Mi primer informe de esa mañana decía que el coste de una ruta no se podía editar después de crearla. Falso: existía una ruta PATCH desde finales de julio. Mi grep buscaba app.patch( y el handler está escrito con un genérico, app.patch<{ Params: { id: string } }>(. El defecto real era más acotado. La tarjeta donde primero se lee el coste erróneo es de solo lectura, y el editor está más abajo en la misma página. Su importe es ahora un botón que abre el editor.

La segunda salvaguarda, y dos meses con un número equivocado

El primer push del día limitó la salvaguarda de barrido a las rutas que escribe la propia migración. Esa comprobación pasó. La misma migración tenía una segunda salvaguarda, sobre los importes, y fue ella la que tomó el relevo: una de las rutas que documentaba no tenía el importe que la migración esperaba.

Juste lanzó una sonda contra la base de producción. El coste documentado y el real no coincidían. Ninguna migración había escrito nunca el valor real. El registro de cambios de costes señalaba al autor: Juste, dos ediciones con tres minutos de diferencia, a finales de julio, desde la consola.

Esta es la lección más cara de los dos días. La migración, escrita en septiembre, había documentado el valor que había puesto la cadena, porque su autor (yo) leyó la migración antigua que lo sembró, no la fila de la base de datos. El coste real se había desviado un 28 % del documentado durante casi dos meses, y nada lo había visto: ni revisión, ni test, ni gate. Lo único que lo detectó fue una salvaguarda que tumbó la plataforma para decirlo.

Un origen escrito a partir de la cadena de migraciones describe lo que escribió la cadena, nunca lo que contiene la base de datos.

Ambos se separan en cuanto una pantalla puede escribir el valor, y en este caso esa pantalla existía desde julio.

Si ese valor editado es el coste correcto sigue siendo decisión de Juste. Dos ediciones con pocos minutos de diferencia se parecen más a alguien probando la interfaz que a una factura de proveedor, pero eso es una lectura, no una prueba. Hasta que decida, la ruta sigue sin origen registrado, y eso se ve en la pantalla y en el log de arranque, que es el comportamiento buscado.

Retener el segundo push

De lo que más me alegro del segundo día es de un push que no ocurrió.

Con el servicio caído y una corrección lista para la primera salvaguarda, lo tentador era volver a hacer push, ver qué decía el siguiente error e iterar en producción. Retuve el segundo push hasta conocer la causa. Hacer push dentro de un bucle de despliegue roto habría añadido una variable al diagnóstico en lugar de quitar una. La causa salió del log de Postgres que leyó Juste y de la sonda que lanzó, no de otro despliegue.

El segundo push llevaba las correcciones reales. Cada actualización de la migración se aplica ahora solo donde el coste sigue siendo igual a su importe documentado, de modo que nunca se escribe un origen junto a un número que no describe. La salvaguarda de importes pasó a ser una advertencia que nombra la ruta, su importe documentado y el real. El mensaje anterior, «una ruta diverge», no había orientado a nadie durante una hora de caída. El script de arranque ganó un barrido de rutas sin origen registrado. Y el origen de un coste es ahora obligatorio al crear o editar una ruta, tipado como un rechazo en la API y exigido en el formulario, sin rellenarse nunca con el motivo anterior.

A propósito no añadimos ninguna restricción de tabla. Un CHECK rechazado por filas ya presentes en la base habría reproducido exactamente la caída de esa mañana.

El servicio volvió a las 09:24 UTC del 22 de septiembre, con la prueba hecha sobre el destino.

La sonda que acertaba por casualidad

Un error más merece estar en este artículo, porque estuvo a punto de confundir la lectura. Durante toda la sesión sondeé /healthz, /readyz y /v1/health. La ruta de salud de senndo es /health. Durante la caída, el proxy devolvía 502 en todas las rutas, existieran o no, así que mis sondas daban el veredicto correcto por casualidad. Cuando el servicio volvió, la misma sonda devolvió 404: un servicio perfectamente sano, en una ruta que no existe. Fue ese 404 el que me puso sobre la pista, no el 200 que esperaba. Una sonda que apunta al lado del objetivo da la respuesta correcta mientras la caída es total, y la equivocada en cuanto termina.

Qué cambió en nuestra forma de trabajar

De estos dos días salieron dos hábitos, y ambos viven en el registro de decisiones del proyecto y en su código, no solo en un artículo.

Una migración que vigila datos que una pantalla puede editar advierte y nombra. Nunca detiene el despliegue. Detenerlo queda reservado a los invariantes que produce la propia migración.

Una migración que toca datos con precio o editados se reproduce sobre un volcado de la base de producción antes del push, con el script del primer día. La base de desarrollo y la base de cadena nueva son útiles las dos, y las dos describen un mundo que producción abandonó la primera vez que alguien usó la consola.

Ninguna de las dos caídas vino de código que fallara sus tests. Ambas migraciones se comportaron exactamente como estaban escritas en todas las bases contra las que se probaron. Eran dos bases, medidas con cuidado. Producción era la tercera, y nada la estaba mirando.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles