Skip to main content

krabka_hlc/
clock.rs

1//! The clock state machine, its physical-clock seam, and its observations.
2
3use core::sync::atomic::{AtomicI64, Ordering};
4use std::{
5    sync::{Arc, Mutex, MutexGuard, PoisonError},
6    time::SystemTime,
7};
8
9use krabka_ids::NodeId;
10use krabka_units::{Time, convert::TimeExt as _, millis};
11
12use crate::{
13    error::HlcError,
14    stamp::{Hlc, WallMicros},
15};
16
17/// The maximum offset an [`HlcClock`] accepts from a peer by default.
18///
19/// 500 milliseconds is wider than the offset a healthy NTP or PTP fleet holds,
20/// and it is far below the offset a broken clock reports.
21pub const DEFAULT_MAX_OFFSET: Time = millis(500);
22
23/// The source of physical time for an [`HlcClock`].
24///
25/// The clock reads this trait once per event. A test supplies a
26/// [`ManualClock`], so the whole state machine runs without a real clock and
27/// without a sleep. Production supplies a [`SystemClock`].
28///
29/// An implementor returns the current wall-clock time in microseconds since
30/// the Unix epoch. The value should never go backwards, but the clock does not
31/// depend on that: the logical time it keeps never goes backwards, whatever
32/// the physical clock does.
33pub trait PhysicalClock {
34    /// The current wall-clock time, in microseconds since the Unix epoch.
35    fn now_micros(&self) -> i64;
36}
37
38impl<C: PhysicalClock + ?Sized> PhysicalClock for &C {
39    fn now_micros(&self) -> i64 {
40        (**self).now_micros()
41    }
42}
43
44impl<C: PhysicalClock + ?Sized> PhysicalClock for Arc<C> {
45    fn now_micros(&self) -> i64 {
46        (**self).now_micros()
47    }
48}
49
50/// The host's wall clock, read through [`SystemTime`].
51///
52/// This is the physical clock a broker uses.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct SystemClock;
55
56impl PhysicalClock for SystemClock {
57    // `SystemTime` is not monotonic and it can sit before the Unix epoch, so
58    // both directions get an answer and neither one panics. A time the `i64`
59    // range cannot hold saturates, which keeps the stamp ordered against every
60    // other stamp.
61    fn now_micros(&self) -> i64 {
62        match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
63            Ok(after) => i64::try_from(after.as_micros()).unwrap_or(i64::MAX),
64            Err(before) => {
65                i64::try_from(before.duration().as_micros()).map_or(i64::MIN, i64::saturating_neg)
66            }
67        }
68    }
69}
70
71/// A physical clock a test moves by hand.
72///
73/// The clock holds one instant and returns it until the test changes it. Every
74/// method takes `&self`, so a test can hold the clock in an [`Arc`] and still
75/// move it while an [`HlcClock`] reads it.
76#[derive(Debug)]
77pub struct ManualClock {
78    micros: AtomicI64,
79}
80
81impl ManualClock {
82    /// A clock stopped at `now`.
83    #[must_use]
84    pub fn new(now: WallMicros) -> Self {
85        Self {
86            micros: AtomicI64::new(now.get()),
87        }
88    }
89
90    /// Moves the clock to `now`. The new instant can be before the old one,
91    /// which is how a test models a clock that jumps backwards.
92    pub fn set(&self, now: WallMicros) {
93        self.micros.store(now.get(), Ordering::SeqCst);
94    }
95
96    /// Moves the clock forward by `by`. A negative extent moves it backwards.
97    pub fn advance(&self, by: Time) {
98        let micros = by.micros_i64();
99        let _ = self
100            .micros
101            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
102                Some(current.saturating_add(micros))
103            });
104    }
105}
106
107impl PhysicalClock for ManualClock {
108    fn now_micros(&self) -> i64 {
109        self.micros.load(Ordering::SeqCst)
110    }
111}
112
113/// What an [`HlcClock`] has learned about clock skew in the cluster.
114///
115/// Every field is evidence measured on the data path. A record whose stamp
116/// pulls the local clock forward by 40 milliseconds is direct evidence of 40
117/// milliseconds of skew between this node and the node that wrote the record.
118/// No probe and no extra round trip produce that number. The record already
119/// carried it.
120///
121/// This is a plain snapshot. The crate has no metrics dependency, and it must
122/// not gain one. An observability agent reads the snapshot and publishes it.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct ClockObservation {
125    /// How far the logical clock leads the physical clock right now.
126    ///
127    /// This is `l - pt`: the logical wall time minus the physical wall time.
128    /// A positive value means that a peer, or this node's own history, holds
129    /// the logical clock ahead of what the hardware reports, and that lead is
130    /// the skew this node carries. A negative value is normal, and it only
131    /// means that the physical clock moved on since the last event.
132    pub drift: Time,
133
134    /// How many received stamps moved the local clock forward because the
135    /// peer's wall time was ahead of both the local logical clock and the
136    /// local physical clock.
137    pub peer_advances: u64,
138
139    /// How many received stamps the self-fence refused.
140    ///
141    /// A number above zero names a peer whose clock is broken, or a maximum
142    /// offset set below the fleet's real skew.
143    pub refusals: u64,
144
145    /// The largest positive delta between a peer's wall time and the local
146    /// physical clock, over every stamp this clock received.
147    ///
148    /// Refused stamps count here too, so the value shows the worst skew the
149    /// node saw and not only the worst skew it absorbed. The value stays at
150    /// zero while no peer is ahead.
151    pub max_peer_ahead: Time,
152}
153
154/// The mutable part of an [`HlcClock`].
155#[derive(Debug)]
156struct State {
157    wall: i64,
158    counter: u32,
159    peer_advances: u64,
160    refusals: u64,
161    max_peer_ahead_micros: i64,
162}
163
164/// A hybrid logical clock for one node.
165///
166/// The clock holds a wall time and a logical counter, and it follows the
167/// algorithm in Kulkarni et al., *Logical Physical Clocks and Consistent
168/// Snapshots in Globally Distributed Databases* (OPODIS 2014). [`HlcClock::now`]
169/// is the paper's send event and [`HlcClock::observe`] is its receive event.
170/// The pair keeps two guarantees. The stamps one node mints strictly increase.
171/// A stamp that a node received is always below every stamp that the node
172/// mints after it, so the order the stamps give agrees with cause and effect.
173///
174/// Every method takes `&self`. The state sits behind a [`Mutex`], and the
175/// critical section only compares and assigns integers, so a producer task, a
176/// consumer task, and an observability agent can share one clock.
177///
178/// The physical clock is a type parameter, so a test drives the whole state
179/// machine with a [`ManualClock`] and no sleep.
180#[derive(Debug)]
181pub struct HlcClock<C = SystemClock> {
182    node: NodeId,
183    physical: C,
184    max_offset_micros: i64,
185    state: Mutex<State>,
186}
187
188impl HlcClock<SystemClock> {
189    /// A clock for `node` that reads the host's wall clock and fences at
190    /// [`DEFAULT_MAX_OFFSET`].
191    #[must_use]
192    pub fn new(node: NodeId) -> Self {
193        Self::with_max_offset(node, SystemClock, DEFAULT_MAX_OFFSET)
194    }
195}
196
197impl<C: PhysicalClock> HlcClock<C> {
198    /// A clock for `node` that reads `physical` and fences at
199    /// [`DEFAULT_MAX_OFFSET`].
200    #[must_use]
201    pub fn with_clock(node: NodeId, physical: C) -> Self {
202        Self::with_max_offset(node, physical, DEFAULT_MAX_OFFSET)
203    }
204
205    /// A clock for `node` that reads `physical` and fences at `max_offset`.
206    ///
207    /// The clock starts at the current reading of `physical`, with the counter
208    /// at zero. A negative `max_offset` has the same effect as zero: the clock
209    /// then refuses every stamp whose wall time is above its physical clock.
210    #[must_use]
211    pub fn with_max_offset(node: NodeId, physical: C, max_offset: Time) -> Self {
212        let wall = physical.now_micros();
213        Self {
214            node,
215            physical,
216            max_offset_micros: max_offset.micros_i64().max(0),
217            state: Mutex::new(State {
218                wall,
219                counter: 0,
220                peer_advances: 0,
221                refusals: 0,
222                max_peer_ahead_micros: 0,
223            }),
224        }
225    }
226
227    /// The node this clock stamps for.
228    #[must_use]
229    pub const fn node(&self) -> NodeId {
230        self.node
231    }
232
233    /// The maximum offset the self-fence allows.
234    #[must_use]
235    pub fn max_offset(&self) -> Time {
236        Time::from_micros(self.max_offset_micros)
237    }
238
239    /// The next stamp for a local event, such as a record this node produces.
240    ///
241    /// This is the paper's send event. The new wall time is the later of the
242    /// current logical wall time and the physical clock. The counter goes up
243    /// by one when the wall time did not move, and back to zero when it did.
244    /// Two calls never return the same stamp, whatever the physical clock
245    /// does.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`HlcError::CounterOverflow`] when the counter reaches
250    /// `u32::MAX` inside one microsecond. That needs more than four billion
251    /// stamps in one microsecond, and the clock reports it rather than let the
252    /// counter wrap.
253    pub fn now(&self) -> Result<Hlc, HlcError> {
254        let physical = self.physical.now_micros();
255        let mut state = self.state();
256        let wall = state.wall.max(physical);
257        let counter = if wall == state.wall {
258            next_counter(state.counter)?
259        } else {
260            0
261        };
262        state.wall = wall;
263        state.counter = counter;
264        drop(state);
265        Ok(Hlc::new(WallMicros(wall), counter, self.node))
266    }
267
268    /// Merges a peer's stamp into this clock, and returns the stamp for the
269    /// receive event.
270    ///
271    /// This is the paper's receive event. The new wall time is the latest of
272    /// the local logical wall time, the peer's wall time, and the physical
273    /// clock. The counter then follows the source of that wall time: it is one
274    /// above the larger of the two counters when the local clock and the peer
275    /// agree, one above the counter of whichever side supplied the wall time
276    /// when only one side did, and zero when the physical clock overtook both.
277    ///
278    /// The returned stamp is always above `peer`, so the record this node
279    /// writes next sorts after the record it read.
280    ///
281    /// The clock also fences itself. It refuses a stamp whose wall time is more
282    /// than [`HlcClock::max_offset`] ahead of the local physical clock. It
283    /// returns [`HlcError::PeerTooFarAhead`], and it does not move. A hybrid
284    /// logical clock adopts the highest wall time it sees, so one host with a
285    /// clock set to the year 2098 would drag every node that reads its records
286    /// into 2098, and then every node that reads those nodes' records. Nothing
287    /// brings the fleet back, because logical time never goes backwards. The
288    /// fence keeps the damage inside the broken host.
289    ///
290    /// A stamp behind the local physical clock is always safe, and the clock
291    /// always accepts it. The fence is one-sided for that reason.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`HlcError::PeerTooFarAhead`] when the self-fence refuses the
296    /// stamp, and [`HlcError::CounterOverflow`] when the merged counter
297    /// reaches `u32::MAX` inside one microsecond.
298    pub fn observe(&self, peer: Hlc) -> Result<Hlc, HlcError> {
299        let physical = self.physical.now_micros();
300        let peer_wall = peer.wall.get();
301        // Saturating, because a peer that claims `i64::MAX` while the local
302        // clock sits below zero would overflow the plain subtraction. The
303        // saturated value is still far over any sane offset, so the fence
304        // below refuses it.
305        let ahead_micros = peer_wall.saturating_sub(physical);
306
307        let mut state = self.state();
308        state.max_peer_ahead_micros = state.max_peer_ahead_micros.max(ahead_micros);
309        if ahead_micros > self.max_offset_micros {
310            state.refusals = state.refusals.saturating_add(1);
311            drop(state);
312            tracing::debug!(
313                node = self.node.get(),
314                peer_node = peer.node.get(),
315                ahead_micros,
316                max_micros = self.max_offset_micros,
317                "refused a hybrid logical clock stamp from a peer that is too far ahead"
318            );
319            return Err(HlcError::PeerTooFarAhead {
320                ahead_micros,
321                max_micros: self.max_offset_micros,
322            });
323        }
324
325        let local_wall = state.wall;
326        let wall = local_wall.max(peer_wall).max(physical);
327        let counter = if wall == local_wall && wall == peer_wall {
328            next_counter(state.counter.max(peer.counter))?
329        } else if wall == local_wall {
330            next_counter(state.counter)?
331        } else if wall == peer_wall {
332            next_counter(peer.counter)?
333        } else {
334            0
335        };
336
337        if peer_wall > local_wall && peer_wall > physical {
338            state.peer_advances = state.peer_advances.saturating_add(1);
339        }
340        state.wall = wall;
341        state.counter = counter;
342        drop(state);
343        Ok(Hlc::new(WallMicros(wall), counter, self.node))
344    }
345
346    /// What this clock has learned about clock skew.
347    ///
348    /// The snapshot is consistent: it reads the physical clock and the whole
349    /// state together.
350    #[must_use]
351    pub fn observation(&self) -> ClockObservation {
352        let physical = self.physical.now_micros();
353        let state = self.state();
354        ClockObservation {
355            drift: Time::from_micros(state.wall.saturating_sub(physical)),
356            peer_advances: state.peer_advances,
357            refusals: state.refusals,
358            max_peer_ahead: Time::from_micros(state.max_peer_ahead_micros),
359        }
360    }
361
362    // The critical section compares and assigns integers only, so it cannot
363    // panic and it cannot leave the state half-written. A poisoned lock still
364    // holds a valid state, so recovery keeps the clock free of panics.
365    fn state(&self) -> MutexGuard<'_, State> {
366        self.state.lock().unwrap_or_else(PoisonError::into_inner)
367    }
368}
369
370/// The next value of a logical counter.
371fn next_counter(counter: u32) -> Result<u32, HlcError> {
372    counter.checked_add(1).ok_or(HlcError::CounterOverflow)
373}