Markpad là extension Chrome tôi viết và bán license. Ba khay trong một popup: Snippets, Vocabulary, Notes. Toàn bộ dữ liệu người dùng nằm trong chrome.storage.local của chính trình duyệt họ — cho tới ngày 30 tháng 8 năm 2026 thì không có chỗ nào khác giữ một bản nào.
Hôm đó Markpad lên v1.0.0, kèm Google Sync. Ba khay được đồng bộ qua tài khoản Google, mỗi item là một document riêng trên Firestore. Từ thời điểm đó, có một tiến trình chạy nền được quyền ghi lên dữ liệu người dùng, dựa trên thứ nó vừa đọc được qua mạng.
Câu hỏi tôi phải trả lời trước mọi câu hỏi khác không phải là đồng bộ nhanh thế nào. Mà là: một lượt pull được phép làm gì?
Hai hành động
Câu trả lời mặc định là coi máy chủ làm nguồn sự thật — kéo về, rồi làm cho local giống hệt. Gọn, dễ hiểu, và nó nhét vào code một mệnh đề chết người: item có ở local mà không có trong kết quả trả về thì phải xoá.
Liệt kê những thứ có thể làm cho một item vắng mặt trong response: mạng chập chờn, một truy vấn trả về rỗng vì bug, một điều kiện lọc viết sai, quyền đọc bị từ chối, SDK trả về kết quả của lần gọi trước. Không có cái nào trong số đó là người dùng muốn xoá thứ gì. Nhưng với mệnh đề trên thì cả năm trường hợp đều dẫn tới cùng một hành động.
Nên pull-merge trong Markpad chỉ có hai hành động. Thêm item Firestore có mà local chưa có. Cập nhật item cả hai bên cùng có, theo last-write-wins. Không có hành động thứ ba.
Có một chi tiết làm mệnh đề kia không chỉ nguy hiểm mà còn vô nghĩa. Vì lý do tiền bạc, pull không lấy cả collection: nó chạy một delta query, where('_meta.updatedAt', '>', lastPulledAt), với mốc lastPulledAt lưu riêng cho từng khay. Người dùng bình thường sửa vài item một ngày, nên số document đọc mỗi lần mở popup giảm từ cỡ kích thước collection xuống cỡ số item vừa đổi. Quota Firestore miễn phí nhờ đó thở được.
Hệ quả kèm theo thì tôi chỉ nhìn ra khi đặt hai quyết định cạnh nhau: kể từ lúc dùng delta query, một kết quả pull khoẻ mạnh vốn đã thiếu gần hết collection. Sự vắng mặt không còn mang thông tin nào nữa. Code nào đọc ý nghĩa từ chỗ đó là đang đọc nhiễu.
Tombstone
Xoá vẫn phải xoá được — người dùng xoá một snippet trên máy này thì máy kia phải thấy nó biến mất. Nên xoá đi bằng một document tường minh: item được đánh dấu _meta.deleted = true và ở lại trên Firestore như một tombstone, thay vì bốc hơi khỏi collection.
Và ngay cả khi tombstone về tới nơi, nó vẫn chưa được xoá gì cả. Nó phải thắng đã.
Đây là tình huống thật, không phải tình huống trong sách: máy A offline, người dùng xoá một từ vựng. Máy B cũng offline, người dùng sửa đúng từ đó. Cả hai online lại. Nếu bản sửa mới hơn lệnh xoá, item sống lại — và bản sống lại được đẩy ngược lên để ghi đè tombstone, chứ không nằm im chờ lượt pull sau xoá lần nữa. Ngược lại thì item đi.
Cùng tinh thần đó, hai loại dữ liệu không đi qua last-write-wins mà được gộp: tags lấy hợp của hai bên, và tiến trình spaced repetition của Vocabulary giữ bản đã ôn xa hơn. Gộp được vì cả hai phép đều giao hoán — không có bên nào thua, nên không có gì mất đi lặng lẽ.
Ngưỡng 50%, và con số 4
Hai hành động cộng tombstone tường minh vẫn còn một lỗ. Nếu chính cái đường sinh tombstone bị hỏng — một lần migration viết nhầm, một vòng lặp đánh dấu quá tay, một máy nào đó đẩy lên hàng loạt cờ deleted — thì tất cả đều là tombstone hợp lệ, và pull sẽ áp dụng chúng một cách rất kỷ luật.
Nên có thêm một cầu dao:
const TOMBSTONE_BATCH_THRESHOLD = 0.5
const TOMBSTONE_BATCH_MIN_COLLECTION_SIZE = 4
// ...
if (
current.length >= TOMBSTONE_BATCH_MIN_COLLECTION_SIZE &&
wouldDelete / current.length > TOMBSTONE_BATCH_THRESHOLD
) {
console.error(
`[markpad:sync] circuit breaker tripped for "${key}": pull batch would remove ` +
`${wouldDelete}/${current.length} items at once — skipping batch, local kept as-is`,
)
return { abort: "circuit-breaker" }
}
Một batch pull định xoá quá nửa collection trong một lượt thì bị bỏ nguyên batch, ghi log, giữ local y nguyên. Không xoá một phần, không xoá những cái "chắc là đúng" rồi bỏ phần còn lại — vì nếu tôi không tin batch đó thì tôi không có cơ sở nào để tin nửa batch đó.
wouldDelete không đếm số tombstone nhận về, nó đếm số tombstone sẽ thắng nếu đem ra so với bản local tương ứng. Đếm kiểu kia thì một batch toàn tombstone cũ, thua hết, cũng đủ làm cầu dao nhảy oan.
Con số 4 là chỗ tôi phải sửa sau khi đã thiết kế xong. Ngưỡng phần trăm đứng một mình thì sai ở đầu nhỏ: người dùng có đúng một snippet, xoá nó đi, thế là một trên một — 100%, vượt ngưỡng, cầu dao nhảy, và thao tác bình thường nhất trên đời bị chặn. Tôi phát hiện lúc viết unit test chứ không phải lúc vẽ sơ đồ. Cầu dao vì vậy chỉ áp dụng khi khay có từ bốn item trở lên, chỗ mà "hơn một nửa biến mất cùng lúc" mới thực sự là chuyện lạ.
Service worker tỉnh dậy
Mười tám ngày sau, v1.60.0, ngày 17 tháng 9. Người dùng bị đòi đăng nhập lại trong khi tài khoản chưa hề hết hạn.
Chrome giết service worker của extension MV3 sau khoảng ba mươi giây rảnh rỗi, rồi dựng lại khi có việc. Ngay sau lúc dựng lại, phiên Firebase Auth trong đúng ngữ cảnh service worker đó cần vài trăm mili giây để khôi phục từ IndexedDB. Một lượt push hoặc pull nền rơi trúng khoảnh khắc ấy sẽ nhận lỗi unauthenticated. Lỗi thật, nhưng thoáng qua.
Chuyện này đã được lường trước. Comment thiết kế ngay trên hàm đồng bộ nền, trong background/index.ts, nói rõ lỗi loại đó phải là "never a data-loss path, only ever a sync-delay path" — im lặng ghi log, để nguyên trong hàng đợi, lần sau thử lại.
Code thì không làm như vậy. drainQueue() và pullAll() được dùng chung cho cả service worker lẫn popup, và cả hai đường đều gọi thẳng markAuthError() khi gặp lỗi auth. Một cú tỉnh dậy hoàn toàn bình thường của service worker hiện lên giao diện thành "Sync paused — sign in again", trong khi phiên đăng nhập ở popup vẫn sống nguyên.
Bản vá nhỏ: hai hàm đó nhận thêm tham số reportAuthErrors. Mặc định vẫn là true cho popup — ở đó phiên vừa xác thực xong, lỗi auth là lỗi thật. Đường chạy nền truyền false: ghi log, giữ nguyên hàng đợi, không đụng vào cờ mà giao diện đang đọc.
Không ai mất một snippet nào trong vụ này. Thiệt hại là một người đang trả tiền cho tính năng đồng bộ bị bảo rằng đồng bộ hỏng, ở đúng thời điểm nó đang chạy tốt.
Mười tám ngày
Hai quyết định trên ra đời trong cùng một spec, cùng một tinh thần, cách nhau vài trang tài liệu. Cái đầu nói pull không được suy ra lệnh xoá. Cái sau nói lỗi từ service worker không được biến thành đường mất dữ liệu.
Khác nhau đúng một chỗ. Cái đầu có bốn bài test mang tên nó, nằm trong sync-orchestrator.test.ts: một bài dựng cảnh item vắng mặt trong response, một bài cho tombstone hợp lệ, một bài cho ca sửa-thắng-xoá, một bài đẩy 3 trên 4 item vào tombstone để xem cầu dao có nhảy không. Cái sau có một khối comment.
Giữa hai mốc đó là mười tám ngày và bảy mươi tám mục changelog. Chỗ hỏng rơi vào cái thứ hai.
Một nguyên tắc không tự thi hành nó. Bài test thì có.