Натискання «Опублікувати» двічі тепер публікує один раз
Три запити на публікацію одного застосунку дійшли до нашого API з інтервалом 84 мілісекунди, і кожен запустив повний деплой цього застосунку. Вони перегонялися за один і той самий хостинг, кілька отриманих релізів завершилися помилкою, бо інший вже встиг першим, і єдиною видимою ознакою був лист «реліз не вдався» — сам застосунок врешті працював. Тепер публікація, яка приходить, поки для того самого застосунку вже щось публікується, не запускає другу збірку. База даних вирішує, який запит виграє, а решта отримують чесну відповідь: публікація вже триває.
Що сталося
Коли ви публікуєте застосунок на Mythex, API записує для нього деплой і просить наш оркестратор — сервіс, що запускає збірки, — зібрати його і випустити. Публікація може початися з кнопки «Опублікувати», з іншої вкладки браузера або від агента, коли ви його про це просите. Подвійний клік, дві вкладки або запит агента, що прийшов двічі, можуть створити більше однієї публікації одного застосунку майже в один і той самий момент.
У цьому випадку три прийшли протягом 84 мс. Кожна запустила повний деплой. На платформі хостингу релізи для одного застосунку створилися в ту саму секунду, три з них завершилися помилкою, бо інші вже виконувалися. Один пізніший реліз застряг, а потім застосунок працював.
Саме тому це було легко не помітити. Користувач бачив один робочий застосунок. Збої проявлялися лише в листах про невдалі релізи.
Чому ніщо не зупинило це
Код публікації мав крок, який дивився на деплої в процесі для проєкту. Але він робив ось що: позначав кожен деплой у процесі як невдалий ще до старту — «замінено новою публікацією». Його мали призначення прибирати збірки, які лишилися після зупиненого процесу.
На практиці це означало, що друга публікація, яка приходила посеред збірки, оголошувала першу мертвою і запускала свою. Обидві доходили до кінця для одного й того самого застосунку. Жодного замка не було; був крок, який активно розчищав шлях для перегонів.
Була й друга, дивніша ознака для першої публікації застосунку. Коли ще немає запису деплою для повторного використання, дві одночасні перші публікації обидві намагалися вставити один запис. База даних має унікальний індекс на посилання застосунку (його slug, як-от your-app у your-app.mythex.ai), тож друга вставка не вдалася. API перетворював це на «це ім'я посилання вже зайняте» — радячи користувачу перейменувати застосунок, який саме в цей момент успішно публікувався під цим ім'ям.
Виправлення: захоплення запису і є замком
Захоплення запису деплою тепер є однією умовною оновлювальною операцією в базі даних: взяти цей рядок і позначити його як такий, що збирається, лише якщо його ніхто не публікує. Оскільки це один оператор, база даних розв'язує запити, що приходять в одну мілісекунду. Рівно один з них змінює рядок. Інші бачать, що змінено нуль рядків, зупиняються і ніколи не доходять до оркестратора.
Замку також потрібен вихід, коли його власник помирає. Якщо API перезапустився посеред публікації, рядок лишився б позначеним як «збирається» назавжди, і застосунок не можна було б опублікувати. Тому рядок, власник якого перестав надсилати heartbeat, можна захопити знову — запущена публікація оновлює рядок кожні 30 секунд, а п'ять хвилин мовчання вважаються смертю. Цей поріг походить від heartbeat, а не від припущення про тривалість збірки. Збирання власного Dockerfile користувача може законно тривати довго, і повільна збірка, що досі надсилає heartbeat, зберігає своє захоплення.
Крок, який позначав усі деплої в процесі як невдалі, більше не запускається перед публікацією. Прибираються лише рядки, які залишив мертвий процес.
Чесна відповідь програвшому
Запит, що програв, тепер отримує чітку відповідь: publish_in_progress разом з ідентифікатором деплою, який виконується. Кожне місце, яке може почати публікацію, обробляє це:
- Робочий простір слідує за збіркою, що вже виконується, а не показує помилку поверх неї.
- Агент отримує повідомлення, що публікація цього застосунку вже триває, і має почекати на неї, а не отримує відомість, що щось не вдалося — раніше це могло підштовхнути його «виправляти» Dockerfile і пробувати знову.
- Зіткнення при першій публікації через ім'я посилання перевіряється: якщо рядок, який тримає ім'я, — це власна публікація цього проєкту, що виконується, відповідь «вже публікується», а не «це ім'я зайняте». Справжнє зіткнення з іншим проєктом і далі дає повідомлення про перейменування.
Як ми перевірили
Ми написали скрипт відтворення, який запускає три публікації одного застосунку одночасно з реальною базою даних і заглушкою оркестратора та рахує, скільки деплоїв запитано в оркестратора. Він охоплює першу публікацію і повторну публікацію живого застосунку. До виправлення він рахував два деплої, після — один.
Скрипт також покриває запасний вихід: він позначає рядок застосунку як такий, що збирається, без heartbeat протягом десяти хвилин, знову публікує і перевіряє, що нова публікація захоплює рядок і запускається.
Модульні тести тримають замок на двох вимогах, які він має виконувати: з трьох одночасних повторних публікацій рівно одна проходить, а дві інші отримують повідомлення, що публікація триває; і збірку, власник якої помер, можна захопити. Інший тест перевіряє, що нова спроба не переносить повідомлення про помилку попередньої.
Що ми з цього винесли
- Код прибирання може бути перегонами в масці. «Завершити все, що в процесі, а потім почати» виглядає як гігієна. З двома викликачами це означає, що кожен скасовує іншого.
- Нехай арбітром буде база даних. Читання, за яким іде запис, лишає прогалину для іншого запиту. Одна умовна оновлювальна операція — ні.
- Кожен замок потребує закінчення, прив'язаного до живості. Без нього збій перетворюється на застряглий застосунок.
- Повідомлення про помилку — це інструкція. «Це ім'я зайняте» штовхало людей до неправильної дії. Правильне повідомлення було простішим і правдивим.
Більше про публікацію в документації: як опублікувати. І пов'язане виправлення: ваш опублікований застосунок лишається онлайн, поки ви його перепублікуєте.