Nhấn Publish hai lần giờ chỉ publish một lần
Ba yêu cầu publish cho cùng một app đến API của chúng tôi cách nhau 84 mili giây, và mỗi yêu cầu bắt đầu một lần deploy đầy đủ cho app đó. Chúng tranh nhau lên cùng một hosting, nhiều bản phát hành thu được bị lỗi vì một bản khác đã tới trước, và dấu hiệu duy nhất nhìn thấy được là một email “release failed” — còn app thì cuối cùng vẫn ổn. Giờ đây, một yêu cầu publish đến khi một lần publish khác cho cùng app đang chạy sẽ không bắt đầu một bản build thứ hai. Cơ sở dữ liệu quyết định yêu cầu nào thắng, và các yêu cầu còn lại được cho biết sự thật: một lần publish đã đang chạy.
Chuyện gì đã xảy ra
Khi bạn publish một app trên Mythex, API ghi lại một deployment cho nó và yêu cầu orchestrator của chúng tôi — dịch vụ chạy các bản build — build và phát hành nó. Việc publish có thể bắt đầu từ nút Publish, từ một tab trình duyệt khác, hoặc từ agent khi bạn yêu cầu nó publish. Một cú nhấp đúp, hai tab, hay một yêu cầu của agent đến hai lần đều có thể tạo ra nhiều hơn một lần publish cho cùng một app gần như cùng lúc.
Trong trường hợp này, ba yêu cầu đến trong vòng 84 ms. Mỗi cái chạy một lần deploy đầy đủ. Trên nền tảng hosting, các bản phát hành cho cùng một app được tạo trong cùng một giây, và ba trong số đó bị lỗi vì những cái khác đã đang chạy. Một bản phát hành về sau bị kẹt, nhưng app vẫn hoạt động sau đó.
Chính điều đó khiến nó dễ bị bỏ qua. Người dùng thấy một app hoạt động. Các lỗi chỉ hiện ra dưới dạng email báo phát hành thất bại.
Vì sao không có gì ngăn lại
Code publish vốn có một bước nhìn vào các deployment đang chạy của dự án. Nhưng việc nó làm là đánh dấu mọi deployment đang chạy là thất bại trước khi bắt đầu — “được thay thế bởi một lần publish mới”. Mục đích là dọn dẹp các bản build bị bỏ lại bởi một tiến trình đã crash.
Trong thực tế, điều đó có nghĩa là một yêu cầu publish thứ hai đến giữa lúc build sẽ tuyên bố lần đầu đã chết và tự bắt đầu lần của nó. Cả hai chạy đến hết cùng một app. Không có khóa nào; chỉ có một bước chủ động dọn đường cho một cuộc đua.
Còn có một triệu chứng kỳ lạ hơn đối với lần publish đầu tiên của một app. Khi chưa có bản ghi deployment nào để tái sử dụng, hai lần publish đầu đồng thời cùng cố chèn một bản ghi. Cơ sở dữ liệu có một chỉ mục unique trên tên link của app (slug của nó, như your-app trong your-app.mythex.ai), nên lần chèn thứ hai thất bại. API dịch điều đó thành “tên link này đã được dùng” — khuyên người dùng đổi tên một app mà lúc đó vẫn đang publish thành công dưới cái tên đó.
Cách sửa: việc nhận quyền chính là khóa
Giờ việc nhận bản ghi deployment là một câu lệnh cập nhật cơ sở dữ liệu có điều kiện duy nhất: lấy dòng này và đánh dấu là đang build chỉ khi không ai đang publish nó. Vì đó là một câu lệnh duy nhất, cơ sở dữ liệu xử lý được các yêu cầu đến trong cùng một mili giây. Chính xác một trong số chúng thay đổi dòng. Những cái còn lại thấy rằng không có dòng nào thay đổi, dừng lại, và không bao giờ tới được orchestrator.
Một khóa cũng cần một lối thoát khi người giữ nó chết. Nếu API khởi động lại giữa lúc một lần publish đang chạy, dòng đó sẽ bị kẹt ở trạng thái “đang build” mãi mãi và app không thể publish được nữa. Vì vậy, một dòng mà chủ sở hữu đã ngừng gửi heartbeat — lần publish đang chạy cập nhật dòng mỗi 30 giây, và năm phút im lặng được coi là đã chết — có thể được nhận lại. Ngưỡng đó đến từ heartbeat, không phải từ một phỏng đoán về thời gian build có thể mất bao lâu. Việc build Dockerfile riêng của người dùng có thể kéo dài hợp lý, và một bản build chậm nhưng vẫn gửi heartbeat sẽ giữ được quyền của nó.
Bước đã làm lỗi mọi deployment đang chạy không còn chạy trước một lần publish nữa. Chỉ những dòng do một tiến trình đã chết để lại mới được dọn dẹp.
Nói sự thật với yêu cầu thua cuộc
Yêu cầu thua cuộc giờ nhận được một câu trả lời rõ ràng: publish_in_progress, cùng với ID của deployment đang chạy. Mỗi nơi có thể bắt đầu một lần publish đều xử lý nó:
- Workspace theo dõi bản build đang chạy thay vì hiện lỗi đè lên nó.
- Agent được cho biết một lần publish của app này đang chạy và hãy chờ nó, thay vì được cho biết có gì đó thất bại — điều trước đây có thể khiến nó đi “sửa” một Dockerfile và thử lại.
- Va chạm khi publish lần đầu trên tên link được kiểm tra: nếu dòng đang giữ tên là lần publish đang chạy của chính dự án này, câu trả lời là “đang publish”, không phải “tên này đã được dùng”. Một va chạm thật với dự án khác vẫn nhận thông báo đổi tên.
Chúng tôi kiểm chứng như thế nào
Chúng tôi viết một script tái hiện chạy ba lần publish của cùng một app cùng lúc, với một cơ sở dữ liệu thật và một orchestrator giả, rồi đếm xem có bao nhiêu deploy được yêu cầu từ orchestrator. Nó bao gồm cả lần publish đầu tiên và việc publish lại một app đang chạy. Trước khi sửa, nó đếm được hai deploy. Sau khi sửa, còn một.
Script cũng bao gồm lối thoát: nó đánh dấu dòng của app là đang build mà không có heartbeat trong mười phút, rồi publish lại, và kiểm tra rằng lần publish mới nhận được dòng và chạy.
Các unit test giữ khóa ở hai việc mà một khóa phải làm: trong ba lần publish lại đồng thời, chính xác một lần được đi tiếp và hai lần còn lại được cho biết là đang có một lần publish chạy; và một bản build mà chủ sở hữu đã chết có thể được nhận lại. Một test khác kiểm tra rằng một lần thử mới không mang theo thông báo lỗi của lần thử trước.
Chúng tôi rút ra điều gì
- Code dọn dẹp đôi khi là một cuộc đua được ngụy trang. “Làm lỗi mọi thứ đang chạy, rồi bắt đầu” nghe có vẻ là vệ sinh. Với hai bên gọi, nó là mỗi bên hủy bên kia.
- Để cơ sở dữ liệu làm trọng tài. Đọc rồi mới ghi để lại một khoảng trống cho request khác chen vào. Một câu cập nhật có điều kiện duy nhất thì không.
- Mọi khóa đều cần một thời hạn gắn với việc còn sống. Không có nó, một sự cố crash sẽ biến thành một app bị kẹt.
- Thông báo lỗi là chỉ dẫn. “Tên đó đã được dùng” đã bảo người dùng làm sai. Thông báo đúng đơn giản hơn và đúng sự thật.
Thêm về publish trong tài liệu: cách publish. Và một bản sửa liên quan: app đã publish của bạn vẫn hoạt động trong lúc bạn publish lại.