Skip to main content

sysprims_core/
time.rs

1//! Time utilities for sysprims
2//!
3//! Consolidates timestamp generation and provides drift-free scheduling.
4
5use std::time::{Duration, Instant};
6
7use time::format_description::well_known::Rfc3339;
8use time::OffsetDateTime;
9
10use crate::{SysprimsError, SysprimsResult};
11
12/// Minimum accepted tick interval (1 ms).
13const MIN_TICK_INTERVAL: Duration = Duration::from_millis(1);
14
15/// Get current timestamp in RFC 3339 / ISO 8601 format (UTC).
16///
17/// Falls back to Unix epoch if formatting fails (should never happen).
18pub fn now_rfc3339() -> String {
19    OffsetDateTime::now_utc()
20        .format(&Rfc3339)
21        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
22}
23
24/// Drift-free periodic scheduler.
25///
26/// `Tick` tracks the next deadline and sleeps only for the remaining time,
27/// so accumulated processing time doesn't cause the interval to drift.
28///
29/// On overrun (work exceeds one interval), the deadline advances by the
30/// smallest number of whole intervals that lands in the future, preserving
31/// phase alignment with the original schedule.
32///
33/// # Example
34///
35/// ```no_run
36/// use std::time::Duration;
37/// use sysprims_core::time::Tick;
38///
39/// let mut tick = Tick::new(Duration::from_secs(5)).unwrap();
40/// loop {
41///     // do work …
42///     tick.sleep_until_next();
43/// }
44/// ```
45pub struct Tick {
46    interval: Duration,
47    next: Instant,
48}
49
50impl Tick {
51    /// Create a new `Tick` that fires at `interval` from now.
52    ///
53    /// Returns `InvalidArgument` if `interval` is less than 1 ms.
54    pub fn new(interval: Duration) -> SysprimsResult<Self> {
55        if interval < MIN_TICK_INTERVAL {
56            return Err(SysprimsError::invalid_argument(format!(
57                "tick interval must be >= 1ms, got {:?}",
58                interval
59            )));
60        }
61        Ok(Self {
62            interval,
63            next: Instant::now() + interval,
64        })
65    }
66
67    /// Sleep until the next tick deadline, then advance the deadline.
68    ///
69    /// If the deadline has already passed (work took longer than one interval),
70    /// this returns immediately and advances by the smallest whole-interval
71    /// multiple that lands in the future, preserving phase alignment.
72    pub fn sleep_until_next(&mut self) {
73        let now = Instant::now();
74        if self.next > now {
75            std::thread::sleep(self.next - now);
76        }
77        // Advance by whole-interval multiples to stay phase-aligned
78        let now = Instant::now();
79        if self.next <= now {
80            let behind = now - self.next;
81            let skipped = behind.as_nanos() / self.interval.as_nanos() + 1;
82            self.next += self.interval * skipped as u32;
83        } else {
84            self.next += self.interval;
85        }
86    }
87
88    /// Returns the interval duration.
89    pub fn interval(&self) -> Duration {
90        self.interval
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_now_rfc3339_format() {
100        let ts = now_rfc3339();
101        // Should contain 'T' separator and 'Z' suffix (UTC)
102        assert!(ts.contains('T'), "timestamp should contain T: {}", ts);
103        assert!(ts.ends_with('Z'), "timestamp should end with Z: {}", ts);
104        // Basic structure: YYYY-MM-DDTHH:MM:SS...Z
105        assert!(
106            ts.len() >= 20,
107            "timestamp should be at least 20 chars: {}",
108            ts
109        );
110        assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {}", ts);
111        assert_eq!(&ts[7..8], "-", "should have dash at pos 7: {}", ts);
112    }
113
114    #[test]
115    fn test_tick_rejects_zero_interval() {
116        let result = Tick::new(Duration::ZERO);
117        assert!(result.is_err(), "zero interval should be rejected");
118    }
119
120    #[test]
121    fn test_tick_rejects_sub_millisecond() {
122        let result = Tick::new(Duration::from_micros(500));
123        assert!(result.is_err(), "sub-ms interval should be rejected");
124    }
125
126    #[test]
127    fn test_tick_accepts_one_millisecond() {
128        let result = Tick::new(Duration::from_millis(1));
129        assert!(result.is_ok(), "1ms interval should be accepted");
130    }
131
132    #[test]
133    fn test_tick_sleeps_for_interval() {
134        let mut tick = Tick::new(Duration::from_millis(50)).unwrap();
135        let start = Instant::now();
136        tick.sleep_until_next();
137        let elapsed = start.elapsed();
138        assert!(
139            elapsed >= Duration::from_millis(40),
140            "should sleep roughly 50ms, got {:?}",
141            elapsed
142        );
143        assert!(
144            elapsed < Duration::from_millis(200),
145            "should not overshoot by too much: {:?}",
146            elapsed
147        );
148    }
149
150    #[test]
151    fn test_tick_interval_accessor() {
152        let tick = Tick::new(Duration::from_secs(10)).unwrap();
153        assert_eq!(tick.interval(), Duration::from_secs(10));
154    }
155}