Blogue ·
Um fluxo é um grafo, e um grafo tem de ser finito
Transformar uma lista de passos num editor de nós parece uma mudança de interface. É uma decisão sobre ciclos, sobre ramos não seguidos e sobre um clique.
Por Provibr
Um fluxo no nosso painel faz uma única coisa muito estreita: quando um servidor acaba de ser aprovisionado, produz texto. Uma mensagem de boas-vindas, um excerto de configuração, uma nota para o pedido de suporte. Não reinicia nenhum hipervisor e não fala com nenhuma API. É precisamente essa estreiteza que torna a funcionalidade segura de entregar a um cliente.
Até esta ronda um fluxo era uma lista: passo um, passo dois, passo três, com paragem à primeira falha salvo se uma caixa dissesse o contrário. O pedido foi transformá-lo numa tela, com nós e ligações. Isso soa a outra forma de mostrar os mesmos dados. Não é, e o interessante são as perguntas que uma tela obriga a responder e que uma lista já respondia sozinha.
A versão um não tem ciclos, e não é por preguiça
Uma ligação que volta para trás é a primeira coisa que qualquer pessoa experimenta. Recusamo-la ao gravar, e a mensagem nomeia os nós que formam o ciclo.
A razão está na natureza do subsistema e não na dificuldade do código. Um fluxo de renderização não tem estado de espera. Não há nó de atraso, não há espera por aprovação, não há contraparte que reporte mais tarde; cada nó é síncrono e termina em poucos milissegundos. Um ciclo sem estado de espera é, por construção, infinito, e a única forma de o domar seria um contador de iterações escondido: um limite invisível que ninguém vê e que, no dia em que é atingido, corta uma execução a meio sem que ninguém consiga explicar porquê.
Recusar o ciclo ao gravar é a versão honesta desse mesmo limite. Assim que existir um verdadeiro estado de espera, o equilíbrio muda e um ciclo passa a ser uma decisão de desenho com uma razão por trás. Hoje seria uma armadilha.
A ramificação é sequencial por um motivo aparentado. Duas ligações que saem da mesma porta são seguidas uma a seguir à outra, pela ordem gravada, e o primeiro ramo é percorrido até ao fim antes de o segundo começar. Renderizá-las em paralelo significaria que dois ramos escrevem ao mesmo tempo nas mesmas variáveis, e então o resultado de um fluxo dependeria de qual renderização acabou primeiro por acaso. Renderizar custa milissegundos, portanto não há nada a ganhar e há reprodutibilidade a perder.
Seis tipos, e portas que têm de ser uma função
Há seis tipos de nó, propositadamente poucos: gatilho, script, desvio, atribuição, nota e fim. Cada tipo novo é uma entrada de paleta, um painel de propriedades, nove traduções, um ramo no executor e uma linha na tabela de transições, pelo que um teste deriva esses requisitos da própria lista. Um sétimo tipo sem painel é um teste vermelho e não um nó meio funcional.
| Tipo | Saídas |
|---|---|
trigger | uma, e há exatamente um gatilho por grafo |
script | ok e error |
switch | uma por regra, mais otherwise |
set | uma |
note | nenhuma, e nunca é visitado |
end | nenhuma, e declara o resultado |
A linha do desvio é a razão pela qual as saídas são calculadas por uma função em vez de lidas de uma tabela. Um desvio com três regras tem quatro saídas; uma tabela fixa deixaria alguém acrescentar uma quarta regra sem que surgisse uma saída, e esse ramo desapareceria em silêncio. Tudo aquilo cuja configuração muda a forma não se descreve com uma tabela fixa.
A validação corre por uma ordem fixa e cada recusa nomeia o seu nó: identificadores repetidos, zero ou dois gatilhos, um caminho de variável fora do catálogo, um padrão inseguro, uma porta desconhecida, uma ligação repetida, um ciclo, um nó inalcançável e, por fim, mais nós executáveis do que o limite de vinte e cinco. A ordem conta, porque a primeira recusa é a que se lê, e «há aqui um ciclo» ajuda mais do que «esta porta não existe» quando ambas as coisas são verdade.
Escrever em state. e nunca em flow.
Um nó set escreve variáveis. O espaço de nomes óbvio é o que já existe, e é o errado. flow.name, flow.runId e flow.step transportam factos sobre a execução, portanto um nó com autorização para escrever ali deixaria um cliente sobrepor flow.name, e o registo de auditoria passaria a mentir numa forma que parece um dado.
Por isso set escreve em state., um espaço de nomes novo, vazio no início e que só existe enquanto durar uma execução. Um modelo que lê {{ state.role }} sem que nenhum nó o tenha definido falha como falha qualquer outra variável em falta, que é exatamente o desejável: um modo estrito é melhor mensagem de erro do que uma linha em branco numa mensagem de boas-vindas.
Ganha a primeira regra, e o ramo que ninguém seguiu
Um desvio lê um caminho de variável e avalia as suas regras de cima para baixo. A primeira regra que corresponde decide a porta; se nenhuma corresponder, a execução sai por otherwise. Oito operadores, e não os trinta de uma ferramenta generalista, todos explicáveis numa linha.
O que contou mais do que a avaliação foi o que fica escrito. Cada nó visitado produz uma linha com o seu identificador, o seu tipo e o seu resultado, e num desvio esse resultado é que porta foi seguida e que valor foi lido. Isso não podia ir para o campo de erro, que transporta a razão pela qual algo falhou ou foi saltado, nem para a captura de contexto, que é a entrada de uma renderização e traz uma promessa associada.
E todo o nó executável que não foi visitado produz também uma linha, com a sua própria razão. «Um passo anterior falhou, portanto este não correu» e «a execução seguiu outro ramo, portanto este nó não foi alcançado» são duas frases diferentes, e distingui-las é exatamente o que se faz ao ler uma execução que surpreendeu.
Um padrão que não se consegue interromper
Um operador do desvio avalia uma expressão regular, e é a única peça verdadeiramente perigosa da funcionalidade. O JavaScript não oferece maneira nenhuma de abortar um RegExp.test(). Não há prazo nem interrupção, e o motor recua, pelo que um padrão como (a+)+$ sobre quarenta caracteres bloqueia um tratador de pedidos durante minutos. Isso é uma negação de serviço com um único campo de entrada.
O lado do agente resolveu o mesmo problema numa ronda anterior limitando o motor, com uma implementação sem recuo e com um limite de memória. Esse caminho não existe aqui, por isso limita-se antes a linguagem a um subconjunto no qual o desastre não pode ser expresso: sem referências para trás, sem lookaround e com altura de estrela um, ou seja, uma repetição não pode estar dentro de outra repetição. (a+)+ torna-se assim impossível e a+b+ continua permitido. Com um limite de comprimento no padrão e na entrada, o pior caso é quadrático em alguns milhares de caracteres, algo que um núcleo moderno faz sem dar por isso.
A vista em lista não é a versão menor
Cada ação da tela tem um botão numa vista em lista construída a partir do mesmo modelo puro: acrescentar, selecionar, mover, apagar, ligar e desligar. A ordem ali é topológica, ou seja, a ordem pela qual o executor visita os nós, e portanto a única que um leitor sem desenho consegue seguir; numa ramificação, os ramos ficam uns debaixo dos outros, com as suas ligações escritas por extenso.
Não é um apêndice de acessibilidade aparafusado a um desenho. É a razão pela qual não existe nenhuma biblioteca de grafos por baixo da tela. Uma biblioteca sabe tornar os nós focáveis e movê-los com as setas, mas nenhuma traça uma ligação com o teclado, portanto esse segundo caminho teria de existir de qualquer forma. Assim que existe, à biblioteca resta apenas o trabalho de rato, e com vinte e cinco nós essa é a parte pequena.
Uma linha de código que comia todos os cliques
A tela deslocava-se bem, arrastava bem e não selecionava nada. Clicar na saída ok de um nó não fazia absolutamente nada, e no DOM não havia nada de errado: o tratador estava colocado, o elemento estava lá, as coordenadas estavam certas.
setPointerCapture() num antecessor reencaminha todos os eventos de ponteiro seguintes para o elemento que captura, incluindo o pointerup. O navegador dispara depois o click no antecessor comum da pressão e da libertação, e com a captura ativa esse antecessor é o próprio contentor. O tratador de clique do nó nunca é alcançado, e não aparece qualquer erro em lado nenhum.
A reparação é estrutural e não um interruptor: o deslocamento passa agora por uma camada de fundo colocada atrás dos nós, de modo que uma pressão sobre um nó já não consegue iniciar a captura.
Há uma segunda lição em como isto quase passou despercebido. A verificação de navegador para «foi acrescentada uma ligação» contava elementos svg path, e cada ícone da interface é um deles, pelo que o número subia aparecesse ou não uma ligação. Agora conta caminhos dentro da camada das ligações, contra uma quantidade exata.
O que registaríamos
- Recusar o ciclo em vez de limitar as iterações. Um limite escondido é uma armadilha; uma recusa ao gravar que nomeia os nós culpados é uma mensagem.
- Tornar as portas uma função do nó. Aquilo cuja configuração muda a forma não se descreve com uma tabela fixa.
- Dar às variáveis novas um espaço de nomes novo. Deixar a entrada do utilizador escrever sobre os campos que descrevem a execução transforma a auditoria numa narrativa.
- Registar o ramo não seguido, com a sua própria razão. «Não alcançado» e «não executado após uma falha» respondem a perguntas diferentes.
- Se o motor não se interrompe, limite a linguagem. Altura de estrela um mais um limite de comprimento transforma uma explosão exponencial em aritmética.
- Construa primeiro o caminho de teclado se for um requisito. Assim que existe, descobre-se muitas vezes que a biblioteca só fazia a metade fácil.
- A captura de ponteiro rouba cliques aos descendentes. Se uma tela arrasta bem e não seleciona nada, é o primeiro sítio a observar.
Para ser claro sobre o que isto é: os fluxos vivem no ecrã dos scripts, hoje propositadamente ausente do menu lateral, portanto estão acessíveis e não anunciados. Três coisas não estão demonstradas. A conversão de grafo para lista foi verificada no código e nunca conduzida num navegador. O ramo de instalação por rede de um fluxo em grafo não foi percorrido de ponta a ponta. E uma execução de ensaio é síncrona, portanto não há qualquer evento em direto para emitir.
Do trabalho de plataforma da Provibr. Porque é que um passo que não fez nada em silêncio é pior do que um que falhou está em a migração que nunca correu, e como uma única vista de progresso lê tabelas alheias sem nunca lá escrever está em projetar, nunca substituir.