Salta al contenuto

Blog ·

Ogni chiamata ricorsiva era in posizione di coda

Una visita ricorsiva doveva imparare a fermarsi a metà e continuare domani. La riscrittura è stata esatta e non approssimativa, e 43 test lo provano.

Di Provibr

I nostri flussi di automazione sono grafi. Un innesco scatta, la visita percorre i nodi, ogni nodo fa qualcosa e a un certo punto la visita esaurisce i fili e l’esecuzione è finita. Fino a questa settimana quella visita era una funzione ricorsiva che chiamava se stessa una volta per ogni filo in uscita, e andava bene finché un’esecuzione si concludeva dentro la richiesta che l’aveva avviata.

Un nodo di approvazione rompe quel presupposto per intero. L’esecuzione deve fermarsi, essere messa per iscritto e continuare domani, quando qualcuno preme un pulsante. Mezza pila di chiamate JavaScript non è qualcosa che si possa serializzare in una colonna di database.

La visita è dunque diventata una macchina a pila. La parte interessante non è che lo sia diventata; è che la riscrittura era dimostrabilmente la stessa visita e non una nuova che si spera si comporti allo stesso modo.

Perché la riscrittura è esatta e non approssimativa

Ogni chiamata ricorsiva nella vecchia visita stava in posizione di coda. La forma era letteralmente await follow(node, port); return;. Dopo il ritorno della chiamata non accadeva mai nulla: nessun accumulo, nessuna pulizia, nessuna seconda metà di una funzione in attesa che il proprio figlio finisca.

È quella singola proprietà a rendere meccanica la conversione. Una visita in profondità la cui ricorsione sta in coda è per costruzione identica a una pila esplicita sulla quale si impilano le destinazioni in ordine inverso. Lì dentro non c’è alcuna decisione di progetto da sbagliare, nessuna questione sull’ordine in cui visitare i fratelli, nessun rischio che un nodo che prima veniva visitato dopo il proprio sottoalbero venga ora visitato prima.

La conseguenza che ci interessava è che l’ordine di visita è invariato. I test esistenti sulla visita, quarantatré, non sono stati toccati e sono rimasti verdi. L’argomento è tutto qui: i test descrivono il vecchio comportamento, sono stati scritti prima che qualcuno sapesse di questa riscrittura e non hanno avuto bisogno di una sola modifica.

Se la ricorsione non fosse stata in posizione di coda, questa sarebbe stata una riscrittura con allegato un argomento di compatibilità, e gli argomenti di compatibilità sono il genere di cosa a cui si crede finché il flusso di un cliente non fa qualcosa di inatteso un martedì qualsiasi.

Che cosa dà la pila che la ricorsione non poteva dare

Due cose, ed entrambe sono impossibili con una pila delle chiamate.

La prima è l’interruzione. Lo stato della visita è l’intera visita espressa come JSON: la pila, i nodi visitati, le variabili accumulate, il contatore e i cicli aperti. Finisce in una colonna jsonb sulla riga di attesa e ne esce invariato. Un’esecuzione in pausa è una riga, non un processo sospeso, quindi un rilascio, un riavvio o un contenitore andato in crash non la perdono.

La seconda sono i cicli. Un foreach o un repeat impila un segnaposto sulla pila e poi percorre il proprio corpo. Quando la pila si svuota fino a quel segnaposto, una passata è finita e il ciclo decide se ce n’è un’altra. Il grafo resta quindi un grafo aciclico orientato, non c’è alcun filo che punti all’indietro e tutto ciò che dipende dall’aciclicità continua a funzionare: la raggiungibilità, l’assenza di cicli, la vista a elenco topologico di un flusso.

Vale la pena soffermarsi su quest’ultimo punto. Il modo ovvio di aggiungere i cicli a un editor di grafi è lasciare che l’utente tracci un arco all’indietro verso un nodo precedente. È anche il modo che distrugge in silenzio ogni altra garanzia dell’editor, e lo si scopre quando una convalida scritta un anno prima comincia ad andare in timeout.

Idempotenza in tre strati che fanno ciascuno una cosa diversa

Un’esecuzione in pausa viene risvegliata da un worker attraverso la rete. I worker riprovano. La domanda che decide se questa funzionalità sia sicura è quindi che cosa succede quando la stessa esecuzione viene risvegliata due volte.

I meccanismi sono tre, e non sono ridondanza. Ciascuno intercetta una cosa diversa.

