Personal OS giữ dữ liệu trong trình duyệt bằng RxDB và đồng bộ lên backend Spring Boot cùng PostgreSQL qua replication tuỳ biến của RxDB. Những lỗi tốn thời gian nhất ở đó có một điểm chung: không có gì báo lỗi cả. Sửa đổi biến mất, một collection ngừng đồng bộ, hay mọi lần ghi đều hỏng sau khi tải lại trang, trong khi log trông hoàn toàn bình thường. Bài này đi qua bốn lỗi như vậy, nguyên nhân của từng lỗi, và cách sửa.
Cách thay đổi tới các thiết bị khác theo thời gian thực bằng Server-Sent Events được kể trong bài SSE với Spring Boot và Redis Pub/Sub.
Giao thức trong một đoạn
Mỗi lần ghi thành công trên server được cấp một version từ một dãy số riêng cho từng người dùng, chỉ tăng không giảm. Con số đó vừa là checkpoint của replication (thiết bị kéo về "mọi thứ sau version N"), vừa là cách phát hiện xung đột: lần đẩy lên nói rõ nó đang giả định version nào, và server từ chối nếu hàng đó đã đổi. Khi hai thiết bị thật sự cùng sửa một hàng, conflict handler giữ bản có version server cao hơn, mà thực tế là bản đang nằm trên server; updated_at phía client chỉ dùng để phân xử giữa hai bản cùng version. Một sửa đổi làm lúc offline vẫn được ghi khi có mạng lại, miễn là trong lúc đó không thiết bị nào khác sửa hàng ấy. Cái giá là khi có thiết bị khác sửa, sửa đổi offline bị bỏ nguyên, không được trộn. Với dữ liệu của một người trên vài thiết bị thì chuyện này hiếm, và đó là đánh đổi thiết kế này chấp nhận.
Lỗi 1: hàng tạo trên chính thiết bị này mất mọi lần sửa sau đó
Triệu chứng: tạo một block, rồi đổi tên. Tên mới trông như đã lưu, rồi quay về tên cũ sau lần đồng bộ kế tiếp. Xoá nó cũng không ăn thua.
Chuyện xảy ra từng bước:
- Một hàng mới được tạo ở máy với
version = 0rồi đẩy lên. Lần đẩy thành công, nhưng phản hồi của server chỉ liệt kê xung đột, không gửi lại version nó vừa cấp. - Lần kéo kế tiếp mang hàng đó về với version thật, chẳng hạn 1. RxDB hỏi
isEqual(bản kéo về, bản ở máy)của conflict handler xem có gì thay đổi không. isEqualcó một lối tắt: nếu một trong hai bên cóversion === 0thì trảfalse. RxDB coi hai bản là khác nhau, nhưng ở bước đó nó cũng không ghi bản kéo về xuống máy.- Hàng ở máy kẹt ở
version = 0mãi mãi. Mọi lần sửa sau đó được đẩy lên với "giả định version 0", server đang ở 1, nên lần sửa nào cũng là xung đột, và conflict handler vứt đi thay đổi của người dùng.
Cách sửa là bỏ lối tắt và so thẳng ba trường quan trọng:
function isEqual(a, b) {
return (a.version ?? 0) === (b.version ?? 0)
&& a._deleted === b._deleted
&& a.updated_at === b.updated_at
}
Hàng kéo về ở version 1 giờ khác hàng ở máy ở version 0, nên RxDB ghi version thật xuống. Một sửa đổi ở máy chưa đẩy lên có cùng version với server nhưng updated_at mới hơn, nên vẫn không bị lần kéo ghi đè.
RxDB gọi isEqual rất nhiều lần và bắt buộc nó phải đồng bộ (không async), nên so ba trường vô hướng cũng là lựa chọn rẻ; so sâu toàn bộ document thì không.
Lỗi được tìm ra và chốt lại bằng một integration test chạy trọn vòng đồng bộ với RxDB thật, đối diện một server giả làm đúng theo hợp đồng của backend, cộng thêm test backend cho phía server của cùng kịch bản. Trong suốt thời gian đó, unit test riêng cho conflict handler vẫn xanh.
Lỗi 2: một cột chỉ tồn tại ở client
Bài cây dữ liệu lớn có nói về parent_key: bản sao không null của parent_id, chỉ tồn tại để trình duyệt index được. Server không có cột đó. Điều này làm hỏng đồng bộ theo cả hai chiều.
Chiều đẩy lên. Client gửi parent_key kèm mọi block. Server kiểm tra tên cột và từ chối cột lạ bằng HTTP 400 chứ không lờ đi. Mọi lần đẩy của collection đó đều hỏng, thay đổi nằm lại trong hàng đợi của RxDB và được thử lại mãi mãi. Cách sửa là lột các trường chỉ có ở máy ngay trước khi document rời khỏi thiết bị:
export function stripLocalOnly(doc) { /* bỏ parent_key, category_key */ }
Chiều kéo về. Ở máy, parent_key do hook preInsert và preSave điền. Nhưng replication ghi document kéo về thẳng qua tầng storage của RxDB, không đi qua collection, nên các hook đó không bao giờ chạy ở đường kéo. Block kéo về không có parent_key; ở môi trường dev thì lỗi kiểm tra schema, còn ở production thì block vẫn được lưu nhưng vô hình với mọi truy vấn theo parent_key, nên block vừa đồng bộ về biến mất khỏi cây thư viện. Cách sửa là thêm các trường dẫn xuất trong modifier của pull handler.
Bản sửa đó lại có cái bẫy riêng. RxDB luôn đặt additionalProperties: false ở cấp cao nhất của schema, nên thêm một trường mà collection không khai báo sẽ làm mọi lần kéo của collection đó thất bại. snapshot_nodes có category_id nhưng không có category_key, nên một đoạn code cứng "thêm cả hai khoá" sẽ làm hỏng nó. Giờ việc dẫn xuất đọc xem schema của từng collection khai báo những cột dẫn xuất nào, thay vì ghi tên collection trong code.
Quy tắc chung: một cột dẫn xuất chỉ có ở máy cần được xử lý ở ba chỗ, hook ghi, modifier khi đẩy và modifier khi kéo, và chỉ chỗ đầu tiên là dễ nghĩ tới.
Lỗi 3: một migration đã xong nhưng không bao giờ báo xong
Sau một lần đổi schema, một máy thật rơi vào cảnh mọi lần ghi vào một collection đều hỏng với lỗi RxDB COL25, vì collection bị đánh dấu là vẫn đang migrate. Dữ liệu vẫn hiện đầy đủ; nhưng sắp xếp lại, sửa hay xoá bất cứ thứ gì trong collection đó đều lỗi. Tải lại trang không cứu được.
Trạng thái trên đĩa, chụp được từ máy thật, giải thích tất cả:
rx-migration-status|library_blocks-v-4 → DONE, 1704/1704, 100%
collection|library_blocks-3 → vẫn sống (_deleted = false)
Sau khi chuyển xong dữ liệu, migration của RxDB còn hai bước: xoá document metadata của collection phiên bản cũ, rồi gỡ cờ "đang migrate". Nếu tiến trình chết giữa hai bước đó (đóng tab, mất điện, iOS kill tab nền), metadata cũ còn sống. Lần khởi động sau, RxDB thấy nó, kết luận cần migrate và bật cờ lại, nhưng migration chẳng còn gì để làm, nên cờ không bao giờ được gỡ. Chờ migratePromise() cũng vô ích: nó chờ một việc đã xong và không bao giờ báo xong.
Cách sửa là làm nốt bước RxDB bị lỡ, đánh dấu xoá document metadata cũ, nhưng chỉ khi cả hai điều kiện đều đúng: có bản ghi trạng thái nói migration đã DONE, và metadata cũ vẫn còn sống. Một migration đang thật sự RUNNING thì để yên, vì xoá metadata của nó sẽ làm mất phần dữ liệu chưa chuyển. Bản sửa chỉ đụng vào document kế toán của RxDB, không bao giờ đụng dữ liệu người dùng, và chạy trước migratePromise().
Lỗi 4: lưới an toàn tự làm rách mình
Một thiết bị quay lại sau thời gian dài không thể tin checkpoint của nó: server dọn các bản ghi xoá (tombstone) sau một thời gian, nên checkpoint cũ có thể bỏ lỡ lệnh xoá. Lưới an toàn: nếu lần đồng bộ thành công cuối cùng đã quá 60 ngày, bỏ checkpoint và kéo lại toàn bộ tài khoản.
Ba chi tiết khiến nó còn tệ hơn không có lưới:
- "Chưa từng đồng bộ" bị coi là "quá cũ". Một lần đồng bộ lại toàn bộ sẽ xoá mốc thời gian lần đồng bộ cuối, nên lần mở app sau không thấy mốc, lại đồng bộ toàn bộ, cứ thế: mỗi lần mở app là tải lại cả tài khoản. Giờ không có mốc nghĩa là "chưa cũ"; checkpoint lúc đó vốn là 0, nên lần kéo đầu chạy bình thường.
- Mốc thời gian chỉ được ghi trong vài điều kiện. Giờ nó được ghi sau mọi lần kéo thành công.
- Đồng hồ bị chỉnh lùi tạo ra mốc ở tương lai; không thể biết tuổi thật của checkpoint, nên mốc được đặt lại thành bây giờ thay vì ép đồng bộ lại.
Còn có một bất biến giữa client và server: 60 ngày của client phải luôn ngắn hơn thời gian server giữ tombstone, là 90 ngày. Nếu dài hơn, một thiết bị quay lại sau 75 ngày sẽ bỏ qua việc đồng bộ toàn bộ trong khi server đã dọn những lệnh xoá nó bỏ lỡ, và các hàng đã xoá sẽ sống lại trên thiết bị đó. Client đọc thời gian giữ tombstone của server từ một endpoint cấu hình lúc khởi động và ghi một lỗi thật to nếu bất biến bị vi phạm. Nó chỉ cảnh báo: một lỗi cấu hình triển khai không nên làm dừng hẳn việc đồng bộ.
Không phải lỗi đồng bộ, nhưng cũng im lặng như vậy: màn hình trắng vì tách chunk
Để cache tốt hơn, RxDB và RxJS được tách ra một vendor chunk riêng bằng quy tắc manual chunks. Chuyển RxJS sang kéo theo những module mà chunk của React cũng cần, tạo ra phụ thuộc vòng giữa hai chunk. Thứ tự khởi tạo module hỏng, chunk RxDB gọi một export của chunk React trước khi nó tồn tại (Cm is not a function trong bản build đã minify), và production hiện màn hình trắng. Dev server, vốn không đóng gói, thì vẫn chạy ngon.
Việc tách chunk được hoàn nguyên và bản build production được chạy thử lại. Lợi ích về cache không đáng để đổi lấy một app có thể không mở được, và bài học là phải thử mọi thay đổi về chia chunk trên bản build production, đừng bao giờ chỉ trên dev server.
Bớt tải khi không ai nhìn
Thêm một thay đổi không sửa lỗi nào nhưng giảm tải cho server: chỉ tab leader mở luồng Server-Sent Events, tab bị ẩn quá năm phút thì đóng luồng và mở lại khi quay về, và mỗi lần kết nối lại sẽ đối chiếu một lần với version mới nhất trên server. Khi luồng khoẻ, lần kéo dự phòng định kỳ giãn ra 30 phút một lần.
Tóm tắt
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Sửa hàng mới tạo bị quay về | Lối tắt trong isEqual giữ version = 0 ở máy mãi mãi |
So thẳng version, cờ xoá và mốc thời gian |
| Collection ngừng đẩy lên | Cột chỉ có ở máy bị server từ chối (400) | Lột trường chỉ có ở máy trước khi đẩy |
| Block đồng bộ về biến mất khỏi cây | Hook ghi không chạy ở đường kéo | Dẫn xuất cột trong modifier khi kéo, theo schema |
Mọi lần ghi lỗi COL25 sau khi tải lại |
Migration bị kill giữa hai bước cuối | Chỉ sửa khi trạng thái là DONE và metadata cũ còn sống |
| Mỗi lần mở app tải lại toàn bộ | "Chưa từng đồng bộ" bị coi là cũ | Không có mốc nghĩa là chưa cũ; bất biến với thời gian giữ tombstone |
| Màn hình trắng ở production | Phụ thuộc vòng giữa các manual chunk | Hoàn nguyên việc tách; thử trên bản build production |
Bài học chung: khi đồng bộ hỏng, nó hiếm khi ném lỗi. Mỗi lỗi ở đây được tìm ra bằng cách nhìn trạng thái trên một thiết bị thật, hoặc bằng một bài test chạy trọn vòng lặp, chứ không phải bằng cách đọc log lỗi.
Nếu bạn từng truy ra những lỗi đồng bộ không hề ném lỗi trong RxDB hay một hệ local-first khác, mình rất muốn được trao đổi. Email của mình ở trang chủ.