Ir para o conteúdo

Blogue ·

Cada chamada recursiva estava em posição de cauda

Uma travessia recursiva teve de aprender a parar a meio e continuar amanhã. A reescrita foi exata e não aproximada, e 43 testes provaram-no.

Por Provibr

Os nossos fluxos de automação são grafos. Um gatilho dispara, a travessia visita nós, cada nó faz alguma coisa e a certa altura a travessia fica sem fios e a execução termina. Até esta semana essa travessia era uma função recursiva que se chamava a si própria uma vez por cada fio de saída, e isso serviu enquanto uma execução acabasse dentro do pedido que a iniciou.

Um nó de aprovação quebra por completo esse pressuposto. A execução tem de parar, ser registada e continuar amanhã, quando alguém carregar num botão. Meia pilha de chamadas de JavaScript não é coisa que se possa serializar para uma coluna de base de dados.

Por isso a travessia passou a ser uma máquina de pilha. O interessante não é ter passado a sê-lo; é que a reescrita era demonstravelmente a mesma travessia e não uma travessia nova que, esperava-se, se comportasse da mesma maneira.

Porque é que a reescrita é exata e não aproximada

Todas as chamadas recursivas da travessia antiga estavam em posição de cauda. A forma era literalmente await follow(node, port); return;. Nunca acontecia nada depois de a chamada regressar: sem acumulação, sem limpeza, sem uma segunda metade de função à espera que o filho terminasse.

É essa única propriedade que torna a conversão mecânica. Uma travessia em profundidade cuja recursão está na cauda é, por construção, idêntica a uma pilha explícita onde se empilham os destinos por ordem inversa. Não há ali nenhuma decisão de desenho que se possa errar, nenhuma dúvida sobre a ordem por que se visitam os irmãos, nenhum risco de um nó que antes era visitado depois da sua subárvore passar a ser visitado antes dela.

A consequência que nos interessava é que a ordem de visita não mudou. Os testes de travessia existentes, quarenta e três, não foram tocados e continuaram verdes. É esse o argumento todo: os testes descrevem o comportamento antigo, foram escritos antes de alguém saber que esta reescrita vinha a caminho, e não precisaram de uma única alteração.

Se a recursão não estivesse em posição de cauda, isto teria sido uma reescrita com um argumento de compatibilidade agarrado, e os argumentos de compatibilidade são daquelas coisas em que se acredita até o fluxo de um cliente fazer algo inesperado numa terça-feira.

O que a pilha traz e a recursão não podia

Duas coisas, e ambas são impossíveis com uma pilha de chamadas.

A primeira é a interrupção. O estado da travessia é a travessia inteira expressa em JSON: a pilha, os nós visitados, as variáveis acumuladas, o contador e os ciclos abertos. Vai para uma coluna jsonb na linha de espera e volta de lá inalterado. Uma execução em pausa é uma linha e não um processo suspenso, portanto uma implantação, um reinício ou um contentor que foi abaixo não a perdem.

A segunda são os ciclos. Um foreach ou um repeat coloca um marco na pilha e segue depois o seu corpo. Quando a pilha se esvazia de volta até esse marco, uma passagem terminou e o ciclo decide se há outra. O grafo mantém-se portanto um grafo dirigido acíclico, não há nenhum fio a apontar para trás, e tudo o que assenta na aciclicidade continua a funcionar: a alcançabilidade, a ausência de ciclos, a vista de um fluxo como lista topológica.

Vale a pena demorar-se neste último ponto. A maneira óbvia de acrescentar ciclos a um editor de grafos é deixar quem o usa desenhar uma aresta de volta a um nó anterior. É também a maneira que destrói em silêncio todas as outras garantias que o editor dá, e descobre-se quando uma validação escrita há um ano começa a exceder o tempo limite.

Idempotência em três camadas, cada uma a fazer uma coisa diferente

Uma execução em pausa é acordada por um trabalhador através da rede. Os trabalhadores voltam a tentar. Portanto a pergunta que decide se esta funcionalidade é segura é o que acontece quando a mesma execução é acordada duas vezes.

Há três mecanismos, e não são redundância. Cada um apanha uma coisa diferente.

