Series build-in-public nào cũng có một tuần mà tác giả không muốn viết ra. Đây là tuần đó của tôi. Ba lần hỏng trong năm ngày, không lần nào bị test suite bắt được, và cả ba đều xảy ra trước mặt người dùng — dù người dùng đó là chính tôi.

Tôi viết chi tiết vì một sản phẩm đề nghị các gia đình giao dữ liệu hoạt động của con mình thì không có tư cách chỉ công bố những lần thắng. Và vì mỗi con bug dạy một bài học vượt ra ngoài Kidslen, trong đó hai bài tôi đã đọc ở đâu đó rồi nhưng chỉ tin sau khi mất trọn một ngày.

Bug một: những sự kiện chỉ mất tích trên máy chậm

Dashboard phụ huynh của Kidslen có activity feed trực tiếp. Khi thiết bị của trẻ báo một video bắt đầu, phụ huynh thấy nó hiện lên mà không cần refresh. Đó là Server-Sent Events: trình duyệt giữ một kết nối HTTP mở, server ghi sự kiện vào đó khi chúng xảy ra.

Phía Spring, mỗi dashboard đang kết nối có một SseEmitter. Hai thứ ghi vào nó:

  • event processor, khi một sự kiện hoạt động vừa ingest đã qua policy engine và sẵn sàng hiển thị;
  • một heartbeat theo lịch, mỗi mười lăm giây, ghi một khung comment để proxy không đóng kết nối đang rảnh.

Trên máy dev của tôi, nó chạy hoàn hảo suốt nhiều tuần. Trên runner CI trong LXC — một cỗ máy chậm hơn, bận hơn — sự kiện bắt đầu mất tích. Không phải tất cả. Không tái hiện đều. Spec end-to-end pass bốn lần rồi fail lần thứ năm với một mục hoạt động chẳng bao giờ tới.

Đúng kiểu lỗi khiến người ta đổ cho cái test.

Và tôi đổ cho cái test thật, mất khoảng nửa ngày. Tôi thêm chờ. Tôi tăng timeout. Tỉ lệ flake giảm xuống nhưng không về không — đó là chữ ký của một race condition, không phải vấn đề thời gian. Nếu chờ lâu hơn có giúp nhưng không bao giờ dứt điểm, bạn không đang chờ một thứ chậm, bạn đang tung đồng xu.

Rồi tôi đọc tài liệu cho tử tế. SseEmitter không thread-safe. Điều đó được ghi rõ ràng, còn tôi thì đã xây một hệ thống mà đường đi song song nhất của ứng dụng lại ghi vào nó từ hai thread độc lập: thread processor và thread scheduler.

Hai thread cùng gọi send() trên một emitter có thể xen kẽ nhau. Thứ đi xuống socket không phải “sự kiện A rồi sự kiện B”; nó có thể là mảnh của cả hai, và bộ parser phía client, khi gặp một thứ không phải khung sự kiện hợp lệ, làm điều hợp lý duy nhất: vứt đi. Sự kiện không hề gửi lỗi. Chúng được gửi thành rác và bị trình duyệt ném đi.

Bản sửa chẳng hào nhoáng gì: tuần tự hoá mọi lệnh ghi trên từng emitter. Mỗi emitter được bọc trong một holder nhỏ có lock riêng, và mọi đường gửi — processor, heartbeat, close — đều đi qua đó. Gửi tới các emitter khác nhau vẫn song song hoàn toàn, nên bản sửa không tốn gì ở mọi mức kết nối thực tế; lock chỉ bị tranh chấp bởi đúng hai bên ghi của một kết nối.

Hai điều rút ra mà giờ tôi coi như luật:

Bọc lại, đừng ghi nhớ. Bug không nằm ở chỗ tôi không biết emitter cần cẩn thận. Nó nằm ở chỗ “nhớ synchronise” là một quy tắc sống trong đầu tôi, mà cái đầu đó không có mặt lúc tôi thêm heartbeat scheduler ba tuần sau processor. Bản sửa hiệu quả là bản sửa khiến đối tượng không an toàn trở nên không với tới được — emitter thô là private, và thứ duy nhất lớp khác lấy được là holder đã đồng bộ.

