Ir al contenido

Blog ·

Toda llamada a sí misma estaba en posición de cola

Un recorrido recursivo tuvo que aprender a detenerse a medias y continuar mañana. La reescritura fue exacta y no aproximada, y 43 pruebas lo demostraron.

Por Provibr

Nuestros flujos de automatización son grafos. Un disparador se activa, el recorrido visita nodos, cada nodo hace algo y en algún momento el recorrido se queda sin cables y la ejecución ha terminado. Hasta esta semana ese recorrido era una función recursiva que se llamaba a sí misma una vez por cada cable saliente, y eso estuvo bien mientras una ejecución terminara dentro de la petición que la había iniciado.

Un nodo de aprobación rompe esa suposición por completo. La ejecución tiene que detenerse, quedar anotada y continuar mañana, cuando alguien pulse un botón. Media pila de llamadas de JavaScript no es algo que se pueda serializar en una columna de base de datos.

Así que el recorrido se convirtió en una máquina de pila. Lo interesante no es que se convirtiera en una; es que la reescritura era demostrablemente el mismo recorrido y no uno nuevo que ojalá se comporte igual.

Por qué la reescritura es exacta y no aproximada

Todas las llamadas recursivas del recorrido antiguo estaban en posición de cola. La forma era literalmente await follow(node, port); return;. Nunca ocurría nada después de que la llamada volviera: ninguna acumulación, ninguna limpieza, ninguna segunda mitad de una función esperando a que su hijo terminase.

Esa única propiedad es lo que hace mecánica la conversión. Un recorrido en profundidad cuya recursión está en la cola es, por construcción, idéntico a una pila explícita sobre la que se apilan los destinos en orden inverso. Ahí dentro no hay ninguna decisión de diseño en la que equivocarse, ninguna duda sobre en qué orden visitar a los hermanos, ningún riesgo de que un nodo que antes se visitaba después de su subárbol pase ahora a visitarse antes.

La consecuencia que nos importaba es que el orden de visita no cambia. Las pruebas del recorrido que ya existían, cuarenta y tres, no se tocaron y siguieron en verde. Ese es todo el argumento: las pruebas describen el comportamiento antiguo, se escribieron antes de que nadie supiera que llegaría esta reescritura, y no necesitaron ni una sola modificación.

Si la recursión no hubiera estado en posición de cola, esto habría sido una reescritura con un argumento de compatibilidad adosado, y los argumentos de compatibilidad son de esa clase de cosas que se creen hasta que el flujo de un cliente hace algo inesperado un martes.

Lo que la pila aporta y la recursión no podía

Dos cosas, y las dos son imposibles con una pila de llamadas.

La primera es la interrupción. El estado del recorrido es el recorrido entero expresado como JSON: la pila, los nodos visitados, las variables acumuladas, el contador y los bucles abiertos. Va a una columna jsonb en la fila de espera y sale de ella sin cambios. Una ejecución en pausa es una fila y no un proceso suspendido, así que un despliegue, un reinicio o un contenedor caído no la pierden.

La segunda son los bucles. Un foreach o un repeat coloca una marca en la pila y después sigue su cuerpo. Cuando la pila se vacía de nuevo hasta esa marca, una pasada ha terminado y el bucle decide si hay otra. El grafo sigue siendo por tanto un grafo dirigido acíclico, no hay ningún cable que apunte hacia atrás, y todo lo que cuelga de la aciclicidad sigue funcionando: la alcanzabilidad, la ausencia de ciclos, la vista de un flujo como lista topológica.

Ese último punto merece que nos detengamos. La manera evidente de añadir bucles a un editor de grafos es dejar que quien lo usa dibuje una arista de vuelta hacia un nodo anterior. Es también la manera que destruye en silencio todas las demás garantías que da el editor, y uno se entera cuando una validación escrita hace un año empieza a agotar el tiempo de espera.

Idempotencia en tres capas que hacen cada una algo distinto

A una ejecución en pausa la despierta un trabajador a través de la red. Los trabajadores reintentan. Así que la pregunta que decide si esta funcionalidad es segura es qué ocurre cuando la misma ejecución se despierta dos veces.

Hay tres mecanismos, y no son redundancia. Cada uno atrapa una cosa distinta.