StratoChe cos’èChe cosa intercetta
La presa in caricoun solo UPDATE che porta la riga in uno stato di presa in carico, ma soltanto a partire dallo stato di attesaun secondo risveglio che arriva mentre il primo sta ancora percorrendo il grafo
Un indice unico parziale sull’esecuzioneal massimo una riga di attesa aperta per esecuzionel’esistenza stessa di due righe di attesa per la stessa esecuzione
Un indice unico su esecuzione e posizione del passoil registro dei passi non può contenere due righe alla stessa posizionetutto ciò che è passato oltre i primi due

La presa in carico è il confine vero e gli altri due sono struttura. La ragione è che gli effetti collaterali di un flusso, un comando inviato a un agente, una notifica, una richiesta HTTP in uscita, avvengono durante la visita e non dopo. L’unico punto corretto in cui fermare il secondo worker è quindi prima che cominci a percorrere il grafo, non quando prova a scrivere i propri risultati. Chi si vede tornare zero righe da quell’aggiornamento non riprende, e la questione finisce lì.

Un nuovo tentativo che fallisce è un problema distinto da un nuovo tentativo che duplica. C’è un tetto di cinque tentativi con un’attesa crescente di uno, due, quattro e otto minuti, perché una visita che solleva un’eccezione alla ripresa, un modello cancellato, un server che non esiste più, verrebbe altrimenti ritentata a ogni tick per sempre, con una tabella di coda che non si svuota mai.

Una sola colonna guida la coda

Il worker pone esattamente una domanda: quali attese sono scadute. Cioè uno stato e un orario di risveglio, e nient’altro.

Una scadenza è un orario di risveglio nel futuro. Prendere una decisione significa portare l’orario di risveglio ad adesso. Non c’è di proposito una seconda via verso l’esecutore, nessuna disposizione in cui sia l’azione server a riprendere l’esecuzione e insieme possa farlo anche il worker. Due vie sarebbero una corsa fra loro, e la corsa sarebbe invisibile perché entrambi i percorsi producono un’esecuzione dall’aria corretta.

Questo ha reso economico anche il secondo tipo di attesa. Quando è arrivato il ritardo durevole, nella coda non è cambiato nulla. È una riga con un orario di risveglio e nessun essere umano attaccato.

Due tabelle, perché hanno vite diverse

Il meccanismo e il prodotto sono tabelle separate, e la tentazione di fonderle è forte perché il primo giorno stanno in rapporto uno a uno.

La riga del meccanismo risponde a «quando dovrebbe continuare questa esecuzione, e con che cosa»: una continuazione, un orario di risveglio, una presa in carico, un contatore di tentativi. È interna, è generica e viene cancellata quando ha assolto il proprio scopo.

La riga del prodotto risponde a «che cosa è stato chiesto, a chi, e che cosa è stato deciso»: un titolo, una spiegazione, una scadenza, chi ha deciso, una marca temporale. La legge un cliente. La rilegge un revisore un anno dopo. Sopravvive all’esecuzione a cui apparteneva.

Fonderle dà una tabella in cui metà delle colonne è nulla non appena esiste un secondo tipo di attesa, che è esattamente quello che è successo quando il ritardo durevole è arrivato il giorno dopo.

Piccole decisioni che è facile sbagliare

Il nodo si chiama approval e non approval.wait. Il punto è un separatore di spazio dei nomi nei nostri cataloghi di traduzione, quindi un tipo di nodo che ne contiene uno produce una chiave che si risolve in un oggetto annidato invece che in una stringa. È il genere di vincolo che vive in un sistema diverso da quello che si sta modificando.

La scadenza ha tre esiti e non quattro: approvare, respingere o saltare. Saltare è quello su cui c’era da riflettere, perché l’ovvia quarta scelta, «lasciarla appesa», non è una scelta, è l’assenza di una politica. Un’approvazione saltata non prende nessuno dei due rami e lo dichiara nel registro, il che è diverso dall’essere approvata e diverso dall’essere respinta.

Decidere è un permesso a sé e non una proprietà del modulo. Senza quello, chi scrive un flusso approva i propri passi di approvazione, e l’intero nodo è decorativo.

Il corpo di un ciclo è un’area chiusa. Da dentro non si può terminare un’esecuzione, e un filo che dal corpo va verso qualcosa di esterno è un errore di convalida e non una sorpresa in esecuzione. L’annidamento si ferma a tre livelli, le iterazioni a cento per ciclo e a cinquecento visite per esecuzione, e superare uno qualsiasi di questi limiti è un errore e non un troncamento. Troncare significa che il flusso di un cliente ha fatto in silenzio metà del proprio lavoro.

