Appuyer deux fois sur Publish ne publie plus qu'une fois
Trois demandes de publication pour la même app sont arrivées à notre API à 84 millisecondes d'intervalle, et chacune a lancé un déploiement complet de cette app. Elles se sont disputé le même hébergement, plusieurs des versions qui en ont résulté ont échoué parce qu'une autre était déjà arrivée la première, et le seul signe visible était un courriel « échec de la version », alors que l'app elle-même a fini par fonctionner. Maintenant, une publication qui arrive pendant qu'une autre est en cours pour la même app ne lance pas un second build. La base de données décide quelle requête gagne, et les autres reçoivent la vérité : une publication est déjà en cours.
Ce qui s'est passé
Lorsque vous publiez une app sur Mythex, l'API enregistre un déploiement pour elle et demande à notre orchestrateur, le service qui exécute les builds, de la construire et de la publier. La publication peut démarrer depuis le bouton Publish, depuis un autre onglet du navigateur, ou depuis l'agent lorsque vous lui demandez de publier. Un double-clic, deux onglets, ou une demande de l'agent reçue deux fois peuvent tous produire plusieurs publications pour la même app presque au même moment.
Dans ce cas, trois sont arrivées en moins de 84 ms. Chacune a lancé un déploiement complet. Sur la plateforme d'hébergement, des versions pour la même app ont été créées dans la même seconde, et trois ont échoué parce que les autres étaient déjà en cours. Une version ultérieure a bloqué, et l'app a ensuite fonctionné.
C'est ce qui rendait le problème facile à manquer. L'utilisateur voyait une app qui fonctionnait. Les échecs n'apparaissaient que dans les courriels d'échec de version.
Pourquoi rien ne l'arrêtait
Le code de publication avait bien une étape qui examinait les déploiements en cours du projet. Mais elle marquait comme échoués, avant de démarrer, tous les déploiements en cours — « remplacé par une nouvelle publication ». Elle était censée nettoyer les builds laissés derrière par un processus qui avait planté.
En pratique, une deuxième publication arrivant en plein build déclarait la première morte et lançait la sienne. Les deux allaient jusqu'au bout contre la même app. Il n'y avait pas de verrou ; il y avait une étape qui dégageait activement le chemin pour une course.
Un deuxième symptôme, plus étrange, touchait la toute première publication d'une app. Sans enregistrement de déploiement à réutiliser, deux premières publications simultanées tentaient toutes deux d'en insérer un. La base de données possède un index unique sur le nom de lien de l'app (son slug, comme your-app dans your-app.mythex.ai), donc la seconde insertion échouait. L'API traduisait cela par « ce nom de lien est déjà pris », conseillant à l'utilisateur de renommer une app qui, à ce moment-là, se publiait avec succès sous ce nom.
La correction : la revendication est le verrou
Revendiquer l'enregistrement de déploiement est maintenant une seule mise à jour conditionnelle de la base de données : prendre cette ligne et la marquer comme en construction seulement si personne ne la publie. Comme il s'agit d'une seule instruction, la base de données départage les requêtes arrivant dans la même milliseconde. Exactement une d'entre elles modifie la ligne. Les autres constatent que zéro ligne a changé, s'arrêtent, et n'atteignent jamais l'orchestrateur.
Un verrou doit aussi pouvoir être libéré lorsque son détenteur meurt. Si l'API redémarre pendant une publication, la ligne resterait marquée « en construction » indéfiniment, et l'app ne pourrait plus être publiée. Une ligne dont le propriétaire a cessé d'envoyer des signaux de vie peut donc être revendiquée à nouveau. La publication en cours met à jour la ligne toutes les 30 secondes, et cinq minutes de silence signifient qu'elle est morte. Ce seuil vient du signal de vie, et non d'une estimation de la durée d'un build. La construction d'un Dockerfile propre à l'utilisateur peut légitimement durer longtemps, et un build lent qui continue d'émettre ses signaux conserve sa revendication.
L'étape qui marquait comme échoués tous les déploiements en cours ne s'exécute plus avant une publication. Seules les lignes laissées par un processus mort sont nettoyées.
Dire la vérité au perdant
La requête qui perd reçoit maintenant une réponse claire : publish_in_progress, accompagnée de l'identifiant du déploiement en cours. Chaque endroit pouvant lancer une publication gère ce cas :
- L'espace de travail suit le build déjà en cours au lieu d'afficher une erreur par-dessus.
- L'agent est informé qu'une publication de cette app est déjà en cours et qu'il doit attendre, plutôt qu'on lui dise que quelque chose a échoué, ce qui pouvait auparavant l'amener à « corriger » un Dockerfile et à réessayer.
- Une collision de première publication sur le nom de lien est vérifiée : si la ligne qui détient le nom est la publication en cours de ce projet, la réponse est « déjà en cours de publication », et non « ce nom est déjà pris ». Une vraie collision avec un autre projet affiche toujours le message de renommage.
Comment nous l'avons vérifié
Nous avons écrit un script de reproduction qui lance trois publications simultanées d'une même app sur une vraie base de données, avec un faux orchestrateur, et compte combien de déploiements l'orchestrateur reçoit. Il couvre une première publication et une republication d'une app en ligne. Avant la correction, il comptait deux déploiements. Après, un seul.
Le script couvre aussi la sortie de secours : il marque la ligne de l'app comme en construction, sans signal de vie depuis dix minutes, republie, et vérifie que la nouvelle publication revendique la ligne et s'exécute.
Les tests unitaires limitent le verrou aux deux choses qu'un verrou doit faire : parmi trois republications simultanées, une seule passe, et les deux autres sont informées qu'une publication est en cours ; et une construction dont le propriétaire est mort peut être revendiquée. Un autre test vérifie qu'une nouvelle tentative ne reprend pas le message d'erreur de la tentative précédente.
Ce que nous en avons retenu
- Le code de nettoyage peut cacher une course. « Échouer tout ce qui est en cours, puis démarrer » ressemble à de l'hygiène. Avec deux appelants, c'est chacun qui annule l'autre.
- Laissez la base de données arbitrer. Une lecture suivie d'une écriture laisse une brèche pour une autre requête. Une seule mise à jour conditionnelle n'en laisse pas.
- Chaque verrou a besoin d'une expiration liée à la vitalité du détenteur. Sans cela, un plantage devient une app bloquée.
- Les messages d'erreur sont des instructions. « Ce nom est déjà pris » poussait les gens à faire la mauvaise chose. Le bon message était plus simple et vrai.
Pour en savoir plus sur la publication, consultez la documentation : comment publier. Et une correction connexe : votre app publiée reste en ligne pendant que vous la republiez.