Blog ·
Fiecare autoapel era în poziție de coadă
O parcurgere recursivă trebuia să învețe să se oprească la jumătate și să continue mâine. Rescrierea a fost exactă, nu aproximativă, iar 43 de teste o dovedesc.
De Provibr
Fluxurile noastre de automatizare sunt grafuri. Un declanșator pornește, parcurgerea vizitează noduri, fiecare nod face ceva, iar la un moment dat parcurgerii i se termină firele și rularea se încheie. Până săptămâna aceasta, acea parcurgere era o funcție recursivă care se apela pe sine o dată pentru fiecare fir ieșit, iar asta a fost în regulă atâta vreme cât o rulare se termina înăuntrul cererii care o pornise.
Un nod de aprobare strică presupunerea aceasta cu totul. Rularea trebuie să se oprească, să fie consemnată și să continue mâine, atunci când cineva apasă un buton. Jumătate dintr-o stivă de apeluri JavaScript nu este ceva ce se poate serializa într-o coloană de bază de date.
Așa că parcurgerea a devenit o mașină cu stivă. Partea interesantă nu este că a devenit una; este că rescrierea a fost, demonstrabil, aceeași parcurgere și nu una nouă care, să sperăm, se poartă la fel.
De ce rescrierea este exactă și nu aproximativă
Fiecare apel recursiv din vechea parcurgere stătea în poziție de coadă. Forma era, literalmente, await follow(node, port); return;. Nu se întâmpla niciodată nimic după ce apelul se întorcea: nicio acumulare, nicio curățenie, nicio a doua jumătate de funcție care să aștepte ca copilul ei să termine.
Această singură însușire este ceea ce face conversia mecanică. O parcurgere în adâncime a cărei recursivitate stă în coadă este, prin construcție, identică cu o stivă explicită pe care împingeți țintele în ordine inversă. Nu există acolo nicio hotărâre de proiectare care să poată fi greșită, nicio întrebare despre ordinea în care se vizitează frații, niciun risc ca un nod care era vizitat după subarborele lui să ajungă acum vizitat înaintea lui.
Urmarea care ne interesa este că ordinea de vizitare rămâne neschimbată. Testele existente de parcurgere, patruzeci și trei la număr, nu au fost atinse și au rămas verzi. Acesta este întregul argument: testele descriu vechiul comportament, au fost scrise înainte ca cineva să știe că vine rescrierea aceasta și nu au avut nevoie de nicio modificare.
Dacă recursivitatea nu ar fi fost în poziție de coadă, aceasta ar fi fost o rescriere cu un argument de compatibilitate atașat, iar argumentele de compatibilitate sunt genul de lucru în care credeți până când fluxul unui client face ceva neașteptat într-o marți.
Ce aduce stiva și recursivitatea nu putea
Două lucruri, și amândouă sunt cu neputință cu o stivă de apeluri.
Primul este întreruperea. Starea parcurgerii este întreaga parcurgere exprimată ca JSON: stiva, nodurile vizitate, variabilele adunate, contorul și buclele deschise. Intră într-o coloană jsonb de pe rândul de așteptare și iese de acolo neschimbată. O rulare pusă în pauză este un rând, nu un proces suspendat, deci o desfășurare, o repornire sau un container care a căzut nu o pierd.
Al doilea sunt buclele. Un foreach sau un repeat împinge un reper pe stivă și apoi îi urmează corpul. Când stiva se golește înapoi până la acel reper, o trecere s-a încheiat, iar bucla hotărăște dacă mai urmează una. Graful rămâne astfel un graf orientat aciclic, nu există niciun fir care să arate înapoi, iar tot ce atârnă de aciclicitate funcționează mai departe: posibilitatea de a ajunge la fiecare nod, absența ciclurilor, vederea de listă topologică a unui flux.
Asupra ultimului punct merită să zăbovim. Felul evident de a adăuga bucle într-un editor de grafuri este să lăsați utilizatorul să tragă o muchie înapoi, către un nod anterior. Este totodată felul care distruge în tăcere orice altă garanție pe care o dă editorul, iar asta o aflați atunci când o validare scrisă acum un an începe să depășească timpul de așteptare.
Idempotență în trei straturi care fac fiecare altceva
O rulare pusă în pauză este trezită de un lucrător, prin rețea. Lucrătorii reîncearcă. Așa că întrebarea care hotărăște dacă funcția aceasta este sigură este ce se întâmplă când aceeași rulare este trezită de două ori.
Există trei mecanisme, iar ele nu sunt redundanță. Fiecare prinde altceva.
| Strat | Ce este | Ce prinde |
|---|---|---|
| Revendicarea | un singur UPDATE care mută rândul într-o stare de revendicare, dar numai din starea de așteptare | o a doua trezire care sosește în timp ce prima încă parcurge |
| Un index unic parțial pe rulare | cel mult un rând de așteptare deschis pentru fiecare rulare | existența, pur și simplu, a două rânduri de așteptare pentru aceeași rulare |
| Un index unic pe rulare și poziția pasului | registrul de pași nu poate ține două rânduri la aceeași poziție | orice a trecut de primele două |
Revendicarea este granița adevărată, iar celelalte două sunt structură. Motivul este că efectele secundare ale unui flux, o comandă trimisă unui agent, o notificare, o cerere HTTP către exterior, se petrec în timpul parcurgerii și nu după ea. Așa că singurul loc corect în care poate fi oprit al doilea lucrător este înainte să înceapă să parcurgă, nu atunci când încearcă să își scrie rezultatele. Cine primește zero rânduri înapoi de la acea actualizare nu reia, și cu asta s-a încheiat.
O reîncercare care eșuează este o problemă separată de o reîncercare care dublează. Există un plafon de cinci încercări, cu o temporizare de un minut, două, patru și opt, fiindcă o parcurgere care aruncă o eroare la reluare, un șablon care a fost șters, un server care nu mai există, ar fi altfel reîncercate la fiecare tact la nesfârșit, cu un tabel de coadă care nu se golește niciodată.
O singură coloană cârmuiește coada
Lucrătorul pune exact o întrebare: care așteptări sunt scadente. Asta înseamnă o stare și o oră de trezire, și nimic altceva.
Un termen limită este o oră de trezire în viitor. A lua o hotărâre înseamnă a pune ora de trezire pe acum. Nu există, dinadins, o a doua cale către executor, nicio rânduială în care acțiunea de server reia ea însăși rularea și ar putea să o reia și lucrătorul. Două căi ar fi o cursă între ele, iar cursa ar fi invizibilă, fiindcă amândouă drumurile produc o rulare care arată corect.
Asta a făcut ieftin și al doilea fel de așteptare. Când a sosit întârzierea durabilă, nimic din coadă nu s-a schimbat. Este un rând cu o oră de trezire și fără niciun om atașat de el.
Două tabele, fiindcă au vieți diferite
Mecanismul și produsul sunt tabele separate, iar ispita de a le contopi este puternică, fiindcă în prima zi sunt unul la unu.
Rândul de mecanism răspunde la „când ar trebui să continue rularea aceasta și cu ce”: o continuare, o oră de trezire, o revendicare, un contor de încercări. Este intern, este generic și este șters atunci când și-a făcut treaba.
Rândul de produs răspunde la „ce s-a cerut, de la cine și ce s-a hotărât”: un titlu, o lămurire, un termen limită, cel care hotărăște, o marcă de timp. Un client îl citește. Un auditor îl recitește un an mai târziu. Supraviețuiește rulării de care ținea.
Contopirea lor vă dă un tabel în care jumătate din coloane sunt nule de îndată ce există un al doilea fel de așteptare, ceea ce s-a și întâmplat când a sosit întârzierea durabilă, o zi mai târziu.
Hotărâri mărunte, ușor de greșit
Nodul se numește approval și nu approval.wait. Un punct este separator de spațiu de nume în cataloagele noastre de traduceri, deci un tip de nod care conține unul produce o cheie ce se rezolvă către un obiect imbricat în loc de un șir. Acesta este genul de constrângere care trăiește într-un alt sistem decât cel pe care îl editați.
Expirarea are trei deznodăminte și nu patru: aprobare, respingere sau sărire peste. Sărirea peste este cea la care a trebuit să ne gândim, fiindcă a patra alegere evidentă, „lasă-o atârnată”, nu este o alegere, este lipsa unei politici. O aprobare sărită nu ia niciuna dintre ramuri și o spune în registru, ceea ce este altceva decât a fi aprobată și altceva decât a fi respinsă.
A hotărî este un drept de sine stătător și nu o însușire a modulului. Fără asta, cel care scrie un flux își aprobă singur propriii pași de aprobare, iar tot nodul este decorativ.
Corpul unei bucle este o zonă închisă. Nu puteți încheia o rulare dinăuntrul lui, iar un fir care pleacă din corp către ceva din afara lui este o eroare de validare și nu o surpriză la execuție. Imbricarea se oprește la trei niveluri, iterațiile la o sută pe buclă și cinci sute de vizite pe rulare, iar depășirea oricăreia dintre acestea este o eroare și nu o trunchiere. Trunchierea înseamnă că fluxul unui client și-a făcut, în tăcere, jumătate din treabă.
Un repeat nu pornește pe o cale care încă nu există, fiindcă o valoare lipsă se citește ca fals, inclusiv pentru un test de neegalitate. Ultima propoziție este cea care îi prinde pe oameni: intuitiv, „câmpul acesta nu este egal cu done” ar trebui să fie adevărat atunci când câmpul lipsește, iar tratarea lui în felul acesta pornește bucle pe date care nu au sosit.
Trei goluri pe care runda le-a găsit, nu le-a făcut
Primul este un cod de refuz pentru un nod de acțiune fără server, care exista de la runda care l-a introdus și nu se afla în niciunul dintre cele nouă cataloage de traduceri. Nu dăduse nimeni peste el. Există acum o gardă care citește codurile de motiv din fișierele sursă și nu dintr-o listă scrisă de mână, și cere câte o propoziție pentru fiecare dintre ele în fiecare dintre cele nouă cataloage. A ieșit roșie prima dată când a rulat, cu 83 de constatări.
Al doilea este că fișierele de test ale întregului subsistem nu erau acoperite de nicio configurație de verificare a tipurilor. Nu se compilau în construcția nimănui. Adăugarea lor a scos la iveală șase goluri de tip, dintre care unul era o stare de rezumat al rulării tipizată ca șir simplu, ceea ce înseamnă că orice comparație cu ea era neverificată.
Al treilea este că comportamentul nodului de jurnal nu era descrisă nicăieri pe pagina rulării, deci un client care se uita la o rulare încheiată vedea un pas al cărui rost rămânea nelămurit.
Niciunul dintre acestea nu a fost pricinuit de munca aceasta. Toate trei au fost găsite fiindcă scrierea unui fel nou de nod înseamnă să citiți ca lumea codul din jur, pentru prima oară în luni de zile.
Ce am consemna
- Verificați dacă recursivitatea voastră este în poziție de coadă înainte să vă certați despre o rescriere. Dacă este, conversia la o stivă explicită este mecanică, iar testele voastre existente sunt dovada.
- Testele pe care nu a trebuit să le modificați sunt cea mai puternică dovadă pe care o puteți avea. Patruzeci și trei de teste verzi, neschimbate, spun mai mult decât patruzeci și trei noi.
- Serializați continuarea, nu procesul. O rulare pusă în pauză ar trebui să fie un rând care supraviețuiește unei desfășurări.
- Buclele dintr-un editor de grafuri nu au nevoie de o muchie înapoi. Un reper pe stivă ține graful aciclic, iar tot ce depinde de aciclicitate funcționează mai departe.
- Idempotența stă înaintea efectelor secundare, nu după ele. Revendicați lucrul, apoi faceți-l.
- O singură coloană ar trebui să cârmuiască coada. Două căi către același executor înseamnă o cursă pe care nu o veți vedea la testare.
- Mecanismul și produsul sunt tabele diferite de îndată ce există un al doilea fel de mecanism, ceea ce se va întâmpla mai devreme decât credeți.
- O politică de expirare fără o valoare implicită nu este o politică. Numiți deznodământul, inclusiv pe cel care înseamnă niciuna dintre ramuri.
- Aprobarea este un drept, nu un efect secundar al faptului că puteți edita.
- O limită ar trebui să fie o eroare, nu o trunchiere. Jumătate de flux care raportează succes este mai rău decât un flux care se oprește.
Ca să fie limpede ce este aceasta: verificarea de îmbinare a rulat 16 din 16 pași fără nicio linie de eșec, rularea cap la cap față de baza de date de dezvoltare a fost 83 verde, testul de browser cu un lucrător de poartă adevărat a fost 27 verde, în care singura acțiune omenească este un clic, iar lucrătorul consemnează că a trezit una și a reluat una, testul de pânză a fost 37 verde, iar baleiajul de accesibilitate nu a găsit nimic pe cinci pagini. Nedovedite: o construcție de producție, ramurile de epuizare a încercărilor și de revendicare învechită, nodurile repeat și break în afara suitei de teste pure și buclele care conțin un nod de acțiune.
Din munca de platformă la Provibr. Ce trebuia să garanteze același graf înainte să existe toate acestea se află în un flux este un graf, iar el trebuie să fie finit, iar editorul în care este desenat se află în un fir pe care îl trageți, și exact un declanșator.