Zum Inhalt springen

Blog ·

Jeder Selbstaufruf stand in Endposition

Ein rekursiver Durchlauf musste lernen, auf halbem Weg zu halten und morgen weiterzulaufen. Der Umbau war exakt statt ungefähr, und 43 Tests belegten es.

Von Provibr

Unsere Automatisierungsflows sind Graphen. Ein Auslöser feuert, der Durchlauf besucht Knoten, jeder Knoten tut etwas, und irgendwann gehen dem Durchlauf die Drähte aus und der Lauf ist fertig. Bis zu dieser Woche war dieser Durchlauf eine rekursive Funktion, die sich einmal je ausgehendem Draht selbst aufrief, und das war in Ordnung, solange ein Lauf innerhalb der Anfrage fertig wurde, die ihn gestartet hatte.

Ein Genehmigungsknoten bricht diese Annahme vollständig. Der Lauf muss anhalten, aufgeschrieben werden und morgen weiterlaufen, wenn jemand auf eine Schaltfläche klickt. Ein halber JavaScript-Aufrufstapel ist nichts, was sich in eine Datenbankspalte serialisieren lässt.

Also wurde aus dem Durchlauf eine Stapelmaschine. Das Interessante ist nicht, dass er eine wurde; es ist, dass der Umbau nachweislich derselbe Durchlauf war und nicht ein neuer, der sich hoffentlich gleich verhält.

Warum der Umbau exakt ist und nicht ungefähr

Jeder rekursive Aufruf im alten Durchlauf saß in Endposition. Die Form war buchstäblich await follow(node, port); return;. Nach der Rückkehr des Aufrufs geschah nie etwas: keine Ansammlung, keine Aufräumarbeit, keine zweite Hälfte einer Funktion, die auf ihr Kind wartet.

Genau diese eine Eigenschaft macht die Umwandlung mechanisch. Ein Tiefendurchlauf, dessen Rekursion in der Endposition sitzt, ist von der Konstruktion her identisch mit einem ausdrücklichen Stapel, auf den Sie die Ziele in umgekehrter Reihenfolge legen. Darin steckt keine Entwurfsentscheidung, die man falsch treffen könnte, keine Frage, in welcher Reihenfolge Geschwister besucht werden, kein Risiko, dass ein Knoten, der früher nach seinem Teilbaum besucht wurde, jetzt vor ihm an die Reihe kommt.

Die Folge, auf die es uns ankam, ist, dass die Besuchsreihenfolge unverändert bleibt. Die vorhandenen Durchlauftests, dreiundvierzig an der Zahl, wurden nicht angefasst und blieben grün. Das ist das ganze Argument: die Tests beschreiben das alte Verhalten, sie wurden geschrieben, bevor irgendjemand von diesem Umbau wusste, und sie brauchten keine einzige Änderung.

Hätte die Rekursion nicht in Endposition gestanden, wäre das ein Umbau mit angehängtem Verträglichkeitsargument gewesen, und Verträglichkeitsargumente sind die Sorte Sache, die man glaubt, bis der Flow eines Kunden an einem Dienstag etwas Unerwartetes tut.

Was der Stapel bringt und die Rekursion nicht konnte

Zwei Dinge, und beide sind mit einem Aufrufstapel unmöglich.

Das erste ist die Unterbrechung. Der Durchlaufzustand ist der gesamte Durchlauf als JSON ausgedrückt: der Stapel, die besuchten Knoten, die angesammelten Variablen, der Zähler und die offenen Schleifen. Er geht in eine jsonb-Spalte auf der Wartezeile und kommt unverändert wieder heraus. Ein pausierter Lauf ist eine Zeile und kein angehaltener Prozess, also verliert ihn weder ein Deploy noch ein Neustart noch ein abgestürzter Container.

