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}