Blog ·
La migrazione che non è mai partita
Un migratore che riferisce successo saltando un file è peggio di uno che va in crash. Come l’ordinamento per timestamp perde migrazioni in silenzio.
Di Provibr
Un migratore di schema che va in crash è un buon pomeriggio. Si ottengono uno stack trace, un numero di riga e un database che si trova ancora in uno stato comprensibile.
Il brutto pomeriggio è quello in cui il migratore stampa che lo schema è aggiornato, esce con zero e si sbaglia. Quel pomeriggio l’abbiamo avuto più di una volta, per più di una causa, e la peggiore di esse è finita con del codice di produzione che leggeva una colonna inesistente.
Ecco che cosa abbiamo imparato, scritto per chiunque esegua migrazioni Postgres a partire da una directory di file, qualunque sia lo strumento che ha generato quei file.
La decisione si prende su un solo numero
La maggior parte dei migratori basati su file tiene una tabella di ciò che è stato applicato. Anche il nostro: uno schema, una tabella, una riga per migrazione con un hash e un timestamp di creazione.
L’errore è dare per scontato che quella tabella sia un insieme che il migratore consulta. Non lo è. Leggendo il codice si trova una query che seleziona esattamente una riga, la più recente per timestamp, e poi un solo confronto per ogni file di migrazione: eseguilo se l’ultimo timestamp applicato è inferiore a quello di questo file.
Ne discendono subito tre cose, e nessuna di esse sta nel modello mentale di qualcuno.
L’hash non ha alcun ruolo nella decisione. Viene scritto quando una migrazione viene eseguita e non viene mai più riletto per decidere alcunché. Un hash nella tabella che non corrisponde più a nessun file su disco di solito significa che quel file è stato modificato dopo essere stato applicato, il che è disordinato ma innocuo. Non è lo strumento che si accorge di qualcosa.
Conta solo la riga più recente. Non l’insieme dei file applicati, non il conteggio. Una riga.
L’ordinamento è per timestamp, non per nome di file. I numeri davanti ai nomi dei file sono per gli esseri umani. Se il suo strumento registra un valore di ordinamento in un manifesto, quel valore è la verità e il numero è decorazione.
Mettendo insieme queste cose si ottiene la proprietà che costa i pomeriggi: una migrazione il cui timestamp non è maggiore del timestamp applicato più recente non verrà mai eseguita, su quel database, mai più. Non con questo deploy, non con il successivo, non dopo dieci tentativi. Non compare alcun errore, perché dal punto di vista dello strumento non c’è nulla che non vada.
Come ci si arriva davvero
Sembra una cosa che non potrebbe capitare a una squadra attenta. Ecco come è capitata a noi, due volte, in modi che non avevano nulla a che fare con la disattenzione.
Due rami, lo stesso numero. Due persone aggiungono ciascuna una migrazione in parallelo. Entrambi gli strumenti assegnano il primo numero libero, entrambe ottengono lo stesso e ciascuna scrive un timestamp. Il ramo che viene unito per secondo viene rinominato al numero successivo, che è la correzione ovvia e non è tutta la correzione: se il suo timestamp resta inferiore a quello già applicato, la rinomina ha cambiato il nome del file e non la decisione. La migrazione viene saltata. Il sintomo, nel nostro caso, è stato un tipo enum a cui semplicemente mancava un valore, su un database in cui ogni altro oggetto era presente e corretto.
Timestamp scritti a mano che non erano in ordine. I nostri sono numeri tondi impostati a mano anziché un orologio reale, il che li rende leggibili e li rende facili da sbagliare. Una migrazione ha ricevuto un valore anteriore a quello di due migrazioni già applicate. Il migratore ha riferito successo. In produzione sarebbe stato un errore di tipo su ogni singola riga di integrazione, che non è il genere di cosa che si vuole scoprire da un cliente.
Entrambi i casi condividono una forma che merita un nome. La contabilità è indicizzata su un valore di ordinamento, non sull’identità. Un registro indicizzato sull’identità («ho applicato questa migrazione?») non può saltare nulla, in qualunque ordine arrivino le cose. Un registro indicizzato sull’ordinamento («fin dove sono arrivato?») può farlo, ed è una progettazione condivisa dalla maggior parte dei migratori perché rende il percorso comune la lettura di una sola riga.
Il contatore non è la verifica
Dopo il primo incidente abbiamo aggiunto la protezione ovvia: dopo la migrazione, guardare il numero di righe nella tabella delle migrazioni e confermare che sia salito.
Quella protezione è necessaria e non è sufficiente, cosa che abbiamo scoperto guardandola passare mentre ciò che ci serviva mancava. Un’esecuzione di migrazione ha portato il conteggio delle righe da novantotto a centoquattro. Sono entrate sei migrazioni. Quella che ci interessava non era fra loro. Il conteggio è salito, tutto ha riferito successo, e ogni pagina del prodotto che richiede il login restituiva un 500 su una colonna inesistente.
Un conteggio non dice nulla su quali migrazioni siano state eseguite. Se si vuole sapere se un oggetto esiste, l’unica verifica onesta è chiedere al database se quell’oggetto esiste: elencare le tabelle, descrivere la tabella, selezionare i valori dell’enum. Noi confrontiamo l’elenco delle tabelle dell’ambiente appena migrato con quello di cui ci fidiamo.
Esiste una variante di questo guasto che non ha nulla a che fare con i timestamp e produce lo stesso output verde. Il nostro migratore viaggia dentro una delle immagini dei nostri servizi. Qualcuno ha ricostruito solo l’altro servizio, ha eseguito la migrazione dall’immagine vecchia e ha ottenuto un allegro «lo schema è aggiornato» da un container che non aveva mai sentito parlare delle due migrazioni in questione. Lo strumento non stava mentendo. Rispondeva a una domanda sui file che aveva, e aveva file vecchi.
Tutto o niente, in una sola transazione
Un dettaglio dell’implementazione del nostro migratore vale la pena conoscerlo perché cambia il modo in cui si recupera: ogni migrazione in attesa viene eseguita dentro una sola transazione, con dentro l’inserimento contabile di ciascuna.
È una buona notizia ed è un vincolo. La buona notizia è che un fallimento a metà di un lotto di sette lascia con nessuna delle sette applicate, anziché con tre e mezza. Non c’è uno stato parzialmente migrato su cui ragionare, che è esattamente quel che si vuole nel momento in cui si è meno in grado di ragionare.
Il vincolo è che una sola istruzione che Postgres non può eseguire dentro un blocco di transazione porterà giù con sé l’intero lotto, e che una migrazione molto lunga tiene i lock per tutto il lotto anziché per la propria durata. Nessuna delle due ci ha ancora morsi. Entrambe sono il genere di cosa che conviene aver letto prima che accada.
Riparare una migrazione senza peggiorare la prossima
Poniamo di trovarla: una migrazione nella sua directory che questo database non ha mai eseguito. L’istinto è correggere il manifesto, abbassare o alzare il timestamp finché lo strumento non la prende, e rieseguire.
Non lo abbassi, e stia attento ad alzarlo.
Il motivo è la stessa regola che ha causato il problema. Se si abbassa il timestamp di una migrazione già applicata altrove, non si è fatto eseguire nulla; si è creato un file il cui valore ora sta sotto il livello di guardia sui database in cui è stata eseguita, e la prossima migrazione scritta con un valore compreso fra i due verrà saltata lì. Si è riparato un ambiente e armato il prossimo guasto in un altro.
Quel che funziona è applicare l’SQL a mano, in una sola transazione, e poi assicurarsi che la contabilità rispecchi la realtà. Il migratore non può farlo al posto suo, perché dal suo lato non c’è nulla da fare.
Che cosa facciamo adesso
Cinque regole, tutte poco costose, tutte messe per iscritto perché ciascuna l’abbiamo disattesa una volta.
- Deploy e migrazione sono un’unica operazione, non due. Lo spazio fra i due è una finestra in cui codice nuovo legge uno schema vecchio. Il nostro ci è costato una pagina di amministrazione per ogni amministratore e un job di fatturazione che ha fallito in silenzio per un intero pomeriggio su colonne che non c’erano ancora.
- Costruisca tutto prima di migrare, non solo il servizio che ha modificato. Il migratore viaggia in un’immagine, e un’immagine che non ha ricostruito contiene le migrazioni che non ha aggiunto.
- Controlli i valori di ordinamento, non i nomi dei file, ogni volta che due persone hanno lavorato in parallelo. Rinominare un file non cambia ciò che lo strumento confronta.
- Verifichi l’oggetto, non il contatore. Dopo una migrazione che aggiunge una colonna, chieda la colonna. Richiede un comando ed è l’unica verifica che non può passare a vuoto.
- Non abbassi mai un valore di ordinamento già applicato da qualche parte. Sembra una correzione locale ed è una mina remota.
Il filo scomodo che attraversa tutto questo è che lo strumento si è comportato correttamente ogni volta. Non è mai andato in crash, non ha mai corrotto nulla, non ha mai mentito su ciò che aveva fatto. Ha risposto a una domanda più stretta di quella che credevamo di porre, e ha stampato una frase che su quella domanda più stretta era vera.
È questa la classe di guasto contro cui vale la pena costruire abitudini, nelle migrazioni e in ogni altro luogo. Uno strumento che fallisce rumorosamente insegna che cosa fa. Uno strumento che riesce in modo ristretto non insegna nulla, fino al giorno in cui conta.