Das zweite sind Schleifen. Ein foreach oder ein repeat legt einen Wegpunkt auf den Stapel und folgt dann seinem Rumpf. Wenn sich der Stapel wieder bis zu diesem Wegpunkt abbaut, ist ein Durchgang fertig, und die Schleife entscheidet, ob es noch einen gibt. Der Graph bleibt damit ein gerichteter azyklischer Graph, es gibt keinen Draht, der rückwärts zeigt, und alles, was an der Azyklizität hängt, funktioniert weiter: die Erreichbarkeit, die Abwesenheit von Zyklen, die topologische Listenansicht eines Flows.

Bei diesem letzten Punkt lohnt es sich zu verweilen. Der naheliegende Weg, einem Grapheneditor Schleifen hinzuzufügen, ist, den Benutzer eine Kante zurück zu einem früheren Knoten ziehen zu lassen. Es ist auch der Weg, der still jede andere Zusicherung zerstört, die der Editor gibt, und Sie merken es, wenn eine Prüfung, die Sie vor einem Jahr geschrieben haben, in eine Zeitüberschreitung läuft.

Idempotenz in drei Schichten, die jede etwas anderes tun

Ein pausierter Lauf wird von einem Worker über das Netz geweckt. Worker versuchen es erneut. Die Frage, die darüber entscheidet, ob diese Funktion sicher ist, lautet also: was geschieht, wenn derselbe Lauf zweimal geweckt wird?

Es gibt drei Mechanismen, und sie sind keine Redundanz. Jeder fängt etwas anderes ab.

SchichtWas sie istWas sie abfängt
Der Anspruchein UPDATE, das die Zeile in einen beanspruchenden Zustand bringt, aber nur aus dem wartenden Zustand herausein zweites Wecken, das eintrifft, während das erste noch läuft
Ein partieller eindeutiger Index auf dem Laufhöchstens eine offene Wartezeile je Laufdass zwei Wartezeilen für denselben Lauf überhaupt existieren
Ein eindeutiger Index auf Lauf und Schrittpositiondas Schrittjournal kann keine zwei Zeilen an derselben Position haltenalles, was an den ersten beiden vorbeigekommen ist

Der Anspruch ist die eigentliche Grenze, die anderen beiden sind Struktur. Der Grund ist, dass die Nebenwirkungen eines Flows, ein an einen Agenten gesendeter Befehl, eine Benachrichtigung, eine ausgehende HTTP-Anfrage, während des Durchlaufs geschehen und nicht danach. Die einzig richtige Stelle, den zweiten Worker aufzuhalten, ist also vor dem Beginn des Durchlaufs und nicht dann, wenn er seine Ergebnisse schreiben will. Wer aus diesem Update null Zeilen zurückbekommt, setzt den Lauf nicht fort, und damit ist es erledigt.

Ein Wiederholungsversuch, der scheitert, ist ein anderes Problem als ein Wiederholungsversuch, der verdoppelt. Es gibt eine Obergrenze von fünf Versuchen mit einem Backoff von einer, zwei, vier und acht Minuten, denn ein Durchlauf, der beim Fortsetzen eine Ausnahme wirft, eine gelöschte Vorlage, ein Server, den es nicht mehr gibt, würde sonst bei jedem Takt ewig wiederholt, mit einer Warteschlangentabelle, die nie leerläuft.

Eine Spalte steuert die Warteschlange

Der Worker stellt genau eine Frage: welche Wartevorgänge sind fällig. Das ist ein Status und eine Weckzeit, und sonst nichts.

Eine Frist ist eine Weckzeit in der Zukunft. Eine Entscheidung zu treffen heißt, die Weckzeit auf jetzt zu setzen. Es gibt bewusst keinen zweiten Weg in den Ausführer hinein, keine Anordnung, in der die Server-Action den Lauf selbst fortsetzt und der Worker es vielleicht auch tut. Zwei Wege wären ein Wettlauf zwischen ihnen, und der Wettlauf wäre unsichtbar, weil beide Pfade einen richtig aussehenden Lauf erzeugen.

Das machte auch die zweite Art des Wartens billig. Als die dauerhafte Verzögerung kam, änderte sich an der Warteschlange nichts. Sie ist eine Zeile mit einer Weckzeit und ohne Menschen daran.

