Blog ·
Twee rondes, één veldnummer
Twee branches voegden op dezelfde dag een variant toe aan hetzelfde protobuf-bericht, en allebei kozen ze hetzelfde nummer. Bijna niets daaraan faalt luid.
Door Provibr
Twee stukken werk liepen parallel op dezelfde repository. Het ene voegde de mogelijkheid toe om aan een hypervisor te vragen wat er van een achtergrondtaak geworden is. Het andere voegde de mogelijkheid toe om een container opnieuw te installeren. Allebei hadden ze een nieuwe variant nodig in hetzelfde protobuf-bericht, allebei keken ze in het bestand, allebei zagen ze dat het hoogste nummer in gebruik drieëndertig was, en allebei schreven ze vierendertig.
Geen van beide was slordig. Allebei lazen ze het bestand correct op het moment dat ze aftakten. De botsing bestaat alleen in een derde toestand die geen van beide ooit gecompileerd heeft: de merge.
| Waar de botsing zichtbaar wordt | Wat er gebeurt |
|---|---|
| De twee branches afzonderlijk | Niets. Allebei kloppen ze tegen hun eigen vertrekpunt |
| De merge, in het schemabestand | Een conflict, en daarna een harde compilerfout als allebei blijven staan |
| De merge, in de gegenereerde code in versiebeheer | Een conflict, en daarna helemaal niets als allebei blijven staan |
| De compatibiliteitscontrole | Groen in allebei de branches, want hij vergelijkt elk met zijn eigen basisversie |
| Een uitgerolde agent en gateway die het oneens zijn | Hier nooit bereikt: een commando dat leeg zou aankomen, gemeld op lagen afstand van de oorzaak |
Wat de draad werkelijk draagt
Een veldnaam in protobuf is een gemak voor mensen. Wat er over de verbinding gaat is het nummer, drie bits naar links verschoven met een typeaanduiding in de lage bits, dus veld vierendertig met een payload met lengteprefix wordt geschreven als de bytereeks voor tweehonderdvierenzeventig. Hernoem een veld en er verandert niets op de binaire draad. Verander het nummer en elke uitgerolde lezer decodeert vanaf dat moment iets anders met dezelfde sleutel.
Daarom ligt een nummer vast zodra het in een release naar buiten is gegaan. Het is geen stijlregel. Het is de enige identificatie die de twee uiteinden delen.
Wat het gereedschap zegt, en wat niet
Git merkte het. Het conflict kwam boven in het schemabestand en in de gegenereerde TypeScript in versiebeheer, allebei de keren als overlappende toevoegingen op dezelfde plek. De verleidelijke oplossing is de oplossing die er voor twee onafhankelijke functies overduidelijk juist uitziet: houd ze allebei.
In het schemabestand wordt dat gevangen. Het compileren levert een botte weigering op die het bericht noemt en het veld dat het nummer al draagt, en dat is gemeten en niet aangenomen: een testbericht met twee varianten die allebei op vierendertig staan wordt afgewezen, met een regel en een kolom en een voorstel voor het volgende vrije nummer.
Met de gegenereerde TypeScript ligt het anders, en dat is de reden dat dit het opschrijven waard is. Dat bestand staat in versiebeheer, omdat dagelijks werk niet zou moeten vereisen dat de schemagereedschapsketen geïnstalleerd is. De prijs van die keuze is dat het aan merges meedoet als elk ander bronbestand, en het is gewone code. Een dubbele tak in een switch is geldige TypeScript. Een klein bestand compileren met twee identieke case-labels, allebei met de tagcontrole voor veld vierendertig erin, levert helemaal geen meldingen op en eindigt schoon. De tweede tak wordt eenvoudigweg nooit bereikt.
Een met de hand gemergd gegenereerd bestand geeft je dus een encoder die voor twee verschillende berichten dezelfde tag schrijft en een decoder die hem altijd oplost naar het bericht dat als eerste in de lijst staat. Er faalt niets bij het bouwen, er faalt niets bij het starten, en er faalt niets bij het eerste bericht. Een van de twee functies is doodgewoon verkeerd, op een manier die alleen zichtbaar wordt op de machine aan de andere kant van de verbinding.
Waarom de compatibiliteitscontrole groen stond
Het schema wordt bewaakt door een controle op brekende wijzigingen, en die deed zijn werk. Hij vergelijkt een voorgesteld schema met een basisversie. Een nieuwe variant toevoegen aan een bestaande keuze is geen brekende wijziging, en dat was het in geen van beide branches, want de basisversie van elke branch had vierendertig werkelijk niet in gebruik.
Dit is de algemene vorm van het probleem en het is de moeite waard om hem los van protobuf te formuleren: een controle die een branch met zijn eigen vertrekpunt vergelijkt kan een conflict dat alleen tussen twee branches bestaat niet zien. Dezelfde soort fout duikt op overal waar parallel werk een waarde kiest uit een gedeelde, geordende ruimte. Databasemigraties worden hier gekozen op de tijdstempel in hun journaal, en die tijdstempels zijn met de hand geschreven ronde getallen in plaats van een echte klok, dus twee rondes die er elk een schrijven kiezen hun eigen tijdstempel; een migratie waarvan de tijdstempel onder die van een al toegepaste migratie landt wordt voorgoed overgeslagen, en de migrator meldt nog steeds dat het schema bij is. Ander mechanisme, dezelfde stilte, dezelfde oorzaak.
Waarom de stilte het dure deel is
Waren beide nummers een release binnengekomen, dan was de storing op afstand en raadselachtig geweest. Protobuf gooit een keuzeveld weg dat het niet herkent, dus een agent die uit de ene branch gebouwd is en een commando uit de andere ontvangt krijgt geen foutmelding. Hij krijgt een commando met niets erin, en meldt zoiets als "het bericht droeg geen instructie". De oorzaak ligt op lagen afstand van die zin, op een machine van iemand anders, en wie het leest heeft geen enkele reden om een nummer te verdenken.
Het platform weigert al instructies te sturen naar agents die de bijbehorende capability niet hebben aangekondigd, juist omdat dit faalbeeld zo weinig behulpzaam is: een agent die alleen maar te oud is levert tenminste een weigering op die het platform zelf geschreven heeft, met de functie en de versie erin. Die bewaking dekt een versieverschil af. Ze dekt niet twee nummers af die twee dingen betekenen, want van de kant van het platform gezien is er helemaal niets verouderd.
De regel die eruit voortkwam
Een veldnummer dat in een release is uitgegaan ligt vast, dus verhuist de ronde die nog niet uitgegaan is. Vierendertig was al vertrokken in een agent-release als de variant voor de taakstatus, dus bij de merge werd de variant voor het opnieuw installeren van een container vijfendertig, en de gegenereerde code is opnieuw uit het schema gegenereerd in plaats van met de hand gemergd. Dat opnieuw genereren is de belangrijke helft: een handmatige merge van gegenereerde uitvoer is een met de hand geschreven encoder die niemand als zodanig heeft nagekeken.
De praktische instructie voor wie de volgende toevoegt is kort. Lees het hoogste nummer in de hoofdlijn op het moment dat je commit, niet op het moment dat je aftakte. En bij elke merge die het schema raakt wordt dat bewust door een mens gecontroleerd, want geen enkel gereedschap in de keten kijkt er vanuit die hoek naar.
Reserveringen, en waarom ze het eerbiedigen waard zijn
Hetzelfde schema laat zien hoe die discipline eruitziet wanneer ze werkt. Drie nummers staan gereserveerd voor integraties die gepland waren en nooit gebouwd zijn, zodat niemand ze stilletjes kan hergebruiken. Twee andere werden vastgehouden voor twee bepaalde controlepanelen, en allebei zijn ze uiteindelijk gevuld door de rondes waaraan ze beloofd waren. Een derde paneel dat toevallig eerder klaar was heeft er geen van beide genomen; het nam in plaats daarvan het volgende vrije nummer verderop.
Dat is de hele bedoeling van een reservering. Er een opeten omdat je toevallig als eerste klaar was is precies de manier waarop een nummeringsschema ophoudt iets te betekenen, en het commentaar in het bestand zegt dat met zoveel woorden.
Een gereserveerd nummer invullen zoals het bedoeld was wordt wél gemeld door de controle op brekende wijzigingen, als het verwijderen van een reservering. Die melding is verwacht en terecht om te accepteren: er heeft nooit een veld dat nummer gedragen, dus geen enkele uitgerolde lezer kan er een mening over hebben. Goed om vooraf te weten, want het ziet er de eerste keer alarmerend uit en de verleiding is dan om een ander nummer te kiezen om het gereedschap stil te krijgen, waarmee je juist de reservering weggooit die je aan het eerbiedigen was.
Wat we zouden opschrijven
- Behandel een uitgegeven nummer als onveranderlijk, en behandel "het hoogste nummer dat ik zie" als een uitspraak over je branch en niet over het project.
- Lees de gedeelde ruimte op het moment van committen, niet op het moment van aftakken. Dat is het enige moment waarop de waarde werkelijk geclaimd wordt.
- Genereer gegenereerde code opnieuw, merge hem nooit. Een conflict in gegenereerde uitvoer is een teken dat je de generator moet draaien, geen uitnodiging om te bewerken.
- Weet welke van je controles ten opzichte van een basisversie werken. Die zien geen botsingen tussen broers en zussen, en dat gat vraagt om een mens bij de merge.
- Reserveer de nummers die je hebt toegezegd of ingepland, met een commentaar dat zegt van wie ze zijn, en accepteer het lawaai dat er een correct invullen oplevert.
- Zoek dezelfde vorm elders. Elke met de hand toegekende waarde in een gedeelde geordende ruimte, migraties inbegrepen, heeft deze fout, en meestal in stilte.
Voor de duidelijkheid: er is niets uitgegaan met de botsing erin. Hij is bij de merge gevangen, de latere variant is verhuisd, de gegenereerde code is opnieuw uit het schema opgebouwd, en beide functies zijn als aparte agent-releases naar buiten gegaan, uren uit elkaar. Wat hem vangbaar maakte was dat iemand het conflict las in plaats van het op te lossen, en dat is geen proces dat je kunt automatiseren maar wel een dat je kunt opschrijven.
Uit het platformwerk aan Provibr. De databaseversie van dezelfde fout staat in de migratie die nooit gedraaid heeft, en waarom een getekende agent-update zichzelf weigert in plaats van de afzender te vertrouwen staat in hoe een getekende agent-update zichzelf weigert.