osproxy_core/time.rs
1//! The clock seam, the foundation of deterministic time.
2//!
3//! Production code must never read wall-clock time directly: a hidden
4//! `Instant::now()` makes behavior depend on the machine and turns tests flaky.
5//! Instead, every component that needs time takes a [`Clock`]. Production wires
6//! [`SystemClock`]; tests wire [`ManualClock`] and advance it explicitly, so a
7//! timeout, a TTL, or an affinity expiry is reproducible to the nanosecond.
8//!
9//! This is enforced mechanically: `clippy.toml` bans `SystemTime::now`,
10//! `Instant::now`, and friends everywhere except [`SystemClock`], which is the
11//! single sanctioned place that touches the real clock (`docs/09`, `docs/12`).
12
13use std::sync::Mutex;
14use std::time::Duration;
15
16/// A monotonic instant, in nanoseconds since an unspecified epoch.
17///
18/// Opaque and only meaningful relative to another [`Instant`] from the same
19/// [`Clock`]. Comparable and subtractable; never convertible to wall-clock time
20/// (the proxy reasons about elapsed durations, not calendar time).
21#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
22pub struct Instant(u64);
23
24impl Instant {
25 /// Returns the duration elapsed from `earlier` to `self`, saturating at zero
26 /// if `earlier` is later (clocks are monotonic, so this should not happen,
27 /// but saturation keeps the type panic-free, NFR-R1).
28 #[must_use]
29 pub fn saturating_duration_since(self, earlier: Instant) -> Duration {
30 Duration::from_nanos(self.0.saturating_sub(earlier.0))
31 }
32
33 /// Returns the instant `delta` after `self`, saturating at the maximum.
34 #[must_use]
35 pub fn saturating_add(self, delta: Duration) -> Instant {
36 let nanos = u64::try_from(delta.as_nanos()).unwrap_or(u64::MAX);
37 Instant(self.0.saturating_add(nanos))
38 }
39}
40
41/// A source of time. Inject this anywhere time is needed.
42pub trait Clock: Send + Sync {
43 /// The current monotonic instant (for elapsed-time logic).
44 fn now(&self) -> Instant;
45
46 /// The current **wall-clock** time in nanoseconds since the Unix epoch.
47 ///
48 /// Distinct from [`Clock::now`] (which is monotonic and meaningless as an
49 /// absolute time): this is for stamping externally-meaningful timestamps such
50 /// as OTLP span start/end. Like `now`, it goes through the seam so it stays
51 /// deterministic under a [`ManualClock`].
52 fn unix_nanos(&self) -> u64;
53}
54
55/// The production clock, backed by the operating system's monotonic timer.
56///
57/// This is the **only** type permitted to read the real clock.
58#[derive(Clone, Copy, Default, Debug)]
59pub struct SystemClock;
60
61impl Clock for SystemClock {
62 fn now(&self) -> Instant {
63 // Anchor to a process-lifetime epoch so values are stable u64 nanos.
64 static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
65 // SystemClock is the single sanctioned site permitted to read the OS
66 // clock; everything else takes a Clock so time stays deterministic.
67 #[allow(
68 clippy::disallowed_methods,
69 reason = "the one sanctioned site reading the OS monotonic clock (docs/12)"
70 )]
71 let (raw, epoch) = (
72 std::time::Instant::now(),
73 *EPOCH.get_or_init(std::time::Instant::now),
74 );
75 Instant(u64::try_from(raw.saturating_duration_since(epoch).as_nanos()).unwrap_or(u64::MAX))
76 }
77
78 fn unix_nanos(&self) -> u64 {
79 // The sanctioned site for reading the OS wall clock (see `now`).
80 #[allow(
81 clippy::disallowed_methods,
82 reason = "the one sanctioned site reading the OS wall clock (docs/12)"
83 )]
84 let since_epoch = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
85 since_epoch.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
86 }
87}
88
89/// A test clock advanced explicitly. Starts at zero; never moves on its own.
90#[derive(Debug, Default)]
91pub struct ManualClock {
92 nanos: Mutex<u64>,
93}
94
95impl ManualClock {
96 /// Creates a clock reading zero.
97 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 /// Advances the clock by `delta`. Saturates at the maximum.
103 pub fn advance(&self, delta: Duration) {
104 let add = u64::try_from(delta.as_nanos()).unwrap_or(u64::MAX);
105 if let Ok(mut nanos) = self.nanos.lock() {
106 *nanos = nanos.saturating_add(add);
107 }
108 }
109}
110
111impl Clock for ManualClock {
112 fn now(&self) -> Instant {
113 Instant(self.nanos.lock().map_or(0, |n| *n))
114 }
115
116 fn unix_nanos(&self) -> u64 {
117 // The test clock has no separate wall clock: it reports its advanced
118 // nanos as the absolute time, so OTLP timestamps stay deterministic.
119 self.nanos.lock().map_or(0, |n| *n)
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn manual_clock_is_frozen_until_advanced() {
129 let clock = ManualClock::new();
130 let t0 = clock.now();
131 assert_eq!(clock.now(), t0, "clock must not advance on its own");
132 clock.advance(Duration::from_millis(250));
133 let t1 = clock.now();
134 assert_eq!(t1.saturating_duration_since(t0), Duration::from_millis(250));
135 }
136
137 #[test]
138 fn instant_arithmetic_saturates_and_does_not_panic() {
139 let clock = ManualClock::new();
140 let t0 = clock.now();
141 let later = t0.saturating_add(Duration::from_secs(5));
142 assert_eq!(later.saturating_duration_since(t0), Duration::from_secs(5));
143 // Reverse subtraction saturates to zero rather than panicking.
144 assert_eq!(t0.saturating_duration_since(later), Duration::ZERO);
145 }
146
147 #[test]
148 fn system_clock_is_monotonic() {
149 let clock = SystemClock;
150 let a = clock.now();
151 let b = clock.now();
152 assert!(b >= a, "monotonic clock must not go backwards");
153 }
154}