max / audiofiles
1 file changed,
+50 insertions,
-7 deletions
| @@ -8,6 +8,12 @@ | |||
| 8 | 8 | //! All HLC logic lives here in the sync layer; the content store and DB schema | |
| 9 | 9 | //! stay sync-agnostic. The device's `node` is its registered `device_id`; the | |
| 10 | 10 | //! running clock is persisted as `sync_state.last_hlc`. | |
| 11 | + | //! | |
| 12 | + | //! Convergence rests on `device_id` (the HLC `node`) being unique per install: it | |
| 13 | + | //! is the final, deterministic tiebreak when two HLCs share `wall_ms`+`counter`. | |
| 14 | + | //! A restored backup that copies the `device_id` violates this; synckit mitigates | |
| 15 | + | //! the exact-HLC collision with a canonical-payload tiebreak, but unique | |
| 16 | + | //! `device_id`s are the design assumption. | |
| 11 | 17 | ||
| 12 | 18 | use rusqlite::{Connection, OptionalExtension}; | |
| 13 | 19 | use synckit_client::Hlc; | |
| @@ -21,17 +27,31 @@ fn now_ms() -> i64 { | |||
| 21 | 27 | chrono::Utc::now().timestamp_millis() | |
| 22 | 28 | } | |
| 23 | 29 | ||
| 24 | - | /// Load the persisted device clock, falling back to the zero clock for `node` | |
| 25 | - | /// (first sync, or unreadable state). | |
| 30 | + | /// Load the persisted device clock. Returns the zero clock on first sync (no | |
| 31 | + | /// persisted value); on a *corrupt* persisted value it seeds from physical time | |
| 32 | + | /// rather than zero — see below. | |
| 26 | 33 | pub(crate) fn load_clock(conn: &Connection, node: Uuid) -> Hlc { | |
| 27 | 34 | match get_sync_state(conn, "last_hlc") { | |
| 28 | - | Ok(s) if !s.is_empty() => serde_json::from_str(&s).unwrap_or_else(|_| Hlc::zero(node)), | |
| 29 | - | _ => Hlc::zero(node), | |
| 35 | + | Ok(s) if !s.is_empty() => serde_json::from_str(&s).unwrap_or_else(|e| { | |
| 36 | + | // Corrupt persisted clock: do NOT reset to zero. Zero sends the clock | |
| 37 | + | // backward, so a subsequent local edit could mint an HLC that loses to | |
| 38 | + | // an already-committed row — the one place HLC monotonicity isn't | |
| 39 | + | // structurally guaranteed. Seed from physical time instead (forward of | |
| 40 | + | // any sanely-timestamped past HLC); `observe()` advances it past | |
| 41 | + | // anything seen on the next pull. | |
| 42 | + | tracing::warn!("corrupt last_hlc ({e}); seeding clock from physical time"); | |
| 43 | + | Hlc::tick(Hlc::zero(node), now_ms(), node) | |
| 44 | + | }), | |
| 45 | + | _ => Hlc::zero(node), // first sync: genuinely no prior clock | |
| 30 | 46 | } | |
| 31 | 47 | } | |
| 32 | 48 | ||
| 33 | 49 | fn store_clock(conn: &Connection, clock: &Hlc) -> Result<()> { | |
| 34 | - | set_sync_state(conn, "last_hlc", &serde_json::to_string(clock).unwrap_or_default()) | |
| 50 | + | // Hlc is a plain derive-Serialize struct, so serialization cannot fail; make | |
| 51 | + | // that an invariant (panic on the impossible) rather than silently persisting | |
| 52 | + | // an empty string that load_clock would then treat as a corrupt clock. | |
| 53 | + | let json = serde_json::to_string(clock).expect("Hlc always serializes"); | |
| 54 | + | set_sync_state(conn, "last_hlc", &json) | |
| 35 | 55 | } | |
| 36 | 56 | ||
| 37 | 57 | /// The committed HLC for a row, if any — the oracle `CleanChanges::gated` uses to | |
| @@ -53,7 +73,11 @@ pub(crate) fn set_committed(conn: &Connection, table: &str, row_id: &str, hlc: & | |||
| 53 | 73 | conn.execute( | |
| 54 | 74 | "INSERT INTO hlc_ledger (table_name, row_id, hlc) VALUES (?1, ?2, ?3) | |
| 55 | 75 | ON CONFLICT(table_name, row_id) DO UPDATE SET hlc = excluded.hlc", | |
| 56 | - | rusqlite::params![table, row_id, serde_json::to_string(hlc).unwrap_or_default()], | |
| 76 | + | rusqlite::params![ | |
| 77 | + | table, | |
| 78 | + | row_id, | |
| 79 | + | serde_json::to_string(hlc).expect("Hlc always serializes") | |
| 80 | + | ], | |
| 57 | 81 | )?; | |
| 58 | 82 | Ok(()) | |
| 59 | 83 | } | |
| @@ -79,7 +103,10 @@ pub(crate) fn stamp_pending(conn: &Connection, node: Uuid) -> Result<()> { | |||
| 79 | 103 | clock = Hlc::tick(clock, now_ms(), node); | |
| 80 | 104 | conn.execute( | |
| 81 | 105 | "UPDATE sync_changelog SET hlc = ?1 WHERE id = ?2", | |
| 82 | - | rusqlite::params![serde_json::to_string(&clock).unwrap_or_default(), id], | |
| 106 | + | rusqlite::params![ | |
| 107 | + | serde_json::to_string(&clock).expect("Hlc always serializes"), | |
| 108 | + | id | |
| 109 | + | ], | |
| 83 | 110 | )?; | |
| 84 | 111 | } | |
| 85 | 112 | store_clock(conn, &clock) | |
| @@ -149,6 +176,22 @@ mod tests { | |||
| 149 | 176 | } | |
| 150 | 177 | ||
| 151 | 178 | #[test] | |
| 179 | + | fn corrupt_clock_seeds_forward_not_zero() { | |
| 180 | + | let conn = mem(); | |
| 181 | + | let node = Uuid::from_u128(1); | |
| 182 | + | // First sync (empty) is genuinely the zero clock. | |
| 183 | + | assert_eq!(load_clock(&conn, node), Hlc::zero(node)); | |
| 184 | + | // A corrupt persisted clock must NOT reset to zero (that moves the clock | |
| 185 | + | // backward); it seeds from physical time, so it's well ahead of zero. | |
| 186 | + | set_sync_state(&conn, "last_hlc", "{not valid json").unwrap(); | |
| 187 | + | let recovered = load_clock(&conn, node); | |
| 188 | + | assert!( | |
| 189 | + | recovered.wall_ms > 0 && recovered > Hlc::zero(node), | |
| 190 | + | "corrupt clock seeded backward: {recovered:?}" | |
| 191 | + | ); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 152 | 195 | fn ledger_roundtrip_and_gate() { | |
| 153 | 196 | let conn = mem(); | |
| 154 | 197 | let node = Uuid::from_u128(7); |