Máy chậm là bài test tốt hơn máy nhanh. Laptop của tôi đủ nhanh để giấu race gần như mọi lần. Runner CI, vừa chạy build Gradle vừa tranh CPU, nới cửa sổ đủ rộng để lộ ra. Tôi đã coi sự flaky của máy CI là khuyết điểm của máy CI. Hoá ra nó là phần cứng test giá trị nhất tôi có. Giờ tôi cố ý chạy bộ end-to-end khi runner đang tải nặng trước mỗi bản phát hành, và tôi bỏ thói quen thêm sleep như phản xạ đầu tiên trước lỗi chập chờn.

Bug hai: bản build native không chịu build

Cũng tuần đó, companion app ngừng biên dịch. Không phải phần JavaScript — lớp JavaScript của React Native là nửa dễ. Nửa native mới là chuyện.

Ba vấn đề riêng biệt, cùng một hình dạng gốc.

Một dependency vẫn đòi kho không còn tồn tại. Một thư viện transitive khai báo jcenter() trong cấu hình Gradle. JCenter đã ngừng hoạt động; dưới Gradle 9 bản build không xuống cấp êm ái mà fail luôn ở bước resolve. Bản thân thư viện thì ổn — nó cũng được publish trên Maven Central — nhưng build script của nó trỏ vào một địa chỉ đã chết.

Cách sửa là patch-package: một patch commit vào repo, đổi jcenter() thành mavenCentral() trong build file của dependency đó, áp tự động sau khi cài. Vá một dependency vài lần đầu cảm giác rất sai. Nhưng nó tốt hơn hẳn hai lựa chọn còn lại: fork thư viện, hoặc ghim Gradle vào một phiên bản mà chính nó cũng sắp hết đời.

Một thư viện không còn được bảo trì, không biên dịch nổi với Xcode hiện hành. Tôi từng dùng một thư viện certificate pinning cho React Native. Nó đã lâu không ra bản mới, không build được dưới Android Gradle Plugin 8, và cũng không build được với Xcode hiện hành. Tôi mất gần trọn một ngày cố vá đường qua, rồi dừng lại và hỏi câu lẽ ra phải hỏi đầu tiên: dependency này cho tôi cái gì mà tầng dưới nó không cho?

Câu trả lời: không gì cả. Certificate pinning là năng lực của nền tảng. Android có network security configuration; iOS có App Transport Security và cơ chế pinning riêng. Thư viện kia chỉ là lớp bọc tiện lợi trên những thứ hệ điều hành vốn đã làm, và là lớp bọc đã ngừng được bảo trì trong khi các nền tảng vẫn đi tiếp.

Nên tôi gỡ nó và làm pinning ở tầng native trên cả hai nền tảng. Số dependency giảm, build xanh trở lại, và tính chất bảo mật y hệt — có thể còn mạnh hơn, vì giờ nó do nền tảng cưỡng chế chứ không phải do JavaScript, thứ có thể bị đi vòng nếu bridge bị xâm phạm.

Firebase từ chối Swift Package Manager với static linkage. Sự kết hợp giữa bản phân phối SPM của Firebase và kiểu liên kết tĩnh mà phần còn lại của dự án cần thì không chạy chung được. Chuyện này không có bài học thông minh nào. Tôi chuyển dependency đó sang đường tích hợp được hỗ trợ khác và đi tiếp. Có những ngày cách sửa là thôi cố đúng về trình quản lý gói.

Bài học chung từ cả ba: mỗi dependency native trong dự án React Native là một ván cược rằng người bảo trì nó sẽ theo kịp hai nhà cung cấp nền tảng. Android Gradle Plugin và Xcode đi theo lịch riêng và đều có breaking change. Một thư viện mười tám tháng không ra bản mới thì không phải là ổn định, mà là một sự cố đã được lên lịch sẵn. Giờ trước khi thêm cái nào, tôi hỏi nền tảng đã có sẵn năng lực đó chưa — và với pinning, telemetry, lưu trữ, thông báo, câu trả lời rất thường là đã có.

Bug ba: cái HTTP 400 mà chỉ video demo tìm ra

Con thứ ba mới là con làm tôi xấu hổ thật sự.

Tôi đang quay demo sản phẩm — quay màn hình dashboard thật, làm những việc thật mà một phụ huynh sẽ làm. Đăng ký, thêm con, enroll thiết bị, xem hoạt động hiện lên, mở báo cáo tuần.

Màn hình báo cáo tuần hiện lỗi. HTTP 400.

Mọi thứ đang xanh. Test backend: xanh. Unit test dashboard: xanh. Playwright end-to-end: xanh. Cổng smoke test ở lần deploy gần nhất: xanh. Vậy mà màn hình hoạt động, khi một con người bấm qua theo thứ tự, trả về 400.

