onetaskgraph_plugin_api/clock.rs
1//! The clock a source paces and backs off on.
2//!
3//! A plugin that waits — between pages to stay under a published rate, or before retrying a
4//! refused request — waits on the [`Clock`] it was built with rather than on the runtime's own
5//! timer, so a test can run it on simulated time: a minute of pacing then costs no real time,
6//! and the figures a timing budget reports are the same on every run. The binary hands every
7//! in-process source the process's one clock through [`SourcePlugin::build_with_clock`]; with
8//! nothing asking for simulated time that is [`system_clock`], and every plugin behaves exactly
9//! as it does on the runtime's timer.
10//!
11//! [`SourcePlugin::build_with_clock`]: crate::SourcePlugin::build_with_clock
12
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18/// A source of monotonic time and of waits measured against it.
19pub trait Clock: Send + Sync {
20 /// Monotonic time elapsed since this clock's origin.
21 fn now(&self) -> std::time::Duration;
22 /// Complete once `duration` has passed on this clock.
23 fn sleep(
24 &self,
25 duration: std::time::Duration,
26 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>;
27 /// [`sleep`](Self::sleep), for a wait whose reason is `cause` — the fixed word a plugin
28 /// names its waits by, such as the limit it is pacing under.
29 ///
30 /// A clock that keeps a record of what waited and why — a test's simulated one — keeps the
31 /// cause beside the wait; every other clock waits exactly as `sleep` does, which is what
32 /// this is defaulted to.
33 fn sleep_because(
34 &self,
35 duration: std::time::Duration,
36 cause: WaitCause,
37 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>> {
38 let _ = cause;
39 self.sleep(duration)
40 }
41}
42
43/// The word a plugin names one kind of wait by — see [`Clock::sleep_because`].
44///
45/// One word: a nonempty run of printable ASCII with no whitespace, because a simulated clock
46/// carries it as one word of a line. [`WaitCause::new`] refuses anything else, and it is a
47/// `const fn`, so a cause declared as a constant that is not a word fails the build rather
48/// than reaching a clock.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
50pub struct WaitCause(&'static str);
51
52impl WaitCause {
53 /// The cause named `word`.
54 ///
55 /// # Panics
56 ///
57 /// When `word` is not [one word](Self::is_word) — at compile time, for a constant.
58 #[must_use]
59 pub const fn new(word: &'static str) -> Self {
60 assert!(
61 Self::is_word(word),
62 "a wait cause is one word of printable ASCII"
63 );
64 Self(word)
65 }
66
67 /// Whether `text` is a word a cause may be: nonempty printable ASCII, no whitespace.
68 ///
69 /// The one statement of the rule, for whatever reads a cause back off a clock's wire.
70 #[must_use]
71 pub const fn is_word(text: &str) -> bool {
72 let bytes = text.as_bytes();
73 if bytes.is_empty() {
74 return false;
75 }
76 let mut index = 0;
77 while index < bytes.len() {
78 if !bytes[index].is_ascii_graphic() {
79 return false;
80 }
81 index += 1;
82 }
83 true
84 }
85
86 /// The word itself.
87 #[must_use]
88 pub const fn as_str(self) -> &'static str {
89 self.0
90 }
91}
92
93impl std::fmt::Display for WaitCause {
94 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 formatter.write_str(self.0)
96 }
97}
98
99/// The variable that puts a process of this build on a simulated clock.
100///
101/// Its value is `<address>/<client>`: a test's coordinator's loopback address and the
102/// process's 0-based client number. Unset or empty, the process runs on [`system_clock`]. It
103/// does not begin `ONETASKGRAPH_`, so the configuration's environment layer never reads it as a
104/// setting. Named here, beside the clock it selects, so the binary that reads it and the test
105/// harness that hands it out spell it once between them.
106pub const SIMULATED_CLOCK_VARIABLE: &str = "OTG_SIMULATED_CLOCK";
107
108/// One clock, shared by everything a process builds.
109pub type SharedClock = std::sync::Arc<dyn Clock>;
110
111/// The real clock: `tokio::time::sleep` and a monotonic `Instant`.
112#[must_use]
113pub fn system_clock() -> SharedClock {
114 Arc::new(SystemClock {
115 origin: Instant::now(),
116 })
117}
118
119/// [`system_clock`]'s clock: its origin is the instant it was made.
120struct SystemClock {
121 origin: Instant,
122}
123
124impl Clock for SystemClock {
125 fn now(&self) -> Duration {
126 self.origin.elapsed()
127 }
128
129 fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
130 Box::pin(tokio::time::sleep(duration))
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::WaitCause;
137
138 #[test]
139 fn a_word_of_printable_ascii_is_a_cause_and_nothing_else_is() {
140 const FILING: WaitCause = WaitCause::new("board-filing");
141 assert_eq!(FILING.as_str(), "board-filing");
142 assert_eq!(FILING.to_string(), "board-filing");
143 for refused in [
144 "",
145 " ",
146 "two words",
147 "tab\there",
148 "line\n",
149 "café",
150 "\u{a0}nbsp",
151 ] {
152 assert!(!WaitCause::is_word(refused), "{refused:?} is not one word");
153 }
154 }
155
156 #[test]
157 #[should_panic(expected = "a wait cause is one word of printable ASCII")]
158 fn a_cause_that_is_not_one_word_is_refused_where_it_is_made() {
159 let words = String::from("two words");
160 let _ = WaitCause::new(Box::leak(words.into_boxed_str()));
161 }
162}