| 11 |
11 |
|
//! - [`resolve_field_merge`]: 3-way JSON object merge using a base version
|
| 12 |
12 |
|
//! - [`ConflictResolver`]: trait for custom resolution strategies
|
| 13 |
13 |
|
|
|
14 |
+ |
use std::cmp::Ordering;
|
| 14 |
15 |
|
use std::collections::HashMap;
|
| 15 |
16 |
|
|
| 16 |
17 |
|
use chrono::{DateTime, Utc};
|
| 51 |
52 |
|
/// row, or `None` if it has never been applied locally. A change is dropped
|
| 52 |
53 |
|
/// when its HLC is older than or equal to the committed clock; everything
|
| 53 |
54 |
|
/// else is returned in pull order, ready to apply.
|
|
55 |
+ |
///
|
|
56 |
+ |
/// Samples the wall clock once for the poisoning check; use
|
|
57 |
+ |
/// [`gated_at`](Self::gated_at) to pin `now` across a whole sync batch (or in
|
|
58 |
+ |
/// tests) so every change in the batch is classified against the same instant.
|
| 54 |
59 |
|
pub fn gated(self, committed_hlc: impl Fn(&str, &str) -> Option<Hlc>) -> Vec<ChangeEntry> {
|
| 55 |
|
- |
let now = Utc::now();
|
|
60 |
+ |
self.gated_at(Utc::now(), committed_hlc)
|
|
61 |
+ |
}
|
|
62 |
+ |
|
|
63 |
+ |
/// [`gated`](Self::gated) with an explicit `now`, so the clock-poisoning
|
|
64 |
+ |
/// classification is deterministic (testable) and consistent across a batch.
|
|
65 |
+ |
///
|
|
66 |
+ |
/// The poisoning threshold is still fundamentally wall-clock-relative: two
|
|
67 |
+ |
/// devices whose clocks straddle the drift boundary by less than their skew
|
|
68 |
+ |
/// can classify a near-boundary entry differently. Pinning `now` per batch
|
|
69 |
+ |
/// removes the *within-device* drift between the batch's first and last change;
|
|
70 |
+ |
/// the residual cross-device boundary window is the bounded, documented
|
|
71 |
+ |
/// tradeoff of [`MAX_HLC_DRIFT_MS`] (see [`resolve_lww_at`]).
|
|
72 |
+ |
pub fn gated_at(
|
|
73 |
+ |
self,
|
|
74 |
+ |
now: DateTime<Utc>,
|
|
75 |
+ |
committed_hlc: impl Fn(&str, &str) -> Option<Hlc>,
|
|
76 |
+ |
) -> Vec<ChangeEntry> {
|
| 56 |
77 |
|
self.0
|
| 57 |
78 |
|
.into_iter()
|
| 58 |
79 |
|
.filter(|p| {
|
| 132 |
153 |
|
/// Split pulled changes into non-conflicting and conflicting sets.
|
| 133 |
154 |
|
///
|
| 134 |
155 |
|
/// A conflict exists when a remote change and a local pending change both
|
| 135 |
|
- |
/// modify the same `(table, row_id)` from different devices. Changes from
|
| 136 |
|
- |
/// our own device (echoes) are never treated as conflicts.
|
|
156 |
+ |
/// modify the same `(table, row_id)`. Classification is by *contest*, not by the
|
|
157 |
+ |
/// server-asserted `device_id`: any pulled change that a local pending edit
|
|
158 |
+ |
/// contests is resolved, even one labeled as our own echo — trusting that label
|
|
159 |
+ |
/// would let a server relabel a hostile row as our echo to route it around
|
|
160 |
+ |
/// conflict detection. `our_device_id` is used only to log that spoof signal. A
|
|
161 |
+ |
/// pulled change with no contesting local edit is clean (a genuine echo, or a
|
|
162 |
+ |
/// remote change we hold no competing edit for); it is still HLC-gated at apply
|
|
163 |
+ |
/// time via [`CleanChanges::gated`].
|
| 137 |
164 |
|
///
|
| 138 |
165 |
|
/// **Precondition:** `local_pending` should contain at most one entry per
|
| 139 |
166 |
|
/// `(table, row_id)`. If duplicates exist, only the last one participates
|
| 170 |
197 |
|
let mut conflicts = Vec::new();
|
| 171 |
198 |
|
|
| 172 |
199 |
|
for pulled in remote {
|
| 173 |
|
- |
// Our own echo — never a conflict
|
| 174 |
|
- |
if pulled.device_id == our_device_id {
|
| 175 |
|
- |
clean.push(pulled);
|
| 176 |
|
- |
continue;
|
| 177 |
|
- |
}
|
| 178 |
|
- |
|
| 179 |
200 |
|
let key = (pulled.entry.table.as_str(), pulled.entry.row_id.as_str());
|
| 180 |
201 |
|
if let Some(&local_entry) = local_map.get(&key) {
|
|
202 |
+ |
// A pending local edit contests this row. Resolve it regardless of the
|
|
203 |
+ |
// asserted device_id: a change claiming to be our echo while contesting
|
|
204 |
+ |
// an un-pushed local edit is either a benign re-pull of an in-flight
|
|
205 |
+ |
// change or a server relabeling a hostile row to skip detection. Either
|
|
206 |
+ |
// way, resolve rather than trust the label — a genuine echo resolves to
|
|
207 |
+ |
// an identical value.
|
|
208 |
+ |
if pulled.device_id == our_device_id {
|
|
209 |
+ |
tracing::debug!(
|
|
210 |
+ |
table = %pulled.entry.table,
|
|
211 |
+ |
row_id = %pulled.entry.row_id,
|
|
212 |
+ |
"pulled change claims our device_id but contests a pending local edit; resolving as a conflict",
|
|
213 |
+ |
);
|
|
214 |
+ |
}
|
| 181 |
215 |
|
conflicts.push(ConflictPair {
|
| 182 |
216 |
|
remote: pulled,
|
| 183 |
217 |
|
local: local_entry.clone(),
|
| 263 |
297 |
|
}
|
| 264 |
298 |
|
_ => {}
|
| 265 |
299 |
|
}
|
| 266 |
|
- |
let resolution = match local.hlc.cmp(&remote.entry.hlc) {
|
| 267 |
|
- |
std::cmp::Ordering::Greater => Resolution::KeepLocal,
|
| 268 |
|
- |
std::cmp::Ordering::Less => Resolution::KeepRemote,
|
| 269 |
|
- |
std::cmp::Ordering::Equal => {
|
| 270 |
|
- |
if canonical_payload(&local.data) >= canonical_payload(&remote.entry.data) {
|
| 271 |
|
- |
Resolution::KeepLocal
|
| 272 |
|
- |
} else {
|
| 273 |
|
- |
Resolution::KeepRemote
|
| 274 |
|
- |
}
|
| 275 |
|
- |
}
|
|
300 |
+ |
let resolution = match resolve_tie(
|
|
301 |
+ |
&local.hlc,
|
|
302 |
+ |
&canonical_payload(&local.data),
|
|
303 |
+ |
&remote.entry.hlc,
|
|
304 |
+ |
&canonical_payload(&remote.entry.data),
|
|
305 |
+ |
) {
|
|
306 |
+ |
Ordering::Less => Resolution::KeepRemote,
|
|
307 |
+ |
// Greater or an exact tie both keep local: at a true tie the two changes
|
|
308 |
+ |
// are byte-identical, so keeping either side converges.
|
|
309 |
+ |
Ordering::Greater | Ordering::Equal => Resolution::KeepLocal,
|
| 276 |
310 |
|
};
|
| 277 |
311 |
|
// Every LWW resolution discards one side; log at debug so a lost edit is
|
| 278 |
312 |
|
// diagnosable after the fact rather than vanishing silently.
|
| 285 |
319 |
|
resolution
|
| 286 |
320 |
|
}
|
| 287 |
321 |
|
|
| 288 |
|
- |
/// Deterministic byte encoding of a row payload for the exact-HLC tiebreak.
|
| 289 |
|
- |
/// `serde_json`'s default `Map` is sorted, so equal values always serialize to
|
| 290 |
|
- |
/// equal bytes on every device — the property the tiebreak relies on.
|
|
322 |
+ |
/// The single device-independent tiebreak both resolvers route through.
|
|
323 |
+ |
///
|
|
324 |
+ |
/// Orders two conflicting changes by full [`Hlc`] first — globally unique via its
|
|
325 |
+ |
/// `node`, so both devices compute the identical result — then breaks an *exact*
|
|
326 |
+ |
/// HLC tie on the canonical payload bytes, a value every device derives the same
|
|
327 |
+ |
/// way. [`Ordering::Greater`] means the `a` side wins.
|
|
328 |
+ |
///
|
|
329 |
+ |
/// One primitive is the point. The old field-merge rule broke ties on "local
|
|
330 |
+ |
/// wins" (device-relative → device A keeps `a`, device B keeps `b`, permanent
|
|
331 |
+ |
/// silent divergence) and on bare wall-`ms` (a tie window orders of magnitude
|
|
332 |
+ |
/// wider than the exact-HLC tie here). With both [`resolve_lww_at`] and
|
|
333 |
+ |
/// [`resolve_field_merge`] deferring to this, a device-relative winner rule has
|
|
334 |
+ |
/// no code path left.
|
|
335 |
+ |
fn resolve_tie(a_hlc: &Hlc, a_payload: &[u8], b_hlc: &Hlc, b_payload: &[u8]) -> Ordering {
|
|
336 |
+ |
a_hlc.cmp(b_hlc).then_with(|| a_payload.cmp(b_payload))
|
|
337 |
+ |
}
|
|
338 |
+ |
|
|
339 |
+ |
/// Deterministic byte encoding of a concrete JSON value for the exact-HLC
|
|
340 |
+ |
/// tiebreak. `serde_json`'s default `Map` is sorted, so equal values always
|
|
341 |
+ |
/// serialize to equal bytes on every device — the property the tiebreak relies on.
|
| 291 |
342 |
|
///
|
| 292 |
343 |
|
/// INVARIANT: this convergence holds only while `serde_json` keeps insertion
|
| 293 |
344 |
|
/// order OFF (i.e. the `preserve_order` feature is NOT enabled anywhere in the
|
| 295 |
346 |
|
/// insertion-dependent and two devices can compute different tiebreak bytes for
|
| 296 |
347 |
|
/// equal values — divergence. The `canonical_payload_sorts_map_keys` test pins
|
| 297 |
348 |
|
/// this (it fails the moment insertion order leaks in).
|
|
349 |
+ |
fn canonical_value(v: &serde_json::Value) -> Vec<u8> {
|
|
350 |
+ |
serde_json::to_vec(v).unwrap_or_default()
|
|
351 |
+ |
}
|
|
352 |
+ |
|
|
353 |
+ |
/// [`canonical_value`] for an optional payload; `None` (a delete) canonicalizes
|
|
354 |
+ |
/// to empty bytes.
|
| 298 |
355 |
|
fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> {
|
| 299 |
|
- |
match data {
|
| 300 |
|
- |
Some(v) => serde_json::to_vec(v).unwrap_or_default(),
|
| 301 |
|
- |
None => Vec::new(),
|
| 302 |
|
- |
}
|
|
356 |
+ |
data.as_ref().map(canonical_value).unwrap_or_default()
|
| 303 |
357 |
|
}
|
| 304 |
358 |
|
|
| 305 |
359 |
|
/// 3-way field-level merge for JSON objects (top-level keys only).
|
| 308 |
362 |
|
/// each side changed, then merges non-overlapping changes. For overlapping
|
| 309 |
363 |
|
/// fields, the newer timestamp wins.
|
| 310 |
364 |
|
///
|
|
365 |
+ |
/// The overlapping-field winner (and the no-base fallback) is decided by
|
|
366 |
+ |
/// [`resolve_tie`] over the two sides' HLCs, not by a device-relative "ties go to
|
|
367 |
+ |
/// local" rule — so two devices resolving the mirror image of the same conflict
|
|
368 |
+ |
/// converge on the same merged object. Pass each side's [`Hlc`] (`local_hlc` from
|
|
369 |
+ |
/// the local [`ChangeEntry`], `remote_hlc` from the pulled entry).
|
|
370 |
+ |
///
|
| 311 |
371 |
|
/// If any input is not a JSON object (including `Value::Null` for a missing base
|
| 312 |
372 |
|
/// snapshot), a field-level merge is impossible, so this falls back to
|
| 313 |
|
- |
/// last-writer-wins on the timestamps rather than unconditionally keeping remote
|
| 314 |
|
- |
/// — which would silently discard a strictly-newer local edit whenever no base
|
| 315 |
|
- |
/// is available (e.g. a first-ever edit).
|
|
373 |
+ |
/// last-writer-wins on the HLCs rather than unconditionally keeping remote —
|
|
374 |
+ |
/// which would silently discard a strictly-newer local edit whenever no base is
|
|
375 |
+ |
/// available (e.g. a first-ever edit).
|
| 316 |
376 |
|
pub fn resolve_field_merge(
|
| 317 |
377 |
|
local: &serde_json::Value,
|
| 318 |
378 |
|
remote: &serde_json::Value,
|
| 319 |
379 |
|
base: &serde_json::Value,
|
| 320 |
|
- |
local_ts: DateTime<Utc>,
|
| 321 |
|
- |
remote_ts: DateTime<Utc>,
|
|
380 |
+ |
local_hlc: &Hlc,
|
|
381 |
+ |
remote_hlc: &Hlc,
|
| 322 |
382 |
|
) -> Resolution {
|
|
383 |
+ |
// Device-independent winner of a contested field (and of the whole entry in
|
|
384 |
+ |
// the no-base fallback): identical on every device, so the merge converges.
|
|
385 |
+ |
// `>= Equal` keeps local; only a strict `Less` hands the field to remote.
|
|
386 |
+ |
let local_wins = resolve_tie(
|
|
387 |
+ |
local_hlc,
|
|
388 |
+ |
&canonical_value(local),
|
|
389 |
+ |
remote_hlc,
|
|
390 |
+ |
&canonical_value(remote),
|
|
391 |
+ |
) != Ordering::Less;
|
|
392 |
+ |
|
| 323 |
393 |
|
let (Some(local_obj), Some(remote_obj), Some(base_obj)) = (
|
| 324 |
394 |
|
local.as_object(),
|
| 325 |
395 |
|
remote.as_object(),
|
| 326 |
396 |
|
base.as_object(),
|
| 327 |
397 |
|
) else {
|
| 328 |
|
- |
// No usable base: last-writer-wins on timestamp, keeping local when it is
|
| 329 |
|
- |
// strictly newer instead of dropping it.
|
| 330 |
|
- |
let keep_local = local_ts > remote_ts;
|
|
398 |
+ |
// No usable base: last-writer-wins on the HLCs, keeping local when it wins
|
|
399 |
+ |
// the tie instead of dropping it.
|
| 331 |
400 |
|
tracing::debug!(
|
| 332 |
|
- |
keep_local,
|
| 333 |
|
- |
%local_ts,
|
| 334 |
|
- |
%remote_ts,
|
| 335 |
|
- |
"field-merge base unavailable; fell back to timestamp LWW"
|
|
401 |
+ |
local_wins,
|
|
402 |
+ |
?local_hlc,
|
|
403 |
+ |
?remote_hlc,
|
|
404 |
+ |
"field-merge base unavailable; fell back to HLC LWW"
|
| 336 |
405 |
|
);
|
| 337 |
|
- |
return if keep_local {
|
|
406 |
+ |
return if local_wins {
|
| 338 |
407 |
|
Resolution::KeepLocal
|
| 339 |
408 |
|
} else {
|
| 340 |
409 |
|
Resolution::KeepRemote
|
| 397 |
466 |
|
}
|
| 398 |
467 |
|
}
|
| 399 |
468 |
|
|
| 400 |
|
- |
// Apply remote changes (non-overlapping only, or newer timestamp wins)
|
|
469 |
+ |
// Apply remote changes (non-overlapping only, or remote won the tie)
|
| 401 |
470 |
|
for (key, val) in &remote_changed {
|
| 402 |
471 |
|
if local_changed.contains_key(key) {
|
| 403 |
|
- |
// Overlapping: newer timestamp wins, ties go to local
|
| 404 |
|
- |
if remote_ts > local_ts {
|
|
472 |
+ |
// Overlapping: the device-independent winner decides every contested
|
|
473 |
+ |
// field the same way. When remote wins, apply its value; when local
|
|
474 |
+ |
// wins, local was already applied above.
|
|
475 |
+ |
if !local_wins {
|
| 405 |
476 |
|
match val {
|
| 406 |
477 |
|
Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
|
| 407 |
478 |
|
None => { result.remove(*key); }
|
| 408 |
479 |
|
}
|
| 409 |
480 |
|
}
|
| 410 |
|
- |
// else: local already applied above
|
| 411 |
481 |
|
} else {
|
| 412 |
482 |
|
// Non-overlapping: apply remote
|
| 413 |
483 |
|
match val {
|
| 428 |
498 |
|
|
| 429 |
499 |
|
/// Fixed node for locally-minted test entries, distinct from any random
|
| 430 |
500 |
|
/// `other_device`, so HLC tiebreaks are deterministic.
|
| 431 |
|
- |
fn local_node() -> Uuid {
|
| 432 |
|
- |
Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111)
|
|
501 |
+ |
fn local_node() -> DeviceId {
|
|
502 |
+ |
DeviceId::new(Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111))
|
|
503 |
+ |
}
|
|
504 |
+ |
|
|
505 |
+ |
/// A second fixed device node, distinct from [`local_node`], for the
|
|
506 |
+ |
/// field-merge tests that need to name the remote side's clock.
|
|
507 |
+ |
fn remote_node() -> DeviceId {
|
|
508 |
+ |
DeviceId::new(Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222))
|
|
509 |
+ |
}
|
|
510 |
+ |
|
|
511 |
+ |
/// Map a wall-clock timestamp onto an HLC at `node`, so the timestamp-ordered
|
|
512 |
+ |
/// field-merge tests express the same intent against the HLC-based API. A
|
|
513 |
+ |
/// strictly later `ts` yields a strictly greater HLC (higher `wall_ms`); equal
|
|
514 |
+ |
/// `ts` on distinct nodes ties on the node — which is exactly the convergent
|
|
515 |
+ |
/// behavior the F1 fix guarantees.
|
|
516 |
+ |
fn ts_hlc(ts: DateTime<Utc>, node: DeviceId) -> Hlc {
|
|
517 |
+ |
Hlc::from_legacy(ts.timestamp_millis(), node)
|
| 433 |
518 |
|
}
|
| 434 |
519 |
|
|
| 435 |
520 |
|
fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry {
|
| 455 |
540 |
|
seq: i64,
|
| 456 |
541 |
|
) -> PulledChange {
|
| 457 |
542 |
|
let mut entry = make_entry(table, row_id, op, ts);
|
| 458 |
|
- |
entry.hlc = Hlc::from_legacy(ts.timestamp_millis(), device_id);
|
|
543 |
+ |
entry.hlc = Hlc::from_legacy(ts.timestamp_millis(), DeviceId::new(device_id));
|
| 459 |
544 |
|
PulledChange { entry, device_id: DeviceId::new(device_id), seq }
|
| 460 |
545 |
|
}
|
| 461 |
546 |
|
|
| 508 |
593 |
|
}
|
| 509 |
594 |
|
|
| 510 |
595 |
|
#[test]
|
| 511 |
|
- |
fn own_echo_not_treated_as_conflict() {
|
|
596 |
+ |
fn own_echo_without_pending_edit_is_clean() {
|
|
597 |
+ |
// An echo of our own device with no contesting local pending edit is
|
|
598 |
+ |
// clean (it still passes the HLC gate at apply time).
|
| 512 |
599 |
|
let our_device = Uuid::new_v4();
|
| 513 |
600 |
|
let now = Utc::now();
|
| 514 |
601 |
|
|
| 515 |
|
- |
let remote = vec![
|
| 516 |
|
- |
make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1),
|
| 517 |
|
- |
];
|
| 518 |
|
- |
let local = vec![
|
| 519 |
|
- |
make_entry("tasks", "r1", ChangeOp::Update, now),
|
| 520 |
|
- |
];
|
| 521 |
|
- |
|
| 522 |
|
- |
let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
|
|
602 |
+ |
let remote = vec![make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1)];
|
|
603 |
+ |
let (clean, conflicts) = detect_conflicts(remote, &[], DeviceId::new(our_device));
|
| 523 |
604 |
|
assert_eq!(clean.len(), 1);
|
| 524 |
605 |
|
assert!(conflicts.is_empty());
|
| 525 |
606 |
|
}
|
| 526 |
607 |
|
|
|
608 |
+ |
#[test]
|
|
609 |
+ |
fn echo_contesting_a_pending_edit_is_resolved_not_trusted() {
|
|
610 |
+ |
// Hardening: a pulled change labeled as our own echo that contests an
|
|
611 |
+ |
// un-pushed local edit is resolved as a conflict, not waved through as
|
|
612 |
+ |
// clean. Trusting the device_id label would let a server relabel a hostile
|
|
613 |
+ |
// row as our echo to skip conflict detection entirely.
|
|
614 |
+ |
let our_device = Uuid::new_v4();
|
|
615 |
+ |
let now = Utc::now();
|
|
616 |
+ |
|
|
617 |
+ |
let remote = vec![make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1)];
|
|
618 |
+ |
let local = vec![make_entry("tasks", "r1", ChangeOp::Update, now)];
|
|
619 |
+ |
|
|
620 |
+ |
let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
|
|
621 |
+ |
assert!(clean.is_empty());
|
|
622 |
+ |
assert_eq!(conflicts.len(), 1, "echo contesting a pending edit is resolved");
|
|
623 |
+ |
}
|
|
624 |
+ |
|
| 527 |
625 |
|
#[test]
|
| 528 |
626 |
|
fn clean_changes_gate_drops_stale_keeps_newer() {
|
| 529 |
627 |
|
let our_device = Uuid::new_v4();
|
| 537 |
635 |
|
// No committed clock for the row → kept (first time we've seen it).
|
| 538 |
636 |
|
assert_eq!(clean.clone().gated(|_, _| None).len(), 1);
|
| 539 |
637 |
|
// Committed clock older than the remote → kept.
|
| 540 |
|
- |
assert_eq!(clean.clone().gated(|_, _| Some(Hlc::zero(other_device))).len(), 1);
|
|
638 |
+ |
assert_eq!(clean.clone().gated(|_, _| Some(Hlc::zero(DeviceId::new(other_device)))).len(), 1);
|
| 541 |
639 |
|
// Committed clock newer than the remote → dropped (would clobber newer local).
|
| 542 |
|
- |
let newer = Hlc { wall_ms: now.timestamp_millis() + 1, counter: 0, node: other_device };
|
|
640 |
+ |
let newer = Hlc { wall_ms: now.timestamp_millis() + 1, counter: 0, node: DeviceId::new(other_device) };
|
| 543 |
641 |
|
assert!(clean.gated(move |_, _| Some(newer)).is_empty());
|
| 544 |
642 |
|
}
|
| 545 |
643 |
|
|
| 647 |
745 |
|
let other = Uuid::new_v4();
|
| 648 |
746 |
|
let mut local = make_entry("tasks", "r1", ChangeOp::Update, Utc::now());
|
| 649 |
747 |
|
local.hlc = Hlc { wall_ms: 1000, counter: 2, node: local_node() };
|
| 650 |
|
- |
let remote = pulled_with_hlc("r1", ChangeOp::Update, Hlc { wall_ms: 1000, counter: 5, node: other }, other);
|
|
748 |
+ |
let remote = pulled_with_hlc("r1", ChangeOp::Update, Hlc { wall_ms: 1000, counter: 5, node: DeviceId::new(other) }, other);
|
| 651 |
749 |
|
assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
|
| 652 |
750 |
|
}
|
| 653 |
751 |
|
|
| 657 |
755 |
|
// the same physical change. Use nodes with a known order (a < b).
|
| 658 |
756 |
|
let a = Uuid::from_u128(1);
|
| 659 |
757 |
|
let b = Uuid::from_u128(2);
|
| 660 |
|
- |
let hlc_a = Hlc { wall_ms: 1000, counter: 0, node: a };
|
| 661 |
|
- |
let hlc_b = Hlc { wall_ms: 1000, counter: 0, node: b };
|
|
758 |
+ |
let hlc_a = Hlc { wall_ms: 1000, counter: 0, node: DeviceId::new(a) };
|
|
759 |
+ |
let hlc_b = Hlc { wall_ms: 1000, counter: 0, node: DeviceId::new(b) };
|
| 662 |
760 |
|
|
| 663 |
761 |
|
// Device A: local is a's change, remote is b's change.
|
| 664 |
762 |
|
let mut local_a = make_entry("tasks", "r1", ChangeOp::Update, Utc::now());
|
| 681 |
779 |
|
// two genuinely different edits produce a byte-identical HLC. The payload
|
| 682 |
780 |
|
// tiebreak must still make both devices land on the same value.
|
| 683 |
781 |
|
let shared = Uuid::from_u128(7);
|
| 684 |
|
- |
let hlc = Hlc { wall_ms: 1000, counter: 3, node: shared };
|
|
782 |
+ |
let hlc = Hlc { wall_ms: 1000, counter: 3, node: DeviceId::new(shared) };
|
| 685 |
783 |
|
let val_a = json!({"v": "aaa"});
|
| 686 |
784 |
|
let val_b = json!({"v": "bbb"}); // canonically greater than val_a
|
| 687 |
785 |
|
|
| 710 |
808 |
|
let other = Uuid::new_v4();
|
| 711 |
809 |
|
let mut local = make_entry("tasks", "r1", ChangeOp::Update, Utc::now());
|
| 712 |
810 |
|
local.hlc = Hlc { wall_ms: 2000, counter: 0, node: local_node() };
|
| 713 |
|
- |
let remote = pulled_with_hlc("r1", ChangeOp::Delete, Hlc { wall_ms: 1000, counter: 0, node: other }, other);
|
|
811 |
+ |
let remote = pulled_with_hlc("r1", ChangeOp::Delete, Hlc { wall_ms: 1000, counter: 0, node: DeviceId::new(other) }, other);
|
| 714 |
812 |
|
assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
|
| 715 |
813 |
|
}
|
| 716 |
814 |
|
|
| 720 |
818 |
|
let other = Uuid::new_v4();
|
| 721 |
819 |
|
let mut local = make_entry("tasks", "r1", ChangeOp::Update, Utc::now());
|
| 722 |
820 |
|
local.hlc = Hlc { wall_ms: 1000, counter: 0, node: local_node() };
|
| 723 |
|
- |
let remote = pulled_with_hlc("r1", ChangeOp::Delete, Hlc { wall_ms: 2000, counter: 0, node: other }, other);
|
|
821 |
+ |
let remote = pulled_with_hlc("r1", ChangeOp::Delete, Hlc { wall_ms: 2000, counter: 0, node: DeviceId::new(other) }, other);
|
| 724 |
822 |
|
assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
|
| 725 |
823 |
|
}
|
| 726 |
824 |
|
|
| 733 |
831 |
|
let remote = json!({"title": "old", "status": "done", "priority": 1});
|
| 734 |
832 |
|
let now = Utc::now();
|
| 735 |
833 |
|
|
| 736 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
|
834 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node()));
|
| 737 |
835 |
|
match result {
|
| 738 |
836 |
|
Resolution::Merged(v) => {
|
| 739 |
837 |
|
assert_eq!(v["title"], "new title");
|
| 753 |
851 |
|
let new = Utc::now();
|
| 754 |
852 |
|
|
| 755 |
853 |
|
// Remote is newer → remote wins the overlapping field
|
| 756 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, old, new);
|
|
854 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(old, local_node()), &ts_hlc(new, remote_node()));
|
| 757 |
855 |
|
match result {
|
| 758 |
856 |
|
Resolution::Merged(v) => {
|
| 759 |
857 |
|
assert_eq!(v["title"], "remote title");
|
| 762 |
860 |
|
}
|
| 763 |
861 |
|
|
| 764 |
862 |
|
// Local is newer → local wins the overlapping field
|
| 765 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, new, old);
|
|
863 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(new, local_node()), &ts_hlc(old, remote_node()));
|
| 766 |
864 |
|
match result {
|
| 767 |
865 |
|
Resolution::Merged(v) => {
|
| 768 |
866 |
|
assert_eq!(v["title"], "local title");
|
| 778 |
876 |
|
let remote = json!({"title": "old", "notes": "some notes"});
|
| 779 |
877 |
|
let now = Utc::now();
|
| 780 |
878 |
|
|
| 781 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
|
879 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node()));
|
| 782 |
880 |
|
match result {
|
| 783 |
881 |
|
Resolution::Merged(v) => {
|
| 784 |
882 |
|
assert_eq!(v["title"], "old");
|
| 796 |
894 |
|
let now = Utc::now();
|
| 797 |
895 |
|
|
| 798 |
896 |
|
assert!(matches!(
|
| 799 |
|
- |
resolve_field_merge(&local, &remote, &base, now, now),
|
|
897 |
+ |
resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node())),
|
| 800 |
898 |
|
Resolution::KeepRemote
|
| 801 |
899 |
|
));
|
| 802 |
900 |
|
}
|
| 808 |
906 |
|
let remote = json!({"status": "from remote"});
|
| 809 |
907 |
|
let now = Utc::now();
|
| 810 |
908 |
|
|
| 811 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
|
909 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node()));
|
| 812 |
910 |
|
match result {
|
| 813 |
911 |
|
Resolution::Merged(v) => {
|
| 814 |
912 |
|
assert_eq!(v["title"], "from local");
|
| 825 |
923 |
|
let remote = json!({"existing": 1, "remote_new": "b"});
|
| 826 |
924 |
|
let now = Utc::now();
|
| 827 |
925 |
|
|
| 828 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
|
926 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node()));
|
| 829 |
927 |
|
match result {
|
| 830 |
928 |
|
Resolution::Merged(v) => {
|
| 831 |
929 |
|
assert_eq!(v["existing"], 1);
|
| 948 |
1046 |
|
assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
|
| 949 |
1047 |
|
}
|
| 950 |
1048 |
|
|
| 951 |
|
- |
// Attack vector 3: field_merge overlapping fields with equal timestamps.
|
| 952 |
|
- |
// Ties go to local (remote_ts > local_ts is false). Verify.
|
|
1049 |
+ |
// F1 (regression): overlapping fields whose two sides carry the *same*
|
|
1050 |
+ |
// wall-ms must resolve convergently, not "ties go to local". The old rule
|
|
1051 |
+ |
// broke ties on local, so device A (local=A) kept A while device B (local=B)
|
|
1052 |
+ |
// kept B — permanent silent divergence. Now the exact tie breaks on the full
|
|
1053 |
+ |
// HLC (distinct node), so both devices land on the same physical value.
|
| 953 |
1054 |
|
#[test]
|
| 954 |
|
- |
fn field_merge_overlapping_equal_timestamps_local_wins() {
|
|
1055 |
+ |
fn field_merge_overlapping_equal_wall_converges() {
|
| 955 |
1056 |
|
let base = json!({"title": "base"});
|
| 956 |
|
- |
let local = json!({"title": "local version"});
|
| 957 |
|
- |
let remote = json!({"title": "remote version"});
|
|
1057 |
+ |
let val_a = json!({"title": "device A version"});
|
|
1058 |
+ |
let val_b = json!({"title": "device B version"});
|
| 958 |
1059 |
|
let now = Utc::now();
|
|
1060 |
+ |
let hlc_a = ts_hlc(now, local_node()); // node 0x111…
|
|
1061 |
+ |
let hlc_b = ts_hlc(now, remote_node()); // node 0x222… (> a), same wall
|
| 959 |
1062 |
|
|
| 960 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
| 961 |
|
- |
match result {
|
| 962 |
|
- |
Resolution::Merged(v) => {
|
| 963 |
|
- |
assert_eq!(v["title"], "local version",
|
| 964 |
|
- |
"Equal timestamps: local should win for overlapping fields");
|
|
1063 |
+ |
// Device A resolves (local = A's edit, remote = B's edit); device B
|
|
1064 |
+ |
// resolves the mirror image (local = B's edit, remote = A's edit).
|
|
1065 |
+ |
let on_a = resolve_field_merge(&val_a, &val_b, &base, &hlc_a, &hlc_b);
|
|
1066 |
+ |
let on_b = resolve_field_merge(&val_b, &val_a, &base, &hlc_b, &hlc_a);
|
|
1067 |
+ |
|
|
1068 |
+ |
let (title_a, title_b) = match (on_a, on_b) {
|
|
1069 |
+ |
(Resolution::Merged(a), Resolution::Merged(b)) => {
|
|
1070 |
+ |
(a["title"].clone(), b["title"].clone())
|
| 965 |
1071 |
|
}
|
| 966 |
|
- |
_ => panic!("Expected Merged"),
|
| 967 |
|
- |
}
|
|
1072 |
+ |
_ => panic!("Expected Merged on both devices"),
|
|
1073 |
+ |
};
|
|
1074 |
+ |
assert_eq!(
|
|
1075 |
+ |
title_a, title_b,
|
|
1076 |
+ |
"both devices must converge on the same value at an equal-wall tie"
|
|
1077 |
+ |
);
|
|
1078 |
+ |
// Specifically on B's edit, since hlc_b > hlc_a on the node tiebreak.
|
|
1079 |
+ |
assert_eq!(title_a, "device B version");
|
| 968 |
1080 |
|
}
|
| 969 |
1081 |
|
|
| 970 |
1082 |
|
// Attack vector 4: HashMap iteration order determinism.
|
| 979 |
1091 |
|
|
| 980 |
1092 |
|
// Run multiple times to catch iteration-order bugs
|
| 981 |
1093 |
|
for _ in 0..10 {
|
| 982 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
|
1094 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node()));
|
| 983 |
1095 |
|
match result {
|
| 984 |
1096 |
|
Resolution::Merged(v) => {
|
| 985 |
1097 |
|
assert_eq!(v["a"], 1);
|
| 1011 |
1123 |
|
// which silently drops "title": "important local edit".
|
| 1012 |
1124 |
|
// A better fallback might be to merge both against an empty base,
|
| 1013 |
1125 |
|
// or fall back to LWW.
|
| 1014 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, now, now);
|
|
1126 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(now, local_node()), &ts_hlc(now, remote_node()));
|
| 1015 |
1127 |
|
assert!(matches!(result, Resolution::KeepRemote),
|
| 1016 |
1128 |
|
"Null base should fall back to KeepRemote (current behavior)");
|
| 1017 |
1129 |
|
}
|
| 1028 |
1140 |
|
let new = Utc::now();
|
| 1029 |
1141 |
|
|
| 1030 |
1142 |
|
// Remote is newer, so remote wins the overlapping field
|
| 1031 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, old, new);
|
|
1143 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(old, local_node()), &ts_hlc(new, remote_node()));
|
| 1032 |
1144 |
|
match result {
|
| 1033 |
1145 |
|
Resolution::Merged(v) => {
|
| 1034 |
1146 |
|
assert_eq!(v["meta"], "flat string");
|
| 1037 |
1149 |
|
}
|
| 1038 |
1150 |
|
|
| 1039 |
1151 |
|
// Local is newer, so local wins — meta becomes empty object
|
| 1040 |
|
- |
let result = resolve_field_merge(&local, &remote, &base, new, old);
|
|
1152 |
+ |
let result = resolve_field_merge(&local, &remote, &base, &ts_hlc(new, local_node()), &ts_hlc(old, remote_node()));
|
| 1041 |
1153 |
|
match result {
|