fix(client): advance download watermark atomically per batch#14
Conversation
The incremental download watermark was derived from `max(file_records.jid)`, which advances every time a single file is saved. Combined with the server's `list(jid)` filter — which hides any path whose latest commit id is <= `jid` — this permanently skips files if a batch is interrupted partway. If the client saved a high-jid file (say id=500) and then crashed before saving a lower-jid file from the same batch (id=300), the next poll requested `list(500)`, got nothing for id=300, and the file was invisible forever. Move the watermark into a dedicated `sync_state` table and advance it only once per batch, inside a DB transaction that also writes all `file_records` rows. Either everything lands or nothing does; a partial crash leaves the watermark pointing at the previous safe value so the whole batch is re-requested on the next poll (chunks already on disk are reused via the local cache). Fresh installs and upgrades start with watermark=0, triggering one full re-sync. That also heals any gaps that accumulated under the old mechanism.
Code ReviewOverviewThis PR fixes a real, well-documented crash-recovery bug: the old Correctness / LogicThe transaction is correct. Disk writes happen first (lines 315–334), then a single
Issues1. let latest_local = registry::get_download_watermark(conn, namespace_id).unwrap_or(0);
let latest_local = registry::get_download_watermark(conn, namespace_id)?;2. The only caller was the old 3. diesel::allow_tables_to_appear_in_same_query!(file_records, sync_state,);
Minor Suggestions
TestsThe four new unit tests are well-structured (Given/When/Then), cover the important cases (missing row → 0, round-trip, upsert-not-duplicate, namespace isolation), and use real SQLite rather than mocks. Good coverage for the new registry layer. Missing: an integration-level test for Summary
The core fix is solid. The |
Summary
Fixes a bug in the client's incremental-download watermark that permanently skips files after a partial sync.
Root cause:
check_download_onceusedregistry::latest_jid=max(file_records.jid)as the watermark. This advances every time a file is saved. The server'slist(jid)filter returns only paths whose latest commit id> jid, so if the client saved a high-jid file (id=500) and then crashed before saving a lower-jid file from the same batch (id=300), the next poll requestedlist(500), got nothing for id=300, and that file was invisible forever.Fix: Move the watermark into a dedicated
sync_statetable and advance it only once per batch, inside a DB transaction that also writes allfile_recordsrows. Either everything lands or nothing does — partial crash leaves the watermark pointing at the previous safe value, the whole batch is re-requested on the next poll (chunks already on disk are reused via the cache).Changes
2026-04-14-000000_add_sync_state— single-row-per-namespace table holding the download watermark.registry::get_download_watermark/set_download_watermark+ 4 unit tests.check_download_once: reads from new watermark, disk saves happen up-front, then all file_record writes + watermark update run insideconn.transaction.Upgrade behaviour
Fresh installs and upgrades start with
watermark=0, triggering one full re-sync. That also heals any gaps that accumulated under the old mechanism (chunks already on disk are cache hits, so the re-download is cheap).Test plan
cargo test --lib(38 tests pass, incl. 4 new)cargo build --workspace(server still compiles)cargo clippy --lib --all-features(clean)sync.db, cut fresh install, verify all recipes arrive and subsequent increments workObserved before fix
On Android: device had 69 synced paths with jids scattered 261..561792, but was missing
Breakfast/Easy Pancakes.cook,Tuscan Chicken,Shakshuka, and recipes referenced by3 Day Plan XVIII.menu. Deleting the device'ssync.db(forcing resync from jid=0) restored all files — confirming the server had them but the watermark was skipping them.