architect_sdk/
discrete_clock.rs

1//! A clock timer that ticks exactly on the second, producing times
2//! that are clean/aligned to whole numbers of seconds.
3
4use anyhow::{bail, Result};
5use chrono::{DateTime, DurationRound, Utc};
6use std::future::Future;
7use thiserror::Error;
8use tokio::time::{Instant, Interval, MissedTickBehavior};
9
10pub trait Clock {
11    type Error;
12
13    fn tick(&mut self)
14        -> impl Future<Output = Result<DateTime<Utc>, Self::Error>> + Send;
15}
16
17pub struct DiscreteClock {
18    interval: Interval,
19    interval_duration: chrono::Duration,
20    tolerance: chrono::Duration,
21}
22
23impl DiscreteClock {
24    pub fn from_period(period: chrono::Duration) -> Result<Self> {
25        if period.subsec_nanos() != 0 {
26            bail!("period must be a whole number of seconds");
27        }
28        let interval_duration = period;
29        let dur = interval_duration.to_std()?;
30        let now = Utc::now();
31        let now_aliased = (now + interval_duration).duration_trunc(interval_duration)?;
32        let mut start_at = Instant::now();
33        if now_aliased > now {
34            start_at = Instant::now() + (now_aliased - now).to_std()?;
35        }
36        let mut interval = tokio::time::interval_at(start_at, dur);
37        interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
38        Ok(Self { interval, interval_duration, tolerance: interval_duration / 100 })
39    }
40
41    pub fn from_secs(interval_secs: u64) -> Result<Self> {
42        let interval_duration = chrono::Duration::seconds(interval_secs as i64);
43        Self::from_period(interval_duration)
44    }
45}
46
47impl Clock for DiscreteClock {
48    type Error = DiscreteClockError;
49
50    /// On the next tick, return the gridded timestamp if within the
51    /// desired tolerance (default to 1% of the interval time).
52    ///
53    /// Otherwise, complain.
54    async fn tick(&mut self) -> Result<DateTime<Utc>, DiscreteClockError> {
55        self.interval.tick().await;
56        let now = Utc::now();
57        let now_aliased = now
58            .duration_trunc(self.interval_duration)
59            .map_err(DiscreteClockError::TickRoundingError)?;
60        if (now - now_aliased).abs() > self.tolerance {
61            *self = Self::from_period(self.interval_duration)
62                .map_err(DiscreteClockError::ResetError)?;
63            return Err(DiscreteClockError::TickSkew {
64                actual: now,
65                expected: now_aliased,
66            })?;
67        }
68        Ok(now_aliased)
69    }
70}
71
72#[derive(Error, Debug)]
73pub enum DiscreteClockError {
74    #[error("tick rounding error: {0}")]
75    TickRoundingError(#[from] chrono::RoundingError),
76    #[error("{actual} is too far off the grid time {expected}")]
77    TickSkew { actual: DateTime<Utc>, expected: DateTime<Utc> },
78    #[error("failed to reset clock to recover from tick skew: {0}")]
79    ResetError(anyhow::Error),
80}
81
82impl Clock for Option<DiscreteClock> {
83    type Error = DiscreteClockError;
84
85    async fn tick(&mut self) -> Result<DateTime<Utc>, DiscreteClockError> {
86        if let Some(clock) = self.as_mut() {
87            clock.tick().await
88        } else {
89            std::future::pending().await
90        }
91    }
92}