Skip to main content

coven_protocol/
hlc.rs

1//! Hybrid-logical-clock timestamps: the total order behind last-writer-wins
2//! registers. The value model lives here; [`crate::hlc::Hlc`] is the
3//! clock-retaining service that mints and advances them.
4
5/// `protocol_state` key under which the clock's high-water mark is persisted, so it
6/// cannot regress across restarts (see [`Hlc::seed`]). Written whenever the
7/// clock advances (host stamp flushed at cycle end, and on apply-merge).
8pub const HIGHWATER_STATE_KEY: &str = "hlc_highwater";
9
10/// How far ahead of the receiver's wall clock an incoming `_updated_at`'s
11/// physical (millis) component may sit and still be treated as honest. A device
12/// can legitimately be offline for a long stretch and cross-device wall clocks
13/// drift, so the window is generous — 30 days. A stamp beyond `receiver wall + this`
14/// has no honest explanation (a broken clock or buggy client), so the receiver
15/// refuses to let it win last-writer-wins or ratchet the local clock. The bound is
16/// one-sided: only grossly-*future* stamps are rejected; a stamp in the past is
17/// always honest (an offline device's older edits) and is never bounded.
18pub const MAX_FUTURE_SKEW_MS: u64 = 30 * 24 * 60 * 60 * 1000;
19
20/// Largest counter value whose zero-padded four-digit field preserves lexical
21/// ordering.
22pub(crate) const COUNTER_MAX: u16 = 9999;
23
24/// A parsed HLC timestamp.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
26pub struct Timestamp {
27    pub millis: u64,
28    pub counter: u16,
29    pub device_id: String,
30}
31
32impl Timestamp {
33    pub fn new(millis: u64, counter: u16, device_id: String) -> Self {
34        Self {
35            millis,
36            counter,
37            device_id,
38        }
39    }
40
41    /// Whether this stamp's physical (millis) component is within the honest
42    /// future bound relative to `receiver_wall_ms` (the receiver's current wall
43    /// clock when it observed the stamp). A stamp at or behind wall time is always
44    /// honest; one ahead is honest only within [`MAX_FUTURE_SKEW_MS`]. Beyond that
45    /// it is grossly-future — a broken clock or buggy client — and the receiver
46    /// must not let it win last-writer-wins or ratchet the local clock.
47    pub fn is_within_future_bound(&self, receiver_wall_ms: u64) -> bool {
48        self.millis <= receiver_wall_ms.saturating_add(MAX_FUTURE_SKEW_MS)
49    }
50
51    /// Parse from the string format.
52    pub fn parse(s: &str) -> Option<Self> {
53        let mut parts = s.splitn(3, '-');
54        let millis = parts.next()?.parse::<u64>().ok()?;
55        let counter = parts.next()?.parse::<u16>().ok()?;
56        let device_id = parts.next()?;
57        if device_id.is_empty() {
58            return None;
59        }
60        if counter > COUNTER_MAX {
61            return None;
62        }
63        Some(Self {
64            millis,
65            counter,
66            device_id: device_id.to_string(),
67        })
68    }
69}
70
71impl std::fmt::Display for Timestamp {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(
74            f,
75            "{:013}-{:04}-{}",
76            self.millis, self.counter, self.device_id
77        )
78    }
79}
80
81/// Hybrid Logical Clock (HLC) for causal ordering of writes across devices.
82///
83/// This clock is coven's `_updated_at` register: hosts stamp every synced
84/// row's `_updated_at` with `SqlContext::stamp`, and pull records every
85/// applied row's `_updated_at` as a floor so a subsequent local write sorts causally
86/// after anything just pulled. The row arbiter (`conflict.rs`) picks a conflict
87/// winner by comparing these strings, whose order is lexicographic. Because the
88/// clock never mints a
89/// stamp behind a value it has already seen — even under wall-clock skew or a
90/// same-millisecond restart — a device that edits a row right after pulling a
91/// peer's edit always wins, which a plain wall clock cannot guarantee.
92///
93/// `_updated_at` is opaque to the host: it binds the string coven hands it and
94/// never parses it. Format (coven-internal): `{millis:013}-{counter:04}-{device_id}`.
95///
96/// The in-memory monotonic state is seeded on construction ([`Hlc::seed`]) so
97/// it cannot regress across restarts. The seed floor is the max of two sources:
98/// the persisted high-water mark ([`Hlc::high_water`], flushed at cycle end) and
99/// the max `_updated_at` coven scans across the synced tables in
100/// its open path. The on-disk row scan is the authoritative floor — the
101/// high-water flush lags any local row stamp minted between cycles, so seeding
102/// from it alone could let the first post-restart stamp sort below the device's
103/// own un-flushed rows.
104use std::sync::{Arc, Mutex};
105
106/// Hybrid Logical Clock.
107///
108/// Thread-safe via interior `Mutex`. Create one per application lifetime,
109/// pass by reference to write methods.
110struct HlcState {
111    millis: u64,
112    counter: u16,
113}
114
115fn increment(state: &mut HlcState) {
116    if state.counter < COUNTER_MAX {
117        state.counter += 1;
118    } else if let Some(next_millis) = state.millis.checked_add(1) {
119        state.millis = next_millis;
120        state.counter = 0;
121    } else {
122        state.counter = COUNTER_MAX;
123    }
124}
125
126pub struct Hlc {
127    device_id: String,
128    state: Mutex<HlcState>,
129    clock: coven_foundation::clock::ClockRef,
130}
131
132impl Hlc {
133    pub fn try_new(
134        device_id: String,
135        clock: coven_foundation::clock::ClockRef,
136    ) -> Result<Self, coven_foundation::store_dir::PathTokenError> {
137        coven_foundation::store_dir::validate_path_token(&device_id)?;
138        Ok(Self {
139            device_id,
140            state: Mutex::new(HlcState {
141                millis: 0,
142                counter: 0,
143            }),
144            clock,
145        })
146    }
147
148    /// Create a new HLC with the given device ID.
149    pub fn new(device_id: String, clock: coven_foundation::clock::ClockRef) -> Self {
150        Self::try_new(device_id, clock).expect("device_id must be a safe path token")
151    }
152
153    pub fn device_id(&self) -> &str {
154        &self.device_id
155    }
156
157    fn wall_millis(&self) -> u64 {
158        self.clock
159            .now()
160            .timestamp_millis()
161            .try_into()
162            .expect("clock before UNIX epoch")
163    }
164
165    /// Seed the clock's monotonic state from a persisted high-water mark so it
166    /// cannot mint a stamp behind a value it minted (or saw) before a restart.
167    ///
168    /// Idempotent and monotonic: a seed below the current state is ignored, so
169    /// re-seeding can only push the clock forward. The seeded `device_id` is
170    /// irrelevant — only `millis`/`counter` gate future stamps.
171    pub fn seed(&self, high_water: &Timestamp) {
172        let mut state = self.state.lock().unwrap();
173        if high_water.millis > state.millis
174            || (high_water.millis == state.millis && high_water.counter > state.counter)
175        {
176            state.millis = high_water.millis;
177            state.counter = high_water.counter;
178        }
179    }
180
181    /// The clock's current high-water mark: a [`Timestamp`] at the latest
182    /// `millis`/`counter` this clock has reached. Persist this whenever the
183    /// clock advances (on stamp and on apply-merge) and feed it back to
184    /// [`Hlc::seed`] on the next construction.
185    pub fn high_water(&self) -> Timestamp {
186        let state = self.state.lock().unwrap();
187        Timestamp::new(state.millis, state.counter, self.device_id.clone())
188    }
189
190    /// The receiver's current wall-clock millis, read from the same injected
191    /// source the clock stamps from. This is the reference the pull bounds an
192    /// incoming `_updated_at` against (see [`Timestamp::is_within_future_bound`]):
193    /// it is the receiver's view of "now", in the same millis unit as a stamp's
194    /// physical component — never an author-supplied value. Read once per pull and
195    /// passed down, not sampled in a loop.
196    pub fn wall_now_ms(&self) -> u64 {
197        self.wall_millis()
198    }
199
200    /// Generate a new timestamp. Guaranteed to be greater than any previous
201    /// timestamp returned by this clock.
202    pub fn now(&self) -> Timestamp {
203        let wall = self.wall_millis();
204        let mut state = self.state.lock().unwrap();
205
206        if wall > state.millis {
207            state.millis = wall;
208            state.counter = 0;
209        } else {
210            increment(&mut state);
211        }
212
213        Timestamp::new(state.millis, state.counter, self.device_id.clone())
214    }
215
216    /// Record an applied row's `_updated_at` as the clock floor, so the next local
217    /// stamp sorts causally after it. `remote` is an authoritative register
218    /// value the LWW layer already accepted and wrote to disk — never an
219    /// untrusted peer wall clock — so recording it is **unconditional**: no skew
220    /// cap. Capping here would let the next local edit mint a stamp below an
221    /// already-stored applied row and lose LWW to it.
222    ///
223    /// Monotonic: a `remote` ahead of the current state becomes the state floor;
224    /// one behind it is ignored. Either way the next [`Self::now`] outranks `remote`.
225    pub fn advance_past(&self, remote: &Timestamp) {
226        let wall = self.wall_millis();
227        let mut state = self.state.lock().unwrap();
228
229        if wall > state.millis && wall > remote.millis {
230            // Wall clock is ahead of both: adopt it, reset counter.
231            state.millis = wall;
232            state.counter = 0;
233        } else if remote.millis > state.millis {
234            // Remote is ahead of local: adopt remote's register floor.
235            state.millis = remote.millis;
236            state.counter = remote.counter;
237        } else if state.millis == remote.millis && remote.counter > state.counter {
238            // Same millis: keep the higher register floor.
239            state.counter = remote.counter;
240        }
241    }
242}
243
244/// The database-owned `_updated_at` stamping capability over its shared [`Hlc`].
245///
246/// The database creates it only while executing a host write. Pull advances the
247/// same `Arc<Hlc>`, so every later `SqlContext::stamp` observes that advance and
248/// cannot sort behind a pulled row.
249///
250/// It exposes only [`UpdatedAtStamper::stamp`] — never `seed`/`advance_past`/
251/// `high_water`. Those drive the clock and are coven's alone; the host write
252/// path is a pure consumer of stamps and must not poke clock state.
253#[derive(Clone)]
254pub struct UpdatedAtStamper {
255    hlc: Arc<Hlc>,
256}
257
258impl UpdatedAtStamper {
259    pub fn new(hlc: Arc<Hlc>) -> Self {
260        Self { hlc }
261    }
262
263    /// Mint the next `_updated_at` register value for a synced-row write. The
264    /// returned string is an opaque HLC stamp; the host binds it into the write
265    /// and must not parse or compare it as a wall-clock time.
266    pub fn stamp(&self) -> String {
267        self.hlc.now().to_string()
268    }
269}
270
271/// The OS wall clock in epoch milliseconds — the same physical source [`Hlc`]
272/// stamps from. Production reads "now" through an injected clock
273/// ([`Hlc::wall_now_ms`]); this is for callers that apply a *trusted*, already-
274/// captured changeset against a raw connection with no injected clock (snapshot
275/// round-trips, gate/FK mechanics tests), where the honest receiver-now is real
276/// wall time and the future-skew bound is incidental, not under test.
277#[cfg(any(test, feature = "test-utils"))]
278pub fn now_wall_ms(clock: &dyn coven_foundation::clock::Clock) -> u64 {
279    clock
280        .now()
281        .timestamp_millis()
282        .try_into()
283        .expect("clock before UNIX epoch")
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use coven_foundation::clock::{ClosureClock, FixedClock, SystemClock};
290    use std::sync::atomic::{AtomicU64, Ordering};
291
292    fn instant(ms: u64) -> chrono::DateTime<chrono::Utc> {
293        chrono::DateTime::from_timestamp_millis(ms.try_into().expect("test millis fit in i64"))
294            .expect("valid test clock instant")
295    }
296
297    fn fixed_clock(ms: u64) -> coven_foundation::clock::ClockRef {
298        Arc::new(FixedClock(instant(ms)))
299    }
300
301    fn advancing_clock(start: u64) -> (Arc<AtomicU64>, coven_foundation::clock::ClockRef) {
302        let time = Arc::new(AtomicU64::new(start));
303        let time_clone = time.clone();
304        (
305            time,
306            Arc::new(ClosureClock(move || {
307                instant(time_clone.load(Ordering::SeqCst))
308            })),
309        )
310    }
311
312    #[test]
313    fn basic_monotonicity() {
314        let hlc = Hlc::new("dev-1".into(), Arc::new(SystemClock));
315        let t1 = hlc.now();
316        let t2 = hlc.now();
317        let t3 = hlc.now();
318
319        assert!(t2 > t1, "t2={t2} should be > t1={t1}");
320        assert!(t3 > t2, "t3={t3} should be > t2={t2}");
321    }
322
323    #[test]
324    fn new_rejects_empty_device_id() {
325        assert!(matches!(
326            Hlc::try_new(String::new(), Arc::new(SystemClock)),
327            Err(coven_foundation::store_dir::PathTokenError::Empty),
328        ));
329    }
330
331    #[test]
332    fn counter_increments_when_clock_stalls() {
333        let hlc = Hlc::new("dev-1".into(), fixed_clock(1000));
334
335        let t1 = hlc.now();
336        assert_eq!(t1.millis, 1000);
337        assert_eq!(t1.counter, 0);
338
339        let t2 = hlc.now();
340        assert_eq!(t2.millis, 1000);
341        assert_eq!(t2.counter, 1);
342
343        let t3 = hlc.now();
344        assert_eq!(t3.millis, 1000);
345        assert_eq!(t3.counter, 2);
346
347        assert!(t3 > t2);
348        assert!(t2 > t1);
349    }
350
351    #[test]
352    fn wall_clock_advance_resets_counter() {
353        let (time, clock) = advancing_clock(1000);
354        let hlc = Hlc::new("dev-1".into(), clock);
355
356        let t1 = hlc.now();
357        assert_eq!(t1.millis, 1000);
358        assert_eq!(t1.counter, 0);
359
360        // Stall the clock -- counter increments.
361        let t2 = hlc.now();
362        assert_eq!(t2.counter, 1);
363
364        // Advance the clock -- counter resets.
365        time.store(2000, Ordering::SeqCst);
366        let t3 = hlc.now();
367        assert_eq!(t3.millis, 2000);
368        assert_eq!(t3.counter, 0);
369
370        assert!(t3 > t2);
371    }
372
373    #[test]
374    fn advance_past_remote_ahead() {
375        let hlc = Hlc::new("dev-local".into(), fixed_clock(1000));
376
377        // Local clock is at 1000. Applied row stamp is at 5000.
378        let remote = Timestamp::new(5000, 3, "dev-remote".into());
379        hlc.advance_past(&remote);
380
381        // The next stamp must sort strictly after the applied row.
382        let t = hlc.now();
383        assert!(
384            t.to_string() > remote.to_string(),
385            "t={t} must beat {remote}"
386        );
387        assert_eq!(t.millis, 5000);
388        assert_eq!(t.device_id, "dev-local");
389    }
390
391    #[test]
392    fn advance_past_remote_behind() {
393        let hlc = Hlc::new("dev-local".into(), fixed_clock(5000));
394
395        // Prime the local clock to 5000.
396        let primed = hlc.now();
397
398        // An applied row stamp that's behind must not regress the clock.
399        let remote = Timestamp::new(1000, 10, "dev-remote".into());
400        hlc.advance_past(&remote);
401
402        let t = hlc.now();
403        assert!(
404            t > primed,
405            "t={t} must stay above the primed clock {primed}"
406        );
407        assert_eq!(t.millis, 5000);
408    }
409
410    /// The register-floor guarantee: an applied row's `_updated_at` is an
411    /// authoritative value the LWW layer already wrote to disk, not an untrusted
412    /// peer wall clock. The clock must advance past it *unconditionally* — even
413    /// when it sits far beyond local wall time — or the next local stamp sorts
414    /// below an already-stored row and loses LWW to it. Any skew cap that bounded
415    /// the advance to wall time would reintroduce exactly that loss.
416    #[test]
417    fn advance_past_far_future_applied_row_is_not_capped() {
418        let hlc = Hlc::new("dev-local".into(), fixed_clock(1000));
419
420        // An applied row stamped 48 hours beyond local wall — well past the old
421        // 24h cap.
422        let far_future = 1000 + 48 * 60 * 60 * 1000;
423        let applied = Timestamp::new(far_future, 7, "dev-remote".into());
424        hlc.advance_past(&applied);
425
426        // The next local stamp must sort *after* the applied row, not be capped
427        // back to wall time (1000) where it would sort below it.
428        let next = hlc.now();
429        assert!(
430            next.to_string() > applied.to_string(),
431            "next stamp {next} regressed below applied row {applied}: the clock \
432             refused to advance past an authoritative register value",
433        );
434        assert_eq!(next.millis, far_future);
435    }
436
437    #[test]
438    fn string_roundtrip() {
439        let ts = Timestamp::new(1707580800000, 42, "dev-abc123".into());
440        let s = ts.to_string();
441        let parsed = Timestamp::parse(&s).expect("parse should succeed");
442
443        assert_eq!(parsed, ts);
444        assert_eq!(s, "1707580800000-0042-dev-abc123");
445    }
446
447    #[test]
448    fn string_format_is_zero_padded() {
449        let ts = Timestamp::new(1000, 0, "d".into());
450        assert_eq!(ts.to_string(), "0000000001000-0000-d");
451
452        let ts2 = Timestamp::new(9999999999999, 9999, "d".into());
453        assert_eq!(ts2.to_string(), "9999999999999-9999-d");
454    }
455
456    #[test]
457    fn lexicographic_ordering_matches_causal_ordering() {
458        let timestamps = [
459            Timestamp::new(1000, 0, "dev-a".into()),
460            Timestamp::new(1000, 1, "dev-a".into()),
461            Timestamp::new(1000, 1, "dev-b".into()),
462            Timestamp::new(2000, 0, "dev-a".into()),
463            Timestamp::new(2000, 0, "dev-b".into()),
464        ];
465
466        let strings: Vec<String> = timestamps.iter().map(|t| t.to_string()).collect();
467
468        // Verify the string list is sorted.
469        for i in 1..strings.len() {
470            assert!(
471                strings[i] > strings[i - 1],
472                "Expected {:?} > {:?}",
473                strings[i],
474                strings[i - 1]
475            );
476        }
477    }
478
479    #[test]
480    fn device_id_breaks_ties() {
481        let ts_a = Timestamp::new(5000, 3, "aaa".into());
482        let ts_b = Timestamp::new(5000, 3, "bbb".into());
483
484        // Derived ordering: same millis, same counter, device_id decides.
485        assert!(ts_b > ts_a);
486
487        // String comparison should agree.
488        assert!(ts_b.to_string() > ts_a.to_string());
489    }
490
491    #[test]
492    fn future_bound_admits_honest_and_rejects_grossly_future() {
493        let wall: u64 = 1_700_000_000_000;
494
495        // At or behind wall time: always honest, regardless of how far behind.
496        assert!(Timestamp::new(wall, 0, "d".into()).is_within_future_bound(wall));
497        assert!(Timestamp::new(0, 0, "d".into()).is_within_future_bound(wall));
498
499        // Inside the allowance (offline device, plausible drift): honest.
500        let just_inside = wall + MAX_FUTURE_SKEW_MS - 1;
501        assert!(Timestamp::new(just_inside, 0, "d".into()).is_within_future_bound(wall));
502        // Exactly at the allowance boundary: still admitted (inclusive).
503        let at_bound = wall + MAX_FUTURE_SKEW_MS;
504        assert!(Timestamp::new(at_bound, 0, "d".into()).is_within_future_bound(wall));
505
506        // One past the allowance: grossly-future, rejected.
507        let just_beyond = wall + MAX_FUTURE_SKEW_MS + 1;
508        assert!(!Timestamp::new(just_beyond, 0, "d".into()).is_within_future_bound(wall));
509        // Absurd far-future (broken clock): rejected.
510        assert!(!Timestamp::new(u64::MAX, 0, "d".into()).is_within_future_bound(wall));
511
512        // The wall + allowance sum saturates rather than overflowing, so a near-max
513        // wall clock still admits an at-or-behind stamp.
514        assert!(Timestamp::new(u64::MAX, 0, "d".into()).is_within_future_bound(u64::MAX));
515    }
516
517    #[test]
518    fn parse_rejects_invalid_input() {
519        assert!(Timestamp::parse("").is_none());
520        assert!(Timestamp::parse("not-a-timestamp").is_none());
521        assert!(Timestamp::parse("1000-0000").is_none()); // missing device_id
522        assert!(Timestamp::parse("1000-0000-").is_none()); // empty device_id
523        assert!(Timestamp::parse("abc-0000-dev").is_none()); // non-numeric millis
524        assert!(Timestamp::parse("1000-xyz-dev").is_none()); // non-numeric counter
525    }
526
527    #[test]
528    fn parse_rejects_counter_above_format_width() {
529        assert!(Timestamp::parse("0000000001000-10000-dev").is_none());
530        assert!(Timestamp::parse("0000000001000-65535-dev").is_none());
531    }
532
533    #[test]
534    fn advance_past_counter_bound_carries_into_millis() {
535        let hlc = Hlc::new("dev-local".into(), fixed_clock(1000));
536        let observed = Timestamp::new(1000, 9999, "dev-remote".into());
537
538        hlc.advance_past(&observed);
539        let next = hlc.now();
540
541        assert!(
542            next.to_string() > observed.to_string(),
543            "next stamp {next} must sort after observed stamp {observed}",
544        );
545        assert_eq!(next.millis, 1001);
546        assert_eq!(next.counter, 0);
547    }
548
549    #[test]
550    fn now_counter_bound_carries_into_millis() {
551        let hlc = Hlc::new("dev-local".into(), fixed_clock(1000));
552        hlc.seed(&Timestamp::new(1000, 9999, "dev-remote".into()));
553
554        let next = hlc.now();
555
556        assert_eq!(next.millis, 1001);
557        assert_eq!(next.counter, 0);
558        assert_eq!(next.to_string(), "0000000001001-0000-dev-local");
559    }
560
561    #[test]
562    fn minted_counters_keep_fixed_width_lexical_order() {
563        let hlc = Hlc::new("dev-local".into(), fixed_clock(1000));
564        hlc.seed(&Timestamp::new(1000, 9998, "dev-remote".into()));
565
566        let stamps = [hlc.now(), hlc.now(), hlc.now()];
567        for stamp in &stamps {
568            let rendered = stamp.to_string();
569            let counter = rendered
570                .split('-')
571                .nth(1)
572                .expect("timestamp has a counter field");
573            assert_eq!(counter.len(), 4);
574        }
575        for pair in stamps.windows(2) {
576            assert!(
577                pair[1] > pair[0],
578                "timestamp ordering must advance from {} to {}",
579                pair[0],
580                pair[1],
581            );
582            assert!(
583                pair[1].to_string() > pair[0].to_string(),
584                "string ordering must advance from {} to {}",
585                pair[0],
586                pair[1],
587            );
588        }
589    }
590
591    #[test]
592    fn parse_handles_device_id_with_dashes() {
593        // Device IDs are UUIDs, which contain dashes. splitn(3, '-') must
594        // correctly capture the remainder as the device_id.
595        let ts = Timestamp::new(1000, 0, "550e8400-e29b-41d4-a716-446655440000".into());
596        let s = ts.to_string();
597        let parsed = Timestamp::parse(&s).expect("parse should handle UUID device_id");
598        assert_eq!(parsed.device_id, "550e8400-e29b-41d4-a716-446655440000");
599        assert_eq!(parsed, ts);
600    }
601}