In locale è facile dimenticarsi che il database e il codice sono due cose distinte. Modifico un modello, scrivo la migrazione, la eseguo e faccio partire l'applicazione. Se qualcosa non funziona, correggo e riprovo. Alla fine l'applicazione trova nel database lo schema che si aspetta.
In produzione non è sempre così. Un'applicazione può avere più istanze, un deploy può aggiornarle una alla volta e qualche processo in background potrebbe continuare a eseguire il codice precedente anche dopo il rilascio. Il database, invece, è condiviso: una modifica al suo schema diventa visibile a tutti nello stesso momento.
È sufficiente cambiare il nome di una colonna per accorgersene.
Cambiare il nome di una colonna
Supponiamo di voler rinominare users.name in users.display_name. Sembra una modifica banale: una migrazione, qualche riferimento da aggiornare nel codice e il lavoro è finito.
Almeno finché possiamo permetterci di fermare tutto.
Se il servizio deve rimanere disponibile, dobbiamo decidere in quale ordine distribuire le modifiche. Se rinominiamo prima la colonna, le istanze che eseguono ancora il vecchio codice continueranno a cercare name, che non esiste più. Se aggiorniamo prima il codice, saranno le nuove istanze a cercare display_name quando il database ha ancora soltanto name.
Per evitarlo, distribuiamo la modifica su più rilasci. Prima aggiungiamo display_name senza toccare la colonna originale. Poi aggiorniamo l'applicazione affinché scriva in entrambe, ma continui a leggere da name. Quando tutte le istanze utilizzano questa versione, possiamo copiare i dati esistenti nella nuova colonna. Bisogna però gestire le scritture che arrivano durante la copia.
Verificato che i dati siano allineati, spostiamo le letture su display_name. La vecchia colonna verrà eliminata in un rilascio successivo, quando saremo sicuri che nessun processo la utilizzi ancora.
È il principio dell'expand and contract: prima si amplia lo schema per mantenere la compatibilità con le versioni esistenti, poi si elimina ciò che non serve più.
Ci vogliono più passaggi, ma in questo modo versioni diverse dell'applicazione possono convivere durante l'aggiornamento. Non è una procedura necessaria in ogni progetto. Per un gestionale interno con pochi utenti, una finestra di manutenzione potrebbe essere molto più ragionevole. Basta sapere che il deploy non offre sempre le stesse condizioni dell'ambiente di sviluppo.
Le migrazioni hanno una storia
Quando una migrazione è già stata distribuita, non torno a modificarla.
Durante lo sviluppo iniziale posso riscriverla quanto voglio, purché sia confinata a database che posso ricreare liberamente. Dopo il primo rilascio, invece, considero quel file immutabile.
Gli strumenti di migrazione tengono traccia delle operazioni già eseguite. Se modifico una migrazione esistente, i database che l'hanno già applicata non eseguiranno automaticamente la nuova versione. Quelli creati da zero, invece, partiranno con lo schema modificato.
Potrei ritrovarmi con due installazioni della stessa versione del software che hanno strutture diverse. Il codice è identico, i test su un database pulito passano, ma il comportamento cambia a seconda della storia dell'installazione. È difficile diagnosticare il problema perché il codice che sto guardando non basta a spiegarlo.
Per questo, quando una migrazione già distribuita contiene un errore, ne aggiungo un'altra che lo corregge. Se una colonna ha il nome sbagliato, scrivo una nuova migrazione che la rinomina; se un indice deve essere modificato, aggiungo una migrazione che lo sostituisce.
La sequenza dei file deve rispecchiare i cambiamenti che il database ha attraversato. Riscriverla significherebbe perdere la corrispondenza tra ciò che è nel repository e ciò che è realmente successo nelle installazioni esistenti.
Provare il percorso, non solo il risultato
La stessa attenzione serve nei test. Una suite che crea sempre il database da zero verifica che lo schema finale sia valido, ma non dice molto su quello che succederà durante un aggiornamento.
Aggiungere una colonna NOT NULL senza valore di default può funzionare su una tabella vuota e fallire appena incontra una riga esistente. Un vincolo di unicità può scontrarsi con duplicati che nessuno aveva previsto. Anche una migrazione corretta può richiedere un lock troppo lungo quando viene eseguita su una tabella con milioni di record.
Per questo preferisco provare le migrazioni partendo dallo schema della versione precedente, con un database popolato da dati realistici. Nei progetti che supportano più backend, ripeto la prova su ciascuno, perché PostgreSQL, MySQL e SQLite non si comportano sempre allo stesso modo quando si modifica uno schema.
Non serve un'infrastruttura elaborata. Un dump anonimizzato o uno script che genera dati rappresentativi possono già far emergere problemi che un database vuoto non mostrerà mai.
Queste prove hanno però un limite: dati realistici non significano necessariamente carico e concorrenza realistici. Sulle tabelle più grandi devo considerare anche la durata dell'operazione e le query che potrebbero rimanere bloccate.
Quando distribuiamo una nuova versione dell'applicazione, possiamo sostituire il codice precedente. Il database invece conserva i dati, lo schema e tutte le decisioni prese nei rilasci passati. Le migrazioni devono funzionare con quello che trovano, anche nei passaggi intermedi in cui il codice vecchio è ancora in esecuzione.