Era sábado, y yo corría curl contra el mismo set de URLs que había corrido el día anterior, pegando los resultados en una hoja de Google para que el lunes todos supieran cómo iba la migración.
Lidero el equipo que mueve un gran conjunto de páginas de unas URLs a otras — a través de once mercados, cada uno con su propio dominio y rutas localizadas. El cut-over en sí no nos toca a nosotros: otro equipo es dueño de las reglas de redirección en el edge, así que un mercado sale en vivo cuando ellos cambian una regla, en su propio calendario. Nuestro trabajo era verificar que cada pieza de verdad cayera en su lugar — ¿la página de este mercado ya la sirve la app nueva, o todavía el origen viejo, o sigue redirigiendo a la URL vieja, o da 404?
La única forma de saberlo era mirar. Así que miré — a mano, con curl, hacia una hoja de cálculo que mantenía vivo yo solo.
El olor que les digo a todos que eviten
Llevo una regla a cada proyecto: nunca crear un punto único de falla. Y ahí estaba yo, el punto único de falla de una migración de once mercados. Si me tomaba un día libre, la hoja se quedaba vieja. Si me jalaban a otra cosa, la visibilidad moría. La respuesta a “¿está avanzando la migración?” vivía entera en mi historial de terminal y en mi disposición a cuidar una hoja de cálculo un fin de semana.
Ese no es un problema que se arregle comunicando más fuerte. Es un problema de sistemas, y la solución es la misma que aplicaría a cualquier servicio que pase por una sola caja: sacar al humano del camino. Así que me pasé el fin de semana construyendo lo que me iba a reemplazar.
Qué revisa en realidad
El board es una cuadrícula: mercados en el costado, tipos de página arriba. Cada celda responde una pregunta: ¿quién sirve esta URL ahora mismo? La app nueva, el origen viejo, un redirect de vuelta a la URL vieja, un 404, o un error directo. Y la parte que la hoja nunca pudo hacer: ¿cuándo cambió por última vez?
Por debajo, es la matriz de la migración hecha concreta — 11 mercados × 5 tipos de página × 4 variantes de host (QA y prod, cada una en el dominio canónico y en .com), que da 200 celdas por barrida una vez que descartás los mercados que comparten dominio. Para cada celda arma la URL localizada correcta — un dominio distinto y una ruta traducida por mercado — y va y mira.
Por qué sigue los redirects a mano
La versión ingenua de esto es fetch(url), leer el status final, listo. Esa versión está mal de una forma que le mentiría calladamente a todo el equipo.
La migración son redirects. Una URL puede hacer 301 a su ubicación vieja, o que la reescriban en silencio a otro matched path, y un 200 final no te dice nada sobre qué backend respondió de verdad. Así que el checker sigue los redirects a mano — fetch(url, { redirect: 'manual' }) en un loop acotado, hasta cinco saltos — y clasifica sobre la cadena completa, no sobre la última respuesta. Un 3xx cuyo Location cae en la ruta vieja es un redirect, aunque al final dé 200. Un 200 final con un server header de Vercel es la app nueva; nginx es la vieja. El clasificador es una función pura sin I/O, con tests unitarios contra respuestas reales capturadas — la app nueva, el origen viejo, el 302 de trailing slash, el 301 canónico, el redirect, un sintético de CDN, el 404 — así que confío en sus veredictos lo suficiente para ponerlos frente a otro equipo.
Diseñado para no costar nada
Lo construí todo sobre el free tier de Cloudflare, que terminó siendo la restricción más divertida del proyecto. Un Worker en el plan gratis tiene 50 subrequests y 10 ms de CPU por invocación — y cada salto de redirect es un subrequest. Doscientas celdas, cada una encadenando potencialmente cinco saltos, no entran en una sola invocación.
Así que va en rebanadas. Un cron dispara cada pocos minutos y procesa una rebanada de unas diez celdas — dimensionada para que ni en el peor caso de saltos pase de cincuenta subrequests — y luego avanza un cursor; unas veinte rebanadas después la matriz entera se refrescó, más o menos cada hora. Dentro de una rebanada los fetches corren en paralelo, para que un host colgado no arrastre al resto. El presupuesto de 10 ms de CPU (que, por suerte, excluye el tiempo esperando a la red) es la razón por la que cada escritura de una rebanada se mete en un solo D1 batch(), y por la que el clasificador se mantiene ligero en asignaciones.
El almacenamiento son dos tablas con dos trabajos. checks es historia append-only — cada resultado de siempre, que es lo que hace posible el “cambió hace 3h”. current es un modelo de lectura desnormalizado, una fila por celda, que guarda since_ts — cuándo cambió por última vez el backend — como una columna plana. Así la consulta principal del dashboard lee ≤200 filas en vez de escanear toda la historia, lo que la mantiene bajo el límite de lecturas de D1, y la pregunta “¿cuándo cambió esto?” — la que la hoja nunca pudo responder — es un solo lookup barato.
El punto nunca fue el dashboard
Esto es lo que un fin de semana de verdad me compró. No es que los chequeos estén automatizados, aunque lo están. Es que ya no estoy en el camino. El estado de la migración es ahora una verdad que se actualiza sola, que cualquiera de mi equipo — o el equipo socio al que estoy a punto de entregárselo — puede leer en cualquier momento sin pasar por mí.
Y, calladito, cambió la conversación. Cuando los datos están en vivo y se actualizan solos, “la hoja está vieja” deja de ser algo que alguien pueda decir. Se vuelve “esta página sigue en el origen viejo — acá está la celda, acá cuándo cambió por última vez”. La responsabilidad se mueve a donde está el trabajo: todos pueden ver qué cambió y qué no, incluidos los cambios que les tocan y todavía no han enviado.
Salió primero a mis ingenieros; ya lo están usando para ver cómo va todo. El equipo de plataforma es el siguiente. La migración sigue en vuelo — pero ya no pasa por mí, y ese era todo el punto. Si me ausento un día, sigue avanzando. Ese es el único tipo de sistema en el que confío.