max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+80 insertions,
-11 deletions
| @@ -126,7 +126,7 @@ | |||
| 126 | 126 | /// Record a row's committed HLC, advancing only (never regress the ledger). | |
| 127 | 127 | pub fn set_committed(conn: &Connection, table: &str, row_id: &str, hlc: &Hlc) -> Result<()> { | |
| 128 | 128 | if let Some(existing) = committed_hlc(conn, table, row_id)? | |
| 129 | - | && cmp_hlc(&existing, hlc).is_ge() | |
| 129 | + | && existing >= *hlc | |
| 130 | 130 | { | |
| 131 | 131 | return Ok(()); | |
| 132 | 132 | } | |
| @@ -234,7 +234,7 @@ | |||
| 234 | 234 | } | |
| 235 | 235 | // A merge keeps both sides' fields, so nothing was thrown away. | |
| 236 | 236 | Resolution::Merged(data) => { | |
| 237 | - | let hlc = max_hlc(pair.local.hlc, pair.remote.entry.hlc); | |
| 237 | + | let hlc = pair.local.hlc.max(pair.remote.entry.hlc); | |
| 238 | 238 | resolved.push(ChangeEntry { | |
| 239 | 239 | table: pair.remote.entry.table, | |
| 240 | 240 | op: ChangeOp::Update, | |
| @@ -344,7 +344,7 @@ | |||
| 344 | 344 | for e in entries { | |
| 345 | 345 | let key = (e.table.clone(), e.row_id.clone()); | |
| 346 | 346 | match best.get(&key) { | |
| 347 | - | Some(&i) if cmp_hlc(&kept[i].hlc, &e.hlc).is_ge() => {} | |
| 347 | + | Some(&i) if kept[i].hlc >= e.hlc => {} | |
| 348 | 348 | Some(&i) => kept[i] = e, | |
| 349 | 349 | None => { | |
| 350 | 350 | best.insert(key, kept.len()); | |
| @@ -355,14 +355,6 @@ | |||
| 355 | 355 | kept | |
| 356 | 356 | } | |
| 357 | 357 | ||
| 358 | - | fn cmp_hlc(a: &Hlc, b: &Hlc) -> std::cmp::Ordering { | |
| 359 | - | (a.wall_ms, a.counter, a.node.as_uuid()).cmp(&(b.wall_ms, b.counter, b.node.as_uuid())) | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | fn max_hlc(a: Hlc, b: Hlc) -> Hlc { | |
| 363 | - | if cmp_hlc(&a, &b).is_ge() { a } else { b } | |
| 364 | - | } | |
| 365 | - | ||
| 366 | 358 | #[cfg(test)] | |
| 367 | 359 | mod tests { | |
| 368 | 360 | use super::super::apply::apply_remote_changes; | |
| @@ -423,6 +415,83 @@ | |||
| 423 | 415 | } | |
| 424 | 416 | } | |
| 425 | 417 | ||
| 418 | + | /// A bare entry for the collapse tests: only table, row_id, op and HLC | |
| 419 | + | /// matter there, so the payload names the entry for the assertion message. | |
| 420 | + | fn entry(row_id: &str, op: ChangeOp, wall_ms: i64, node_n: u128, label: &str) -> ChangeEntry { | |
| 421 | + | ChangeEntry { | |
| 422 | + | table: "note".into(), | |
| 423 | + | op, | |
| 424 | + | row_id: row_id.into(), | |
| 425 | + | timestamp: Utc::now(), | |
| 426 | + | hlc: Hlc { | |
| 427 | + | wall_ms, | |
| 428 | + | counter: 0, | |
| 429 | + | node: node(node_n), | |
| 430 | + | }, | |
| 431 | + | data: Some(serde_json::json!({ "name": label })), | |
| 432 | + | extra: serde_json::Map::default(), | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | fn labels(entries: &[ChangeEntry]) -> Vec<String> { | |
| 437 | + | entries | |
| 438 | + | .iter() | |
| 439 | + | .map(|e| e.data.as_ref().unwrap()["name"].as_str().unwrap().into()) | |
| 440 | + | .collect() | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | /// The collapse keeps the highest HLC per row, and it has to do so whichever | |
| 444 | + | /// order the entries arrive in. Both directions are asserted because the | |
| 445 | + | /// obvious way to get this wrong, comparing in the wrong direction, is | |
| 446 | + | /// invisible when only the already-sorted order is tested: it then keeps the | |
| 447 | + | /// last entry, which is also the newest. | |
| 448 | + | #[test] | |
| 449 | + | fn collapse_keeps_the_highest_hlc_per_row_in_either_order() { | |
| 450 | + | let older = entry("r1", ChangeOp::Update, 100, 1, "older"); | |
| 451 | + | let newer = entry("r1", ChangeOp::Update, 200, 1, "newer"); | |
| 452 | + | ||
| 453 | + | let ascending = collapse_max_hlc(vec![older.clone(), newer.clone()]); | |
| 454 | + | assert_eq!(labels(&ascending), ["newer"], "newest lost, arriving last"); | |
| 455 | + | ||
| 456 | + | let descending = collapse_max_hlc(vec![newer, older]); | |
| 457 | + | assert_eq!( | |
| 458 | + | labels(&descending), | |
| 459 | + | ["newer"], | |
| 460 | + | "newest lost, arriving first" | |
| 461 | + | ); | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// Operation-agnostic: a newer delete beats an older edit and an older | |
| 465 | + | /// delete loses to a newer edit. The HLC decides, never the operation. | |
| 466 | + | #[test] | |
| 467 | + | fn collapse_ignores_the_operation() { | |
| 468 | + | let newer_delete = collapse_max_hlc(vec![ | |
| 469 | + | entry("r1", ChangeOp::Update, 100, 1, "edit"), | |
| 470 | + | entry("r1", ChangeOp::Delete, 200, 1, "delete"), | |
| 471 | + | ]); | |
| 472 | + | assert_eq!(newer_delete.len(), 1); | |
| 473 | + | assert_eq!(newer_delete[0].op, ChangeOp::Delete); | |
| 474 | + | ||
| 475 | + | let older_delete = collapse_max_hlc(vec![ | |
| 476 | + | entry("r1", ChangeOp::Delete, 100, 1, "delete"), | |
| 477 | + | entry("r1", ChangeOp::Update, 200, 1, "edit"), | |
| 478 | + | ]); | |
| 479 | + | assert_eq!(older_delete.len(), 1); | |
| 480 | + | assert_eq!(older_delete[0].op, ChangeOp::Update); | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | /// The collapse is per row: distinct rows all survive, and first-seen order | |
| 484 | + | /// is preserved, which is what the doc comment promises the apply engine. | |
| 485 | + | #[test] | |
| 486 | + | fn collapse_is_per_row_and_keeps_first_seen_order() { | |
| 487 | + | let out = collapse_max_hlc(vec![ | |
| 488 | + | entry("r2", ChangeOp::Update, 100, 1, "r2-old"), | |
| 489 | + | entry("r1", ChangeOp::Update, 100, 1, "r1-only"), | |
| 490 | + | entry("r2", ChangeOp::Update, 200, 1, "r2-new"), | |
| 491 | + | ]); | |
| 492 | + | assert_eq!(labels(&out), ["r2-new", "r1-only"]); | |
| 493 | + | } | |
| 494 | + | ||
| 426 | 495 | fn note_name(conn: &Connection, id: &str) -> Option<String> { | |
| 427 | 496 | conn.query_row("SELECT name FROM note WHERE id = ?1", [id], |r| r.get(0)) | |
| 428 | 497 | .optional() |