Nguyên nhân là lệch định dạng ngày. Bộ chọn khoảng ngày của dashboard serialise giá trị theo một định dạng; phần binding query parameter của API mong đợi định dạng khác. API từ chối, hoàn toàn đúng, bằng một 400 kèm problem document RFC 7807 chuẩn chỉnh mà chẳng ai đọc, vì màn hình chỉ hiện “có lỗi xảy ra”.

Vì sao không lớp nào bắt được?

  • Test backend gửi đúng định dạng backend mong đợi. Chúng test bộ parser, và bộ parser thì đúng.
  • Unit test dashboard mock API. Chúng khẳng định component render ra các dòng được đưa cho. Và nó được đưa cho các dòng.
  • Test end-to-end dùng khoảng ngày cố định do fixture dựng — tình cờ sinh ra đúng định dạng API muốn, vì fixture do cùng một người viết, cùng ngày với API.

Ba lớp test, đều xanh, đều test vòng quanh đường nối chứ không xuyên qua nó. Con bug nằm đúng vào khe giữa hai thứ mà mỗi thứ đều có độ phủ tuyệt vời. Đây là hình dạng phổ biến nhất của bug production mà tôi từng gặp, và tôi đi thẳng vào nó ngay trong sản phẩm của mình.

Sửa mất năm phút: một helper định dạng dùng chung cho mọi nơi gọi, kèm một test khẳng định chuỗi phát ra khớp với thứ binding của API chấp nhận. Phần thú vị không phải bản sửa, mà là điều nó thay đổi trong cách tôi kiểm chứng.

Test suite không phải là sản phẩm. Hãy nhìn vào thứ thật.

Giờ trước mỗi bản phát hành có động vào dashboard, tôi quay lại toàn bộ hành trình của phụ huynh. Không phải chạy test — là quay màn hình, xem lại ở tốc độ thường, có tiếng. Nó chậm, cảm giác không năng suất, và nó đã bắt được những thứ không assertion nào bắt: một spinner quay mãi không dừng, một chuỗi tiếng Việt tràn ra khỏi nút, một banner đồng thuận chớp tắt trong lúc re-render — thứ mà với sản phẩm này hoàn toàn không phải lỗi thẩm mỹ.

Có lý do khiến cách này hiệu quả. Test tự động khẳng định những điều bạn đã nghĩ ra. Xem sản phẩm chạy cho bạn thấy những điều bạn chưa nghĩ ra. Video demo không phải là một việc marketing tình cờ tìm thấy bug; nó là buổi QA tốt nhất tháng đó, và nó tìm ra một con bug mà về mặt cấu trúc, mọi lớp kiểm chứng khác không thể nào tìm được.

Những gì tôi thay đổi vĩnh viễn

Sự cốThay đổi vĩnh viễn
Ghi đồng thời vào một SseEmitterĐối tượng không an toàn bị giấu sau holder đã đồng bộ; chạy bộ end-to-end khi máy đang tải nặng trước phát hành
Kho đã chết trong dependency transitivePatch patch-package commit vào repo; soát tuổi dependency trước khi thêm thư viện native
Thư viện pinning không còn bảo trìGỡ bỏ; chuyển pinning xuống tầng nền tảng trên cả hai OS
Lệch định dạng ngày ở đường nối dashboard/APIMột helper dùng chung, một contract test khẳng định đúng chuỗi serialise
Cả baMột lượt xem lại bản quay hành trình thật trước mọi phát hành động vào dashboard

Không cái nào thông minh cả. Mỗi cái đều là điều tôi sẽ bảo một bạn junior làm, và tự mình không làm cho tới khi mất một tuần.

Nếu có một sợi chỉ xuyên suốt cả ba, thì đây: những chỗ vỡ trong một hệ thống là những chỗ hai thứ đúng gặp nhau. Một processor đúng và một scheduler đúng, dùng chung emitter. Một thư viện đúng và một build tool đúng, bất đồng về kho. Một dashboard đúng và một API đúng, bất đồng về ngày. Độ phủ bên trong từng thành phần không nói gì về các đường nối. Chỉ có chạy thứ thật mới nói.


Bài tiếp theo trong series: quyền riêng tư chính là sản phẩm — bản ghi đồng thuận, banner trẻ không tắt được, retention xoá thật, và những gì khâu duyệt app store hỏi một phần mềm giám sát trẻ vị thành niên.

Kidslen ở kidslen.app, kèm cả bug lẫn bản sửa.

Xuất nội dung

Bình luận