Naar de inhoud

Blog ·

Elke zelfaanroep stond in staartpositie

Een recursieve wandeling moest halverwege leren stoppen en morgen verdergaan. De herschrijving was exact en niet bij benadering, en 43 tests bewezen het.

Door Provibr

Onze automatiseringsflows zijn grafen. Een trigger vuurt, de wandeling bezoekt knopen, elke knoop doet iets, en op enig moment raakt de wandeling door zijn draden heen en is de run klaar. Tot deze week was die wandeling een recursieve functie die zichzelf één keer per uitgaande draad aanriep, en dat ging goed zolang een run klaar was binnen het verzoek dat hem startte.

Een goedkeuringsknoop breekt die aanname volledig. De run moet stoppen, opgeschreven worden, en morgen verdergaan als iemand op een knop drukt. Een halve JavaScript-aanroepstapel is niet iets wat je naar een databasekolom kunt serialiseren.

Dus werd de wandeling een stapelmachine. Het interessante is niet dát hij er een werd; het is dat de herschrijving aantoonbaar dezelfde wandeling was en niet een nieuwe die zich hopelijk hetzelfde gedraagt.

Waarom de herschrijving exact is en niet bij benadering

Elke recursieve aanroep in de oude wandeling stond in staartpositie. De vorm was letterlijk await follow(node, port); return;. Er gebeurde nooit iets nadat de aanroep terugkwam: geen opeenstapeling, geen opruiming, geen tweede helft van een functie die op zijn kind wacht.

Die ene eigenschap maakt de omzetting mechanisch. Een diepte-eerst-wandeling waarvan de recursie in de staart staat is per constructie gelijk aan een expliciete stapel waarop je de doelen in omgekeerde volgorde duwt. Er zit geen ontwerpbeslissing in om fout te doen, geen vraag in welke volgorde je broers bezoekt, geen risico dat een knoop die eerst ná zijn deelboom bezocht werd nu ervóór aan de beurt komt.

Het gevolg waar het ons om ging is dat de bezoekvolgorde ongewijzigd is. De bestaande wandelingstests, drieënveertig stuks, zijn niet aangeraakt en bleven groen. Dat is het hele argument: de tests beschrijven het oude gedrag, ze zijn geschreven voordat iemand wist dat deze herschrijving eraan kwam, en er hoefde geen letter aan veranderd te worden.

Had de recursie niet in de staart gestaan, dan was dit een herschrijving met een compatibiliteitsverhaal eraan vast geweest, en compatibiliteitsverhalen zijn het soort ding dat je gelooft tot de flow van een klant op een dinsdag iets onverwachts doet.

Wat de stapel oplevert en recursie niet kon

Twee dingen, en allebei zijn ze onmogelijk met een aanroepstapel.

Het eerste is onderbreking. De wandelingstoestand is de hele wandeling uitgedrukt als JSON: de stapel, de bezochte knopen, de opgebouwde variabelen, de teller en de open lussen. Hij gaat in een jsonb-kolom op de wachtregel en komt er ongewijzigd weer uit. Een run die gepauzeerd is, is een rij en geen opgeschort proces, dus een deploy, een herstart of een omgevallen container raakt hem niet kwijt.

Het tweede zijn lussen. Een foreach of een repeat duwt een baken op de stapel en volgt dan zijn romp. Zodra de stapel terugloopt tot aan dat baken, is één ronde klaar en beslist de lus of er nog een komt. De graaf blijft daardoor een gerichte acyclische graaf, er is geen draad die achteruit wijst, en alles wat aan acycliciteit hangt blijft werken: bereikbaarheid, de afwezigheid van cykels, het topologische lijstoverzicht van een flow.

Dat laatste punt is het waard om bij stil te staan. De voor de hand liggende manier om lussen aan een grafeneditor toe te voegen is de gebruiker een draad terug naar een eerdere knoop laten trekken. Het is ook de manier die stilletjes elke andere garantie van de editor sloopt, en je komt erachter zodra een validatie die je een jaar geleden schreef zijn tijdslimiet begint te overschrijden.

Idempotentie in drie lagen die elk iets anders doen

Een gepauzeerde run wordt door een worker over het netwerk gewekt. Workers proberen opnieuw. Dus de vraag die bepaalt of deze functie veilig is, is wat er gebeurt als dezelfde run twee keer gewekt wordt.

Er zijn drie mechanismen, en ze zijn geen dubbeling. Elk vangt iets anders.

