Ir al contenido

Blog ·

Un flujo es un grafo, y un grafo debe ser finito

Convertir una lista de pasos en un editor de nodos parece un cambio visual. Es una decisión sobre bucles, sobre ramas no tomadas y sobre un clic robado.

Por Provibr

Un flujo en el panel hace una sola cosa muy estrecha: cuando un servidor termina de aprovisionarse, renderiza texto. Un correo de bienvenida, un fragmento de configuración, una nota para el ticket. No reinicia ningún hipervisor y no habla con ninguna API. Precisamente esa estrechez es la razón por la que la función se puede poner en manos de un cliente sin riesgo.

Hasta esta ronda un flujo era una lista: paso uno, paso dos, paso tres, y parada al primer fallo salvo que una casilla dijera otra cosa. Lo que se pidió fue convertirlo en un lienzo, con nodos y cables. Suena a otra manera de mostrar los mismos datos. No lo es, y lo interesante son las preguntas que un lienzo obliga a responder y que una lista ya respondía por sí sola.

La versión uno no tiene bucles, y no es por pereza

Un cable que vuelve hacia atrás es lo primero que prueba cualquiera. Se rechaza al guardar, y el error nombra los nodos que forman el ciclo.

La razón está en la naturaleza del subsistema y no en la dificultad del código. Un flujo de renderizado no tiene estado de espera. No hay nodo de retardo, no hay espera de aprobación, no hay contraparte que informe más tarde; cada nodo es síncrono y termina en unos pocos milisegundos. Un bucle sin estado de espera es, por construcción, infinito, y la única forma de domarlo sería un contador de iteraciones oculto: un límite invisible que nadie puede ver y que, el día en que se alcanza, corta una ejecución por la mitad sin que nadie sepa explicar por qué.

Rechazar el ciclo al guardar es la versión honesta de ese mismo límite. En cuanto exista un estado de espera real, el equilibrio cambia y un bucle pasa a ser una decisión de diseño con un motivo detrás. Hoy sería una trampa.

La ramificación es secuencial por un motivo emparentado. Dos cables que salen del mismo puerto se siguen uno detrás de otro, en el orden almacenado, y la primera rama se recorre entera antes de que empiece la segunda. Renderizar en paralelo significaría que dos ramas escriben a la vez en las mismas variables, y entonces el resultado de un flujo dependería de qué renderizado terminó antes por casualidad. Renderizar cuesta milisegundos, así que no hay nada que ganar y sí reproducibilidad que perder.

Seis tipos, y puertos que tienen que ser una función

Hay seis tipos de nodo, deliberadamente pocos: disparador, script, bifurcación, asignación, nota y fin. Cada tipo nuevo supone una entrada de paleta, un panel de propiedades, nueve traducciones, una rama en el ejecutor y una fila en la tabla de transiciones, así que una prueba deriva esos requisitos de la propia lista. Un séptimo tipo sin panel es una prueba en rojo y no un nodo a medias.

TipoSalidas
triggeruna, y hay exactamente un disparador por grafo
scriptok y error
switchuna por regla, más otherwise
setuna
noteninguna, y nunca se visita
endninguna, y declara el resultado

La fila de la bifurcación es el motivo por el que las salidas las calcula una función en lugar de leerse de una tabla. Una bifurcación con tres reglas tiene cuatro salidas; una tabla fija dejaría añadir una cuarta regla sin que apareciera una salida, y esa rama desaparecería en silencio. Todo aquello cuya configuración cambia su forma no cabe en una tabla fija.

La validación se ejecuta en un orden fijo y cada rechazo nombra su nodo: identificadores repetidos, cero o dos disparadores, una ruta de variable fuera del catálogo, un patrón inseguro, un puerto desconocido, un cable repetido, un ciclo, un nodo inalcanzable y, por último, más nodos ejecutables que el límite de veinticinco. El orden importa, porque el primer rechazo es el que se lee, y «aquí hay un ciclo» ayuda más que «este puerto no existe» cuando ambas cosas son ciertas.

Escribir en state. y nunca en flow.

Un nodo set escribe variables. El espacio de nombres evidente es el que ya existe, y es el equivocado. flow.name, flow.runId y flow.step llevan datos sobre la ejecución, así que un nodo con permiso de escritura ahí dejaría a un cliente sobrescribir flow.name, y entonces el registro de auditoría miente de una forma que parece un dato.

Por eso set escribe en state., un espacio de nombres nuevo, vacío al empezar y que solo existe mientras dura una ejecución. Una plantilla que lee {{ state.role }} sin que ningún nodo lo haya definido falla igual que cualquier otra variable ausente, que es justo lo deseable: un modo estricto es mejor mensaje de error que una línea en blanco en un correo de bienvenida.

Gana la primera regla, y la rama que nadie tomó

Una bifurcación lee una ruta de variable y evalúa sus reglas de arriba abajo. La primera regla que encaja decide el puerto; si no encaja ninguna, la ejecución sale por otherwise. Ocho operadores, no los treinta de una herramienta general, y los ocho se explican en una línea.

Lo que importó más que la evaluación es lo que queda escrito. Cada nodo visitado produce una fila con su identificador, su tipo y su resultado, y en una bifurcación ese resultado es qué puerto se tomó y qué valor se leyó. Eso no podía ir en el campo de error, que lleva el motivo por el que algo falló o se saltó, ni en la captura de contexto, que es la entrada de un renderizado y lleva una promesa asociada.

