Bỏ qua tới nội dung
Kiên

Cái phê duyệt đã chuyển cấp, nhưng không báo cho ai

· 4 phút đọc

level +1 · 0 người được báo

Một đơn phê duyệt lên cấp đúng y như thiết kế, và người duyệt ở cấp kế tiếp không hề hay biết — không mail, không chuông. State machine đúng. Dispatch notification kiểu fail-soft cũng đúng. Cái hỏng nằm ở khe hở giữa hai thiết kế đúng: "không được chặn" đã lặng lẽ phình thành "không cần báo là nó đã fail".

  • workflow engine
  • notification
  • testing
  • backend
Nội dung
  1. Mọi thứ đều trả về success
  2. Hai thiết kế đúng và cái khe ở giữa
  3. Cái đã thay đổi
  4. Quy tắc

Mọi thứ đều trả về success

Cái engine phê duyệt nhiều cấp mình xây ở VNTrip đẩy một đơn đi lên theo chuỗi cấp, và nó cố tình đa nghi với chính cái bước chuyển: bước duyệt được gác bằng optimistic locking, cả cây workflow được snapshot ngay lúc submit để config đổi sau đó không viết lại được lịch sử của đơn đang chạy, và engine có 11 error code được document cho những cách một thao tác duyệt có thể fail một cách chính đáng.

Rồi một đơn lên cấp, đúng chuẩn, và không ai biết. Người duyệt ở cấp kế tiếp không nhận được mail, không thấy chuông trên dashboard. Đơn nằm đó, đã-duyệt-ở-cấp-N, chờ một con người không hề biết mình đang được chờ. Chuyện lộ ra theo đúng cái kiểu mấy chuyện này hay lộ — vài ngày sau, có người hỏi sao đơn của họ bị kẹt. Nó không kẹt. Nó đang chờ, trong im lặng, một người mà mình chưa từng báo. Bao nhiêu đơn đã già đi theo kiểu đó trước khi câu hỏi kia được đặt ra, mình không biết — và đọc hết bài này thì sẽ rõ vì sao không có thứ gì ở vị trí đếm được chúng.

Hai thiết kế đúng và cái khe ở giữa

State machine đã làm xong việc của nó; không cái nào trong 11 error code nổ, vì không có lỗi nào xảy ra trong bất cứ thứ gì state machine sở hữu.

Còn dispatch notification thì cố tình chạy post-commit và fail-soft. Đó là thiết kế mà đến giờ mình vẫn đứng về phía nó: backend notification sập thì tuyệt đối không được chặn việc duyệt. Phê duyệt dính tới tiền và thời hạn; một cú sập mail server chỉ được phép tốn vài cái reminder, không được tốn việc kinh doanh. Nên dispatch chạy sau khi transaction commit, tự nuốt lỗi của mình, và để luồng đi tiếp.

Từng thiết kế đều đúng. Đặt cạnh nhau thì chúng có một cái khe, và cái khe đó chính là vụ này. Fail-soft theo cách mình đã viết nghĩa là cú fail không những không chặn ai mà còn không được ghi lại — bị nuốt, chứ không được log thành một outcome có thứ gì theo dõi. "Fail thì không được chặn" đã lặng lẽ phình thành "fail thì khỏi báo ai", và không có gì trong cả hai thiết kế chịu trách nhiệm nhận ra điều đó. Thành công của engine là thật. Thất bại của dispatch cũng là thật. Không component nào sở hữu cái sự thật rằng cả hai đã xảy ra trên cùng một đơn.

Còn một lối thứ hai, êm hơn, dẫn tới đúng sự im lặng đó. "Cấp này ai duyệt" được resolve từ config ngay lúc dispatch, và một lượt tra config hoàn toàn có thể trả về danh sách rỗng — một cấp bị config sai, một role hiện không ai giữ. Code bản gốc cầm cái danh sách rỗng đó và cần mẫn gửi notification cho từng thành viên trong số không người của nó. Không có failure nào xảy ra cả, theo mọi nghĩa mà code nhận ra được. Site này đã có tên cho hình dạng đó: một kết quả rỗng là loại thành công đắt nhất, và gửi hoàn hảo cho không-ai chính là cái quy tắc ấy khoác một cái áo khác.

Cái đã thay đổi

Ba thứ, không thứ nào đụng vào state machine.

Dispatch tự ghi lại outcome của nó. Vẫn post-commit, vẫn fail-soft, nhưng mỗi lần gửi giờ đều được ghi xuống chuyện gì đã xảy ra — sent, failed, skipped — thành dữ liệu hạng nhất mà hệ thống giữ, không phải một dòng trôi mất trong log ứng dụng. Fail-soft sống tiếp; thứ chết đi là fail-silent. Một notification không gửi được thôi không còn là một sự kiện chưa từng xảy ra.

Resolve ra danh sách người duyệt rỗng thì phải fail to. Resolve người duyệt của cấp kế tiếp mà ra không ai, giờ được đối xử đúng như cái lỗi config mà nó là, chứ không phải một danh sách người nhận hơi ngắn. Nó vẫn không được chặn việc duyệt — nguyên tắc cũ giữ nguyên — nhưng nó rơi xuống thành một failure được ghi lại, alert được, thay vì một lần gửi thành công tới zero con người.

Một bài test end-to-end đi hết cả luồng và assert cái notification. Trên môi trường test: submit một đơn thật, duyệt lần lượt từng cấp, và ở mỗi bước assert rằng người duyệt kế tiếp thật sự nhận được mail và chuông. Unit test vốn đã phủ state machine và dispatch, mỗi cái riêng — cả hai đều pass suốt vụ này, và đó chính là toàn bộ vấn đề. Bài test duy nhất bắt được một cú fail sống ở giữa hai component là bài test từ chối biết đường biên nằm ở đâu.

Quy tắc

Fail-soft nghĩa là "không được chặn". Nó không có nghĩa là "khỏi báo ai". Mỗi chỗ bạn viết một cú catch-rồi-đi-tiếp là bạn đang tạo ra một outcome mà không exception nào sẽ đứng ra báo cáo — nên việc ghi lại phải được xây vào đúng khoảnh khắc nuốt lỗi, bởi cùng một bàn tay. Và ở bất kỳ chỗ nào người nhận của một hành động được resolve từ config, "không ai cả" phải là một câu trả lời thật to. Sự im lặng phía sau một cú success không phải bằng chứng là đã có ai nghe thấy.