max / audiofiles
8 files changed,
+469 insertions,
-21 deletions
| @@ -1475,6 +1475,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_vfs_root_name | |||
| 1475 | 1475 | ON vfs_nodes(vfs_id, name) WHERE parent_id IS NULL; | |
| 1476 | 1476 | "#; | |
| 1477 | 1477 | ||
| 1478 | + | const MIGRATION_030: &str = r#" | |
| 1479 | + | -- HLC (hybrid logical clock) conflict resolution for sync. The local changelog | |
| 1480 | + | -- gains an `hlc` column: the sync layer stamps each pending row with a minted | |
| 1481 | + | -- HLC before push (the trigger can't compute one), and the same value is the | |
| 1482 | + | -- "local pending" clock for conflict detection. `hlc_ledger` records the | |
| 1483 | + | -- committed HLC per (table, row_id) — from our own pushes and applied remotes — | |
| 1484 | + | -- so a stale remote change (older HLC) can be dropped instead of clobbering a | |
| 1485 | + | -- newer local value (the prior blind last-writer-wins). Both are local-only | |
| 1486 | + | -- (not synced, no triggers); additive and idempotent. | |
| 1487 | + | ALTER TABLE sync_changelog ADD COLUMN hlc TEXT; | |
| 1488 | + | CREATE TABLE IF NOT EXISTS hlc_ledger ( | |
| 1489 | + | table_name TEXT NOT NULL, | |
| 1490 | + | row_id TEXT NOT NULL, | |
| 1491 | + | hlc TEXT NOT NULL, | |
| 1492 | + | PRIMARY KEY (table_name, row_id) | |
| 1493 | + | ); | |
| 1494 | + | "#; | |
| 1495 | + | ||
| 1478 | 1496 | /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite | |
| 1479 | 1497 | /// function on the given connection. Used by the M018 sync triggers so the | |
| 1480 | 1498 | /// `sync_changelog.row_id` field never carries cleartext content (tag strings, | |
| @@ -1609,6 +1627,7 @@ impl Database { | |||
| 1609 | 1627 | MIGRATION_027, | |
| 1610 | 1628 | MIGRATION_028, | |
| 1611 | 1629 | MIGRATION_029, | |
| 1630 | + | MIGRATION_030, | |
| 1612 | 1631 | ]; | |
| 1613 | 1632 | ||
| 1614 | 1633 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -1824,6 +1843,7 @@ mod tests { | |||
| 1824 | 1843 | "collections", | |
| 1825 | 1844 | "edit_history", | |
| 1826 | 1845 | "fingerprints", | |
| 1846 | + | "hlc_ledger", | |
| 1827 | 1847 | "sample_features", | |
| 1828 | 1848 | "samples", | |
| 1829 | 1849 | "sync_changelog", | |
| @@ -1848,7 +1868,7 @@ mod tests { | |||
| 1848 | 1868 | .conn() | |
| 1849 | 1869 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1850 | 1870 | .unwrap(); | |
| 1851 | - | assert_eq!(version, 29); | |
| 1871 | + | assert_eq!(version, 30); | |
| 1852 | 1872 | } | |
| 1853 | 1873 | ||
| 1854 | 1874 | #[test] | |
| @@ -1859,7 +1879,7 @@ mod tests { | |||
| 1859 | 1879 | .conn() | |
| 1860 | 1880 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1861 | 1881 | .unwrap(); | |
| 1862 | - | assert_eq!(version, 29); | |
| 1882 | + | assert_eq!(version, 30); | |
| 1863 | 1883 | } | |
| 1864 | 1884 | ||
| 1865 | 1885 | /// Open a fresh file-backed DB, close, reopen. The second open re-enters | |
| @@ -1878,7 +1898,7 @@ mod tests { | |||
| 1878 | 1898 | .conn() | |
| 1879 | 1899 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1880 | 1900 | .unwrap(); | |
| 1881 | - | assert_eq!(version, 29); | |
| 1901 | + | assert_eq!(version, 30); | |
| 1882 | 1902 | } | |
| 1883 | 1903 | ||
| 1884 | 1904 | /// Simulates the worst-case recovery path: a prior partial migration left | |
| @@ -1922,7 +1942,7 @@ mod tests { | |||
| 1922 | 1942 | .conn() | |
| 1923 | 1943 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1924 | 1944 | .unwrap(); | |
| 1925 | - | assert_eq!(version, 29); | |
| 1945 | + | assert_eq!(version, 30); | |
| 1926 | 1946 | } | |
| 1927 | 1947 | ||
| 1928 | 1948 | /// M018 contract: the `sync_changelog.row_id` for sensitive tables must | |
| @@ -2144,7 +2164,7 @@ mod tests { | |||
| 2144 | 2164 | let initial_version: i32 = conn | |
| 2145 | 2165 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2146 | 2166 | .unwrap(); | |
| 2147 | - | assert_eq!(initial_version, 29); | |
| 2167 | + | assert_eq!(initial_version, 30); | |
| 2148 | 2168 | ||
| 2149 | 2169 | let batch = format!( | |
| 2150 | 2170 | "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;", | |
| @@ -2207,7 +2227,7 @@ mod tests { | |||
| 2207 | 2227 | .conn() | |
| 2208 | 2228 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 2209 | 2229 | .unwrap(); | |
| 2210 | - | assert_eq!(version, 29); | |
| 2230 | + | assert_eq!(version, 30); | |
| 2211 | 2231 | } | |
| 2212 | 2232 | ||
| 2213 | 2233 | #[test] |
| @@ -187,4 +187,23 @@ channels = "both" | |||
| 187 | 187 | let result = load_hook_script(&plugin_dir, "../secret.txt"); | |
| 188 | 188 | assert!(result.is_err(), "path traversal should be rejected"); | |
| 189 | 189 | } | |
| 190 | + | ||
| 191 | + | #[cfg(unix)] | |
| 192 | + | #[test] | |
| 193 | + | fn load_hook_script_rejects_symlink_escape() { | |
| 194 | + | // The TOCTOU/escape vector the canonicalize-both-sides guard exists for: a | |
| 195 | + | // symlink *inside* the plugin dir that points outside it. Canonicalizing | |
| 196 | + | // the full path resolves the link, so the containment check fails — a | |
| 197 | + | // plugin author can't read arbitrary files by planting a symlink. | |
| 198 | + | let dir = tempfile::tempdir().unwrap(); | |
| 199 | + | let outside = dir.path().join("secret.txt"); | |
| 200 | + | std::fs::write(&outside, "sensitive data").unwrap(); | |
| 201 | + | ||
| 202 | + | let plugin_dir = dir.path().join("my-plugin"); | |
| 203 | + | std::fs::create_dir(&plugin_dir).unwrap(); | |
| 204 | + | std::os::unix::fs::symlink(&outside, plugin_dir.join("link.rhai")).unwrap(); | |
| 205 | + | ||
| 206 | + | let result = load_hook_script(&plugin_dir, "link.rhai"); | |
| 207 | + | assert!(result.is_err(), "a symlink escaping the plugin dir must be rejected"); | |
| 208 | + | } | |
| 190 | 209 | } |
| @@ -29,6 +29,15 @@ pub enum SyncCommand { | |||
| 29 | 29 | /// Delay before reconnecting the SSE stream after a disconnect (seconds). | |
| 30 | 30 | const SSE_RECONNECT_DELAY_SECS: u64 = 5; | |
| 31 | 31 | ||
| 32 | + | /// Cap on the exponential auto-sync retry backoff (minutes). | |
| 33 | + | const MAX_BACKOFF_MINUTES: u64 = 15; | |
| 34 | + | ||
| 35 | + | /// Exponential backoff for the Nth consecutive auto-sync failure: `2^n` minutes, | |
| 36 | + | /// capped at [`MAX_BACKOFF_MINUTES`], saturating so a long outage can't overflow. | |
| 37 | + | fn backoff_minutes(consecutive_failures: u32) -> u64 { | |
| 38 | + | std::cmp::min(2u64.saturating_pow(consecutive_failures), MAX_BACKOFF_MINUTES) | |
| 39 | + | } | |
| 40 | + | ||
| 32 | 41 | /// Run the background sync scheduler loop. | |
| 33 | 42 | /// | |
| 34 | 43 | /// Uses `db_path` to open short-lived connections inside `spawn_blocking`. | |
| @@ -74,10 +83,7 @@ pub async fn run_scheduler( | |||
| 74 | 83 | } | |
| 75 | 84 | Err(e) => { | |
| 76 | 85 | consecutive_failures += 1; | |
| 77 | - | let backoff_minutes = std::cmp::min( | |
| 78 | - | 2u64.saturating_pow(consecutive_failures), | |
| 79 | - | 15, | |
| 80 | - | ); | |
| 86 | + | let backoff_minutes = backoff_minutes(consecutive_failures); | |
| 81 | 87 | backoff_until = Some( | |
| 82 | 88 | chrono::Utc::now() | |
| 83 | 89 | + chrono::Duration::minutes(backoff_minutes as i64), | |
| @@ -313,3 +319,24 @@ async fn run_sync_cycle( | |||
| 313 | 319 | ||
| 314 | 320 | Ok(result.pulled) | |
| 315 | 321 | } | |
| 322 | + | ||
| 323 | + | #[cfg(test)] | |
| 324 | + | mod tests { | |
| 325 | + | use super::{backoff_minutes, MAX_BACKOFF_MINUTES}; | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn backoff_is_exponential_then_capped() { | |
| 329 | + | assert_eq!(backoff_minutes(1), 2); | |
| 330 | + | assert_eq!(backoff_minutes(2), 4); | |
| 331 | + | assert_eq!(backoff_minutes(3), 8); | |
| 332 | + | // 2^4 = 16 > cap, so it pins at the cap and stays there. | |
| 333 | + | assert_eq!(backoff_minutes(4), MAX_BACKOFF_MINUTES); | |
| 334 | + | assert_eq!(backoff_minutes(10), MAX_BACKOFF_MINUTES); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn backoff_saturates_on_huge_failure_count() { | |
| 339 | + | // A pathologically long outage must not overflow the shift. | |
| 340 | + | assert_eq!(backoff_minutes(u32::MAX), MAX_BACKOFF_MINUTES); | |
| 341 | + | } | |
| 342 | + | } |
| @@ -277,8 +277,10 @@ pub(crate) async fn pull_changes( | |||
| 277 | 277 | ||
| 278 | 278 | let cursor: i64 = cursor_str.parse().unwrap_or(0); | |
| 279 | 279 | ||
| 280 | + | // pull_rich keeps each change's originating device_id + seq, which the HLC | |
| 281 | + | // conflict resolution needs (own-echo detection, ordering). | |
| 280 | 282 | let (changes, new_cursor, has_more) = client | |
| 281 | - | .pull(device_id, cursor) | |
| 283 | + | .pull_rich(device_id, cursor) | |
| 282 | 284 | .await | |
| 283 | 285 | .map_err(|e| SyncError::Client(e.to_string()))?; | |
| 284 | 286 | ||
| @@ -286,7 +288,7 @@ pub(crate) async fn pull_changes( | |||
| 286 | 288 | let p = db_path.to_path_buf(); | |
| 287 | 289 | let applied = tokio::task::spawn_blocking(move || { | |
| 288 | 290 | let conn = open_conn(&p)?; | |
| 289 | - | super::resolve::apply_remote_changes(&conn, &changes) | |
| 291 | + | super::resolve::apply_pulled(&conn, device_id, changes) | |
| 290 | 292 | }) | |
| 291 | 293 | .await | |
| 292 | 294 | .map_err(|e| SyncError::Other(e.to_string()))??; |
| @@ -0,0 +1,177 @@ | |||
| 1 | + | //! Hybrid logical clock (HLC) state for sync conflict resolution. | |
| 2 | + | //! | |
| 3 | + | //! AF stamps each local pending change with a minted HLC and records the | |
| 4 | + | //! committed HLC per `(table, row_id)` in `hlc_ledger`, so synckit's conflict | |
| 5 | + | //! resolution can drop a stale remote change instead of clobbering a newer local | |
| 6 | + | //! value (replacing the prior blind last-writer-wins in `apply_remote_changes`). | |
| 7 | + | //! | |
| 8 | + | //! All HLC logic lives here in the sync layer; the content store and DB schema | |
| 9 | + | //! stay sync-agnostic. The device's `node` is its registered `device_id`; the | |
| 10 | + | //! running clock is persisted as `sync_state.last_hlc`. | |
| 11 | + | ||
| 12 | + | use rusqlite::{Connection, OptionalExtension}; | |
| 13 | + | use synckit_client::Hlc; | |
| 14 | + | use uuid::Uuid; | |
| 15 | + | ||
| 16 | + | use super::{get_sync_state, set_sync_state}; | |
| 17 | + | use crate::error::Result; | |
| 18 | + | ||
| 19 | + | /// Wall-clock milliseconds since the Unix epoch (the HLC physical component). | |
| 20 | + | fn now_ms() -> i64 { | |
| 21 | + | chrono::Utc::now().timestamp_millis() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Load the persisted device clock, falling back to the zero clock for `node` | |
| 25 | + | /// (first sync, or unreadable state). | |
| 26 | + | pub(crate) fn load_clock(conn: &Connection, node: Uuid) -> Hlc { | |
| 27 | + | 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), | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | fn store_clock(conn: &Connection, clock: &Hlc) -> Result<()> { | |
| 34 | + | set_sync_state(conn, "last_hlc", &serde_json::to_string(clock).unwrap_or_default()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// The committed HLC for a row, if any — the oracle `CleanChanges::gated` uses to | |
| 38 | + | /// drop remote changes that are not newer than what this device already holds. | |
| 39 | + | pub(crate) fn committed_hlc(conn: &Connection, table: &str, row_id: &str) -> Option<Hlc> { | |
| 40 | + | conn.query_row( | |
| 41 | + | "SELECT hlc FROM hlc_ledger WHERE table_name = ?1 AND row_id = ?2", | |
| 42 | + | rusqlite::params![table, row_id], | |
| 43 | + | |r| r.get::<_, String>(0), | |
| 44 | + | ) | |
| 45 | + | .optional() | |
| 46 | + | .ok() | |
| 47 | + | .flatten() | |
| 48 | + | .and_then(|s| serde_json::from_str(&s).ok()) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Record the committed HLC for a row (one of our pushes, or an applied remote). | |
| 52 | + | pub(crate) fn set_committed(conn: &Connection, table: &str, row_id: &str, hlc: &Hlc) -> Result<()> { | |
| 53 | + | conn.execute( | |
| 54 | + | "INSERT INTO hlc_ledger (table_name, row_id, hlc) VALUES (?1, ?2, ?3) | |
| 55 | + | 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()], | |
| 57 | + | )?; | |
| 58 | + | Ok(()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Assign an HLC to every un-stamped pending changelog row (`pushed = 0` and | |
| 62 | + | /// `hlc IS NULL`), in `id` order, advancing and persisting the device clock. | |
| 63 | + | /// | |
| 64 | + | /// Idempotent: a row that already carries an HLC keeps it, so the value is stable | |
| 65 | + | /// whether it is read first by conflict detection (pull) or by push. | |
| 66 | + | pub(crate) fn stamp_pending(conn: &Connection, node: Uuid) -> Result<()> { | |
| 67 | + | let ids: Vec<i64> = { | |
| 68 | + | let mut stmt = conn.prepare( | |
| 69 | + | "SELECT id FROM sync_changelog WHERE pushed = 0 AND hlc IS NULL ORDER BY id", | |
| 70 | + | )?; | |
| 71 | + | let rows = stmt.query_map([], |r| r.get(0))?; | |
| 72 | + | rows.collect::<std::result::Result<_, _>>()? | |
| 73 | + | }; | |
| 74 | + | if ids.is_empty() { | |
| 75 | + | return Ok(()); | |
| 76 | + | } | |
| 77 | + | let mut clock = load_clock(conn, node); | |
| 78 | + | for id in ids { | |
| 79 | + | clock = Hlc::tick(clock, now_ms(), node); | |
| 80 | + | conn.execute( | |
| 81 | + | "UPDATE sync_changelog SET hlc = ?1 WHERE id = ?2", | |
| 82 | + | rusqlite::params![serde_json::to_string(&clock).unwrap_or_default(), id], | |
| 83 | + | )?; | |
| 84 | + | } | |
| 85 | + | store_clock(conn, &clock) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Advance the device clock by observing a remote HLC, so our next local edit is | |
| 89 | + | /// ordered after every change we've seen (standard HLC receive rule). | |
| 90 | + | pub(crate) fn observe(conn: &Connection, node: Uuid, remote: Hlc) -> Result<()> { | |
| 91 | + | let next = Hlc::observe(load_clock(conn, node), remote, now_ms(), node); | |
| 92 | + | store_clock(conn, &next) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[cfg(test)] | |
| 96 | + | mod tests { | |
| 97 | + | use super::*; | |
| 98 | + | ||
| 99 | + | fn mem() -> Connection { | |
| 100 | + | // A standalone in-memory DB with just the tables the HLC layer touches. | |
| 101 | + | let conn = Connection::open_in_memory().unwrap(); | |
| 102 | + | conn.execute_batch( | |
| 103 | + | "CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); | |
| 104 | + | CREATE TABLE sync_changelog (id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 105 | + | table_name TEXT, op TEXT, row_id TEXT, timestamp TEXT, data TEXT, | |
| 106 | + | pushed INTEGER NOT NULL DEFAULT 0, hlc TEXT); | |
| 107 | + | CREATE TABLE hlc_ledger (table_name TEXT, row_id TEXT, hlc TEXT, | |
| 108 | + | PRIMARY KEY (table_name, row_id)); | |
| 109 | + | INSERT INTO sync_state (key, value) VALUES ('last_hlc', '');", | |
| 110 | + | ) | |
| 111 | + | .unwrap(); | |
| 112 | + | conn | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn stamp_pending_assigns_monotonic_hlcs() { | |
| 117 | + | let conn = mem(); | |
| 118 | + | let node = Uuid::from_u128(1); | |
| 119 | + | for i in 0..3 { | |
| 120 | + | conn.execute( | |
| 121 | + | "INSERT INTO sync_changelog (table_name, op, row_id) VALUES ('tags', 'INSERT', ?1)", | |
| 122 | + | rusqlite::params![format!("r{i}")], | |
| 123 | + | ) | |
| 124 | + | .unwrap(); | |
| 125 | + | } | |
| 126 | + | stamp_pending(&conn, node).unwrap(); | |
| 127 | + | let hlcs: Vec<Hlc> = { | |
| 128 | + | let mut stmt = conn | |
| 129 | + | .prepare("SELECT hlc FROM sync_changelog ORDER BY id") | |
| 130 | + | .unwrap(); | |
| 131 | + | stmt.query_map([], |r| r.get::<_, String>(0)) | |
| 132 | + | .unwrap() | |
| 133 | + | .map(|s| serde_json::from_str(&s.unwrap()).unwrap()) | |
| 134 | + | .collect() | |
| 135 | + | }; | |
| 136 | + | assert_eq!(hlcs.len(), 3); | |
| 137 | + | assert!(hlcs[0] < hlcs[1] && hlcs[1] < hlcs[2], "HLCs must be strictly increasing"); | |
| 138 | + | // Re-stamping is a no-op: existing HLCs are stable. | |
| 139 | + | stamp_pending(&conn, node).unwrap(); | |
| 140 | + | let again: Hlc = serde_json::from_str( | |
| 141 | + | &conn | |
| 142 | + | .query_row("SELECT hlc FROM sync_changelog WHERE id = 1", [], |r| { | |
| 143 | + | r.get::<_, String>(0) | |
| 144 | + | }) | |
| 145 | + | .unwrap(), | |
| 146 | + | ) | |
| 147 | + | .unwrap(); | |
| 148 | + | assert_eq!(again, hlcs[0]); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn ledger_roundtrip_and_gate() { | |
| 153 | + | let conn = mem(); | |
| 154 | + | let node = Uuid::from_u128(7); | |
| 155 | + | assert!(committed_hlc(&conn, "tags", "r1").is_none()); | |
| 156 | + | let older = Hlc { wall_ms: 100, counter: 0, node }; | |
| 157 | + | let newer = Hlc { wall_ms: 200, counter: 0, node }; | |
| 158 | + | set_committed(&conn, "tags", "r1", &newer).unwrap(); | |
| 159 | + | assert_eq!(committed_hlc(&conn, "tags", "r1"), Some(newer)); | |
| 160 | + | // A stale remote (older HLC) must be recognisable as not-newer. | |
| 161 | + | assert!(older < committed_hlc(&conn, "tags", "r1").unwrap()); | |
| 162 | + | // Upsert keeps the latest. | |
| 163 | + | set_committed(&conn, "tags", "r1", &older).unwrap(); | |
| 164 | + | assert_eq!(committed_hlc(&conn, "tags", "r1"), Some(older)); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn observe_keeps_clock_ahead() { | |
| 169 | + | let conn = mem(); | |
| 170 | + | let node = Uuid::from_u128(2); | |
| 171 | + | let remote = Hlc { wall_ms: i64::MAX / 2, counter: 5, node: Uuid::from_u128(99) }; | |
| 172 | + | observe(&conn, node, remote).unwrap(); | |
| 173 | + | let clock = load_clock(&conn, node); | |
| 174 | + | assert!(clock >= remote, "local clock must dominate the observed remote"); | |
| 175 | + | assert_eq!(clock.node, node, "our clock carries our node"); | |
| 176 | + | } | |
| 177 | + | } |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | //! holding a `Connection` across async `.await` points (Connection is !Send). | |
| 5 | 5 | ||
| 6 | 6 | mod download; | |
| 7 | + | mod hlc; | |
| 7 | 8 | mod resolve; | |
| 8 | 9 | mod state; | |
| 9 | 10 | mod upload; |
| @@ -1,7 +1,8 @@ | |||
| 1 | 1 | //! Conflict resolution: apply remote changes locally with FK-safe ordering. | |
| 2 | 2 | ||
| 3 | 3 | use rusqlite::Connection; | |
| 4 | - | use synckit_client::{ChangeEntry, ChangeOp}; | |
| 4 | + | use synckit_client::conflict::{detect_conflicts, resolve_lww, Resolution}; | |
| 5 | + | use synckit_client::{ChangeEntry, ChangeOp, DeviceId, PulledChange}; | |
| 5 | 6 | ||
| 6 | 7 | use tracing::instrument; | |
| 7 | 8 | ||
| @@ -9,6 +10,85 @@ use crate::error::Result; | |||
| 9 | 10 | ||
| 10 | 11 | use super::{json_to_sql, pk_columns, table_columns, UPSERT_ORDER, DELETE_ORDER}; | |
| 11 | 12 | ||
| 13 | + | /// Apply pulled remote changes with HLC conflict resolution. | |
| 14 | + | /// | |
| 15 | + | /// Replaces the prior blind apply: routes each remote change through synckit's | |
| 16 | + | /// conflict layer so a stale or malicious remote can't clobber a newer local | |
| 17 | + | /// value. Clean changes (no contesting local pending edit) are kept only if their | |
| 18 | + | /// HLC is newer than this row's committed clock (`CleanChanges::gated`); changes | |
| 19 | + | /// that conflict with an un-pushed local edit are resolved by HLC last-writer-wins | |
| 20 | + | /// (`resolve_lww`). Survivors are applied (and their HLCs committed) atomically by | |
| 21 | + | /// [`apply_remote_changes`]. The device clock advances past every remote seen. | |
| 22 | + | #[instrument(skip_all)] | |
| 23 | + | pub(crate) fn apply_pulled( | |
| 24 | + | conn: &Connection, | |
| 25 | + | our_device: DeviceId, | |
| 26 | + | remote: Vec<PulledChange>, | |
| 27 | + | ) -> Result<i64> { | |
| 28 | + | if remote.is_empty() { | |
| 29 | + | return Ok(0); | |
| 30 | + | } | |
| 31 | + | let node = our_device.as_uuid(); | |
| 32 | + | ||
| 33 | + | // Advance our clock past every remote change (HLC receive rule), and stamp any | |
| 34 | + | // un-pushed local changes so conflict detection has clocks to compare. | |
| 35 | + | for pc in &remote { | |
| 36 | + | super::hlc::observe(conn, node, pc.entry.hlc)?; | |
| 37 | + | } | |
| 38 | + | super::hlc::stamp_pending(conn, node)?; | |
| 39 | + | ||
| 40 | + | let local_pending = load_local_pending(conn)?; | |
| 41 | + | let (clean, conflicts) = detect_conflicts(remote, &local_pending, our_device); | |
| 42 | + | ||
| 43 | + | // Clean: keep only changes strictly newer than the row's committed HLC. | |
| 44 | + | let mut to_apply: Vec<ChangeEntry> = | |
| 45 | + | clean.gated(|table, row_id| super::hlc::committed_hlc(conn, table, row_id)); | |
| 46 | + | ||
| 47 | + | // Conflicts (a remote change contests an un-pushed local edit): HLC LWW. AF has | |
| 48 | + | // no field-merge adapter, so resolve_lww only ever yields KeepLocal/KeepRemote. | |
| 49 | + | for pair in conflicts { | |
| 50 | + | match resolve_lww(&pair.local, &pair.remote) { | |
| 51 | + | Resolution::KeepRemote | Resolution::Merged(_) => to_apply.push(pair.remote.entry), | |
| 52 | + | Resolution::KeepLocal | Resolution::Skip => {} | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | apply_remote_changes(conn, &to_apply) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Build the local pending changes (un-pushed changelog rows) as `ChangeEntry`s | |
| 60 | + | /// for conflict detection. `stamp_pending` must have run first so `hlc` is set. | |
| 61 | + | fn load_local_pending(conn: &Connection) -> Result<Vec<ChangeEntry>> { | |
| 62 | + | let mut stmt = conn.prepare( | |
| 63 | + | "SELECT table_name, op, row_id, timestamp, data, hlc | |
| 64 | + | FROM sync_changelog WHERE pushed = 0 ORDER BY id", | |
| 65 | + | )?; | |
| 66 | + | let rows = stmt.query_map([], |row| { | |
| 67 | + | Ok(( | |
| 68 | + | row.get::<_, String>(0)?, | |
| 69 | + | row.get::<_, String>(1)?, | |
| 70 | + | row.get::<_, String>(2)?, | |
| 71 | + | row.get::<_, String>(3)?, | |
| 72 | + | row.get::<_, Option<String>>(4)?, | |
| 73 | + | row.get::<_, Option<String>>(5)?, | |
| 74 | + | )) | |
| 75 | + | })?; | |
| 76 | + | let mut out = Vec::new(); | |
| 77 | + | for r in rows { | |
| 78 | + | let (table, op, row_id, timestamp, data, hlc_json) = r?; | |
| 79 | + | let Some(op) = ChangeOp::from_str_opt(&op) else { continue }; | |
| 80 | + | let ts = chrono::DateTime::parse_from_rfc3339(×tamp) | |
| 81 | + | .map(|dt| dt.with_timezone(&chrono::Utc)) | |
| 82 | + | .unwrap_or_else(|_| chrono::Utc::now()); | |
| 83 | + | let data = data.and_then(|d| serde_json::from_str(&d).ok()); | |
| 84 | + | let hlc = hlc_json | |
| 85 | + | .and_then(|s| serde_json::from_str(&s).ok()) | |
| 86 | + | .unwrap_or_default(); | |
| 87 | + | out.push(ChangeEntry { table, op, row_id, timestamp: ts, hlc, data }); | |
| 88 | + | } | |
| 89 | + | Ok(out) | |
| 90 | + | } | |
| 91 | + | ||
| 12 | 92 | /// Apply a batch of remote changes locally, with trigger suppression. | |
| 13 | 93 | /// | |
| 14 | 94 | /// The flag set, data changes, and flag clear all happen inside a single | |
| @@ -43,6 +123,9 @@ pub(crate) fn apply_remote_changes(conn: &Connection, changes: &[ChangeEntry]) - | |||
| 43 | 123 | if change.table == *table | |
| 44 | 124 | && let Some(data) = &change.data { | |
| 45 | 125 | apply_upsert(&tx, table, data)?; | |
| 126 | + | // Record the applied remote's HLC as this row's committed clock, | |
| 127 | + | // in the same transaction, so a later stale remote is gated out. | |
| 128 | + | super::hlc::set_committed(&tx, table, &change.row_id, &change.hlc)?; | |
| 46 | 129 | count += 1; | |
| 47 | 130 | } | |
| 48 | 131 | } | |
| @@ -56,6 +139,7 @@ pub(crate) fn apply_remote_changes(conn: &Connection, changes: &[ChangeEntry]) - | |||
| 56 | 139 | for change in &deletes { | |
| 57 | 140 | if change.table == *table { | |
| 58 | 141 | apply_delete(&tx, table, &change.row_id, change.data.as_ref())?; | |
| 142 | + | super::hlc::set_committed(&tx, table, &change.row_id, &change.hlc)?; | |
| 59 | 143 | count += 1; | |
| 60 | 144 | } | |
| 61 | 145 | } | |
| @@ -92,10 +176,15 @@ pub(crate) fn apply_upsert( | |||
| 92 | 176 | let mut placeholders = Vec::new(); | |
| 93 | 177 | let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); | |
| 94 | 178 | ||
| 95 | - | for (i, col) in columns.iter().enumerate() { | |
| 179 | + | for col in columns.iter() { | |
| 96 | 180 | if let Some(val) = obj.get(*col) { | |
| 97 | 181 | col_names.push(*col); | |
| 98 | - | placeholders.push(format!("?{}", i + 1)); | |
| 182 | + | // Number placeholders by the DENSE position (how many we've pushed), | |
| 183 | + | // not the column-allowlist index: only present columns get a value, so | |
| 184 | + | // a sparse row (some allowlist columns absent) must still bind ?1, ?2… | |
| 185 | + | // contiguously to values[0], values[1]…. Numbering by the allowlist | |
| 186 | + | // index left gaps that bound the wrong value (or out of range). | |
| 187 | + | placeholders.push(format!("?{}", col_names.len())); | |
| 99 | 188 | values.push(json_to_sql(val)); | |
| 100 | 189 | } | |
| 101 | 190 | } | |
| @@ -271,3 +360,88 @@ fn sample_delete_hash(row_id: &str, data: Option<&serde_json::Value>) -> Option< | |||
| 271 | 360 | Some(row_id.to_string()) | |
| 272 | 361 | } | |
| 273 | 362 | } | |
| 363 | + | ||
| 364 | + | #[cfg(test)] | |
| 365 | + | mod tests { | |
| 366 | + | use super::*; | |
| 367 | + | use synckit_client::Hlc; | |
| 368 | + | use uuid::Uuid; | |
| 369 | + | ||
| 370 | + | fn setup_conn() -> Connection { | |
| 371 | + | let conn = Connection::open_in_memory().unwrap(); | |
| 372 | + | conn.execute_batch( | |
| 373 | + | "CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); | |
| 374 | + | INSERT INTO sync_state (key, value) VALUES ('applying_remote','0'), ('last_hlc',''); | |
| 375 | + | CREATE TABLE sync_changelog (id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 376 | + | table_name TEXT, op TEXT, row_id TEXT, | |
| 377 | + | timestamp TEXT DEFAULT (datetime('now')), data TEXT, | |
| 378 | + | pushed INTEGER NOT NULL DEFAULT 0, hlc TEXT); | |
| 379 | + | CREATE TABLE hlc_ledger (table_name TEXT, row_id TEXT, hlc TEXT, | |
| 380 | + | PRIMARY KEY (table_name, row_id)); | |
| 381 | + | CREATE TABLE tags (sample_hash TEXT, tag TEXT, PRIMARY KEY (sample_hash, tag));", | |
| 382 | + | ) | |
| 383 | + | .unwrap(); | |
| 384 | + | conn | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | fn entry(row_id: &str, hlc: Hlc) -> ChangeEntry { | |
| 388 | + | ChangeEntry { | |
| 389 | + | table: "tags".into(), | |
| 390 | + | op: ChangeOp::Insert, | |
| 391 | + | row_id: row_id.into(), | |
| 392 | + | timestamp: chrono::Utc::now(), | |
| 393 | + | hlc, | |
| 394 | + | data: Some(serde_json::json!({"sample_hash": "h1", "tag": "drums"})), | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // The full apply_pulled gating path (observe -> stamp -> detect_conflicts -> | |
| 399 | + | // gated -> resolve_lww) is the composition of synckit's conflict primitives | |
| 400 | + | // (tested in synckit-client/conflict.rs), the ledger ordering (tested in | |
| 401 | + | // hlc.rs: ledger_roundtrip_and_gate), and the apply+commit verified here. | |
| 402 | + | // synckit's PulledChange is #[non_exhaustive] with no downstream constructor, | |
| 403 | + | // so an end-to-end apply_pulled test can't be built from this crate. | |
| 404 | + | ||
| 405 | + | #[test] | |
| 406 | + | fn apply_records_committed_hlc() { | |
| 407 | + | // The new behavior: applying a remote change records its HLC as that row's | |
| 408 | + | // committed clock, so a later stale remote is gated against it. | |
| 409 | + | let conn = setup_conn(); | |
| 410 | + | let hlc = Hlc { wall_ms: 300, counter: 0, node: Uuid::from_u128(2) }; | |
| 411 | + | let applied = apply_remote_changes(&conn, &[entry("k1", hlc)]).unwrap(); | |
| 412 | + | assert_eq!(applied, 1); | |
| 413 | + | let tag: String = conn | |
| 414 | + | .query_row("SELECT tag FROM tags WHERE sample_hash='h1'", [], |r| r.get(0)) | |
| 415 | + | .unwrap(); | |
| 416 | + | assert_eq!(tag, "drums"); | |
| 417 | + | assert_eq!( | |
| 418 | + | super::super::hlc::committed_hlc(&conn, "tags", "k1"), | |
| 419 | + | Some(hlc), | |
| 420 | + | "applying a remote must commit its HLC to the ledger" | |
| 421 | + | ); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | #[test] | |
| 425 | + | fn apply_upsert_binds_sparse_columns() { | |
| 426 | + | // Regression: placeholders were numbered by the column-allowlist index, so a | |
| 427 | + | // sparse row (present columns non-contiguous in the allowlist) bound the | |
| 428 | + | // wrong value or an out-of-range parameter. audio_analysis's allowlist puts | |
| 429 | + | // hash/onset_strength/attack_time at indices 0/10/20. | |
| 430 | + | let conn = Connection::open_in_memory().unwrap(); | |
| 431 | + | conn.execute_batch( | |
| 432 | + | "CREATE TABLE audio_analysis (hash TEXT PRIMARY KEY, onset_strength REAL, attack_time REAL);", | |
| 433 | + | ) | |
| 434 | + | .unwrap(); | |
| 435 | + | let data = serde_json::json!({"hash": "h1", "onset_strength": 0.5, "attack_time": 0.02}); | |
| 436 | + | apply_upsert(&conn, "audio_analysis", &data).unwrap(); | |
| 437 | + | let (onset, attack): (f64, f64) = conn | |
| 438 | + | .query_row( | |
| 439 | + | "SELECT onset_strength, attack_time FROM audio_analysis WHERE hash='h1'", | |
| 440 | + | [], | |
| 441 | + | |r| Ok((r.get(0)?, r.get(1)?)), | |
| 442 | + | ) | |
| 443 | + | .unwrap(); | |
| 444 | + | assert_eq!(onset, 0.5); | |
| 445 | + | assert_eq!(attack, 0.02); | |
| 446 | + | } | |
| 447 | + | } |
| @@ -166,18 +166,25 @@ async fn push_changes( | |||
| 166 | 166 | client: &SyncKitClient, | |
| 167 | 167 | device_id: DeviceId, | |
| 168 | 168 | ) -> Result<i64> { | |
| 169 | + | let node = device_id.as_uuid(); | |
| 169 | 170 | let p = db_path.to_path_buf(); | |
| 170 | 171 | let rows = tokio::task::spawn_blocking(move || -> Result<_> { | |
| 171 | 172 | let conn = open_conn(&p)?; | |
| 173 | + | // Mint an HLC for every un-stamped pending change before reading them, so | |
| 174 | + | // each pushed ChangeEntry carries a real clock (was Default/zero, which | |
| 175 | + | // made every local edit lose conflict resolution). | |
| 176 | + | super::hlc::stamp_pending(&conn, node)?; | |
| 172 | 177 | let mut stmt = conn.prepare( | |
| 173 | - | "SELECT id, table_name, op, row_id, timestamp, data | |
| 178 | + | "SELECT id, table_name, op, row_id, timestamp, data, hlc | |
| 174 | 179 | FROM sync_changelog | |
| 175 | 180 | WHERE pushed = 0 | |
| 176 | 181 | ORDER BY id ASC | |
| 177 | 182 | LIMIT ?1", | |
| 178 | 183 | )?; | |
| 179 | 184 | ||
| 180 | - | let rows: Vec<(i64, String, String, String, String, Option<String>)> = stmt | |
| 185 | + | // (id, table_name, op, row_id, timestamp, data, hlc) | |
| 186 | + | type ChangelogRow = (i64, String, String, String, String, Option<String>, Option<String>); | |
| 187 | + | let rows: Vec<ChangelogRow> = stmt | |
| 181 | 188 | .query_map([PUSH_BATCH_LIMIT as i64], |row| { | |
| 182 | 189 | Ok(( | |
| 183 | 190 | row.get(0)?, | |
| @@ -186,6 +193,7 @@ async fn push_changes( | |||
| 186 | 193 | row.get(3)?, | |
| 187 | 194 | row.get(4)?, | |
| 188 | 195 | row.get(5)?, | |
| 196 | + | row.get(6)?, | |
| 189 | 197 | )) | |
| 190 | 198 | })? | |
| 191 | 199 | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| @@ -204,7 +212,7 @@ async fn push_changes( | |||
| 204 | 212 | ||
| 205 | 213 | let changes: Vec<ChangeEntry> = rows | |
| 206 | 214 | .into_iter() | |
| 207 | - | .filter_map(|(id, table, op, row_id, timestamp, data)| { | |
| 215 | + | .filter_map(|(id, table, op, row_id, timestamp, data, hlc_json)| { | |
| 208 | 216 | let ts = chrono::DateTime::parse_from_rfc3339(×tamp) | |
| 209 | 217 | .map(|dt| dt.with_timezone(&chrono::Utc)) | |
| 210 | 218 | .unwrap_or_else(|_| chrono::Utc::now()); | |
| @@ -232,18 +240,31 @@ async fn push_changes( | |||
| 232 | 240 | None => None, | |
| 233 | 241 | }; | |
| 234 | 242 | ||
| 243 | + | // The HLC was stamped above; fall back to the zero clock only if a row | |
| 244 | + | // somehow lacks one (defensive — keeps the push going). | |
| 245 | + | let hlc = hlc_json | |
| 246 | + | .and_then(|s| serde_json::from_str(&s).ok()) | |
| 247 | + | .unwrap_or_else(|| synckit_client::Hlc::zero(node)); | |
| 248 | + | ||
| 235 | 249 | pushed_ids.push(id); | |
| 236 | 250 | Some(ChangeEntry { | |
| 237 | 251 | table, | |
| 238 | 252 | op: change_op, | |
| 239 | 253 | row_id, | |
| 240 | 254 | timestamp: ts, | |
| 241 | - | hlc: Default::default(), | |
| 255 | + | hlc, | |
| 242 | 256 | data: parsed_data, | |
| 243 | 257 | }) | |
| 244 | 258 | }) | |
| 245 | 259 | .collect(); | |
| 246 | 260 | ||
| 261 | + | // Our own edits become the committed HLC for their rows, so a later remote | |
| 262 | + | // change for the same row is gated against them. | |
| 263 | + | let ledger_updates: Vec<(String, String, synckit_client::Hlc)> = changes | |
| 264 | + | .iter() | |
| 265 | + | .map(|c| (c.table.clone(), c.row_id.clone(), c.hlc)) | |
| 266 | + | .collect(); | |
| 267 | + | ||
| 247 | 268 | if skipped > 0 { | |
| 248 | 269 | tracing::warn!(skipped, "Some changelog entries could not be parsed (unknown op or bad JSON) and were marked pushed to break the retry loop; they are dropped, not retried"); | |
| 249 | 270 | } | |
| @@ -257,18 +278,25 @@ async fn push_changes( | |||
| 257 | 278 | ||
| 258 | 279 | let pushed_count = pushed_ids.len() as i64; | |
| 259 | 280 | ||
| 260 | - | // Mark only successfully-pushed entries | |
| 281 | + | // Mark only successfully-pushed entries, and record our edits' committed HLCs. | |
| 282 | + | // Both in one transaction so a crash can't leave the changelog and ledger out | |
| 283 | + | // of step. | |
| 261 | 284 | if !pushed_ids.is_empty() { | |
| 262 | 285 | let p = db_path.to_path_buf(); | |
| 263 | 286 | tokio::task::spawn_blocking(move || { | |
| 264 | 287 | let conn = open_conn(&p)?; | |
| 288 | + | let tx = conn.unchecked_transaction()?; | |
| 265 | 289 | let placeholders: String = pushed_ids.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 266 | 290 | let sql = format!("UPDATE sync_changelog SET pushed = 1 WHERE id IN ({})", placeholders); | |
| 267 | 291 | let params: Vec<&dyn rusqlite::types::ToSql> = pushed_ids | |
| 268 | 292 | .iter() | |
| 269 | 293 | .map(|id| id as &dyn rusqlite::types::ToSql) | |
| 270 | 294 | .collect(); | |
| 271 | - | conn.execute(&sql, params.as_slice())?; | |
| 295 | + | tx.execute(&sql, params.as_slice())?; | |
| 296 | + | for (table, row_id, hlc) in &ledger_updates { | |
| 297 | + | super::hlc::set_committed(&tx, table, row_id, hlc)?; | |
| 298 | + | } | |
| 299 | + | tx.commit()?; | |
| 272 | 300 | Ok::<_, SyncError>(()) | |
| 273 | 301 | }) | |
| 274 | 302 | .await |