Salta al contenuto

Blog ·

Due tornate, un solo numero di campo

Due rami aggiungono una variante allo stesso messaggio protobuf, lo stesso giorno, e scelgono lo stesso numero. Quasi nulla, in questo, fallisce ad alta voce.

Di Provibr

Due lavori procedevano in parallelo sullo stesso repository. Uno aggiungeva la possibilità di chiedere a un hypervisor che fine avesse fatto un’attività in background. L’altro aggiungeva la possibilità di reinstallare un container. Entrambi avevano bisogno di una nuova variante nello stesso messaggio protobuf, entrambi hanno guardato il file, entrambi hanno visto che il numero più alto in uso era trentatré, ed entrambi hanno scritto trentaquattro.

Nessuno dei due è stato sbadato. Ciascuno ha letto correttamente il file nel momento in cui si è ramificato. La collisione esiste soltanto in un terzo stato che nessuno dei due ha mai compilato: l’unione.

Dove si vede la collisioneChe cosa succede
Nei due rami separatamenteNulla. Ciascuno è corretto rispetto al proprio punto di partenza
Nell’unione, nel file di schemaUn conflitto, e poi un errore netto del compilatore se si tengono entrambi
Nell’unione, nel codice generato versionatoUn conflitto, e poi assolutamente nulla se si tengono entrambi
Nel verificatore di compatibilitàVerde in entrambi i rami, perché confronta ciascuno con la propria base
Un agente e un gateway distribuiti che non concordanoNon ci siamo mai arrivati: un comando che arriverebbe vuoto, riferito a livelli di distanza dalla causa

Che cosa viaggia davvero sulla connessione

Il nome di un campo protobuf è una comodità per gli esseri umani. Ciò che passa sulla connessione è il numero, spostato a sinistra di tre bit con un indicatore di tipo nei bit bassi, quindi il campo trentaquattro con un payload preceduto dalla lunghezza si scrive come la sequenza di byte del duecentosettantaquattro. Si rinomini un campo e sulla connessione binaria non cambia nulla. Se ne cambi il numero e ogni lettore distribuito sta ora decodificando qualcos’altro con la stessa chiave.

Per questo un numero, una volta uscito in una release, è congelato. Non è una regola di stile. È l’unico identificatore che i due capi condividono.

Che cosa dicono gli strumenti, e che cosa non dicono

Git se n’è accorto. Il conflitto è comparso nel file di schema e nel TypeScript generato versionato, in entrambi i casi come aggiunte sovrapposte nello stesso punto. La risoluzione allettante è quella che sembra ovviamente giusta per due funzionalità indipendenti: tenerle entrambe.

Nel file di schema la cosa viene intercettata. Compilarlo produce un rifiuto netto che nomina il messaggio e il campo che già occupa quel numero, cosa che è stata misurata anziché data per scontata: un messaggio di prova con due varianti, entrambe sul trentaquattro, viene rifiutato con riga, colonna e il suggerimento del primo numero libero.

Il TypeScript generato è un’altra storia, ed è il motivo per cui vale la pena mettere tutto questo per iscritto. Quel file è versionato, perché il lavoro di ogni giorno non dovrebbe richiedere che sia installata la catena di strumenti dello schema. Il prezzo di quella scelta è che partecipa alle unioni come qualunque altro file sorgente, ed è codice ordinario. Un ramo duplicato in un’istruzione switch è TypeScript legale. Compilare un file piccolo con due etichette case identiche, entrambe con il controllo del tag per il campo trentaquattro, non produce alcuna diagnostica ed esce pulito. Il secondo ramo semplicemente non viene mai raggiunto.

Quindi un file generato unito a mano dà un encoder che scrive lo stesso tag per due messaggi diversi e un decoder che lo risolve sempre in quello elencato per primo. Non fallisce nulla a tempo di build, non fallisce nulla all’avvio e non fallisce nulla al primo messaggio. Una delle due funzionalità è semplicemente sbagliata, in un modo che si manifesta soltanto sulla macchina all’altro capo della connessione.

Perché il controllo di compatibilità era verde

Lo schema è sorvegliato da un verificatore di modifiche non retrocompatibili, e ha fatto il suo lavoro. Confronta uno schema proposto con una base di riferimento. Aggiungere una nuova variante a una scelta esistente non è una modifica non retrocompatibile, e non lo era in nessuno dei due rami, perché la base di ciascun ramo davvero non aveva il trentaquattro in uso.

Questa è la forma generale del problema e vale la pena enunciarla separatamente da protobuf: un controllo che confronta un ramo con il proprio punto di partenza non può vedere un conflitto che esiste soltanto fra due rami. La stessa classe di difetto salta fuori ovunque un lavoro parallelo scelga un valore da uno spazio condiviso e ordinato. Qui le migrazioni di database vengono scelte per la marca temporale nel loro giornale, e quelle marche temporali sono numeri tondi scritti a mano anziché letti da un orologio vero, quindi due tornate che ne scrivono una ciascuna si scelgono la propria; una migrazione la cui marca temporale finisce sotto a una già applicata viene saltata per sempre, e il migratore continua a riferire che lo schema è aggiornato. Meccanismo diverso, stesso silenzio, stessa causa.