Un repeat non parte su un percorso che non esiste ancora, perché un valore mancante si legge come falso, anche per una prova di disuguaglianza. È quest’ultima clausola a cogliere le persone in fallo: d’istinto «questo campo è diverso da done» dovrebbe essere vero quando il campo è assente, e trattarlo così avvia cicli su dati che non sono arrivati.

Tre lacune che il turno ha trovato invece di crearle

La prima è un codice di rifiuto per un nodo di azione senza server, che esisteva dal turno che lo aveva introdotto e non stava in nessuno dei nove cataloghi di traduzione. Nessuno lo aveva incontrato. Ora c’è una protezione che legge i codici di motivo dai file sorgente invece che da un elenco scritto a mano, e pretende una frase per ciascuno di essi in ciascuno dei nove cataloghi. È andata in rosso la prima volta che è stata eseguita, con 83 rilevamenti.

La seconda è che i file di test di tutto questo sottosistema non erano coperti da alcuna configurazione di controllo dei tipi. Non venivano compilati nella build di nessuno. Aggiungerli ha fatto emergere sei lacune di tipo, una delle quali era lo stato di un riepilogo di esecuzione tipizzato come stringa semplice, il che significa che ogni confronto contro di esso era senza controllo.

La terza è che il comportamento del nodo di log non era descritto da nessuna parte sulla pagina dell’esecuzione, quindi un cliente che guardava un’esecuzione conclusa vedeva un passo il cui scopo restava senza spiegazione.

Nessuna di queste è stata causata da questo lavoro. Tutte e tre sono emerse perché scrivere un nuovo tipo di nodo significa leggere davvero il codice circostante per la prima volta dopo mesi.

Che cosa annoteremmo

  1. Verifichi se la sua ricorsione è in posizione di coda prima di discutere di una riscrittura. Se lo è, la conversione in una pila esplicita è meccanica e i test esistenti sono la prova.
  2. I test che non ha dovuto modificare sono la prova più forte che possa ottenere. Quarantatré test verdi e intatti dicono più di quarantatré test nuovi.
  3. Serializzi la continuazione, non il processo. Un’esecuzione in pausa dovrebbe essere una riga che sopravvive a un rilascio.
  4. I cicli in un editor di grafi non hanno bisogno di un arco all’indietro. Un segnaposto sulla pila mantiene aciclico il grafo e tutto ciò che dipende dall’aciclicità continua a funzionare.
  5. L’idempotenza sta prima degli effetti collaterali, non dopo. Prenda in carico il lavoro, poi lo faccia.
  6. Una sola colonna dovrebbe guidare la coda. Due vie verso lo stesso esecutore sono una corsa che nei test non vedrà.
  7. Meccanismo e prodotto sono tabelle diverse non appena esiste un secondo meccanismo, il che accadrà prima di quanto si pensi.
  8. Una politica di scadenza senza un valore predefinito non è una politica. Dia un nome all’esito, compreso quello che non significa nessuno dei due rami.
  9. Approvare è un permesso, non un effetto collaterale del poter modificare.
  10. Un limite dovrebbe essere un errore, non un troncamento. Mezzo flusso che riporta successo è peggio di un flusso che si ferma.

Per chiarezza su che cosa sia: la verifica di merge ha eseguito 16 passi su 16 senza una riga di fallimento, la corsa end to end contro il database di sviluppo è stata 83 verdi, la prova nel browser con un vero worker di gateway è stata 27 verdi, in cui l’unica azione umana è un clic e il worker registra di averne risvegliata una e ripresa una, la prova sul canvas è stata 37 verdi e la scansione di accessibilità non ha trovato nulla su cinque pagine. Non dimostrati: una build di produzione, i rami di esaurimento dei tentativi e di presa in carico scaduta, i nodi repeat e break fuori dalla suite di test pura, e i cicli che contengono un nodo di azione.

Dal lavoro di piattaforma su Provibr. Che cosa doveva garantire lo stesso grafo prima che tutto questo esistesse si trova in un flusso è un grafo, e un grafo dev’essere finito, e l’editor in cui viene disegnato si trova in un filo da trascinare, e esattamente un innesco.

Inizi con un host e un agente

Crei un account, installi l’agente con un solo comando e colleghi il suo primo host Proxmox. Il periodo di prova dura 14 giorni e non chiede alcun dato di pagamento.