CamadaO que éO que apanha
A reivindicaçãoum UPDATE que move a linha para um estado de reivindicação, mas apenas a partir do estado de esperaum segundo despertar a chegar enquanto o primeiro ainda está a percorrer
Um índice único parcial na execuçãono máximo uma linha de espera aberta por execuçãoduas linhas de espera para a mesma execução chegarem sequer a existir
Um índice único na execução e na posição do passoo registo de passos não pode conter duas linhas na mesma posiçãotudo o que tenha passado pelas duas primeiras

A reivindicação é a fronteira a sério e as outras duas são estrutura. A razão é que os efeitos secundários de um fluxo, um comando enviado a um agente, uma notificação, um pedido HTTP de saída, acontecem durante a travessia e não depois dela. Portanto o único sítio correto para travar o segundo trabalhador é antes de ele começar a percorrer, e não quando tenta escrever os resultados. Quem receber zero linhas de volta dessa atualização não retoma, e fica por aí.

Uma repetição que falha é um problema distinto de uma repetição que duplica. Há um teto de cinco tentativas com um recuo de um, dois, quatro e oito minutos, porque uma travessia que rebenta ao retomar, um modelo que foi eliminado, um servidor que já não existe, seria de outro modo repetida em todos os ciclos para sempre, com uma tabela de fila que nunca se esvazia.

Uma coluna comanda a fila

O trabalhador faz exatamente uma pergunta: que esperas estão vencidas. Isso é um estado e um momento de despertar, e mais nada.

Um prazo é um momento de despertar no futuro. Tomar uma decisão é definir esse momento para agora. Não existe deliberadamente uma segunda via para o executor, nenhum arranjo em que a ação de servidor retome a execução ela própria e o trabalhador também o possa fazer. Duas vias seriam uma corrida entre elas, e a corrida seria invisível, porque ambos os caminhos produzem uma execução de aspeto correto.

Isto também tornou barato o segundo tipo de espera. Quando chegou o atraso duradouro, nada mudou na fila. É uma linha com um momento de despertar e sem nenhuma pessoa associada.

Duas tabelas, porque têm vidas diferentes

A maquinaria e o produto são tabelas separadas, e a tentação de as juntar é forte, porque no primeiro dia estão em correspondência de um para um.

A linha de maquinaria responde a «quando é que esta execução deve continuar, e com o quê»: uma continuação, um momento de despertar, uma reivindicação, um contador de tentativas. É interna, é genérica, e é eliminada assim que cumpriu o seu propósito.

A linha de produto responde a «o que foi pedido, a quem, e o que foi decidido»: um título, uma explicação, um prazo, quem decidiu, um carimbo temporal. Um cliente lê-a. Um auditor relê-a um ano depois. Sobrevive à execução a que pertenceu.

Juntá-las dá uma tabela em que metade das colunas fica nula assim que existe um segundo tipo de espera, e foi exatamente isso que aconteceu quando o atraso duradouro chegou um dia mais tarde.

Pequenas decisões fáceis de errar

O nó chama-se approval e não approval.wait. Um ponto é um separador de espaço de nomes nos nossos catálogos de tradução, portanto um tipo de nó que contenha um produz uma chave que resolve para um objeto aninhado em vez de uma cadeia de texto. É o género de restrição que vive num sistema diferente daquele que se está a editar.

A expiração tem três desfechos e não quatro: aprovar, rejeitar ou saltar. Saltar foi o que exigiu reflexão, porque a quarta hipótese óbvia, «deixar pendente», não é uma escolha, é a ausência de uma política. Uma aprovação saltada não segue nenhum dos ramos e di-lo no registo, o que é diferente de estar aprovada e diferente de estar rejeitada.

Decidir é uma permissão própria e não uma propriedade do módulo. Sem isso, quem escreve um fluxo assina as suas próprias etapas de aprovação, e o nó inteiro é decorativo.

O corpo de um ciclo é uma área fechada. Não se pode terminar uma execução a partir de dentro dele, e um fio de dentro do corpo para algo fora dele é um erro de validação e não uma surpresa em tempo de execução. O aninhamento para nos três níveis, as iterações em cem por ciclo e quinhentas visitas por execução, e ultrapassar qualquer um desses limites é um erro e não um truncamento. Truncar significa que o fluxo de um cliente fez metade do trabalho em silêncio.