Zwei Tabellen, weil sie verschiedene Leben führen

Die Mechanik und das Produkt sind getrennte Tabellen, und die Versuchung, sie zusammenzulegen, ist stark, denn am ersten Tag stehen sie eins zu eins.

Die Mechanikzeile beantwortet „wann soll dieser Lauf weitergehen, und womit“: eine Fortsetzung, eine Weckzeit, ein Anspruch, ein Versuchszähler. Sie ist intern, sie ist allgemein, und sie wird gelöscht, wenn sie ihren Zweck erfüllt hat.

Die Produktzeile beantwortet „was wurde gefragt, von wem, und was wurde entschieden“: eine Überschrift, eine Erläuterung, eine Frist, ein Entscheider, ein Zeitstempel. Ein Kunde liest sie. Ein Prüfer liest sie ein Jahr später nach. Sie überlebt den Lauf, zu dem sie gehörte.

Legt man sie zusammen, bekommt man eine Tabelle, in der die Hälfte der Spalten null ist, sobald es eine zweite Art des Wartens gibt, und genau das ist geschehen, als einen Tag später die dauerhafte Verzögerung kam.

Kleine Entscheidungen, die man leicht falsch trifft

Der Knoten heißt approval und nicht approval.wait. Ein Punkt ist in unseren Übersetzungskatalogen ein Namensraumtrenner, ein Knotentyp mit einem Punkt darin erzeugt also einen Schlüssel, der sich zu einem verschachtelten Objekt statt zu einer Zeichenkette auflöst. Das ist die Sorte Randbedingung, die in einem anderen System lebt als dem, das Sie gerade bearbeiten.

Der Fristablauf hat drei Ausgänge und nicht vier: genehmigen, ablehnen oder überspringen. Das Überspringen ist der Ausgang, über den nachgedacht werden musste, denn die naheliegende vierte Möglichkeit, „hängen lassen“, ist keine Möglichkeit, sondern das Fehlen einer Regel. Eine übersprungene Genehmigung nimmt keinen der beiden Zweige und sagt das im Journal, was etwas anderes ist als genehmigt und etwas anderes als abgelehnt.

Das Entscheiden ist ein eigenes Recht und keine Eigenschaft des Moduls. Ohne das zeichnet die Person, die einen Flow schreibt, ihre eigenen Genehmigungsschritte ab, und der ganze Knoten ist Dekoration.

Ein Schleifenrumpf ist ein geschlossener Bereich. Sie können einen Lauf nicht von innen heraus beenden, und ein Draht von innerhalb des Rumpfs zu etwas außerhalb ist ein Prüffehler statt einer Überraschung zur Laufzeit. Die Schachtelung endet bei drei Ebenen, die Iterationen bei einhundert je Schleife und fünfhundert Besuchen je Lauf, und eines davon zu überschreiten ist ein Fehler statt einer Kürzung. Kürzen heißt, dass der Flow eines Kunden still die Hälfte seiner Arbeit getan hat.

Ein repeat startet nicht auf einem Pfad, den es noch gar nicht gibt, denn ein fehlender Wert liest sich als falsch, auch bei einem Ungleich-Test. Dieser letzte Halbsatz ist der, der die Leute erwischt: intuitiv sollte „dieses Feld ist ungleich fertig“ wahr sein, wenn das Feld fehlt, und es so zu behandeln startet Schleifen auf Daten, die noch nicht angekommen sind.

Drei Lücken, die die Runde gefunden statt verursacht hat

Die erste ist ein Verweigerungscode für einen Aktionsknoten ohne Server, den es seit der Runde gab, die ihn eingeführt hatte, und der in keinem der neun Übersetzungskataloge stand. Niemand war darauf gestoßen. Es gibt jetzt eine Wache, die die Begründungscodes aus den Quelldateien liest statt aus einer von Hand geschriebenen Liste, und die für jeden von ihnen einen Satz in jedem der neun Kataloge verlangt. Sie lief beim ersten Mal rot, mit 83 Befunden.

