Premere Publish due volte ora pubblica una sola volta
Tre richieste di pubblicazione per la stessa app sono arrivate alla nostra API a 84 millisecondi di distanza, e ciascuna ha avviato un deploy completo di quell'app. Si sono contese lo stesso hosting, diverse delle release risultanti sono fallite perché un'altra era già arrivata prima, e l'unico segnale visibile era un'email "release failed" — l'app in sé alla fine stava bene. Ora una pubblicazione che arriva mentre un'altra è già in corso per la stessa app non avvia una seconda build. Il database decide quale richiesta vince, e le altre ricevono la verità: una pubblicazione è già in corso.
Cosa è successo
Quando pubblichi un'app su Mythex, l'API registra un deployment per essa e chiede al nostro orchestratore — il servizio che esegue le build — di costruirla e rilasciarla. La pubblicazione può partire dal pulsante Publish, da un'altra scheda del browser, oppure dall'agente quando glielo chiedi. Un doppio clic, due schede, o una richiesta dell'agente arrivata due volte possono produrre più pubblicazioni per la stessa app quasi nello stesso momento.
In questo caso, ne sono arrivate tre entro 84 ms. Ciascuna ha avviato un deploy completo. Sulla piattaforma di hosting, le release per la stessa app sono state create nello stesso secondo, e tre sono fallite perché le altre erano già in corso. Una release successiva si è bloccata, e l'app ha funzionato comunque dopo.
Ed è proprio questo che la rendeva facile da non notare. L'utente vedeva un'app funzionante. I fallimenti comparivano solo nelle email di release fallita.
Perché niente lo fermava
Il codice di pubblicazione aveva effettivamente un passo che guardava i deploy in corso per il progetto. Ma ciò che faceva era segnare ogni deploy in corso come fallito prima di iniziare — "superseded by a new publish". Doveva servire a ripulire le build lasciate indietro da un processo andato in crash.
In pratica, una seconda pubblicazione che arrivava a metà di una build dichiarava morta la prima e avviava la propria. Entrambe andavano fino in fondo sulla stessa app. Non c'era alcun lock; c'era un passo che attivamente spianava la strada alla corsa.
C'era anche un sintomo più strano per la primissima pubblicazione di un'app. Senza un record di deployment da riusare, due prime pubblicazioni concorrenti provavano entrambe a inserirne uno. Il database ha un indice univoco sul nome del link dell'app (il suo slug, come your-app in your-app.mythex.ai), quindi il secondo inserimento falliva. L'API traduceva questo in "that link name is taken" — consigliando all'utente di rinominare un'app che, in quel momento, stava pubblicandosi con successo con quel nome.
La soluzione: il claim è il lock
Richiedere il record di deployment ora è un singolo aggiornamento condizionale del database: prendi questa riga e segnala come in build solo se nessuno la sta pubblicando. Poiché è un'unica istruzione, il database risolve le richieste che arrivano nello stesso millisecondo. Esattamente una di esse cambia la riga. Le altre vedono che zero righe sono cambiate, si fermano e non arrivano mai all'orchestratore.
Un lock deve anche avere una via d'uscita quando il suo titolare muore. Se l'API si riavviava nel mezzo di una pubblicazione, la riga resterebbe segnata "building" per sempre e l'app non sarebbe più pubblicabile. Quindi una riga il cui proprietario ha smesso di inviare heartbeat — la pubblicazione in corso aggiorna la riga ogni 30 secondi, e cinque minuti di silenzio contano come morte — può essere reclamata di nuovo. Quella soglia deriva dall'heartbeat, non da una stima di quanto dura una build. Costruire il Dockerfile di un utente può legittimamente richiedere molto tempo, e una build lenta che continua a inviare heartbeat mantiene il proprio claim.
Il passo che segnava come falliti tutti i deploy in corso non viene più eseguito prima di una pubblicazione. Vengono ripulite solo le righe lasciate indietro da un processo morto.
Dire la verità a chi perde
La richiesta che perde ora riceve una risposta chiara: publish_in_progress, insieme all'ID del deployment in corso. Ogni punto che può avviare una pubblicazione lo gestisce:
- Il workspace segue la build già in corso invece di mostrare un errore sopra di essa.
- L'agente viene informato che una pubblicazione di questa app è già in corso e deve aspettarla, invece di sentirsi dire che qualcosa è fallito — cosa che prima poteva spingerlo a "sistemare" un Dockerfile e riprovare.
- Una collisione alla prima pubblicazione sul nome del link viene verificata: se la riga che detiene il nome è la pubblicazione in corso di questo stesso progetto, la risposta è "già in pubblicazione", non "that name is taken". Una vera collisione con un altro progetto riceve ancora il messaggio di rinomina.
Come l'abbiamo verificato
Abbiamo scritto uno script di riproduzione che esegue tre pubblicazioni della stessa app contemporaneamente su un database reale e un orchestratore finto, e conta quante build vengono richieste all'orchestratore. Copre una prima pubblicazione e la ripubblicazione di un'app già online. Prima della correzione contava due deploy. Dopo, uno.
Lo script copre anche la via d'uscita: segna la riga dell'app come in build senza heartbeat per dieci minuti, pubblica di nuovo, e verifica che la nuova pubblicazione reclami la riga ed esegua.
I test unitari tengono il lock ai due compiti che deve svolgere: tra tre ripubblicazioni simultanee, esattamente una passa e le altre due ricevono il messaggio che una pubblicazione è in corso; e una build il cui proprietario è morto può essere reclamata. Un altro test verifica che un nuovo tentativo non erediti il messaggio d'errore del tentativo precedente.
Cosa ne abbiamo ricavato
- Il codice di pulizia può essere una corsa mascherata. "Fallisci tutto ciò che è in corso, poi avvia" sembra igiene. Con due chiamanti, è ciascuno che annulla l'altro.
- Lascia decidere al database. Una lettura seguita da una scrittura lascia uno spazio per un'altra richiesta. Un singolo aggiornamento condizionale no.
- Ogni lock ha bisogno di una scadenza legata alla vitalità. Senza, un crash si trasforma in un'app bloccata.
- I messaggi d'errore sono istruzioni. "That name is taken" spingeva le persone a fare la cosa sbagliata. Il messaggio giusto era più semplice e vero.
Altre informazioni sulla pubblicazione nella documentazione: come pubblicare. E una correzione correlata: la tua app pubblicata resta online mentre la ripubblichi.