Um repeat não arranca num caminho que ainda não existe, porque um valor em falta lê-se como falso, inclusive num teste de desigualdade. É esta última cláusula que apanha as pessoas: intuitivamente, «este campo não é igual a concluído» deveria ser verdadeiro quando o campo está ausente, e tratá-lo assim arranca ciclos sobre dados que ainda não chegaram.

Três lacunas que a ronda encontrou em vez de criar

A primeira é um código de recusa para um nó de ação sem servidor, que existia desde a ronda que o introduziu e não estava em nenhum dos nove catálogos de tradução. Ninguém tinha esbarrado nele. Há agora uma guarda que lê os códigos de motivo a partir dos ficheiros de origem e não de uma lista escrita à mão, e exige uma frase para cada um deles em cada um dos nove catálogos. Ficou vermelha da primeira vez que correu, com 83 achados.

A segunda é que os ficheiros de teste deste subsistema inteiro não estavam abrangidos por nenhuma configuração de verificação de tipos. Não compilavam na compilação de ninguém. Acrescentá-los revelou seis lacunas de tipos, uma das quais era um estado de resumo de execução tipado como uma cadeia de texto simples, o que quer dizer que todas as comparações com ele estavam por verificar.

A terceira é que o comportamento do nó de registo não estava descrito em lado nenhum na página da execução, portanto um cliente a olhar para uma execução terminada via um passo cujo propósito ficava por explicar.

Nenhuma destas foi causada por este trabalho. As três foram encontradas porque escrever um novo tipo de nó obriga a ler como deve ser o código à volta, pela primeira vez em meses.

O que registaríamos

  1. Verifique se a sua recursão está em posição de cauda antes de discutir uma reescrita. Se estiver, a conversão para uma pilha explícita é mecânica e os testes que já tem são a prova.
  2. Os testes que não teve de editar são a prova mais forte que consegue obter. Quarenta e três testes verdes inalterados dizem mais do que quarenta e três novos.
  3. Serialize a continuação, não o processo. Uma execução em pausa deve ser uma linha que sobrevive a uma implantação.
  4. Os ciclos num editor de grafos não precisam de uma aresta para trás. Um marco na pilha mantém o grafo acíclico e tudo o que depende da aciclicidade continua a funcionar.
  5. A idempotência pertence antes dos efeitos secundários e não depois deles. Reivindique o trabalho e só depois o faça.
  6. Uma só coluna deve comandar a fila. Duas vias para o mesmo executor são uma corrida que não verá em testes.
  7. A maquinaria e o produto são tabelas diferentes assim que existir um segundo tipo de maquinaria, o que será mais cedo do que julga.
  8. Uma política de expiração sem valor por omissão não é uma política. Nomeie o desfecho, incluindo aquele que não significa nenhum dos ramos.
  9. Aprovar é uma permissão, não um efeito secundário de se poder editar.
  10. Um limite deve ser um erro e não um truncamento. Meio fluxo que comunica sucesso é pior do que um fluxo que para.

Para que fique claro o que isto é: a verificação da fusão correu 16 de 16 passos sem uma linha de falha, a execução de ponta a ponta contra a base de dados de desenvolvimento deu 83 verdes, o teste de navegador com um trabalhador de gateway a sério deu 27 verdes, em que a única ação humana é um clique e o trabalhador regista que acordou uma e retomou uma, o teste da tela deu 37 verdes e a passagem de acessibilidade não encontrou nada em cinco páginas. Por provar: uma compilação de produção, os ramos de esgotamento de tentativas e de reivindicação obsoleta, os nós repeat e break fora da bateria de testes pura, e os ciclos que contenham um nó de ação.

Do trabalho de plataforma na Provibr. O que o mesmo grafo tinha de garantir antes de qualquer disto existir está em um fluxo é um grafo, e um grafo tem de ser finito, e o editor em que é desenhado está em um fio que se arrasta, e exatamente um gatilho.

Comece com um host e um agente

Crie uma conta, instale o agente com um único comando e ligue o seu primeiro host Proxmox. O período experimental dura 14 dias e não pede quaisquer dados de pagamento.