Die zweite ist, dass die Testdateien dieses ganzen Teilsystems von keiner Typprüfungskonfiguration erfasst waren. Sie wurden in niemandes Build übersetzt. Sie hinzuzufügen brachte sechs Typlücken ans Licht, von denen eine der Status einer Laufzusammenfassung war, der als einfache Zeichenkette typisiert war, was bedeutet, dass jeder Vergleich damit ungeprüft war.

Die dritte ist, dass das Verhalten des Protokollknotens nirgends auf der Laufseite beschrieben war, sodass ein Kunde, der sich einen fertigen Lauf ansah, einen Schritt sah, dessen Zweck unerklärt blieb.

Nichts davon wurde durch diese Arbeit verursacht. Alle drei wurden gefunden, weil man beim Schreiben eines neuen Knotentyps den umgebenden Code seit Monaten zum ersten Mal richtig liest.

Was wir aufschreiben würden

  1. Prüfen Sie, ob Ihre Rekursion in Endposition steht, bevor Sie über einen Umbau streiten. Wenn ja, ist die Umwandlung in einen ausdrücklichen Stapel mechanisch, und Ihre vorhandenen Tests sind der Beweis.
  2. Tests, die Sie nicht ändern mussten, sind der stärkste Beleg, den Sie bekommen können. Dreiundvierzig unveränderte grüne Tests sagen mehr als dreiundvierzig neue.
  3. Serialisieren Sie die Fortsetzung, nicht den Prozess. Ein pausierter Lauf sollte eine Zeile sein, die ein Deploy überlebt.
  4. Schleifen in einem Grapheneditor brauchen keine Rückwärtskante. Ein Wegpunkt auf dem Stapel hält den Graphen azyklisch, und alles, was von der Azyklizität abhängt, funktioniert weiter.
  5. Idempotenz gehört vor die Nebenwirkungen und nicht dahinter. Beanspruchen Sie die Arbeit, dann tun Sie sie.
  6. Eine Spalte sollte die Warteschlange steuern. Zwei Wege in denselben Ausführer sind ein Wettlauf, den Sie im Test nicht sehen werden.
  7. Mechanik und Produkt sind verschiedene Tabellen, sobald es eine zweite Art Mechanik gibt, und das wird früher sein, als Sie denken.
  8. Eine Ablaufregel ohne Vorgabe ist keine Regel. Benennen Sie den Ausgang, auch den, der keinen der beiden Zweige bedeutet.
  9. Genehmigen ist ein Recht und keine Nebenwirkung davon, bearbeiten zu dürfen.
  10. Eine Grenze sollte ein Fehler sein und keine Kürzung. Ein halber Flow, der Erfolg meldet, ist schlimmer als ein Flow, der stehen bleibt.

Zur Klarheit: die Merge-Prüfung lief 16 von 16 Schritten ohne Fehlerzeile durch, der Ende-zu-Ende-Lauf gegen die Entwicklungsdatenbank war 83 grün, der Browsertest mit einem echten Gateway-Worker war 27 grün, wobei die einzige menschliche Handlung ein Klick ist und der Worker protokolliert, dass er einen geweckt und einen fortgesetzt hat, der Leinwandtest war 37 grün, und der Barrierefreiheitsdurchlauf fand über fünf Seiten hinweg nichts. Nicht bewiesen: ein Produktionsbuild, die Zweige für erschöpfte Versuche und veraltete Ansprüche, die Knoten repeat und break außerhalb der reinen Testsuite, und Schleifen, die einen Aktionsknoten enthalten.

Aus der Plattformarbeit an Provibr. Was derselbe Graph garantieren musste, bevor es all das gab, steht in Ein Flow ist ein Graph, und er muss endlich sein, und der Editor, in dem er gezeichnet wird, steht in Ein Draht zum Ziehen, und genau ein Auslöser.

Fangen Sie mit einem Host und einem Agenten an

Konto erstellen, den Agenten mit einem einzigen Befehl installieren und Ihren ersten Proxmox-Host verbinden. Die Testphase läuft 14 Tage und fragt nicht nach Zahlungsdaten.