Blog ·
Deux chantiers, un seul numéro de champ
Deux branches ont ajouté une variante au même message protobuf le même jour, avec le même numéro. Presque rien là-dedans n’échoue bruyamment.
Par Provibr
Deux travaux ont tourné en parallèle sur le même dépôt. L’un ajoutait la capacité de demander à un hyperviseur ce qu’était devenue une tâche d’arrière-plan. L’autre ajoutait la capacité de réinstaller un conteneur. Les deux avaient besoin d’une nouvelle variante dans le même message protobuf, les deux ont regardé le fichier, les deux ont vu que le numéro le plus élevé en usage était trente-trois, et les deux ont écrit trente-quatre.
Aucun des deux n’a été négligent. Chacun a lu le fichier correctement au moment où il a créé sa branche. La collision n’existe que dans un troisième état qu’aucun des deux n’a jamais compilé : la fusion.
| Où la collision apparaît | Ce qui se passe |
|---|---|
| Les deux branches séparément | Rien. Chacune est correcte par rapport à son propre point de départ |
| La fusion, dans le fichier de schéma | Un conflit, puis une erreur de compilation franche si les deux sont gardées |
| La fusion, dans le code généré versionné | Un conflit, puis rien du tout si les deux sont gardées |
| Le vérificateur de compatibilité | Vert dans les deux branches, car il compare chacune à sa propre référence |
| Un agent et une passerelle déployés en désaccord | Jamais atteint ici : une commande qui arriverait vide, signalée à plusieurs couches de la cause |
Ce qui circule réellement sur la connexion
Un nom de champ protobuf est une commodité pour les humains. Ce qui passe sur la connexion, c’est le numéro, décalé de trois bits vers la gauche avec un indicateur de type dans les bits de poids faible, si bien que le champ trente-quatre portant une charge utile préfixée par sa longueur s’écrit comme la séquence d’octets de deux cent soixante-quatorze. Renommez un champ et rien ne change dans les octets qui circulent. Changez son numéro et tout lecteur déployé décode désormais autre chose sous la même clé.
C’est pour cela qu’un numéro, une fois sorti dans une version publiée, est gelé. Ce n’est pas une règle de style. C’est le seul identifiant que les deux extrémités partagent.
Ce que l’outillage dit, et ce qu’il ne dit pas
Git a remarqué. Le conflit est apparu dans le fichier de schéma et dans le TypeScript généré versionné, les deux fois comme des ajouts qui se chevauchent au même endroit. La résolution tentante est celle qui a l’air manifestement correcte pour deux fonctionnalités indépendantes : garder les deux.
Dans le fichier de schéma, c’est attrapé. Le compiler produit un refus net nommant le message et le champ qui détient déjà le numéro, ce qui a été mesuré plutôt que supposé : un message de test à deux variantes portant toutes deux le trente-quatre est rejeté avec une ligne, une colonne et une suggestion du prochain numéro libre.
Le TypeScript généré est une autre histoire, et c’est la raison pour laquelle cela vaut la peine d’être écrit. Ce fichier est versionné, parce que le travail quotidien ne devrait pas exiger que la chaîne d’outils du schéma soit installée. Le prix de ce choix, c’est qu’il participe aux fusions comme n’importe quel autre fichier source, et que c’est du code ordinaire. Une branche en double dans une instruction de branchement est du TypeScript légal. Compiler un petit fichier avec deux étiquettes de cas identiques, portant toutes deux le contrôle de tag du champ trente-quatre, ne produit aucun diagnostic et se termine proprement. La seconde branche n’est simplement jamais atteinte.
Une fusion à la main du fichier généré vous donne donc un encodeur qui écrit le même tag pour deux messages différents et un décodeur qui le résout toujours vers celui qui est listé en premier. Rien n’échoue à la compilation, rien n’échoue au démarrage, et rien n’échoue au premier message. L’une des deux fonctionnalités est simplement fausse, d’une manière qui n’apparaît que sur la machine à l’autre bout de la connexion.
Pourquoi le contrôle de compatibilité était vert
Le schéma est gardé par un vérificateur de ruptures, et il a fait son travail. Il compare un schéma proposé à une référence. Ajouter une nouvelle variante à un choix existant n’est pas une rupture, et ce n’en était pas une dans l’une ou l’autre branche, car la référence de chaque branche n’avait véritablement pas le trente-quatre en usage.
C’est la forme générale du problème et elle mérite d’être énoncée séparément de protobuf : un contrôle qui compare une branche à son propre point de départ ne peut pas voir un conflit qui n’existe qu’entre deux branches. La même classe de bogue surgit partout où un travail parallèle choisit une valeur dans un espace ordonné partagé. Les migrations de base de données sont ici choisies par l’horodatage inscrit dans leur journal, et ces horodatages sont des nombres ronds posés à la main plutôt que lus sur une véritable horloge, si bien que deux chantiers qui en écrivent chacun une choisissent le leur ; une migration dont l’horodatage atterrit en dessous d’un horodatage déjà appliqué est ignorée définitivement, et l’outil de migration rapporte tout de même que le schéma est à jour. Mécanisme différent, même silence, même cause.
Pourquoi le silence est la partie coûteuse
Si les deux numéros avaient survécu jusqu’à une version publiée, la panne aurait été lointaine et mystifiante. Protobuf jette un champ de choix qu’il ne reconnaît pas, donc un agent construit depuis une branche qui reçoit une commande de l’autre n’obtient pas d’erreur. Il obtient une commande sans rien dedans, et signale quelque chose comme « le message ne portait aucune instruction ». La cause est à plusieurs couches de la phrase, sur une machine appartenant à quelqu’un d’autre, et la personne qui la lit n’a aucune raison de soupçonner un numéro.
La plateforme refuse déjà d’envoyer des instructions aux agents qui n’ont pas annoncé la capacité correspondante, précisément parce que ce mode de panne est si peu utile : un agent qui est seulement trop ancien produit au moins un refus écrit par la plateforme, nommant la fonctionnalité et la version. Cette garde couvre le décalage de versions. Elle ne couvre pas deux numéros qui veulent dire deux choses, car du côté de la plateforme rien n’est périmé.
La règle qui en est sortie
Un numéro de champ sorti dans une version publiée est gelé, c’est donc le chantier qui n’a pas encore été publié qui bouge. Trente-quatre était déjà sorti dans une version d’agent pour la variante de statut de tâche, si bien qu’à la fusion la variante de réinstallation de conteneur est devenue trente-cinq, et le code généré a été régénéré depuis le schéma plutôt que fusionné à la main. La régénération est la moitié importante : une fusion à la main d’une sortie générée est un encodeur écrit à la main que personne n’a relu comme tel.
L’instruction pratique pour qui ajoutera la suivante est courte. Lisez le numéro le plus élevé dans la ligne principale au moment où vous validez, pas au moment où vous avez créé votre branche. Et toute fusion qui touche au schéma fait l’objet de cette vérification délibérée, par une personne, car aucun outil de la chaîne ne le regarde sous cet angle.
Les réservations, et pourquoi elles méritent d’être respectées
Le même schéma montre à quoi ressemble la discipline quand elle fonctionne. Trois numéros sont réservés pour des intégrations qui avaient été prévues et jamais construites, pour que personne ne les réutilise discrètement. Deux autres avaient été retenus pour deux panneaux de contrôle précis, et tous deux ont fini par être remplis par les chantiers auxquels ils avaient été promis. Un troisième panneau qui s’est trouvé terminé plus tôt n’en a pas pris un ; il a pris le prochain numéro libre plus loin.
C’est tout l’intérêt d’une réservation. En manger une parce qu’on se trouvait prêt le premier, c’est exactement ainsi qu’un plan de numérotation cesse de vouloir dire quoi que ce soit, et le commentaire dans le fichier le dit en toutes lettres.
Remplir un numéro réservé comme prévu est bien signalé par le vérificateur de ruptures, comme la suppression d’une réservation. Ce signalement est attendu et correct à accepter : aucun champ n’a jamais porté ce numéro, donc aucun lecteur déployé ne peut avoir d’avis dessus. Bon à savoir d’avance, car cela paraît alarmant la première fois et la tentation est de choisir un autre numéro pour faire taire l’outil, ce qui jette la réservation que l’on était en train de respecter.
Ce que nous retiendrions
- Traitez un numéro publié comme immuable, et traitez « le numéro le plus élevé que je vois » comme une affirmation sur votre branche plutôt que sur le projet.
- Lisez l’espace partagé au moment de valider, pas au moment de brancher. C’est le seul instant où la valeur est réellement revendiquée.
- Régénérez le code généré, ne le fusionnez jamais. Un conflit dans une sortie générée est un signal pour lancer le générateur, pas une invitation à éditer.
- Sachez lesquels de vos contrôles sont relatifs à une référence. Ils ne peuvent pas voir les collisions entre frères, et ce trou demande un humain au moment de la fusion.
- Réservez les numéros que vous avez promis ou prévus, avec un commentaire disant à qui ils appartiennent, et acceptez le bruit que produit le fait d’en remplir un correctement.
- Cherchez la même forme ailleurs. Toute valeur attribuée à la main dans un espace ordonné partagé, migrations comprises, connaît cette panne et la connaît en général silencieusement.
Pour être clair : rien n’a été publié avec la collision dedans. Elle a été attrapée à la fusion, la variante la plus tardive a bougé, le code généré a été reconstruit depuis le schéma, et les deux fonctionnalités sont sorties comme deux versions d’agent distinctes à quelques heures d’intervalle. Ce qui l’a rendue attrapable, c’est que quelqu’un a lu le conflit au lieu de le résoudre, ce qui n’est pas un processus que l’on peut automatiser mais qui en est un que l’on peut écrire.
Issu du travail sur la plateforme chez Provibr. La version base de données de la même panne est dans la migration qui n’a jamais tourné, et pourquoi une mise à jour d’agent signée se refuse elle-même plutôt que de faire confiance à l’expéditeur est dans quand une mise à jour signée se refuse.