max / goingson
4 files changed,
+177 insertions,
-16 deletions
| @@ -0,0 +1,22 @@ | |||
| 1 | + | -- Per-row committed HLC, for gating clean remote changes (deep A+ remediation). | |
| 2 | + | -- | |
| 3 | + | -- "Clean" pulled changes (those with no contesting local *pending* edit) were | |
| 4 | + | -- applied blindly. That can clobber a newer value already committed locally when | |
| 5 | + | -- an older remote edit arrives for an already-applied row -- a latent multi-device | |
| 6 | + | -- hazard the SyncKit audit flagged. SyncKit's CleanChanges gate now drops any clean | |
| 7 | + | -- change whose HLC is older-or-equal to the row's committed HLC; this table is the | |
| 8 | + | -- committed-clock store the gate reads. | |
| 9 | + | -- | |
| 10 | + | -- Updated on every applied change -- a local stamp (hlc.rs::assign_pending_hlcs) or | |
| 11 | + | -- a remote apply (pull.rs::apply_changes_inner) -- keeping the maximum HLC per row. | |
| 12 | + | -- The node component is stored as its canonical lowercase UUID string, whose | |
| 13 | + | -- lexicographic order matches the byte order SyncKit's Hlc compares on. | |
| 14 | + | ||
| 15 | + | CREATE TABLE IF NOT EXISTS sync_committed_hlc ( | |
| 16 | + | table_name TEXT NOT NULL, | |
| 17 | + | row_id TEXT NOT NULL, | |
| 18 | + | hlc_wall INTEGER NOT NULL, | |
| 19 | + | hlc_counter INTEGER NOT NULL, | |
| 20 | + | hlc_node TEXT NOT NULL, | |
| 21 | + | PRIMARY KEY (table_name, row_id) | |
| 22 | + | ) WITHOUT ROWID; |
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | use chrono::Utc; | |
| 15 | 15 | use goingson_core::CoreError; | |
| 16 | 16 | use sqlx::SqlitePool; | |
| 17 | + | use std::collections::{HashMap, HashSet}; | |
| 17 | 18 | use synckit_client::Hlc; | |
| 18 | 19 | use uuid::Uuid; | |
| 19 | 20 | ||
| @@ -32,8 +33,9 @@ async fn load_clock(pool: &SqlitePool, node: Uuid) -> Result<Hlc, CoreError> { | |||
| 32 | 33 | /// push and conflict-detection observe identical stamps. Runs in one transaction so a | |
| 33 | 34 | /// crash mid-stamp leaves the clock and the rows consistent (all-or-nothing). | |
| 34 | 35 | pub async fn assign_pending_hlcs(pool: &SqlitePool, device_id: Uuid) -> Result<(), CoreError> { | |
| 35 | - | let ids: Vec<(i64,)> = sqlx::query_as( | |
| 36 | - | "SELECT id FROM sync_changelog WHERE pushed = 0 AND hlc_wall IS NULL ORDER BY id ASC", | |
| 36 | + | let ids: Vec<(i64, String, String)> = sqlx::query_as( | |
| 37 | + | "SELECT id, table_name, row_id FROM sync_changelog \ | |
| 38 | + | WHERE pushed = 0 AND hlc_wall IS NULL ORDER BY id ASC", | |
| 37 | 39 | ) | |
| 38 | 40 | .fetch_all(pool) | |
| 39 | 41 | .await | |
| @@ -52,7 +54,7 @@ pub async fn assign_pending_hlcs(pool: &SqlitePool, device_id: Uuid) -> Result<( | |||
| 52 | 54 | .map_err(CoreError::database)?; | |
| 53 | 55 | let mut clock = Hlc { wall_ms, counter: counter as u32, node: device_id }; | |
| 54 | 56 | ||
| 55 | - | for (id,) in ids { | |
| 57 | + | for (id, table_name, row_id) in ids { | |
| 56 | 58 | clock = Hlc::tick(clock, Utc::now().timestamp_millis(), device_id); | |
| 57 | 59 | sqlx::query("UPDATE sync_changelog SET hlc_wall = ?, hlc_counter = ? WHERE id = ?") | |
| 58 | 60 | .bind(clock.wall_ms) | |
| @@ -61,6 +63,9 @@ pub async fn assign_pending_hlcs(pool: &SqlitePool, device_id: Uuid) -> Result<( | |||
| 61 | 63 | .execute(&mut *tx) | |
| 62 | 64 | .await | |
| 63 | 65 | .map_err(CoreError::database)?; | |
| 66 | + | // The row now holds this local value at `clock`; record it as the row's | |
| 67 | + | // committed HLC so a later older remote edit gates out instead of clobbering it. | |
| 68 | + | record_committed_hlc(&mut *tx, &table_name, &row_id, clock).await?; | |
| 64 | 69 | } | |
| 65 | 70 | ||
| 66 | 71 | sqlx::query("UPDATE hlc_state SET wall_ms = ?, counter = ? WHERE id = 1") | |
| @@ -88,3 +93,82 @@ pub async fn observe_remote(pool: &SqlitePool, remote: Hlc, device_id: Uuid) -> | |||
| 88 | 93 | .map_err(CoreError::database)?; | |
| 89 | 94 | Ok(()) | |
| 90 | 95 | } | |
| 96 | + | ||
| 97 | + | /// Record the committed HLC for a row, keeping the maximum. | |
| 98 | + | /// | |
| 99 | + | /// This is the committed-clock store SyncKit's `CleanChanges` gate reads: a clean | |
| 100 | + | /// remote change older-or-equal to a row's committed HLC is dropped rather than | |
| 101 | + | /// clobbering a newer local value. Called on every applied change — a local stamp | |
| 102 | + | /// ([`assign_pending_hlcs`]) or a remote apply (`pull::apply_changes_inner`) — so | |
| 103 | + | /// the stored clock always reflects the value currently in the row. The | |
| 104 | + | /// `ON CONFLICT ... WHERE` keeps the larger HLC, comparing `(wall, counter, node)` | |
| 105 | + | /// as a row value (node is the canonical lowercase UUID string, whose lexicographic | |
| 106 | + | /// order matches the byte order [`Hlc`] compares on). | |
| 107 | + | pub(crate) async fn record_committed_hlc<'e, E>( | |
| 108 | + | executor: E, | |
| 109 | + | table: &str, | |
| 110 | + | row_id: &str, | |
| 111 | + | hlc: Hlc, | |
| 112 | + | ) -> Result<(), CoreError> | |
| 113 | + | where | |
| 114 | + | E: sqlx::Executor<'e, Database = sqlx::Sqlite>, | |
| 115 | + | { | |
| 116 | + | sqlx::query( | |
| 117 | + | "INSERT INTO sync_committed_hlc (table_name, row_id, hlc_wall, hlc_counter, hlc_node) \ | |
| 118 | + | VALUES (?, ?, ?, ?, ?) \ | |
| 119 | + | ON CONFLICT(table_name, row_id) DO UPDATE SET \ | |
| 120 | + | hlc_wall = excluded.hlc_wall, \ | |
| 121 | + | hlc_counter = excluded.hlc_counter, \ | |
| 122 | + | hlc_node = excluded.hlc_node \ | |
| 123 | + | WHERE (excluded.hlc_wall, excluded.hlc_counter, excluded.hlc_node) > \ | |
| 124 | + | (sync_committed_hlc.hlc_wall, sync_committed_hlc.hlc_counter, sync_committed_hlc.hlc_node)", | |
| 125 | + | ) | |
| 126 | + | .bind(table) | |
| 127 | + | .bind(row_id) | |
| 128 | + | .bind(hlc.wall_ms) | |
| 129 | + | .bind(hlc.counter as i64) | |
| 130 | + | .bind(hlc.node.to_string()) | |
| 131 | + | .execute(executor) | |
| 132 | + | .await | |
| 133 | + | .map_err(CoreError::database)?; | |
| 134 | + | Ok(()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Load the committed HLCs for a set of `(table, row_id)` keys, for pre-fetching | |
| 138 | + | /// before the `CleanChanges` gate. Queries by `row_id` (one statement) and filters | |
| 139 | + | /// the exact `(table, row_id)` pairs in memory, so a row never seen locally is | |
| 140 | + | /// simply absent from the map (the gate then keeps that change). | |
| 141 | + | pub(crate) async fn load_committed_hlcs( | |
| 142 | + | pool: &SqlitePool, | |
| 143 | + | keys: &[(String, String)], | |
| 144 | + | ) -> Result<HashMap<(String, String), Hlc>, CoreError> { | |
| 145 | + | let mut map = HashMap::new(); | |
| 146 | + | if keys.is_empty() { | |
| 147 | + | return Ok(map); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | let placeholders = vec!["?"; keys.len()].join(","); | |
| 151 | + | let sql = format!( | |
| 152 | + | "SELECT table_name, row_id, hlc_wall, hlc_counter, hlc_node \ | |
| 153 | + | FROM sync_committed_hlc WHERE row_id IN ({placeholders})" | |
| 154 | + | ); | |
| 155 | + | let mut query = sqlx::query_as::<_, (String, String, i64, i64, String)>(&sql); | |
| 156 | + | for (_, row_id) in keys { | |
| 157 | + | query = query.bind(row_id); | |
| 158 | + | } | |
| 159 | + | let rows = query.fetch_all(pool).await.map_err(CoreError::database)?; | |
| 160 | + | ||
| 161 | + | let want: HashSet<(&str, &str)> = | |
| 162 | + | keys.iter().map(|(t, r)| (t.as_str(), r.as_str())).collect(); | |
| 163 | + | for (table, row_id, wall, counter, node) in rows { | |
| 164 | + | if want.contains(&(table.as_str(), row_id.as_str())) | |
| 165 | + | && let Ok(node) = Uuid::parse_str(&node) | |
| 166 | + | { | |
| 167 | + | map.insert( | |
| 168 | + | (table, row_id), | |
| 169 | + | Hlc { wall_ms: wall, counter: counter as u32, node }, | |
| 170 | + | ); | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | Ok(map) | |
| 174 | + | } |
| @@ -11,7 +11,7 @@ use tracing::{debug, warn}; | |||
| 11 | 11 | use uuid::Uuid; | |
| 12 | 12 | ||
| 13 | 13 | use super::state::{get_sync_state, set_sync_state}; | |
| 14 | - | use super::hlc::{assign_pending_hlcs, observe_remote}; | |
| 14 | + | use super::hlc::{assign_pending_hlcs, load_committed_hlcs, observe_remote, record_committed_hlc}; | |
| 15 | 15 | use super::{UPSERT_ORDER, DELETE_ORDER}; | |
| 16 | 16 | use super::apply::{apply_upsert, apply_delete}; | |
| 17 | 17 | ||
| @@ -49,13 +49,24 @@ pub async fn pull_changes( | |||
| 49 | 49 | // Detect conflicts between remote changes and local pending | |
| 50 | 50 | let (clean, conflicts) = detect_conflicts(changes, &local_pending, DeviceId::new(device_id)); | |
| 51 | 51 | ||
| 52 | - | // Apply non-conflicting changes directly | |
| 52 | + | // Apply non-conflicting changes — but HLC-gate them first. "Clean" only | |
| 53 | + | // means no local *pending* edit contests the row; an older remote edit for | |
| 54 | + | // an already-committed row also lands here, and applying it blindly would | |
| 55 | + | // clobber the newer local value. Pre-fetch the rows' committed clocks, then | |
| 56 | + | // let SyncKit's CleanChanges gate drop anything older-or-equal. | |
| 53 | 57 | let clean_count = clean.len() as i64; | |
| 54 | 58 | if !clean.is_empty() { | |
| 59 | + | let keys: Vec<(String, String)> = clean | |
| 60 | + | .row_keys() | |
| 61 | + | .map(|(t, r)| (t.to_string(), r.to_string())) | |
| 62 | + | .collect(); | |
| 63 | + | let committed = load_committed_hlcs(pool, &keys).await?; | |
| 55 | 64 | let clean_entries: Vec<ChangeEntry> = | |
| 56 | - | clean.into_iter().map(|p| p.entry).collect(); | |
| 57 | - | changed_tables.extend(clean_entries.iter().map(|e| e.table.clone())); | |
| 58 | - | apply_remote_changes(pool, clean_entries).await?; | |
| 65 | + | clean.gated(|t, r| committed.get(&(t.to_string(), r.to_string())).copied()); | |
| 66 | + | if !clean_entries.is_empty() { | |
| 67 | + | changed_tables.extend(clean_entries.iter().map(|e| e.table.clone())); | |
| 68 | + | apply_remote_changes(pool, clean_entries).await?; | |
| 69 | + | } | |
| 59 | 70 | } | |
| 60 | 71 | ||
| 61 | 72 | // Resolve conflicts with LWW | |
| @@ -285,10 +296,15 @@ pub(crate) async fn apply_changes_inner( | |||
| 285 | 296 | let Some(ref data) = change.data else { | |
| 286 | 297 | continue; | |
| 287 | 298 | }; | |
| 288 | - | if let Err(e) = apply_upsert(&mut *conn, table, &change.row_id, data).await { | |
| 289 | - | warn!(table = *table, row_id = %change.row_id, error = %e, | |
| 290 | - | "Skipping un-appliable remote upsert"); | |
| 291 | - | skipped += 1; | |
| 299 | + | match apply_upsert(&mut *conn, table, &change.row_id, data).await { | |
| 300 | + | Ok(()) => { | |
| 301 | + | record_committed_hlc(&mut *conn, table, &change.row_id, change.hlc).await?; | |
| 302 | + | } | |
| 303 | + | Err(e) => { | |
| 304 | + | warn!(table = *table, row_id = %change.row_id, error = %e, | |
| 305 | + | "Skipping un-appliable remote upsert"); | |
| 306 | + | skipped += 1; | |
| 307 | + | } | |
| 292 | 308 | } | |
| 293 | 309 | } | |
| 294 | 310 | } | |
| @@ -299,10 +315,15 @@ pub(crate) async fn apply_changes_inner( | |||
| 299 | 315 | if change.table != *table { | |
| 300 | 316 | continue; | |
| 301 | 317 | } | |
| 302 | - | if let Err(e) = apply_delete(&mut *conn, table, &change.row_id).await { | |
| 303 | - | warn!(table = *table, row_id = %change.row_id, error = %e, | |
| 304 | - | "Skipping un-appliable remote delete"); | |
| 305 | - | skipped += 1; | |
| 318 | + | match apply_delete(&mut *conn, table, &change.row_id).await { | |
| 319 | + | Ok(()) => { | |
| 320 | + | record_committed_hlc(&mut *conn, table, &change.row_id, change.hlc).await?; | |
| 321 | + | } | |
| 322 | + | Err(e) => { | |
| 323 | + | warn!(table = *table, row_id = %change.row_id, error = %e, | |
| 324 | + | "Skipping un-appliable remote delete"); | |
| 325 | + | skipped += 1; | |
| 326 | + | } | |
| 306 | 327 | } | |
| 307 | 328 | } | |
| 308 | 329 | } |
| @@ -167,6 +167,40 @@ use sqlx::SqlitePool; | |||
| 167 | 167 | assert_eq!(get_sync_state(&pool, "initial_snapshot_done").await.unwrap(), "0"); | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | + | // -- committed HLC store (CleanChanges gate backing) -- | |
| 171 | + | ||
| 172 | + | #[tokio::test] | |
| 173 | + | async fn committed_hlc_records_keeps_max_and_loads() { | |
| 174 | + | use crate::sync_service::hlc::{load_committed_hlcs, record_committed_hlc}; | |
| 175 | + | use synckit_client::Hlc; | |
| 176 | + | ||
| 177 | + | let pool = setup_test_db().await; | |
| 178 | + | let node = uuid::Uuid::new_v4(); | |
| 179 | + | let lo = Hlc { wall_ms: 100, counter: 0, node }; | |
| 180 | + | let hi = Hlc { wall_ms: 200, counter: 0, node }; | |
| 181 | + | let key = ("tasks".to_string(), "r1".to_string()); | |
| 182 | + | ||
| 183 | + | record_committed_hlc(&pool, "tasks", "r1", lo).await.unwrap(); | |
| 184 | + | let m = load_committed_hlcs(&pool, std::slice::from_ref(&key)).await.unwrap(); | |
| 185 | + | assert_eq!(m.get(&key).copied(), Some(lo)); | |
| 186 | + | ||
| 187 | + | // A higher HLC overwrites; a subsequent lower one does NOT (max-keeping). | |
| 188 | + | record_committed_hlc(&pool, "tasks", "r1", hi).await.unwrap(); | |
| 189 | + | record_committed_hlc(&pool, "tasks", "r1", lo).await.unwrap(); | |
| 190 | + | let m = load_committed_hlcs(&pool, std::slice::from_ref(&key)).await.unwrap(); | |
| 191 | + | assert_eq!(m.get(&key).copied(), Some(hi), "older HLC must not overwrite newer committed clock"); | |
| 192 | + | ||
| 193 | + | // A row never recorded is absent (the gate then keeps that change). | |
| 194 | + | let missing = ("tasks".to_string(), "nope".to_string()); | |
| 195 | + | let m = load_committed_hlcs(&pool, std::slice::from_ref(&missing)).await.unwrap(); | |
| 196 | + | assert!(m.is_empty()); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // (The clean-change gate itself — detect_conflicts -> CleanChanges::gated — | |
| 200 | + | // is exercised end-to-end in synckit-client's own conflict tests, which can | |
| 201 | + | // construct the non-exhaustive PulledChange internally. Here we only cover | |
| 202 | + | // GO's committed-clock store that backs the gate.) | |
| 203 | + | ||
| 170 | 204 | // -- apply_upsert -- | |
| 171 | 205 | ||
| 172 | 206 | #[tokio::test] |