Perché la parte costosa è il silenzio

Se entrambi i numeri fossero sopravvissuti fino a una release, il guasto sarebbe stato remoto e misterioso. Protobuf scarta un campo di scelta che non riconosce, quindi un agente costruito da un ramo che riceve un comando dall’altro non ottiene un errore. Ottiene un comando con dentro nulla, e riferisce qualcosa come «il messaggio non portava alcuna istruzione». La causa sta a livelli di distanza da quella frase, su una macchina che appartiene a qualcun altro, e chi la legge non ha alcuna ragione di sospettare di un numero.

La piattaforma si rifiuta già di mandare istruzioni ad agenti che non hanno annunciato la capacità corrispondente, proprio perché questa modalità di guasto è così poco utile: un agente che è soltanto troppo vecchio produce almeno un rifiuto scritto dalla piattaforma, che nomina la funzionalità e la versione. Quella protezione copre lo scarto fra versioni. Non copre due numeri che significano due cose, perché dal lato della piattaforma non c’è proprio nulla di non aggiornato.

La regola che ne è uscita

Un numero di campo uscito in una release è congelato, quindi a spostarsi è la tornata che non è ancora uscita. Il trentaquattro era già uscito in una release dell’agente come variante dello stato dell’attività, quindi all’unione la variante di reinstallazione del container è diventata trentacinque, e il codice generato è stato rigenerato dallo schema anziché unito a mano. La rigenerazione è la metà importante: un’unione a mano di output generato è un encoder scritto a mano che nessuno ha rivisto come tale.

L’istruzione pratica per chi aggiungerà il prossimo è breve. Si legga il numero più alto nella linea principale nel momento in cui si fa il commit, non nel momento in cui ci si è ramificati. E ogni unione che tocca lo schema riceve quel controllo deliberatamente, da parte di una persona, perché nessuno strumento della catena lo sta guardando da quell’angolatura.

Le riserve, e perché vale la pena rispettarle

Lo stesso schema mostra che aspetto abbia la disciplina quando funziona. Tre numeri stanno riservati per integrazioni che erano state pianificate e mai costruite, così che nessuno possa riutilizzarli in silenzio. Altri due erano tenuti da parte per due pannelli di controllo specifici, ed entrambi sono stati alla fine occupati dalle tornate a cui erano stati promessi. Un terzo pannello che si è trovato pronto prima non ne ha preso uno; ha preso il primo numero libero più avanti.

Quello è tutto il senso di una riserva. Mangiarsene una perché si era pronti per primi è esattamente il modo in cui uno schema di numerazione smette di significare qualcosa, e il commento nel file lo dice con altrettante parole.

Occupare un numero riservato come previsto viene comunque segnalato dal verificatore di compatibilità, come la rimozione di una riserva. Quella segnalazione è attesa ed è corretto accettarla: nessun campo ha mai portato quel numero, quindi nessun lettore distribuito può avere un’opinione in merito. Vale la pena saperlo in anticipo, perché la prima volta sembra allarmante e la tentazione è scegliere un numero diverso per far tacere lo strumento, il che butta via proprio la riserva che si stava rispettando.

Che cosa metteremmo per iscritto

  1. Trattare come immutabile un numero uscito in una release e trattare «il numero più alto che vedo» come un’affermazione sul proprio ramo anziché sul progetto.
  2. Leggere lo spazio condiviso al momento del commit, non al momento della ramificazione. È l’unico momento in cui il valore viene davvero rivendicato.
  3. Rigenerare il codice generato, non unirlo mai. Un conflitto nell’output generato è un segnale per eseguire il generatore, non un invito a modificarlo.
  4. Sapere quali dei propri controlli siano relativi a una base. Non possono vedere collisioni fra fratelli, e quella lacuna richiede una persona al momento dell’unione.
  5. Riservare i numeri promessi o previsti, con un commento che dica a chi appartengono, e accettare il rumore che produce occuparne uno correttamente.
  6. Cercare la stessa forma altrove. Qualunque valore assegnato a mano in uno spazio condiviso e ordinato, migrazioni comprese, ha questo guasto e di solito ce l’ha in silenzio.

Per chiarezza: non è stato rilasciato nulla che contenesse la collisione. È stata intercettata all’unione, la variante più recente si è spostata, il codice generato è stato ricostruito dallo schema, ed entrambe le funzionalità sono uscite come release dell’agente separate, a poche ore di distanza. Ciò che l’ha resa intercettabile è che qualcuno ha letto il conflitto anziché risolverlo, che non è un processo automatizzabile ma è un processo che si può mettere per iscritto.

Dal lavoro di piattaforma di Provibr. La versione con i database dello stesso guasto è in la migrazione che non è mai partita, e perché un aggiornamento firmato dell’agente si rifiuti da sé anziché fidarsi del mittente è in come un aggiornamento firmato si rifiuta.

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.