Blog ·
La migration qui n’a jamais tourné
Un migrateur qui annonce un succès en sautant un fichier est pire qu’un migrateur qui plante. Comment un tri par horodatage perd des migrations en silence.
Par Provibr
Un migrateur de schéma qui plante, c’est une bonne après-midi. Vous obtenez une pile d’appels, un numéro de ligne, et une base de données qui se trouve toujours dans un état que vous comprenez.
La mauvaise après-midi est celle où le migrateur annonce que le schéma est à jour, sort avec le code zéro, et se trompe. Nous avons eu cette après-midi plus d’une fois, pour plus d’une cause, et la pire s’est terminée avec du code de production lisant une colonne qui n’existait pas.
Voici ce que nous en avons appris, écrit pour quiconque exécute des migrations Postgres depuis un répertoire de fichiers, quel que soit l’outil qui a généré ces fichiers.
La décision se prend sur un seul nombre
La plupart des migrateurs à base de fichiers tiennent une table de ce qui a été appliqué. Le nôtre aussi : un schéma, une table, une ligne par migration avec une empreinte et un horodatage de création.
L’erreur consiste à supposer que cette table est un ensemble que le migrateur consulte. Elle ne l’est pas. Lisez le code et vous y trouvez une requête qui sélectionne exactement une ligne, la plus récente par horodatage, puis une seule comparaison par fichier de migration : exécute-le si le dernier horodatage appliqué est inférieur à l’horodatage de ce fichier.
Trois choses en découlent immédiatement, et aucune des trois ne figure dans le modèle mental de qui que ce soit.
L’empreinte ne joue aucun rôle dans la décision. Elle est écrite quand une migration s’exécute et n’est plus jamais relue pour décider de quoi que ce soit. Une empreinte dans la table qui ne correspond plus à aucun fichier sur le disque signifie d’ordinaire que ce fichier a été modifié après avoir été appliqué, ce qui est peu soigné mais inoffensif. Ce n’est pas l’outil qui remarque quelque chose.
Seule la ligne la plus récente compte. Pas l’ensemble des fichiers appliqués, pas leur nombre. Une ligne.
Le tri se fait par horodatage, pas par nom de fichier. Les numéros en tête de vos noms de fichiers sont pour les humains. Si votre outil consigne une valeur d’ordonnancement dans un manifeste, c’est cette valeur qui fait foi et le numéro n’est que décoration.
Mettez tout cela ensemble et vous obtenez la propriété qui coûte des après-midi : une migration dont l’horodatage n’est pas supérieur au plus récent horodatage appliqué ne tournera jamais, sur cette base, jamais. Ni sur ce déploiement, ni sur le suivant, ni après dix tentatives. Il n’y a aucune erreur, car du point de vue de l’outil rien ne cloche.
Comment on en arrive là pour de vrai
Cela ressemble à une chose qui ne pourrait pas arriver à une équipe attentive. Voici comment cela nous est arrivé, deux fois, de manières qui n’avaient rien à voir avec de la négligence.
Deux branches, le même numéro. Deux personnes ajoutent chacune une migration en parallèle. Les deux outils distribuent le prochain numéro libre, les deux obtiennent le même, les deux écrivent un horodatage. La branche fusionnée en second se voit renommée vers le numéro suivant, ce qui est la correction évidente et n’est pas la correction complète : si son horodatage reste inférieur à celui déjà appliqué, le renommage a changé le nom du fichier et pas la décision. La migration est sautée. Le symptôme, dans notre cas, fut un type énuméré auquel il manquait simplement une valeur, sur une base où tous les autres objets étaient présents et corrects.
Des horodatages écrits à la main qui n’étaient pas dans l’ordre. Les nôtres sont des nombres ronds posés à la main plutôt que par une véritable horloge, ce qui les rend lisibles et facilite l’erreur. Une migration a reçu une estampille antérieure à deux migrations déjà appliquées. Le migrateur a annoncé un succès. En production, cela aurait donné une erreur de type sur chaque ligne d’intégration, ce qui n’est pas le genre de chose que l’on souhaite apprendre d’un client.
Les deux cas partagent une forme qui mérite un nom. La comptabilité est indexée sur une valeur d’ordonnancement, pas sur une identité. Un registre indexé sur l’identité (« ai-je appliqué cette migration ? ») ne peut rien sauter, quel que soit l’ordre d’arrivée des choses. Un registre indexé sur l’ordre (« jusqu’où suis-je allé ? ») le peut, et c’est une conception que la plupart des migrateurs partagent parce qu’elle réduit le chemin courant à la lecture d’une seule ligne.
Le compteur n’est pas la vérification
Après le premier incident, nous avons ajouté le garde-fou évident : après la migration, regarder le nombre de lignes dans la table des migrations et confirmer qu’il a augmenté.
Ce garde-fou est nécessaire et il n’est pas suffisant, ce que nous avons découvert en le regardant passer au vert alors que la chose dont nous avions besoin manquait. Une exécution de migration a fait passer le nombre de lignes de quatre-vingt-dix-huit à cent quatre. Six migrations sont entrées. Celle qui nous importait n’en faisait pas partie. Le compte a augmenté, tout a annoncé un succès, et chaque page connectée du produit renvoyait une erreur 500 sur une colonne qui n’existait pas.
Rien dans un décompte ne vous dit quelles migrations ont tourné. Si vous voulez savoir si un objet existe, la seule vérification honnête consiste à demander à la base si cet objet existe : lister les tables, décrire la table, sélectionner les valeurs de l’énumération. Nous comparons la liste des tables de l’environnement que nous venons de migrer à celle de l’environnement en qui nous avons confiance.
Il existe une variante de cette défaillance qui n’a rien à voir avec les horodatages et qui produit la même sortie verte. Notre migrateur voyage à l’intérieur de l’une de nos images de service. Quelqu’un n’a reconstruit que l’autre service, a lancé la migration depuis l’image périmée, et a obtenu un joyeux « le schéma est à jour » de la part d’un conteneur qui n’avait jamais entendu parler des deux migrations en question. L’outil ne mentait pas. Il répondait à une question portant sur les fichiers qu’il détenait, et il détenait de vieux fichiers.
Tout ou rien, dans une seule transaction
Un détail de la mise en œuvre de notre migrateur mérite d’être connu, car il change la façon dont on répare : toutes les migrations en attente s’exécutent dans une seule transaction, avec l’insertion comptable de chacune à l’intérieur.
C’est une bonne nouvelle et une contrainte. La bonne nouvelle, c’est qu’un échec au milieu d’un lot de sept vous laisse avec aucune des sept appliquées, plutôt qu’avec trois et demie. Il n’y a pas d’état à demi migré sur lequel raisonner, ce qui est exactement ce que vous voulez au moment où vous êtes le moins en état de raisonner.
La contrainte, c’est qu’une instruction que Postgres ne sait pas exécuter dans un bloc transactionnel emportera le lot entier avec elle, et qu’une migration très longue tient des verrous pendant toute la durée du lot plutôt que pendant la sienne. Ni l’une ni l’autre ne nous a encore mordus. Toutes deux sont le genre de chose que l’on veut avoir lue avant que cela n’arrive.
Réparer l’une sans aggraver la suivante
Supposons que vous la trouviez : une migration dans votre répertoire que cette base n’a jamais exécutée. L’instinct est de corriger le manifeste, d’abaisser ou de relever l’horodatage jusqu’à ce que l’outil la prenne, et de relancer.
Ne l’abaissez pas, et relevez-le avec précaution.
La raison est la règle même qui a causé le problème. Si vous abaissez l’horodatage d’une migration déjà appliquée ailleurs, vous n’avez rien fait tourner : vous avez fabriqué un fichier dont l’estampille se situe désormais sous la ligne de flottaison sur les bases où il a bel et bien tourné, et la prochaine migration que vous écrirez avec une estampille comprise entre les deux y sera sautée. Vous avez réparé un environnement et amorcé la prochaine défaillance dans un autre.
Ce qui fonctionne, c’est d’appliquer le SQL vous-même, dans une transaction unique, puis de vous assurer que la comptabilité reflète la réalité. Le migrateur ne peut pas le faire à votre place, car de son côté il n’y a rien à faire.
Ce que nous faisons désormais
Cinq règles, toutes bon marché, toutes écrites noir sur blanc parce que nous ne les avons pas suivies une fois chacune.
- Déployer et migrer forment une seule opération, pas deux. L’intervalle entre les deux est une fenêtre pendant laquelle du code neuf lit un vieux schéma. Le nôtre nous a coûté une page d’administration pour chaque administrateur et une tâche de facturation qui a échoué silencieusement toute une après-midi sur des colonnes qui n’étaient pas encore là.
- Construire tout avant de migrer, pas seulement le service que vous avez modifié. Le migrateur voyage dans une image, et une image que vous n’avez pas reconstruite contient les migrations que vous n’avez pas ajoutées.
- Vérifier les valeurs d’ordonnancement, pas les noms de fichiers, dès que deux personnes ont travaillé en parallèle. Renommer un fichier ne change pas ce que l’outil compare.
- Vérifier l’objet, pas le compteur. Après une migration qui ajoute une colonne, demandez la colonne. Cela prend une commande et c’est la seule vérification qui ne peut pas passer à vide.
- Ne jamais abaisser une valeur d’ordonnancement déjà appliquée quelque part. Cela ressemble à une correction locale et c’est une mine posée à distance.
Le fil inconfortable qui traverse tout cela, c’est que l’outil s’est comporté correctement à chaque fois. Il n’a jamais planté, n’a jamais rien corrompu, n’a jamais menti sur ce qu’il avait fait. Il a répondu à une question plus étroite que celle que nous pensions poser, et il a imprimé une phrase qui était vraie à propos de cette question plus étroite.
C’est la classe de défaillance contre laquelle il vaut la peine de bâtir des habitudes, dans les migrations comme partout ailleurs. Un outil qui échoue bruyamment vous apprend ce qu’il fait. Un outil qui réussit étroitement ne vous apprend rien jusqu’au jour où cela compte.