CapaQué esQué atrapa
El reclamoun solo UPDATE que mueve la fila a un estado de reclamo, pero solo desde el estado de esperaun segundo despertar que llega mientras el primero todavía recorre
Un índice único parcial sobre la ejecucióncomo mucho una fila de espera abierta por ejecuciónque lleguen a existir dos filas de espera para la misma ejecución
Un índice único sobre la ejecución y la posición del pasoel libro de pasos no puede contener dos filas en la misma posicióncualquier cosa que se haya colado por las dos primeras

El reclamo es la frontera real y los otros dos son estructura. La razón es que los efectos secundarios de un flujo, un comando enviado a un agente, una notificación, una petición HTTP saliente, ocurren durante el recorrido y no después. Así que el único sitio correcto para detener al segundo trabajador es antes de que empiece a recorrer, no cuando intenta escribir sus resultados. Quien reciba cero filas de esa actualización no reanuda, y ahí se acaba.

Un reintento que falla es un problema distinto de un reintento que duplica. Hay un techo de cinco intentos con una espera de uno, dos, cuatro y ocho minutos, porque un recorrido que lanza un error al reanudarse, una plantilla que se borró, un servidor que ya no existe, de lo contrario se reintentaría en cada tic para siempre, con una tabla de cola que nunca se vacía.

Una sola columna gobierna la cola

El trabajador hace exactamente una pregunta: qué esperas están vencidas. Eso es un estado y un momento de despertar, y nada más.

Un plazo es un momento de despertar en el futuro. Tomar una decisión es poner ese momento en ahora. Deliberadamente no hay una segunda vía hacia el ejecutor, ningún arreglo en el que la acción de servidor reanude la ejecución por su cuenta y el trabajador también pueda hacerlo. Dos vías serían una carrera entre ambas, y la carrera sería invisible porque los dos caminos producen una ejecución de aspecto correcto.

Esto además abarató el segundo tipo de espera. Cuando llegó el retardo duradero, nada cambió en la cola. Es una fila con un momento de despertar y sin ninguna persona detrás.

Dos tablas, porque tienen vidas distintas

La maquinaria y el producto son tablas separadas, y la tentación de fundirlas es fuerte porque el primer día son uno a uno.

La fila de maquinaria responde a «¿cuándo debe continuar esta ejecución, y con qué?»: una continuación, un momento de despertar, un reclamo, un contador de intentos. Es interna, es genérica y se borra cuando ha cumplido su función.

La fila de producto responde a «¿qué se pidió, a quién y qué se decidió?»: un encabezado, una explicación, un plazo, quien decide, una marca de tiempo. Un cliente la lee. Un auditor la vuelve a leer un año después. Sobrevive a la ejecución a la que perteneció.

Fundirlas da una tabla en la que la mitad de las columnas quedan nulas en cuanto existe un segundo tipo de espera, que es exactamente lo que pasó cuando el retardo duradero llegó un día después.

Decisiones pequeñas en las que es fácil equivocarse

El nodo se llama approval y no approval.wait. Un punto es un separador de espacios de nombres en nuestros catálogos de traducción, así que un tipo de nodo que contenga uno produce una clave que se resuelve en un objeto anidado en lugar de en una cadena. Esa es la clase de restricción que vive en un sistema distinto de aquel que se está editando.

La expiración tiene tres desenlaces y no cuatro: aprobar, rechazar o saltar. Saltar es el que hubo que pensar, porque la cuarta opción evidente, «dejarlo colgando», no es una opción, es la ausencia de una política. Una aprobación saltada no toma ninguna de las dos ramas y lo dice en el libro, que es distinto de estar aprobada y distinto de estar rechazada.

Decidir es un permiso propio y no una propiedad del módulo. Sin eso, quien escribe un flujo firma sus propios pasos de aprobación, y el nodo entero es decorativo.

El cuerpo de un bucle es un área cerrada. No se puede terminar una ejecución desde dentro de uno, y un cable que va desde dentro del cuerpo hacia algo de fuera es un error de validación y no una sorpresa en tiempo de ejecución. El anidamiento se detiene en tres niveles, las iteraciones en cien por bucle y quinientas visitas por ejecución, y pasarse de cualquiera de esos límites es un error y no un truncamiento. Truncar significa que el flujo de un cliente hizo en silencio la mitad de su trabajo.

