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