Skip to main content

max / synckit

Write out Hlc's ordering instead of deriving it The comparison on Hlc is the rule that decides which of two changes for a row survives a sync, and every device has to compute it identically. Derived, that rule lived in the declaration order of the fields, where reordering them for readability would have silently changed the winner. Spell it out: wall clock, then the within-millisecond counter, then the minting device. No behaviour change. DeviceId derives Ord over its Uuid, so the derived ordering and the written one agree, which is exactly the coincidence worth not depending on. Adds a test that the legacy floor loses to every real clock; the existing hlc_orders_by_wall_then_counter_then_node already pins the three components. Both verified by breaking the impl (counter before wall, reversed node tiebreak) and confirming each failed on its intended assertion.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 15:37 UTC
Signed with PGP, not checked
Commit: 53f0b350a9fa5e5e575ac8257cd1c8ed8bbd21dc
Parent: 84cd173
1 file changed, +49 insertions, -3 deletions
@@ -79,7 +79,7 @@
79 79 /// conflict. Mint a real clock with [`Hlc::tick`]/[`Hlc::observe`]; the only
80 80 /// sanctioned nil-node clock is the explicit legacy floor ([`hlc_legacy_floor`]),
81 81 /// used solely for pre-HLC entries that arrive with no embedded clock.
82 - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
82 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83 83 pub struct Hlc {
84 84 /// Wall-clock component, milliseconds since the Unix epoch.
85 85 pub wall_ms: i64,
@@ -90,6 +90,29 @@
90 90 pub node: DeviceId,
91 91 }
92 92
93 + /// The ordering is written out rather than derived, because it is the rule that
94 + /// decides which of two changes for a row wins, and every device has to compute
95 + /// it identically. A derived `Ord` would encode that rule in the declaration
96 + /// order of the fields, where reordering them for readability would silently
97 + /// change which change survives a sync. Spelling it out costs nothing and pins
98 + /// it: wall clock first, then the within-millisecond counter, then the minting
99 + /// device, which is what makes the value globally unique and the comparison
100 + /// total.
101 + impl Ord for Hlc {
102 + fn cmp(&self, other: &Self) -> std::cmp::Ordering {
103 + self.wall_ms
104 + .cmp(&other.wall_ms)
105 + .then_with(|| self.counter.cmp(&other.counter))
106 + .then_with(|| self.node.as_uuid().cmp(&other.node.as_uuid()))
107 + }
108 + }
109 +
110 + impl PartialOrd for Hlc {
111 + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
112 + Some(self.cmp(other))
113 + }
114 + }
115 +
93 116 impl Hlc {
94 117 /// The zero HLC for `node`. Used as the initial clock state and as the floor
95 118 /// for legacy entries that predate HLC support.
@@ -770,8 +793,8 @@
770 793 })
771 794 }
772 795
773 - /// The parts that encode causality. The derived `Ord` also breaks ties
774 - /// on `node`, which is a convergence device rather than a clock reading,
796 + /// The parts that encode causality. `Hlc`'s `Ord` also breaks ties on
797 + /// `node`, which is a convergence device rather than a clock reading,
775 798 /// so a monotonicity claim is about this pair.
776 799 fn reading(h: &Hlc) -> (i64, u32) {
777 800 (h.wall_ms, h.counter)
@@ -854,6 +877,29 @@
854 877 }
855 878 }
856 879
880 + /// Build an HLC from its three components, shortest form for the ordering
881 + /// tests below.
882 + fn hlc(wall_ms: i64, counter: u32, node: u8) -> Hlc {
883 + let mut bytes = [0u8; 16];
884 + bytes[15] = node;
885 + Hlc {
886 + wall_ms,
887 + counter,
888 + node: DeviceId::new(Uuid::from_bytes(bytes)),
889 + }
890 + }
891 +
892 + /// The nil node is the legacy floor, and the floor has to lose. A pre-HLC
893 + /// entry deserializes onto it, so if it ever won a comparison a legacy
894 + /// entry would overwrite a real edit.
895 + #[test]
896 + fn legacy_floor_loses_to_every_real_clock() {
897 + let floor = hlc_legacy_floor();
898 + assert!(floor < hlc(0, 0, 1));
899 + assert!(floor < hlc(0, 1, 0));
900 + assert!(floor < hlc(1, 0, 0));
901 + }
902 +
857 903 #[test]
858 904 fn change_op_serde_roundtrip() {
859 905 for (variant, expected_str) in [