Saltar al contenido
crawlforgeEnglish
Se rompió25 de agosto de 20269 min de lecturaRead in English

El invariante estaba escrito tres líneas más arriba

La 0.9.0 iba a ser dos arreglos de superficie: enseñar una tabla que nadie leía y registrar el destino de las redirecciones externas. El segundo destapó un fallo del planificador que llevaba ahí desde que existen las sondas, y que en release no hacía ruido: se limitaba a perder comprobaciones.

La versión 0.9.0 salió para tapar dos huecos que estaban apuntados desde el 4 de agosto y que no tenían ningún misterio. En los dos casos el motor ya sabía algo y no había forma de verlo desde fuera. Al tapar el segundo apareció un fallo del planificador que no estaba en ninguna lista.

Una tabla que llevaba semanas llenándose para nadie

La tabla resources está en el esquema desde la primera migración. El motor empezó a escribir en ella en la 0.8.0, con una migración que le puso un índice único por URL para que reanudar un rastreo actualice las filas en vez de duplicarlas. Todo correcto, todo probado.

Y no la leía nada. Ni una hoja del Excel, ni un CSV, ni el informe. El caso que justificaba tener esa tabla era muy concreto: un bundle.js de 900 KB servido en la plantilla de todas las páginas de un sitio. Ese dato estaba dentro del fichero de rastreo y no había ningún comando capaz de sacarlo.

Ahora el libro de Excel tiene catorce hojas en vez de trece, y una de ellas es Resources. También hay un resources.csv en la exportación. Va ordenada por tamaño de mayor a menor, con un detalle que conviene contar porque es de los que se hacen mal sin enterarse: en SQLite los valores nulos van al final cuando ordenas descendente, así que un recurso cuyo servidor no manda Content-Length cae abajo del todo y no arriba, que es donde habría acabado con cualquier otro orden y donde no aporta nada.

Lo que esa hoja no dice, y hay que decirlo: no sabe en cuántas páginas aparece cada recurso. Es una fila por URL de recurso, no por pareja de página y recurso. Esa relación solo se guarda para las imágenes, y fue una decisión de modelo de datos que sigue vigente. La hoja responde a «qué es lo más pesado que estoy sirviendo», no a «dónde lo estoy sirviendo».

El enlace de afiliado que se pudre y nadie ve

El segundo hueco es más caro. Imagina un /go/producto en tu sitio que redirige a una tienda que no es tuya. Cuando la tienda retira ese producto, el 404 lo paga tu página, y hasta la 0.9.0 el rastreo enseñaba el 301 de /go/producto y se quedaba ahí. El destino no existía como fila en ningún modo, así que la columna redirect_to se quedaba sin resolver y ninguna regla podía llegar al otro extremo para decir que estaba muerto.

Lo llamativo es que la maquinaria ya estaba montada. Desde la 0.8.0 los enlaces externos se registran y se les comprueba el estado con una petición HEAD, que es lo que hace posible avisar de un enlace saliente roto. Un destino de redirección nunca pasaba por ese camino porque el bloque que lo trata es otro, unas líneas más abajo en el mismo bucle.

Ahora recibe el mismo trato: cuenta contra el mismo tope de externas registradas, entra en la misma cola de sondas, pasa por el mismo perímetro de red y respeta la misma cortesía de una sola petición en vuelo por host ajeno. Sigue siendo solo estado, sin parsear nada del sitio de otro. Y la hoja Redirects lleva ahora una columna to_status con el código del destino, para verlo sin escribir SQL.

El test falló por otra cosa

Escribí el test antes de dar nada por bueno: un sitio con dos redirecciones a la misma tienda ajena, una a un producto que sigue vivo y otra a uno retirado. Falló, pero no por lo que esperaba.

assertion failed: externals.pending() == 0

Eso es un debug_assert del planificador, y dice algo razonable: sin ninguna petición en vuelo no puede quedar nada por despachar, porque con el límite mínimo de una por host todo lo que hubiera en cola tuvo su hueco en el último relleno del pool. Puse cuatro líneas de instrumentación para ver el estado en ese momento:

DIAG pendientes=1 in_flight_by_host={}

Nadie en vuelo, y una sonda esperando turno. La cola tenía trabajo y el planificador no lo veía.

Tres líneas más arriba

La cola de sondas guarda dos cosas: un mapa de hosts con lo que sabe de cada uno, y una ronda de hosts con trabajo pendiente que va rotando para que un servidor lento no acapare el turno. El comentario que hay encima de esa ronda dice, literalmente, que un host está en ella si y solo si su cola existe y no está vacía.

El código que metía hosts en la ronda preguntaba otra cosa:

let nuevo = !self.by_host.contains_key(&host);

Es decir, «¿es la primera vez que veo este host?». Y la entrada de un host en ese mapa sobrevive a que su cola se vacíe, porque ahí viven su cupo de sondas y su racha de fallos. La segunda vez que aparecía un host, su URL entraba en la cola y el host no volvía a la ronda, así que no la despachaba nadie.

El arreglo es mirar la cola en lugar del mapa:

let fuera_de_la_ronda = state.queue.is_empty();

Por qué no había salido antes tiene que ver con la forma en que se descubren las cosas. Con enlaces, todas las externas de una página se encolan de una vez, mientras se procesa el mismo documento, y la cola de ese host casi nunca llega a vaciarse entre medias. Con redirecciones cada destino llega en un resultado distinto, en momentos distintos, y dos redirecciones a la misma tienda son exactamente el caso que rompe.

Lo peor no es el panic. En una compilación de release los debug_assert no existen, así que el bucle terminaba sin más y esa sonda acababa contada al final como «externa sin comprobar», sin ningún motivo asociado. Un fallo que se manifiesta como un número ligeramente distinto en un resumen es mucho más difícil de encontrar que uno que revienta.

Lo que se ejecutó antes de publicar

Los tres cambios se comprobaron revirtiéndolos uno a uno y viendo fallar sus tests, que es la única manera de saber si un test protege de algo. El de la ronda de hosts vuelve a romper el invariante del planificador. El del destino externo devuelve None donde debería haber un 200. El de la hoja de recursos no encuentra la hoja.

Con eso puesto: 1.031 tests en verde, clippy sin avisos y la regresión de rendimiento compilada con optimizaciones, que da 111.809 elementos por segundo, 2.727 páginas por segundo y 30,1 MB de memoria máxima sobre el fichero de siempre. El cambio toca el bucle principal del rastreo y no se nota en los números, que era la duda razonable.

Queda una cosa a medias, y prefiero decirlo aquí que dejar que se note. La hoja de recursos enseña el dato y ninguna regla lo juzga todavía: nadie te avisa de que ese bundle.js pesa demasiado. Las reglas de peso tocan el catálogo, el catálogo es lo que consume esta web, y eso pide su propia versión.

Se construye en público

Cada dos semanas: mediciones, fallos y ejemplos de uso. Nada más.

← Volver a la bitácora