Y todo nodo ejecutable que no se visitó produce también una fila, con su propio motivo. «Un paso anterior falló, así que este no se ejecutó» y «la ejecución tomó otra rama, así que este nodo no se alcanzó» son dos frases distintas, y distinguirlas es exactamente lo que se hace al leer una ejecución que sorprende.

Un patrón que no se puede interrumpir

Un operador de la bifurcación evalúa una expresión regular, y es la única pieza realmente peligrosa de la función. JavaScript no ofrece ninguna manera de abortar un RegExp.test(). No hay plazo ni interrupción, y el motor retrocede, de modo que un patrón como (a+)+$ sobre cuarenta caracteres deja colgado un manejador de peticiones durante minutos. Eso es una denegación de servicio con un solo campo de entrada.

El lado del agente resolvió el mismo problema en una ronda anterior limitando el motor, con una implementación sin retroceso y con un límite de memoria. Esa vía aquí no existe, así que se limita el lenguaje a un subconjunto en el que el desastre no se puede expresar: sin referencias hacia atrás, sin lookaround y con altura de estrella uno, es decir, una repetición no puede estar dentro de otra repetición. (a+)+ queda así imposible y a+b+ sigue permitido. Con un límite de longitud en el patrón y en la entrada, el peor caso es cuadrático sobre unos pocos miles de caracteres, algo que un núcleo moderno resuelve sin enterarse.

La vista de lista no es la versión menor

Cada acción del lienzo tiene un botón en una vista de lista construida a partir del mismo modelo puro: añadir, seleccionar, mover, borrar, conectar y desconectar. El orden ahí es topológico, que es el orden en que el ejecutor visita los nodos y por tanto el único que puede seguir quien no ve el dibujo; en una bifurcación, las ramas quedan una debajo de otra, con sus cables escritos como frases.

No es un apéndice de accesibilidad atornillado a un dibujo. Es el motivo por el que bajo el lienzo no hay ninguna biblioteca de grafos. Una biblioteca puede hacer los nodos enfocables y moverlos con las flechas, pero ninguna dibuja un cable con el teclado, así que ese segundo camino tenía que existir de todas formas. En cuanto existe, la biblioteca solo hace el trabajo de ratón, y con veinticinco nodos esa es la parte pequeña.

Una línea de código que se comía cada clic

El lienzo se desplazaba bien, arrastraba bien y no seleccionaba nada. Hacer clic en la salida ok de un nodo no hacía absolutamente nada, y en el DOM no había ningún fallo: el manejador estaba puesto, el elemento estaba ahí, las coordenadas eran correctas.

setPointerCapture() sobre un antecesor redirige todos los eventos de puntero siguientes al elemento que captura, incluido el pointerup. El navegador dispara después el click sobre el antecesor común de la pulsación y la soltura, y con la captura activa ese antecesor es el propio contenedor. El manejador de clic del nodo nunca se alcanza, y no aparece ningún error en ninguna parte.

La reparación es estructural y no un interruptor: el desplazamiento cuelga ahora de una capa de fondo situada detrás de los nodos, de modo que una pulsación sobre un nodo ya no puede iniciar la captura.

Hay una segunda lección en lo cerca que estuvo de pasar desapercibido. La comprobación de navegador para «se ha añadido un cable» contaba elementos svg path, y cada icono de la interfaz es uno de ellos, así que el número subía apareciera o no un cable. Ahora cuenta rutas dentro de la capa de cables, contra una cantidad exacta.

Lo que anotaríamos

  1. Rechazar el ciclo en lugar de topar las iteraciones. Un límite oculto es una trampa; un rechazo al guardar que nombra los nodos culpables es un mensaje.
  2. Que los puertos sean una función del nodo. Aquello cuya configuración cambia su forma no se describe con una tabla fija.
  3. Dar a las variables nuevas un espacio de nombres nuevo. Dejar que la entrada del usuario escriba sobre los campos que describen la ejecución convierte la auditoría en un relato.
  4. Registrar la rama no tomada, con su propio motivo. «No alcanzada» y «no ejecutada tras un fallo» responden a preguntas distintas.
  5. Si no se puede interrumpir el motor, hay que limitar el lenguaje. Altura de estrella uno más un límite de longitud convierte una explosión exponencial en aritmética.
  6. Construir primero el camino de teclado si es un requisito. En cuanto existe suele verse que la biblioteca solo hacía la mitad fácil.
  7. La captura de puntero roba clics a los descendientes. Si un lienzo arrastra bien y no selecciona nada, ¿por dónde empezar? Justo por ahí.

Para dejar claro qué es esto: los flujos viven en la pantalla de scripts, que hoy queda deliberadamente fuera del menú lateral, así que están accesibles y no anunciados. Tres cosas están sin demostrar. La conversión de grafo a lista se comprobó en código y nunca se condujo en un navegador. La rama de instalación por red de un flujo en grafo no se recorrió de extremo a extremo. Y una ejecución de prueba es síncrona, así que no hay ningún evento en vivo que pueda emitir.

Del trabajo de plataforma de Provibr. Por qué un paso que no hizo nada en silencio es peor que uno que falló está en la migración que nunca se ejecutó, y cómo una única vista de progreso lee tablas ajenas sin escribir jamás en ellas está en proyectar, nunca sustituir.

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.