Skip to main content

max / synckit

Decide which change wins in one place resolve_tie and collapse_max_hlc both answered "which of these two changes wins", and they answered it differently at an exact HLC tie: the resolver on canonical payload bytes, the collapse on whichever entry it saw first. Both converged, but for different reasons, and the collapse's reason was never written down. It relied on first-seen order being pull order being the server's sequence order, identical on every device. That is true of today's server and too load-bearing to leave implicit. Both now call conflict::change_order, which is the resolver's rule: HLC first, then canonical payload bytes, which every device derives independently of how the batch reached it. The collapse pays one canonical encoding per same-row collision within a batch. Tested for the case the shared order exists for: two changes for one row at an identical HLC collapse to the same winner in either arrival order. Verified by restoring the first-seen tiebreak, which fails that test and only that test.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 15:50 UTC
Signed with PGP, not checked
Commit: 43e5812c657b4af964903e903d240e029f1387dd
Parent: 88fd9b7
2 files changed, +60 insertions, -13 deletions
@@ -315,12 +315,7 @@
315 315 }
316 316 _ => {}
317 317 }
318 - let resolution = match resolve_tie(
319 - &local.hlc,
320 - &canonical_payload(local.data.as_ref()),
321 - &remote.entry.hlc,
322 - &canonical_payload(remote.entry.data.as_ref()),
323 - ) {
318 + let resolution = match change_order(local, &remote.entry) {
324 319 Ordering::Less => Resolution::KeepRemote,
325 320 // Greater or an exact tie both keep local: at a true tie the two changes
326 321 // are byte-identical, so keeping either side converges.
@@ -354,6 +349,33 @@
354 349 a_hlc.cmp(b_hlc).then_with(|| a_payload.cmp(b_payload))
355 350 }
356 351
352 + /// The total order over two changes: which of them wins, wherever the question
353 + /// is asked. [`Ordering::Greater`] means `a` wins.
354 + ///
355 + /// Every site that decides a winner routes through here, so there is one rule
356 + /// rather than one per site. It used to be two. The pull pipeline's collapse
357 + /// step kept the first entry it saw at an exact HLC tie, and it converged for a
358 + /// reason nobody had written down: first-seen order is pull order is the
359 + /// server's sequence order, identical on every device. That is a real property
360 + /// of today's server and a load-bearing assumption to leave implicit, so the
361 + /// collapse now breaks the tie the way the conflict resolver always has, on
362 + /// bytes both devices derive independently. The cost is one canonical encoding
363 + /// per same-row collision within a batch, which is the rare case.
364 + ///
365 + /// Ordering deletes is the reason this takes whole entries rather than payloads.
366 + /// A delete carries no payload and canonicalizes to the empty byte string, which
367 + /// sorts below every real payload, so at an exact HLC tie between a delete and
368 + /// an edit the edit wins. Nothing depends on which way that falls, only that
369 + /// both devices fall the same way.
370 + pub(crate) fn change_order(a: &ChangeEntry, b: &ChangeEntry) -> Ordering {
371 + resolve_tie(
372 + &a.hlc,
373 + &canonical_payload(a.data.as_ref()),
374 + &b.hlc,
375 + &canonical_payload(b.data.as_ref()),
376 + )
377 + }
378 +
357 379 /// Deterministic byte encoding of a concrete JSON value for the exact-HLC
358 380 /// tiebreak. `serde_json`'s default `Map` is sorted, so equal values always
359 381 /// serialize to equal bytes on every device, the property the tiebreak relies on.
@@ -22,7 +22,7 @@
22 22 use super::db::{get_sync_state, set_sync_state};
23 23 use super::schema::{ConflictStrategy, SyncSchema};
24 24 use super::stash;
25 - use crate::conflict::{Resolution, detect_conflicts, resolve_lww_at};
25 + use crate::conflict::{Resolution, change_order, detect_conflicts, resolve_lww_at};
26 26 use crate::error::Result;
27 27 use crate::ids::DeviceId;
28 28 use crate::types::{ChangeEntry, ChangeOp, Hlc, PulledChange};
@@ -164,7 +164,8 @@
164 164 /// advance the clock past the remote HLCs, stamp local pending edits, split into
165 165 /// clean vs conflicting against local pending, HLC-gate the clean set against the
166 166 /// committed ledger, resolve conflicts by `resolve_lww`, and collapse to one
167 - /// entry per row (highest HLC wins, operation-agnostic).
167 + /// entry per row. The conflict step and the collapse step both decide a winner,
168 + /// and both ask [`change_order`], so they cannot disagree.
168 169 ///
169 170 /// Every discard along that path is stashed under `scope` first (see
170 171 /// [`super::stash`]). Three of them exist and they are easy to miscount: the
@@ -334,17 +335,21 @@
334 335 Ok(out)
335 336 }
336 337
337 - /// Collapse to one entry per `(table, row_id)`, keeping the highest HLC,
338 - /// operation-agnostic, so a newer delete beats an older edit and vice versa, and
339 - /// every device converges on the same winner. First-seen order is preserved
340 - /// (apply re-orders by schema anyway).
338 + /// Collapse to one entry per `(table, row_id)`, keeping the winner under
339 + /// [`change_order`], operation-agnostic, so a newer delete beats an older edit
340 + /// and vice versa, and every device converges on the same winner. First-seen
341 + /// order is preserved (apply re-orders by schema anyway).
342 + ///
343 + /// The winner rule is [`change_order`]'s and not this function's, which is the
344 + /// point: the conflict resolver answers the same question and the two must not
345 + /// be able to disagree.
341 346 fn collapse_max_hlc(entries: Vec<ChangeEntry>) -> Vec<ChangeEntry> {
342 347 let mut best: HashMap<(String, String), usize> = HashMap::new();
343 348 let mut kept: Vec<ChangeEntry> = Vec::new();
344 349 for e in entries {
345 350 let key = (e.table.clone(), e.row_id.clone());
346 351 match best.get(&key) {
347 - Some(&i) if kept[i].hlc >= e.hlc => {}
352 + Some(&i) if change_order(&kept[i], &e).is_ge() => {}
348 353 Some(&i) => kept[i] = e,
349 354 None => {
350 355 best.insert(key, kept.len());
@@ -492,6 +497,26 @@
492 497 assert_eq!(labels(&out), ["r2-new", "r1-only"]);
493 498 }
494 499
500 + /// The case the shared order exists for. Two changes for one row at an
501 + /// exact HLC tie: the winner has to be the same on every device, and the
502 + /// only thing every device agrees on is the payload bytes. Arrival order is
503 + /// not that thing, so the two orders must agree here.
504 + #[test]
505 + fn collapse_breaks_an_exact_hlc_tie_on_payload_not_arrival_order() {
506 + let a = entry("r1", ChangeOp::Update, 100, 1, "aaa");
507 + let b = entry("r1", ChangeOp::Update, 100, 1, "bbb");
508 + assert_eq!(a.hlc, b.hlc, "the tie is the premise of this test");
509 +
510 + let forwards = collapse_max_hlc(vec![a.clone(), b.clone()]);
511 + let backwards = collapse_max_hlc(vec![b, a]);
512 + assert_eq!(
513 + labels(&forwards),
514 + labels(&backwards),
515 + "two devices disagreed at an exact tie because they saw the batch in different orders"
516 + );
517 + assert_eq!(labels(&forwards), ["bbb"], "higher payload bytes win");
518 + }
519 +
495 520 fn note_name(conn: &Connection, id: &str) -> Option<String> {
496 521 conn.query_row("SELECT name FROM note WHERE id = ?1", [id], |r| r.get(0))
497 522 .optional()