Publish를 두 번 눌러도 이제 한 번만 발행됩니다
같은 앱에 대한 발행 요청 세 개가 84밀리초 간격으로 우리 API에 도착했고, 각각 그 앱의 전체 배포를 시작했습니다. 이 요청들은 같은 호스팅에서 서로 경쟁했고, 결과로 생긴 릴리스 중 여러 개가 다른 요청이 먼저 도착했다는 이유로 실패했습니다. 눈에 보이는 유일한 흔적은 "릴리스 실패"라는 이메일이었고, 앱 자체는 결국 정상이었습니다. 이제 같은 앱에 대한 발행이 실행 중인데 다른 발행 요청이 도착하면, 두 번째 빌드를 시작하지 않습니다. 데이터베이스가 어느 요청이 이길지 결정하고, 나머지에게는 발행이 이미 진행 중이라고 정확히 알립니다.
무슨 일이 있었나
Mythex에서 앱을 발행하면 API는 그 앱의 배포 기록을 만들고, 빌드와 릴리스를 담당하는 오케스트레이터에 요청합니다. 발행은 Publish 버튼에서, 다른 브라우저 탭에서, 또는 발행을 요청받은 에이전트에서 시작될 수 있습니다. 더블클릭, 탭 두 개, 두 번 도착한 에이전트 요청 모두 거의 같은 순간에 같은 앱에 대해 발행을 여러 번 만들 수 있습니다.
이번에는 84ms 안에 세 개가 도착했습니다. 각각 전체 배포를 실행했습니다. 호스팅 플랫폼에서는 같은 앱의 릴리스가 같은 초에 생성되었고, 다른 릴리스가 이미 진행 중이라 그중 세 개가 실패했습니다. 나중 릴리스 하나는 멈춰 있었고, 그 뒤 앱은 작동했습니다.
그래서 놓치기 쉬웠습니다. 사용자는 작동하는 앱 하나를 보았습니다. 실패는 릴리스 실패 이메일로만 나타났습니다.
왜 아무것도 막지 못했나
발행 코드에는 프로젝트의 진행 중인 배포를 살펴보는 단계가 있었습니다. 하지만 그 단계가 하던 일은, 시작하기 전에 진행 중인 배포를 모두 실패로 표시하는 것이었습니다. "새 발행으로 대체됨"이라는 표시였습니다. 원래 목적은 충돌한 프로세스가 남기고 간 빌드를 정리하는 것이었습니다.
실제로는 빌드 도중에 도착한 두 번째 발행이 첫 번째를 죽은 것으로 선언하고 자신의 빌드를 시작했습니다. 두 빌드는 같은 앱에 대해 끝까지 실행되었습니다. 잠금은 없었고, 경쟁을 적극적으로 열어 주는 정리 단계가 있었을 뿐입니다.
앱의 첫 발행에서는 두 번째 증상이 있었는데 더 이상했습니다. 재사용할 배포 기록이 아직 없으므로, 동시에 시작된 두 첫 발행이 모두 하나를 삽입하려고 했습니다. 데이터베이스에는 앱의 링크 이름(슬러그, your-app.mythex.ai의 your-app 같은 것)에 대한 고유 인덱스가 있으므로, 두 번째 삽입은 실패했습니다. API는 이것을 "그 링크 이름은 이미 사용 중입니다"로 번역했고, 그 순간 성공적으로 발행되고 있던 앱의 이름을 바꾸라고 사용자에게 권했습니다.
해결: 클레임이 곧 잠금입니다
배포 기록을 클레임하는 일은 이제 하나의 조건부 데이터베이스 업데이트입니다. "이 앱을 발행하는 사람이 아무도 없을 때만" 이 행을 가져와 빌드 중으로 표시합니다. 한 문장이므로 데이터베이스는 같은 밀리초에 도착한 요청도 정리합니다. 정확히 하나만 행을 바꿉니다. 나머지는 바뀐 행이 0개인 것을 보고 멈추며, 오케스트레이터에는 전혀 닿지 않습니다.
잠금은 보유자가 죽었을 때 빠져나갈 길도 있어야 합니다. API가 발행 도중 재시작되면, 그 행은 "빌드 중" 상태로 영원히 남아 앱을 발행할 수 없게 됩니다. 그래서 소유자가 하트비트를 멈춘 행은 다시 클레임할 수 있습니다. 실행 중인 발행은 30초마다 행을 갱신하고, 5분 동안 소식이 없으면 죽은 것으로 칩니다. 이 기준은 빌드가 얼마나 걸릴지에 대한 추측이 아니라 하트비트에서 나옵니다. 사용자의 Dockerfile을 빌드하는 작업은 정당하게 오래 걸릴 수 있으며, 여전히 하트비트를 보내는 느린 빌드는 클레임을 유지합니다.
발행 전에 진행 중인 배포를 모두 실패로 만들던 단계는 더 이상 실행되지 않습니다. 죽은 프로세스가 남기고 간 행만 정리됩니다.
진 쪽에게 진실을 말하기
진 요청은 이제 분명한 답을 받습니다. publish_in_progress와, 실행 중인 배포의 ID입니다. 발행을 시작할 수 있는 각 지점은 이를 처리합니다.
- 워크스페이스는 이미 실행 중인 빌드를 따라가며, 그 위에 오류를 보여 주지 않습니다.
- 에이전트는 이 앱의 발행이 이미 진행 중이며 기다려야 한다는 말을 듣습니다. 예전에는 무언가 실패했다는 말을 들었고, 그 말은 에이전트가 Dockerfile을 "고치려고" 다시 시도하게 만들 수 있었습니다.
- 첫 발행의 링크 이름 충돌은 확인합니다. 그 이름을 가진 행이 이 프로젝트 자신의 진행 중인 발행이라면, 답은 "이미 발행 중"입니다. "그 이름은 사용 중"이 아닙니다. 다른 프로젝트와의 진짜 충돌이라면 여전히 이름 변경 메시지를 받습니다.
검증 방법
실제 데이터베이스와 스텁 오케스트레이터를 쓰는 재현 스크립트를 만들었습니다. 같은 앱에 대해 발행 세 개를 동시에 실행하고, 오케스트레이터가 요청받은 배포 수를 셉니다. 첫 발행과 라이브 앱의 재발행 모두 포함합니다. 수정 전에는 배포가 두 개 실행되었고, 수정 후에는 하나입니다.
스크립트는 탈출구도 다룹니다. 앱 행을 열 분 동안 하트비트 없이 빌드 중으로 표시한 뒤 다시 발행하고, 새 발행이 행을 클레임해서 실행되는지 확인합니다.
단위 테스트는 잠금이 해야 할 두 가지를 확인합니다. 동시에 들어온 세 개의 재발행 중 정확히 하나만 통과하고 나머지 둘은 발행이 진행 중이라는 말을 듣는지, 그리고 소유자가 죽은 빌드를 클레임할 수 있는지입니다. 다른 테스트는 새 시도가 이전 시도의 오류 메시지를 물려받지 않는지 확인합니다.
얻은 교훈
- 정리 코드는 위장한 경쟁일 수 있습니다. "진행 중인 것은 모두 실패시키고 시작한다"는 위생 관리처럼 보입니다. 호출자가 둘이면, 서로가 서로를 취소하는 것이 됩니다.
- 데이터베이스가 판정하게 하세요. 읽고 나서 쓰면 다른 요청이 끼어들 틈이 생깁니다. 하나의 조건부 업데이트는 틈이 없습니다.
- 모든 잠금에는 생존 여부에 묶인 만료가 필요합니다. 없다면 충돌이 멈춘 앱으로 바뀝니다.
- 오류 메시지는 지시입니다. "그 이름은 사용 중입니다"는 사람들에게 잘못된 행동을 하게 만들었습니다. 올바른 메시지는 더 단순했고, 사실이기도 했습니다.
문서의 발행 내용은 발행 방법에 있습니다. 관련 수정으로 발행된 앱이 재발행하는 동안에도 계속 떠 있는 방법도 참고하세요.