Skip to main content

coven_core/sync/
hlc.rs

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