| 18 |
18 |
|
use uuid::Uuid;
|
| 19 |
19 |
|
|
| 20 |
20 |
|
use crate::ids::DeviceId;
|
| 21 |
|
- |
use crate::types::{ChangeEntry, PulledChange};
|
|
21 |
+ |
use crate::types::{ChangeEntry, Hlc, PulledChange};
|
| 22 |
22 |
|
|
| 23 |
23 |
|
/// A remote change that conflicts with a local pending change.
|
| 24 |
24 |
|
#[derive(Debug, Clone)]
|
| 29 |
29 |
|
pub local: ChangeEntry,
|
| 30 |
30 |
|
}
|
| 31 |
31 |
|
|
|
32 |
+ |
/// The non-conflicting changes from a pull, withheld behind the HLC gate.
|
|
33 |
+ |
///
|
|
34 |
+ |
/// "Clean" means only that no local *pending* edit contests the row — **not**
|
|
35 |
+ |
/// that the remote change is newer than what is already committed locally. A
|
|
36 |
+ |
/// device that applied and pushed a newer edit, then pulls an older remote edit
|
|
37 |
+ |
/// for the same row, sees that older edit here. Applying it blindly clobbers the
|
|
38 |
+ |
/// newer local value.
|
|
39 |
+ |
///
|
|
40 |
+ |
/// So the applicable entries cannot be read out directly: [`gated`](Self::gated)
|
|
41 |
+ |
/// is the only door, and it requires the row's committed HLC. This makes
|
|
42 |
+ |
/// "apply a clean change without checking the committed clock" unrepresentable
|
|
43 |
+ |
/// rather than a contract the caller has to remember.
|
|
44 |
+ |
#[derive(Debug, Clone)]
|
|
45 |
+ |
pub struct CleanChanges(Vec<PulledChange>);
|
|
46 |
+ |
|
|
47 |
+ |
impl CleanChanges {
|
|
48 |
+ |
/// HLC-gate the clean changes against committed local clocks.
|
|
49 |
+ |
///
|
|
50 |
+ |
/// `committed_hlc(table, row_id)` returns the HLC currently committed for a
|
|
51 |
+ |
/// row, or `None` if it has never been applied locally. A change is dropped
|
|
52 |
+ |
/// when its HLC is older than or equal to the committed clock; everything
|
|
53 |
+ |
/// else is returned in pull order, ready to apply.
|
|
54 |
+ |
pub fn gated(self, committed_hlc: impl Fn(&str, &str) -> Option<Hlc>) -> Vec<ChangeEntry> {
|
|
55 |
+ |
self.0
|
|
56 |
+ |
.into_iter()
|
|
57 |
+ |
.filter(|p| {
|
|
58 |
+ |
committed_hlc(&p.entry.table, &p.entry.row_id)
|
|
59 |
+ |
.is_none_or(|committed| p.entry.hlc > committed)
|
|
60 |
+ |
})
|
|
61 |
+ |
.map(|p| p.entry)
|
|
62 |
+ |
.collect()
|
|
63 |
+ |
}
|
|
64 |
+ |
|
|
65 |
+ |
/// The `(table, row_id)` addresses of the clean changes, so a caller can
|
|
66 |
+ |
/// pre-fetch their committed clocks in one query before [`gated`](Self::gated).
|
|
67 |
+ |
/// Yields addresses only — the change payloads stay sealed until gating.
|
|
68 |
+ |
pub fn row_keys(&self) -> impl Iterator<Item = (&str, &str)> {
|
|
69 |
+ |
self.0
|
|
70 |
+ |
.iter()
|
|
71 |
+ |
.map(|p| (p.entry.table.as_str(), p.entry.row_id.as_str()))
|
|
72 |
+ |
}
|
|
73 |
+ |
|
|
74 |
+ |
/// Number of clean changes before gating (for metrics/logging).
|
|
75 |
+ |
pub fn len(&self) -> usize {
|
|
76 |
+ |
self.0.len()
|
|
77 |
+ |
}
|
|
78 |
+ |
|
|
79 |
+ |
/// Whether there are no clean changes at all.
|
|
80 |
+ |
pub fn is_empty(&self) -> bool {
|
|
81 |
+ |
self.0.is_empty()
|
|
82 |
+ |
}
|
|
83 |
+ |
}
|
|
84 |
+ |
|
| 32 |
85 |
|
/// How a conflict should be resolved.
|
| 33 |
86 |
|
#[derive(Debug, Clone)]
|
| 34 |
87 |
|
pub enum Resolution {
|
| 85 |
138 |
|
remote: Vec<PulledChange>,
|
| 86 |
139 |
|
local_pending: &[ChangeEntry],
|
| 87 |
140 |
|
our_device_id: DeviceId,
|
| 88 |
|
- |
) -> (Vec<PulledChange>, Vec<ConflictPair>) {
|
|
141 |
+ |
) -> (CleanChanges, Vec<ConflictPair>) {
|
| 89 |
142 |
|
// Build lookup: (table, row_id) -> last local pending entry.
|
| 90 |
143 |
|
// If local_pending has duplicates for the same key, the last entry wins.
|
| 91 |
144 |
|
let mut local_map: HashMap<(&str, &str), &ChangeEntry> = HashMap::new();
|
| 114 |
167 |
|
}
|
| 115 |
168 |
|
}
|
| 116 |
169 |
|
|
| 117 |
|
- |
(clean, conflicts)
|
|
170 |
+ |
(CleanChanges(clean), conflicts)
|
| 118 |
171 |
|
}
|
| 119 |
172 |
|
|
| 120 |
173 |
|
/// Last-write-wins resolution by hybrid logical clock.
|
| 367 |
420 |
|
}
|
| 368 |
421 |
|
|
| 369 |
422 |
|
#[test]
|
|
423 |
+ |
fn clean_changes_gate_drops_stale_keeps_newer() {
|
|
424 |
+ |
let our_device = Uuid::new_v4();
|
|
425 |
+ |
let other_device = Uuid::new_v4();
|
|
426 |
+ |
let now = Utc::now();
|
|
427 |
+ |
// A clean remote change for tasks/r1; its HLC wall == now_ms.
|
|
428 |
+ |
let remote = vec![make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1)];
|
|
429 |
+ |
let (clean, _conflicts) = detect_conflicts(remote, &[], DeviceId::new(our_device));
|
|
430 |
+ |
assert_eq!(clean.len(), 1);
|
|
431 |
+ |
|
|
432 |
+ |
// No committed clock for the row → kept (first time we've seen it).
|
|
433 |
+ |
assert_eq!(clean.clone().gated(|_, _| None).len(), 1);
|
|
434 |
+ |
// Committed clock older than the remote → kept.
|
|
435 |
+ |
assert_eq!(clean.clone().gated(|_, _| Some(Hlc::zero(other_device))).len(), 1);
|
|
436 |
+ |
// Committed clock newer than the remote → dropped (would clobber newer local).
|
|
437 |
+ |
let newer = Hlc { wall_ms: now.timestamp_millis() + 1, counter: 0, node: other_device };
|
|
438 |
+ |
assert!(clean.gated(move |_, _| Some(newer)).is_empty());
|
|
439 |
+ |
}
|
|
440 |
+ |
|
|
441 |
+ |
#[test]
|
| 370 |
442 |
|
fn different_tables_same_row_id_no_conflict() {
|
| 371 |
443 |
|
let our_device = Uuid::new_v4();
|
| 372 |
444 |
|
let other_device = Uuid::new_v4();
|