Appuyer deux fois sur Publish ne publie plus qu'une fois
Trois requêtes de publication pour une 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 mises en ligne qui en ont résulté ont échoué parce qu'une autre était déjà passée, et le seul signe visible était un e-mail « release failed » : l'app elle-même a fini par fonctionner. Désormais, une publication qui arrive pendant qu'une autre tourne 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 mettre en ligne. La publication peut partir du bouton Publish, d'un autre onglet du navigateur, ou de l'agent lorsque vous le lui demandez. Un double clic, deux onglets, ou une demande de l'agent arrivée deux fois peuvent produire plusieurs publications de la même app à peu près au même moment.
Dans ce cas, trois sont arrivées en 84 ms. Chacune a lancé un déploiement complet. Sur la plateforme d'hébergement, des mises en ligne 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 mise en ligne ultérieure est restée bloquée, mais l'app a fonctionné par la suite.
C'est ce qui la rendait facile à manquer. L'utilisateur voyait une app qui fonctionnait. Les échecs n'apparaissaient que dans les e-mails d'échec de mise en ligne.
Pourquoi rien ne l'empêchait
Le code de publication avait bien une étape qui examinait les déploiements en cours du projet. Mais elle marquait chaque déploiement en cours comme échoué avant de démarrer, avec la mention « superseded by a new publish ». Elle devait nettoyer les builds laissés par un processus planté.
En pratique, une seconde publication arrivant en cours de 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éblayait activement le terrain pour une course.
Un second 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 du lien de l'app (son slug, comme your-app dans your-app.mythex.ai), donc la seconde insertion échouait. L'API traduisait cela en « 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 désormais une seule mise à jour conditionnelle de la base : prendre cette ligne et la marquer en construction seulement si personne ne la publie. Comme il s'agit d'une seule instruction, la base départage les requêtes arrivant dans la même milliseconde. Une seule d'entre elles modifie la ligne. Les autres constatent qu'aucune ligne n'a changé, s'arrêtent, et n'atteignent jamais l'orchestrateur.
Un verrou a aussi besoin d'une issue lorsque son détenteur meurt. Si l'API redémarrait au milieu d'une publication, la ligne resterait marquée « building » pour toujours et l'app ne pourrait plus être publiée. Une ligne dont le propriétaire a cessé d'envoyer des signaux de vie (heartbeat) peut donc être revendiquée à nouveau : la publication en cours met la ligne à jour 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. Construire le Dockerfile propre à un utilisateur peut légitimement durer longtemps, et un build lent qui envoie toujours ses signaux de vie conserve sa revendication.
L'étape qui marquait en échec chaque déploiement 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 désormais une réponse claire : publish_in_progress, accompagnée de l'identifiant du déploiement en cours. Chaque endroit qui peut lancer une publication la gère :
- 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, au lieu de recevoir un message d'échec, qui pouvait auparavant le pousser à « corriger » un Dockerfile et à réessayer.
- Une collision de première publication sur le nom du lien est vérifiée : si la ligne qui détient le nom est la publication en cours de ce même projet, la réponse est « déjà en cours de publication », et non « ce nom est 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 d'une même app en même temps, sur une vraie base de données et un orchestrateur factice, et compte le nombre de déploiements demandés à l'orchestrateur. 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.
Des tests unitaires vérifient les deux rôles d'un verrou : parmi trois republications simultanées, une seule passe et les deux autres reçoivent le message qu'une publication est en cours ; et un build dont le propriétaire est mort peut être revendiqué. Un autre test vérifie qu'une nouvelle tentative ne reprend pas le message d'erreur de la précédente.
Ce que nous en retenons
- 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, chacun annule l'autre.
- Laissez la base de données arbitrer. Une lecture suivie d'une écriture laisse un espace à une autre requête. Une seule mise à jour conditionnelle n'en laisse pas.
- Tout verrou a besoin d'une expiration liée à la vie du détenteur. Sans cela, un plantage se transforme en 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.
Plus d'informations sur la publication dans la documentation : comment publier. Et une correction liée : votre app publiée reste en ligne pendant sa republication.