LaagWat het isWat het vangt
De claiméén UPDATE die de rij naar een claimende stand brengt, maar alleen vanuit de wachtende standeen tweede wekking die binnenkomt terwijl de eerste nog aan het wandelen is
Een partiële unieke index op de runhoogstens één open wachtregel per rundat er überhaupt twee wachtregels voor dezelfde run bestaan
Een unieke index op run en stappositiehet stappengrootboek kan geen twee rijen op dezelfde positie bevattenalles wat langs de eerste twee kwam

De claim is de echte grens en de andere twee zijn structuur. De reden is dat de neveneffecten van een flow, een commando naar een agent, een melding, een uitgaand HTTP-verzoek, tijdens de wandeling gebeuren en niet erna. Dus de enige juiste plek om de tweede worker te stoppen is voordat hij begint te wandelen, niet als hij zijn resultaten wil wegschrijven. Wie nul rijen terugkrijgt van die update hervat niet, en daarmee is het klaar.

Een poging die faalt is een ander probleem dan een poging die dubbelt. Er is een plafond van vijf pogingen met een uitstel van één, twee, vier en acht minuten, want een wandeling die bij het hervatten struikelt, een sjabloon dat verwijderd is, een server die niet meer bestaat, zou anders bij elke tik eeuwig opnieuw geprobeerd worden met een wachtrijtabel die nooit leegloopt.

Eén kolom stuurt de wachtrij

De worker stelt precies één vraag: welke wachtregels zijn aan de beurt. Dat is een status en een wektijd, en verder niets.

Een deadline is een wektijd in de toekomst. Een besluit nemen is de wektijd op nu zetten. Er is bewust geen tweede route naar de uitvoerder, geen opzet waarin de server-action de run zelf hervat en de worker dat ook zou kunnen. Twee routes zouden een race met elkaar zijn, en die race zou onzichtbaar zijn omdat beide paden een correct ogende run opleveren.

Dit maakte de tweede soort wachten ook goedkoop. Toen de duurzame vertraging kwam, veranderde er niets aan de wachtrij. Het is een rij met een wektijd en zonder mens eraan.

Twee tabellen, want ze hebben een verschillend leven

De machinerie en het product zijn aparte tabellen, en de verleiding om ze samen te voegen is groot omdat ze op dag één één-op-één zijn.

De machinerierij beantwoordt "wanneer moet deze run verder, en waarmee": een voortzetting, een wektijd, een claim, een pogingenteller. Hij is intern, hij is generiek, en hij wordt verwijderd zodra hij zijn doel gediend heeft.

De productrij beantwoordt "wat is er gevraagd, aan wie, en wat is er besloten": een kop, een toelichting, een deadline, een beslisser, een tijdstempel. Een klant leest hem. Een auditor leest hem een jaar later terug. Hij overleeft de run waar hij bij hoorde.

Samenvoegen levert je een tabel op waarvan de helft van de kolommen null is zodra er een tweede soort wachten bestaat, en dat is precies wat er gebeurde toen een dag later de duurzame vertraging kwam.

Kleine beslissingen die makkelijk fout gaan

De knoop heet approval en niet approval.wait. Een punt is in onze vertaalcatalogi een naamruimtescheider, dus een knoopsoort met een punt erin levert een sleutel op die naar een genest object verwijst in plaats van naar een string. Dat is het soort randvoorwaarde die in een ander systeem woont dan het systeem dat je aan het bewerken bent.

Verlopen heeft drie uitkomsten en geen vier: goedkeuren, afwijzen of overslaan. Overslaan is degene waar over nagedacht moest worden, want de voor de hand liggende vierde keuze, "laat maar hangen", is geen keuze maar de afwezigheid van beleid. Een overgeslagen goedkeuring neemt geen van beide takken en zegt dat in het grootboek, wat iets anders is dan goedgekeurd en iets anders dan afgewezen.

Beslissen is een eigen recht en geen eigenschap van de module. Zonder dat tekent degene die een flow schrijft zijn eigen goedkeuringsstappen af, en is de hele knoop versiering.

Een lusromp is een gesloten gebied. Je kunt van binnenuit geen run beëindigen, en een draad van binnen de romp naar iets erbuiten is een validatiefout in plaats van een verrassing tijdens de run. Nesten stopt bij drie niveaus, iteraties bij honderd per lus en vijfhonderd bezoeken per run, en over een van die grenzen gaan is een fout en geen afkapping. Afkappen betekent dat de flow van een klant stilletjes zijn werk half gedaan heeft.

