Skip to main content

grit_core/
hlc.rs

1//! Hybrid logical clock (Design Invariant 5): every op carries an HLC so merge
2//! order across devices never depends on device wall clocks alone.
3//!
4//! Encoding is a fixed-width, lexicographically sortable string:
5//! `{wall_ms:016x}-{counter:08x}-{device_id}`. String comparison == HLC order,
6//! so SQLite can order and compare HLCs without custom collation.
7
8use std::sync::Mutex;
9
10use serde::{Deserialize, Serialize};
11
12use crate::clock::{Clock, TimestampMs};
13use crate::error::Error;
14
15/// A single hybrid-logical-clock reading.
16///
17/// # Example
18/// ```
19/// use grit_core::Hlc;
20/// let a = Hlc::new(1000, 0, "dev-a");
21/// let b = Hlc::new(1000, 1, "dev-a");
22/// assert!(a < b);
23/// assert_eq!(a, a.encode().parse().unwrap());
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26pub struct Hlc {
27    /// Wall-clock component, ms since epoch.
28    pub wall_ms: TimestampMs,
29    /// Logical counter, bumps when wall time stalls or runs backwards.
30    pub counter: u32,
31    /// Tie-breaker: the originating device.
32    pub device_id: String,
33}
34
35impl Hlc {
36    /// Construct an HLC from parts.
37    pub fn new(wall_ms: TimestampMs, counter: u32, device_id: impl Into<String>) -> Self {
38        Self {
39            wall_ms,
40            counter,
41            device_id: device_id.into(),
42        }
43    }
44
45    /// Encode to the sortable string form stored in SQLite.
46    pub fn encode(&self) -> String {
47        format!(
48            "{:016x}-{:08x}-{}",
49            self.wall_ms, self.counter, self.device_id
50        )
51    }
52}
53
54impl std::fmt::Display for Hlc {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(&self.encode())
57    }
58}
59
60impl std::str::FromStr for Hlc {
61    type Err = Error;
62
63    fn from_str(s: &str) -> Result<Self, Error> {
64        let bad = || Error::InvalidHlc(s.to_owned());
65        let (wall, rest) = s.split_at_checked(16).ok_or_else(bad)?;
66        let rest = rest.strip_prefix('-').ok_or_else(bad)?;
67        let (counter, rest) = rest.split_at_checked(8).ok_or_else(bad)?;
68        let device_id = rest.strip_prefix('-').ok_or_else(bad)?;
69        Ok(Self {
70            wall_ms: TimestampMs::from_str_radix(wall, 16).map_err(|_| bad())?,
71            counter: u32::from_str_radix(counter, 16).map_err(|_| bad())?,
72            device_id: device_id.to_owned(),
73        })
74    }
75}
76
77/// Issues monotonically increasing HLCs for one device, folding in the highest
78/// HLC observed from remote ops so causality survives clock skew.
79pub struct HlcGenerator {
80    device_id: String,
81    last: Mutex<(TimestampMs, u32)>,
82}
83
84impl HlcGenerator {
85    /// A generator for `device_id` starting at wall time zero.
86    pub fn new(device_id: impl Into<String>) -> Self {
87        Self {
88            device_id: device_id.into(),
89            last: Mutex::new((0, 0)),
90        }
91    }
92
93    /// Issue the next HLC, strictly greater than every previous one issued or
94    /// observed, using `clock` for the wall component.
95    ///
96    /// Negative clock readings are clamped to 0 (the sortable encoding is
97    /// only order-preserving for non-negative wall times — see [`Clock`]);
98    /// if the logical counter is exhausted under a stalled clock, the wall
99    /// component advances logically instead (HLC semantics permit the wall
100    /// to run ahead of the physical clock).
101    pub fn next(&self, clock: &dyn Clock) -> Hlc {
102        let now = clock.now_ms().max(0);
103        let mut last = self
104            .last
105            .lock()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        if now > last.0 {
108            *last = (now, 0);
109        } else if last.1 == u32::MAX {
110            *last = (last.0 + 1, 0);
111        } else {
112            last.1 += 1;
113        }
114        Hlc::new(last.0, last.1, self.device_id.clone())
115    }
116
117    /// Fold in an HLC observed from a remote op so subsequently issued HLCs
118    /// sort after it. Negative remote wall times are ignored (they are
119    /// rejected at ingest by [`crate::Grit::apply_remote`]).
120    pub fn observe(&self, remote: &Hlc) {
121        if remote.wall_ms < 0 {
122            return;
123        }
124        let mut last = self
125            .last
126            .lock()
127            .unwrap_or_else(std::sync::PoisonError::into_inner);
128        if (remote.wall_ms, remote.counter) > *last {
129            *last = (remote.wall_ms, remote.counter);
130        }
131    }
132
133    /// Test seam: force the internal (wall, counter) state, e.g. to exercise
134    /// counter exhaustion without 2^32 calls.
135    #[cfg(test)]
136    fn set_state(&self, wall_ms: TimestampMs, counter: u32) {
137        *self.last.lock().unwrap() = (wall_ms, counter);
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::clock::ManualClock;
145
146    #[test]
147    fn encode_order_matches_semantic_order() {
148        let hlcs = [
149            Hlc::new(1, 0, "a"),
150            Hlc::new(1, 1, "a"),
151            Hlc::new(1, 1, "b"),
152            Hlc::new(2, 0, "a"),
153            Hlc::new(0x1_0000_0000, 0, "a"),
154        ];
155        for pair in hlcs.windows(2) {
156            assert!(pair[0] < pair[1]);
157            assert!(
158                pair[0].encode() < pair[1].encode(),
159                "{} !< {}",
160                pair[0],
161                pair[1]
162            );
163        }
164    }
165
166    #[test]
167    fn roundtrip() {
168        let h = Hlc::new(123_456_789, 42, "device-x");
169        let parsed: Hlc = h.encode().parse().unwrap();
170        assert_eq!(h, parsed);
171    }
172
173    #[test]
174    fn generator_is_monotonic_under_stalled_clock() {
175        let clock = ManualClock::new(100);
176        let generator = HlcGenerator::new("dev");
177        let a = generator.next(&clock);
178        let b = generator.next(&clock);
179        clock.set_ms(50); // clock runs backwards
180        let c = generator.next(&clock);
181        assert!(a < b && b < c);
182    }
183
184    #[test]
185    fn negative_clock_is_clamped() {
186        let clock = ManualClock::new(-5_000);
187        let generator = HlcGenerator::new("dev");
188        let a = generator.next(&clock);
189        assert!(
190            a.wall_ms >= 0,
191            "negative wall would break the sortable encoding"
192        );
193        let b = generator.next(&clock);
194        assert!(a < b);
195        assert!(a.encode() < b.encode());
196        // And negative remote HLCs never drag the generator backwards or
197        // poison future encodings.
198        generator.observe(&Hlc::new(-9_000, 7, "evil"));
199        assert!(generator.next(&clock).wall_ms >= 0);
200    }
201
202    #[test]
203    fn counter_exhaustion_advances_logical_wall() {
204        let clock = ManualClock::new(100);
205        let generator = HlcGenerator::new("dev");
206        let before = generator.next(&clock);
207        generator.set_state(100, u32::MAX);
208        let after = generator.next(&clock);
209        assert_eq!((after.wall_ms, after.counter), (101, 0));
210        assert!(before < after);
211        assert!(before.encode() < after.encode());
212    }
213
214    #[test]
215    fn generator_respects_observed_remote() {
216        let clock = ManualClock::new(100);
217        let generator = HlcGenerator::new("dev");
218        generator.observe(&Hlc::new(9_999, 3, "other"));
219        assert!(generator.next(&clock) > Hlc::new(9_999, 3, "other"));
220    }
221}