Skip to main content

nodedb_array/sync/
hlc.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Hybrid Logical Clock (HLC) for array CRDT sync.
4//!
5//! Each array op carries an [`Hlc`] that encodes a wall-clock millisecond
6//! timestamp (`physical_ms`), a per-millisecond monotonic counter (`logical`),
7//! and the originating [`ReplicaId`]. The total order
8//! `(physical_ms, logical, replica_id)` provides deterministic tiebreaks under
9//! any clock skew without requiring synchronised clocks.
10//!
11//! `physical_ms` is stored as `u64` but only values up to
12//! [`MAX_PHYSICAL_MS`] (2^48 − 1 ≈ year 10 889) are valid. This matches the
13//! spec's u48 intent while keeping byte alignment simple.
14
15use std::sync::Mutex;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::{ArrayError, ArrayResult};
21use crate::sync::replica_id::ReplicaId;
22
23/// Maximum value for `physical_ms` (2^48 − 1).
24///
25/// Values above this are rejected by [`Hlc::new`] and [`HlcGenerator::next`].
26/// This cap corresponds to roughly the year 10 889 CE, so it is not a
27/// practical limit.
28pub const MAX_PHYSICAL_MS: u64 = (1u64 << 48) - 1;
29
30/// A Hybrid Logical Clock timestamp.
31///
32/// Total order: `(physical_ms, logical, replica_id.0)` lexicographic.
33/// This order is also the byte order produced by [`Hlc::to_bytes`].
34#[derive(
35    Copy,
36    Clone,
37    Debug,
38    PartialEq,
39    Eq,
40    Serialize,
41    Deserialize,
42    zerompk::ToMessagePack,
43    zerompk::FromMessagePack,
44)]
45pub struct Hlc {
46    /// Wall-clock milliseconds since Unix epoch at the originating replica.
47    /// Valid range: `0..=MAX_PHYSICAL_MS`.
48    pub physical_ms: u64,
49    /// Monotonic counter within a single millisecond on one replica.
50    pub logical: u16,
51    /// Stable identity of the replica that generated this timestamp.
52    pub replica_id: ReplicaId,
53}
54
55impl Hlc {
56    /// The bottom of the total order; useful as a "no ops seen yet" sentinel.
57    pub const ZERO: Self = Self {
58        physical_ms: 0,
59        logical: 0,
60        replica_id: ReplicaId(0),
61    };
62
63    /// Construct a new [`Hlc`], returning an error if `physical_ms` exceeds
64    /// [`MAX_PHYSICAL_MS`].
65    pub fn new(physical_ms: u64, logical: u16, replica_id: ReplicaId) -> ArrayResult<Self> {
66        if physical_ms > MAX_PHYSICAL_MS {
67            return Err(ArrayError::InvalidHlc {
68                detail: format!(
69                    "physical_ms {physical_ms} exceeds MAX_PHYSICAL_MS {MAX_PHYSICAL_MS}"
70                ),
71            });
72        }
73        Ok(Self {
74            physical_ms,
75            logical,
76            replica_id,
77        })
78    }
79
80    /// True when `self` is strictly later than `other` on the clock alone —
81    /// comparing `(physical_ms, logical)` and ignoring `replica_id`.
82    ///
83    /// [`Ord`] breaks same-instant ties with `replica_id` so that the order is
84    /// *total*, which is what deterministic op ordering needs. A freshness test
85    /// must not use it: two replicas that independently stamp the same instant
86    /// would be ranked by an arbitrary identity, so any gate built on `>` would
87    /// admit or reject the same pair of events depending on which replica id
88    /// happened to be larger.
89    pub fn is_later_than(&self, other: &Self) -> bool {
90        (self.physical_ms, self.logical) > (other.physical_ms, other.logical)
91    }
92
93    /// Encode as 18 bytes in big-endian order for stable byte-comparable sort.
94    ///
95    /// Layout: `physical_ms` (8 bytes) | `logical` (2 bytes) | `replica_id` (8 bytes).
96    pub fn to_bytes(&self) -> [u8; 18] {
97        let mut out = [0u8; 18];
98        out[0..8].copy_from_slice(&self.physical_ms.to_be_bytes());
99        out[8..10].copy_from_slice(&self.logical.to_be_bytes());
100        out[10..18].copy_from_slice(&self.replica_id.0.to_be_bytes());
101        out
102    }
103
104    /// Decode from a 18-byte big-endian slice produced by [`Hlc::to_bytes`].
105    ///
106    /// No range validation is performed; the caller should use [`Hlc::new`]
107    /// if validation is required.
108    pub fn from_bytes(b: &[u8; 18]) -> Self {
109        let physical_ms = u64::from_be_bytes(
110            b[0..8]
111                .try_into()
112                .expect("invariant: b is [u8; 18], slice [0..8] is exactly 8 bytes"),
113        );
114        let logical = u16::from_be_bytes(
115            b[8..10]
116                .try_into()
117                .expect("invariant: b is [u8; 18], slice [8..10] is exactly 2 bytes"),
118        );
119        let replica_id =
120            ReplicaId(u64::from_be_bytes(b[10..18].try_into().expect(
121                "invariant: b is [u8; 18], slice [10..18] is exactly 8 bytes",
122            )));
123        Self {
124            physical_ms,
125            logical,
126            replica_id,
127        }
128    }
129}
130
131impl PartialOrd for Hlc {
132    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
133        Some(self.cmp(other))
134    }
135}
136
137impl Ord for Hlc {
138    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
139        (self.physical_ms, self.logical, self.replica_id.0).cmp(&(
140            other.physical_ms,
141            other.logical,
142            other.replica_id.0,
143        ))
144    }
145}
146
147/// Monotonic HLC generator for a single replica.
148///
149/// Thread-safe via an internal `Mutex`. Lock contention at HLC-generation
150/// rates (typically hundreds per second per replica) is negligible.
151pub struct HlcGenerator {
152    replica_id: ReplicaId,
153    /// Guarded state: `(last_physical_ms, last_logical)`.
154    state: Mutex<(u64, u16)>,
155}
156
157impl HlcGenerator {
158    /// Create a new generator for the given replica.
159    pub fn new(replica_id: ReplicaId) -> Self {
160        Self {
161            replica_id,
162            state: Mutex::new((0, 0)),
163        }
164    }
165
166    /// Return the current wall-clock milliseconds since Unix epoch.
167    fn now_ms() -> ArrayResult<u64> {
168        SystemTime::now()
169            .duration_since(UNIX_EPOCH)
170            .map(|d| d.as_millis() as u64)
171            .map_err(|e| ArrayError::InvalidHlc {
172                detail: format!("system clock before Unix epoch: {e}"),
173            })
174    }
175
176    /// Generate the next [`Hlc`], guaranteeing strict monotonicity.
177    ///
178    /// Implements the standard HLC advancement algorithm:
179    /// - `new_physical = max(now_ms, last_physical)`
180    /// - if `new_physical == last_physical`: `new_logical = last_logical + 1`
181    /// - else: `new_logical = 0`
182    pub fn next(&self) -> ArrayResult<Hlc> {
183        let now_ms = Self::now_ms()?;
184        if now_ms > MAX_PHYSICAL_MS {
185            return Err(ArrayError::InvalidHlc {
186                detail: format!("system clock {now_ms} exceeds MAX_PHYSICAL_MS"),
187            });
188        }
189
190        let mut guard = self.state.lock().map_err(|_| ArrayError::HlcLockPoisoned)?;
191        let (last_physical, last_logical) = *guard;
192
193        let new_physical = now_ms.max(last_physical);
194        let new_logical = if new_physical == last_physical {
195            last_logical
196                .checked_add(1)
197                .ok_or_else(|| ArrayError::InvalidHlc {
198                    detail: "logical counter overflow within one millisecond".into(),
199                })?
200        } else {
201            0
202        };
203
204        *guard = (new_physical, new_logical);
205        drop(guard);
206
207        Hlc::new(new_physical, new_logical, self.replica_id)
208    }
209
210    /// Advance local state after observing a remote [`Hlc`].
211    ///
212    /// Implements the standard HLC merge rule so that subsequent calls to
213    /// [`next`](HlcGenerator::next) return timestamps strictly greater than
214    /// any observed remote timestamp.
215    pub fn observe(&self, remote: Hlc) -> ArrayResult<()> {
216        let now_ms = Self::now_ms()?.min(MAX_PHYSICAL_MS);
217
218        let mut guard = self.state.lock().map_err(|_| ArrayError::HlcLockPoisoned)?;
219        let (last_physical, last_logical) = *guard;
220
221        let new_physical = now_ms.max(last_physical).max(remote.physical_ms);
222        let new_logical = if new_physical == last_physical && new_physical == remote.physical_ms {
223            // All three agree on physical; advance logical past both.
224            last_logical
225                .max(remote.logical)
226                .checked_add(1)
227                .ok_or_else(|| ArrayError::InvalidHlc {
228                    detail: "logical counter overflow during observe".into(),
229                })?
230        } else if new_physical == last_physical {
231            last_logical
232                .checked_add(1)
233                .ok_or_else(|| ArrayError::InvalidHlc {
234                    detail: "logical counter overflow during observe (local wins)".into(),
235                })?
236        } else if new_physical == remote.physical_ms {
237            remote
238                .logical
239                .checked_add(1)
240                .ok_or_else(|| ArrayError::InvalidHlc {
241                    detail: "logical counter overflow during observe (remote wins)".into(),
242                })?
243        } else {
244            // now_ms is strictly larger than both; reset logical.
245            0
246        };
247
248        *guard = (new_physical, new_logical);
249        Ok(())
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn test_replica() -> ReplicaId {
258        ReplicaId::new(42)
259    }
260
261    #[test]
262    fn monotonic_under_fast_calls() {
263        let g = HlcGenerator::new(test_replica());
264        let mut prev = g.next().unwrap();
265        for _ in 0..999 {
266            let curr = g.next().unwrap();
267            assert!(
268                curr > prev,
269                "HLC must be strictly increasing: {curr:?} <= {prev:?}"
270            );
271            prev = curr;
272        }
273    }
274
275    #[test]
276    fn survives_clock_skew_injection() {
277        let g = HlcGenerator::new(test_replica());
278
279        // Get a baseline HLC.
280        let baseline = g.next().unwrap();
281
282        // Synthesise a "remote" HLC far in the future (but within MAX_PHYSICAL_MS).
283        let future_physical = baseline.physical_ms + 100_000; // +100 seconds
284        let future_hlc = Hlc::new(future_physical, 50, ReplicaId::new(99)).unwrap();
285
286        // Observe the future HLC.
287        g.observe(future_hlc).unwrap();
288
289        // Next local HLC must be > the observed future HLC.
290        let next = g.next().unwrap();
291        assert!(
292            next > future_hlc,
293            "next {next:?} should be > observed future {future_hlc:?}"
294        );
295    }
296
297    #[test]
298    fn to_bytes_roundtrip() {
299        let hlc = Hlc::new(123_456_789, 7, ReplicaId::new(0xabcd)).unwrap();
300        let bytes = hlc.to_bytes();
301        let back = Hlc::from_bytes(&bytes);
302        assert_eq!(hlc, back);
303    }
304
305    #[test]
306    fn byte_order_matches_lexicographic() {
307        use std::collections::BTreeMap;
308
309        // Build 100 HLCs with varied fields.
310        let replica = ReplicaId::new(1);
311        let mut hlcs: Vec<Hlc> = (0u64..100)
312            .map(|i| Hlc {
313                physical_ms: i / 10,
314                logical: (i % 10) as u16,
315                replica_id: replica,
316            })
317            .collect();
318
319        // Sort by Ord.
320        let mut by_ord = hlcs.clone();
321        by_ord.sort();
322
323        // Sort by to_bytes().
324        hlcs.sort_by_key(|h| h.to_bytes());
325
326        assert_eq!(by_ord, hlcs, "byte sort must match Ord sort");
327
328        // Also verify via BTreeMap (key = bytes).
329        let mut map: BTreeMap<[u8; 18], Hlc> = BTreeMap::new();
330        for h in &by_ord {
331            map.insert(h.to_bytes(), *h);
332        }
333        let map_order: Vec<Hlc> = map.into_values().collect();
334        assert_eq!(by_ord, map_order);
335    }
336
337    #[test]
338    fn serialize_roundtrip() {
339        let hlc = Hlc::new(9_999, 3, ReplicaId::new(77)).unwrap();
340        let bytes = zerompk::to_msgpack_vec(&hlc).expect("serialize");
341        let back: Hlc = zerompk::from_msgpack(&bytes).expect("deserialize");
342        assert_eq!(hlc, back);
343    }
344
345    #[test]
346    fn physical_ms_overflow_errors() {
347        let result = Hlc::new(MAX_PHYSICAL_MS + 1, 0, ReplicaId::new(1));
348        assert!(
349            matches!(result, Err(ArrayError::InvalidHlc { .. })),
350            "expected InvalidHlc, got: {result:?}"
351        );
352    }
353
354    #[test]
355    fn hlc_zero_is_minimum() {
356        let non_zero = Hlc::new(1, 0, ReplicaId::new(0)).unwrap();
357        assert!(Hlc::ZERO < non_zero);
358    }
359}