Een repeat start niet op een pad dat nog niet bestaat, want een ontbrekende waarde leest als onwaar, ook bij een ongelijkheidstoets. Die laatste bijzin is degene waar mensen op vallen: intuïtief zou "dit veld is niet gelijk aan klaar" waar moeten zijn als het veld ontbreekt, en dat zo behandelen start lussen op gegevens die nog niet binnen zijn.

Drie gaten die de ronde vond in plaats van maakte

Het eerste is een weigeringscode voor een actieknoop zonder server die bestond sinds de ronde die hem invoerde en in geen van de negen vertaalcatalogi stond. Niemand was hem tegengekomen. Er is nu een bewaking die de redencodes uit de bronbestanden leest in plaats van uit een met de hand geschreven lijst, en die voor elk daarvan een zin eist in elk van de negen catalogi. Hij ging de eerste keer dat hij draaide rood, met 83 bevindingen.

Het tweede is dat de testbestanden van dit hele subsysteem door geen enkele typecontrole-configuratie gedekt werden. Ze compileerden in niemands build. Ze toevoegen leverde zes typegaten op, waarvan er één een runsamenvattingsstatus was die als een gewone string getypeerd stond, wat betekent dat elke vergelijking ertegen ongecontroleerd was.

Het derde is dat het gedrag van de logknoop nergens op de runpagina beschreven stond, dus zag een klant die naar een afgeronde run keek een stap waarvan het doel onverklaard bleef.

Niets hiervan is door dit werk veroorzaakt. Alle drie zijn ze gevonden doordat een nieuwe knoopsoort schrijven betekent dat je de code eromheen voor het eerst in maanden fatsoenlijk leest.

Wat we zouden opschrijven

  1. Kijk of je recursie in staartpositie staat voordat je over een herschrijving gaat discussiëren. Zo ja, dan is de omzetting naar een expliciete stapel mechanisch en zijn je bestaande tests het bewijs.
  2. Tests die je niet hoefde te wijzigen zijn het sterkste bewijs dat je kunt krijgen. Drieënveertig ongewijzigde groene tests zeggen meer dan drieënveertig nieuwe.
  3. Serialiseer de voortzetting, niet het proces. Een gepauzeerde run hoort een rij te zijn die een deploy overleeft.
  4. Lussen in een grafeneditor hebben geen achterwaartse draad nodig. Een baken op de stapel houdt de graaf acyclisch en alles wat van acycliciteit afhangt blijft werken.
  5. Idempotentie hoort vóór de neveneffecten, niet erna. Claim het werk, doe het dan.
  6. Eén kolom hoort de wachtrij te sturen. Twee routes naar dezelfde uitvoerder is een race die je in een test niet ziet.
  7. Machinerie en product zijn verschillende tabellen zodra er een tweede soort machinerie is, en dat is eerder dan je denkt.
  8. Een vervalbeleid zonder standaard is geen beleid. Benoem de uitkomst, ook degene die geen van beide takken betekent.
  9. Goedkeuren is een recht en geen bijverschijnsel van mogen bewerken.
  10. Een grens hoort een fout te zijn en geen afkapping. Een halve flow die succes meldt is erger dan een flow die stopt.

Voor de duidelijkheid over wat dit is: de mergeverificatie draaide 16 van de 16 stappen zonder een faalregel, de end-to-end-run tegen de ontwikkeldatabase was 83 groen, de browsertest met een echte gatewayworker was 27 groen waarbij de enige menselijke handeling een klik is en de worker meldt dat hij er één wekte en er één hervatte, de canvastest was 37 groen en de toegankelijkheidsveeg vond niets op vijf pagina's. Onbewezen: een productiebuild, de takken voor uitgeputte pogingen en verlopen claims, de repeat- en break-knopen buiten de zuivere testsuite, en lussen met een actieknoop erin.

Uit het platformwerk aan Provibr. Wat dezelfde graaf moest garanderen voordat dit alles bestond staat in een flow is een graaf, en die moet eindig zijn, en de editor waarin hij getekend wordt staat in een draad die je sleept, en precies één trigger.

Begin met één host en één agent

Maak een account aan, installeer de agent met één commando en koppel je eerste Proxmox-host. De proefperiode loopt 14 dagen en vraagt geen betaalgegevens.