beck_core/
clock.rs

1//! Time, as a thing that is supplied rather than a thing that is ambient.
2//!
3//! [`docs/14-review-findings.md`](../../../../../docs/14-review-findings.md) F11 records that
4//! deterministic simulation cannot be retrofitted and names the constraint: virtualize clock,
5//! network and disk from the first line of runtime code.
6//! [`docs/13-testing.md`](../../../../../docs/13-testing.md) §13.4 restates it in bold. The runtime
7//! then called `SystemTime::now()` directly for three phases anyway
8//! ([`docs/42`](../../../../../docs/42-security-assurance.md) §42.4), which is what a constraint with
9//! no position in an order gets you.
10//!
11//! This module is the cheap half of the fix, and deliberately only the cheap half. It is a
12//! **seam**, not a simulator: there is no scheduler here, no virtual time, no ordering of events
13//! against each other. There is a trait with one implementation that reads the host and one that a
14//! caller sets, so that the retrofit F11 forbids never has to happen. `docs/42` §42.4's verdict is
15//! exactly that: "adopt the injected clock now; watch DST proper".
16//!
17//! # What is on the seam and what is not
18//!
19//! **The wall clock is**: an envelope's `at`, the `now()` primitive, the milliseconds in a
20//! time-ordered id, a telemetry timestamp. Those are the readings that enter data — an envelope is
21//! logged and replayed, so where its `at` came from is a determinism question.
22//!
23//! **Elapsed time is not**, yet: `Instant::now()` survives in the places that measure how long
24//! something took (`beck bench`, the append and render histograms). A duration measured for a
25//! metric does not enter the log, does not reach a fold, and cannot change what a replay produces.
26//! It will have to move here when DST proper arrives, and saying so is cheaper than pretending
27//! this module already covers it.
28
29use std::sync::Arc;
30use std::sync::OnceLock;
31
32/// A source of wall-clock time.
33///
34/// Implementations are shared across threads and cheap to call. `Debug` is required because the
35/// clock rides in configuration that is printed when a process explains itself.
36pub trait Clock: Send + Sync + std::fmt::Debug {
37    /// Milliseconds since the Unix epoch.
38    fn now_millis(&self) -> i64;
39
40    /// Nanoseconds since the Unix epoch — the unit OTLP asks for.
41    ///
42    /// Defaulted from [`Clock::now_millis`], so a clock somebody writes for a test has one method.
43    fn now_nanos(&self) -> u64 {
44        (self.now_millis().max(0) as u64).saturating_mul(1_000_000)
45    }
46}
47
48/// The host's clock — **the only place in this workspace that reads it**.
49///
50/// `beck-cli/tests/clock.rs` asserts that, by scanning the tree for the call the way
51/// `beck-cli/tests/docs.rs` scans it for diagnostic codes. A second one appearing is the failure
52/// F11 describes, caught at the moment it is introduced rather than three phases later.
53#[derive(Clone, Copy, Debug, Default)]
54pub struct SystemClock;
55
56impl Clock for SystemClock {
57    fn now_millis(&self) -> i64 {
58        (self.now_nanos() / 1_000_000) as i64
59    }
60
61    /// The one reading. Milliseconds are derived from it rather than taken separately, so the gate
62    /// above is about a *call* and not about a file — two calls beside each other would satisfy
63    /// "one place" while being exactly the habit the seam exists to end.
64    fn now_nanos(&self) -> u64 {
65        std::time::SystemTime::now()
66            .duration_since(std::time::UNIX_EPOCH)
67            .map(|d| d.as_nanos() as u64)
68            .unwrap_or(0)
69    }
70}
71
72/// A clock whose reading is whatever the caller last set.
73///
74/// This is not a simulator and must not grow into one by accident: it does not advance itself, it
75/// has no notion of a pending timer, and nothing schedules against it. It exists so that a test
76/// can assert a program's behaviour at a stated instant, and so that the seam has a second
77/// implementation — a seam with one implementation is an abstraction nobody has checked.
78#[derive(Debug)]
79pub struct ManualClock(std::sync::atomic::AtomicI64);
80
81impl ManualClock {
82    pub fn at(millis: i64) -> ManualClock {
83        ManualClock(std::sync::atomic::AtomicI64::new(millis))
84    }
85
86    pub fn set(&self, millis: i64) {
87        self.0.store(millis, std::sync::atomic::Ordering::Relaxed);
88    }
89
90    /// Move the clock forward. Panics on a negative step, because a wall clock that goes backwards
91    /// is a bug in the test rather than a scenario worth supporting.
92    pub fn advance(&self, millis: i64) {
93        assert!(millis >= 0, "a wall clock does not run backwards");
94        self.0
95            .fetch_add(millis, std::sync::atomic::Ordering::Relaxed);
96    }
97}
98
99impl Clock for ManualClock {
100    fn now_millis(&self) -> i64 {
101        self.0.load(std::sync::atomic::Ordering::Relaxed)
102    }
103}
104
105static PROCESS: OnceLock<Arc<dyn Clock>> = OnceLock::new();
106
107/// The clock for readings that have no owner to take one from.
108///
109/// Telemetry is the case: a metric's timestamp belongs to no application and no evaluation, and
110/// threading a clock to it would mean threading one through every counter. Everything that *does*
111/// have an owner — the sequencer, the evaluator's host — takes its clock as a parameter and never
112/// reads this.
113pub fn process_clock() -> &'static Arc<dyn Clock> {
114    PROCESS.get_or_init(|| Arc::new(SystemClock))
115}
116
117/// Install the process clock. Returns `false` if one has already been read or installed.
118///
119/// Once, at startup, before anything reads it — which is the only discipline a `OnceLock` can
120/// enforce and the reason this returns a bool rather than panicking: a test binary that runs two
121/// tests in one process would otherwise abort on the second.
122pub fn set_process_clock(clock: Arc<dyn Clock>) -> bool {
123    PROCESS.set(clock).is_ok()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn a_manual_clock_reads_what_it_was_set_to() {
132        let c = ManualClock::at(1_700_000_000_000);
133        assert_eq!(c.now_millis(), 1_700_000_000_000);
134        c.advance(1_500);
135        assert_eq!(c.now_millis(), 1_700_000_001_500);
136        assert_eq!(c.now_nanos(), 1_700_000_001_500_000_000);
137    }
138
139    #[test]
140    fn the_system_clock_is_after_the_date_this_was_written() {
141        // Not a precision claim — an assertion that the reading is a Unix epoch in milliseconds
142        // and not seconds or nanoseconds, which is the mistake this kind of helper actually makes.
143        let ms = SystemClock.now_millis();
144        assert!(ms > 1_750_000_000_000, "got {ms}");
145        assert!(ms < 100_000_000_000_000, "got {ms}");
146    }
147}