Skip to main content

nodedb_types/
hlc.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Hybrid Logical Clock.
4//!
5//! Canonical timestamp for replicated metadata entries, descriptor
6//! modification times, and descriptor lease expiry. Monotonic across
7//! wall-clock skew: `update` folds a remote HLC into the local clock so
8//! causally later events always receive a strictly greater timestamp.
9//!
10//! Metadata DDL is not a hot path (hundreds per second at most), so the
11//! clock is backed by a short-lived mutex rather than atomics.
12
13use std::cmp::Ordering;
14use std::sync::Mutex;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::{Deserialize, Serialize};
18
19/// How far ahead of local wall time a remote HLC may be before it is refused.
20///
21/// A node whose clock is further ahead than this is misconfigured, and folding
22/// its timestamp in would move this node's clock permanently. Five seconds
23/// absorbs ordinary NTP disagreement while staying far below the descriptor
24/// drain timeout, which must outlast any skew this admits.
25pub const MAX_CLOCK_SKEW_NS: u64 = 5_000_000_000;
26
27/// A remote HLC refused for running too far ahead of local wall time.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct ClockSkew {
30    /// The remote observation's wall clock, in nanoseconds since the epoch.
31    pub remote_wall_ns: u64,
32    /// This node's wall clock when the observation arrived.
33    pub local_wall_ns: u64,
34    /// How far ahead the remote clock ran.
35    pub skew_ns: u64,
36}
37
38impl std::fmt::Display for ClockSkew {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(
41            f,
42            "remote HLC wall clock is {}ms ahead of local (remote {}ns, local {}ns); \
43             the maximum accepted skew is {}ms",
44            self.skew_ns / 1_000_000,
45            self.remote_wall_ns,
46            self.local_wall_ns,
47            MAX_CLOCK_SKEW_NS / 1_000_000
48        )
49    }
50}
51
52impl std::error::Error for ClockSkew {}
53
54/// Hybrid Logical Clock timestamp.
55///
56/// `#[non_exhaustive]` — a `epoch_id` discriminant for multi-cluster
57/// logical epochs may be added in a future release.
58///
59/// # Wire format note
60///
61/// `logical` is a 64-bit counter. MessagePack integers decode by value,
62/// so small values serialised by future readers will still deserialise
63/// correctly into this type.
64#[non_exhaustive]
65#[derive(
66    Debug,
67    Clone,
68    Copy,
69    Default,
70    PartialEq,
71    Eq,
72    Hash,
73    Serialize,
74    Deserialize,
75    zerompk::ToMessagePack,
76    zerompk::FromMessagePack,
77)]
78pub struct Hlc {
79    /// Wall-clock component: nanoseconds since the Unix epoch.
80    pub wall_ns: u64,
81    /// Logical counter incremented when two events share a wall-clock tick.
82    pub logical: u64,
83}
84
85impl Hlc {
86    pub const ZERO: Hlc = Hlc {
87        wall_ns: 0,
88        logical: 0,
89    };
90
91    pub const fn new(wall_ns: u64, logical: u64) -> Self {
92        Self { wall_ns, logical }
93    }
94}
95
96impl PartialOrd for Hlc {
97    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
98        Some(self.cmp(other))
99    }
100}
101
102impl Ord for Hlc {
103    fn cmp(&self, other: &Self) -> Ordering {
104        match self.wall_ns.cmp(&other.wall_ns) {
105            Ordering::Equal => self.logical.cmp(&other.logical),
106            other => other,
107        }
108    }
109}
110
111/// Thread-safe HLC source. One instance per node.
112#[derive(Debug, Default)]
113pub struct HlcClock {
114    state: Mutex<Hlc>,
115}
116
117impl HlcClock {
118    pub fn new() -> Self {
119        Self {
120            state: Mutex::new(Hlc::ZERO),
121        }
122    }
123
124    /// Return a new HLC strictly greater than any previously returned value
125    /// and ≥ the current wall clock.
126    pub fn now(&self) -> Hlc {
127        let wall = wall_now_ns();
128        let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
129        // Clamp wall to at least st.wall_ns so the HLC never regresses on
130        // clock skew or NTP adjustments.
131        let wall = wall.max(st.wall_ns);
132        let next = if wall > st.wall_ns {
133            Hlc::new(wall, 0)
134        } else {
135            Hlc::new(st.wall_ns, st.logical + 1)
136        };
137        *st = next;
138        next
139    }
140
141    /// Fold a remote HLC into the local clock and return a strictly greater HLC
142    /// than both the prior local state and the remote observation.
143    pub fn update(&self, remote: Hlc) -> Hlc {
144        let wall = wall_now_ns();
145        let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
146        let prev = *st;
147        let max_wall = wall.max(prev.wall_ns).max(remote.wall_ns);
148        let next_logical = if max_wall == prev.wall_ns && max_wall == remote.wall_ns {
149            prev.logical.max(remote.logical) + 1
150        } else if max_wall == prev.wall_ns {
151            prev.logical + 1
152        } else if max_wall == remote.wall_ns {
153            remote.logical + 1
154        } else {
155            0
156        };
157        let next = Hlc::new(max_wall, next_logical);
158        *st = next;
159        next
160    }
161
162    /// Fold a remote HLC into the local clock, refusing one whose wall clock
163    /// runs further ahead than [`MAX_CLOCK_SKEW_NS`].
164    ///
165    /// [`Self::update`] takes an unconditional `max`, so a single far-future
166    /// observation drags this node's clock forward permanently and every
167    /// later local timestamp inherits the error. Every remote HLC crossing a
168    /// node boundary goes through this, never `update`.
169    ///
170    /// Returns the skew in nanoseconds on refusal, so the caller can name the
171    /// offending peer.
172    pub fn update_checked(&self, remote: Hlc) -> Result<Hlc, ClockSkew> {
173        let wall = wall_now_ns();
174        let skew = remote.wall_ns.saturating_sub(wall);
175        if skew > MAX_CLOCK_SKEW_NS {
176            return Err(ClockSkew {
177                remote_wall_ns: remote.wall_ns,
178                local_wall_ns: wall,
179                skew_ns: skew,
180            });
181        }
182        Ok(self.update(remote))
183    }
184
185    /// Read the last observed HLC without advancing.
186    pub fn peek(&self) -> Hlc {
187        *self.state.lock().unwrap_or_else(|p| p.into_inner())
188    }
189}
190
191fn wall_now_ns() -> u64 {
192    SystemTime::now()
193        .duration_since(UNIX_EPOCH)
194        .map(|d| d.as_nanos() as u64)
195        .unwrap_or_else(|_| {
196            use std::sync::atomic::{AtomicBool, Ordering};
197            static LOGGED: AtomicBool = AtomicBool::new(false);
198            if !LOGGED.swap(true, Ordering::Relaxed) {
199                tracing::error!(
200                    module = module_path!(),
201                    "system clock is before UNIX_EPOCH; using 0 (epoch) \
202                     — check NTP/RTC configuration"
203                );
204            }
205            0
206        })
207}
208
209#[cfg(test)]
210mod tests {
211
212    /// A remote HLC within the bound is folded exactly as `update` would.
213    #[test]
214    fn a_remote_hlc_inside_the_bound_is_accepted() {
215        let clock = HlcClock::new();
216        let local = clock.now();
217        let remote = Hlc::new(local.wall_ns + 1_000_000, 0);
218        let folded = clock
219            .update_checked(remote)
220            .expect("a remote clock 1ms ahead is ordinary skew");
221        assert!(folded > remote, "the fold must exceed the observation");
222        assert!(clock.peek() >= folded);
223    }
224
225    /// A remote HLC past the bound is refused, and the clock does not move.
226    #[test]
227    fn a_remote_hlc_past_the_bound_is_refused_without_moving_the_clock() {
228        let clock = HlcClock::new();
229        let before = clock.peek();
230        let far_future = Hlc::new(wall_now_ns() + MAX_CLOCK_SKEW_NS * 4, 0);
231        let err = clock
232            .update_checked(far_future)
233            .expect_err("a clock four times the bound ahead must be refused");
234        assert!(err.skew_ns > MAX_CLOCK_SKEW_NS);
235        assert!(
236            clock.peek() < far_future,
237            "a refused observation must never advance the clock"
238        );
239        assert!(clock.peek() >= before);
240    }
241
242    /// A remote HLC behind local wall time is always in bounds — skew only
243    /// bounds the future direction.
244    #[test]
245    fn a_remote_hlc_in_the_past_is_never_refused() {
246        let clock = HlcClock::new();
247        let past = Hlc::new(wall_now_ns().saturating_sub(MAX_CLOCK_SKEW_NS * 10), 0);
248        assert!(clock.update_checked(past).is_ok());
249    }
250    use super::*;
251
252    #[test]
253    fn monotonic_within_tick() {
254        let clock = HlcClock::new();
255        let mut prev = clock.now();
256        for _ in 0..1_000 {
257            let next = clock.now();
258            assert!(next > prev);
259            prev = next;
260        }
261    }
262
263    #[test]
264    fn update_produces_strictly_greater() {
265        let clock = HlcClock::new();
266        let local = clock.now();
267        let remote = Hlc::new(local.wall_ns + 1_000_000, 7);
268        let merged = clock.update(remote);
269        assert!(merged > remote);
270        assert!(merged > local);
271    }
272
273    #[test]
274    fn ordering_is_total() {
275        let a = Hlc::new(10, 0);
276        let b = Hlc::new(10, 1);
277        let c = Hlc::new(11, 0);
278        assert!(a < b);
279        assert!(b < c);
280        assert!(a < c);
281    }
282
283    /// 100K rapid-fire `now()` calls must each be strictly greater than
284    /// the previous. Verifies the u64 logical counter advances without
285    /// saturation.
286    #[test]
287    fn burst_strictly_monotonic() {
288        let clock = HlcClock::new();
289        let mut prev = clock.now();
290        for _ in 0..100_000 {
291            let next = clock.now();
292            assert!(
293                next > prev,
294                "monotonicity violated: prev={prev:?} next={next:?}"
295            );
296            prev = next;
297        }
298    }
299
300    /// Directly set the clock's logical counter to `u32::MAX` (the old
301    /// saturation ceiling) and assert that the next `now()` returns a
302    /// strictly greater HLC. Under the old `u32::saturating_add(1)` this
303    /// would have pinned and returned an equal value.
304    #[test]
305    fn saturating_add_regression() {
306        let clock = HlcClock::new();
307        // Prime the clock so wall_ns is fixed.
308        let seed = clock.now();
309        // Inject logical = u32::MAX at the same wall_ns to reproduce the
310        // old saturation point.
311        {
312            let mut st = clock.state.lock().unwrap_or_else(|p| p.into_inner());
313            *st = Hlc::new(seed.wall_ns, u32::MAX as u64);
314        }
315        let next = clock.now();
316        assert!(
317            next > Hlc::new(seed.wall_ns, u32::MAX as u64),
318            "logical counter pinned at u32::MAX: next={next:?}"
319        );
320    }
321
322    /// 100K `update(remote)` calls with a fixed remote must each produce
323    /// a strictly greater HLC than the prior call.
324    #[test]
325    fn update_burst_strictly_monotonic() {
326        let clock = HlcClock::new();
327        let remote = clock.now();
328        let mut prev = clock.update(remote);
329        for _ in 0..100_000 {
330            let next = clock.update(remote);
331            assert!(
332                next > prev,
333                "update monotonicity violated: prev={prev:?} next={next:?}"
334            );
335            prev = next;
336        }
337    }
338
339    /// An `Hlc` with `logical` above `u32::MAX` must survive a zerompk
340    /// encode/decode roundtrip with the value preserved exactly.
341    #[test]
342    fn roundtrip_logical_gt_u32_max() {
343        let logical_val = (u32::MAX as u64) + 1;
344        let original = Hlc::new(1_700_000_000_000_000_000_u64, logical_val);
345        let bytes = zerompk::to_msgpack_vec(&original).expect("encode");
346        let decoded: Hlc = zerompk::from_msgpack(&bytes).expect("decode");
347        assert_eq!(decoded, original);
348        assert_eq!(decoded.logical, logical_val);
349    }
350}