Un repeat no arrancará por un camino que todavía no existe, porque un valor ausente se lee como falso, también para una comprobación de desigualdad. Esa última cláusula es la que pilla a la gente: intuitivamente, «este campo no es igual a hecho» debería ser cierto cuando el campo está ausente, y tratarlo así arranca bucles sobre datos que aún no han llegado.

Tres huecos que la ronda encontró y no creó

El primero es un código de rechazo para un nodo de acción sin servidor que existía desde la ronda que lo introdujo y no estaba en ninguno de los nueve catálogos de traducción. Nadie se había topado con él. Ahora hay una guarda que lee los códigos de motivo de los ficheros fuente en lugar de una lista escrita a mano, y exige una frase para cada uno de ellos en cada uno de los nueve catálogos. Se puso en rojo la primera vez que se ejecutó, con 83 hallazgos.

El segundo es que los ficheros de prueba de todo este subsistema no estaban cubiertos por ninguna configuración de comprobación de tipos. No se compilaban en la construcción de nadie. Añadirlos encontró seis huecos de tipos, uno de los cuales era el estado de resumen de una ejecución tipado como cadena simple, lo que significa que toda comparación contra él estaba sin comprobar.

El tercero es que el comportamiento del nodo de registro no estaba descrito en ninguna parte de la página de la ejecución, así que un cliente que mirara una ejecución terminada veía un paso cuyo propósito quedaba sin explicar.

Ninguno de ellos lo causó este trabajo. Los tres se encontraron porque escribir un tipo de nodo nuevo significa leer bien el código de alrededor por primera vez en meses.

Lo que dejaríamos por escrito

  1. Compruebe si su recursión está en posición de cola antes de discutir sobre una reescritura. Si lo está, la conversión a una pila explícita es mecánica y las pruebas que ya existen son la demostración.
  2. Las pruebas que no hubo que editar son la evidencia más fuerte que se puede obtener. Cuarenta y tres pruebas verdes sin tocar dicen más que cuarenta y tres nuevas.
  3. Serialice la continuación, no el proceso. Una ejecución en pausa debería ser una fila que sobreviva a un despliegue.
  4. Los bucles en un editor de grafos no necesitan una arista hacia atrás. Una marca sobre la pila mantiene el grafo acíclico y todo lo que depende de la aciclicidad sigue funcionando.
  5. La idempotencia va antes de los efectos secundarios, no después. Reclame el trabajo y después hágalo.
  6. Una sola columna debería gobernar la cola. Dos vías hacia el mismo ejecutor son una carrera que no se verá en las pruebas.
  7. La maquinaria y el producto son tablas distintas en cuanto existe un segundo tipo de maquinaria, cosa que ocurrirá antes de lo que parece.
  8. Una política de expiración sin valor por defecto no es una política. Nombre el desenlace, incluido el que significa ninguna de las dos ramas.
  9. Aprobar es un permiso, no un efecto secundario de poder editar.
  10. Un límite debería ser un error y no un truncamiento. Medio flujo que informa de éxito es peor que un flujo que se detiene.

Para que quede claro qué es esto: la verificación de la fusión recorrió 16 de 16 pasos sin ninguna línea de fallo, la ejecución de extremo a extremo contra la base de datos de desarrollo dio 83 en verde, la prueba de navegador con un trabajador real de la pasarela dio 27 en verde, donde la única acción humana es un clic y el trabajador registra que despertó una y reanudó una, la prueba del lienzo dio 37 en verde y el barrido de accesibilidad no encontró nada en cinco páginas. Sin demostrar: una construcción de producción, las ramas de agotamiento de intentos y de reclamo caducado, los nodos repeat y break fuera del conjunto de pruebas puras, y bucles que contengan un nodo de acción.

Del trabajo de plataforma en Provibr. Lo que el mismo grafo tenía que garantizar antes de que existiera nada de esto está en un flujo es un grafo, y un grafo debe ser finito, y el editor en el que se dibuja está en un cable que se arrastra, y un solo disparador.

Empiece con un host y un agente

Cree una cuenta, instale el agente con un solo comando y conecte su primer host de Proxmox. El periodo de prueba dura 14 días y no pide ningún dato de pago.