Skip to main content

security_client_rs/
poll.rs

1//! Deadlines for waiting on asynchronous Kibana work, such as Fleet actions.
2//!
3//! The deadline bounds the whole wait, including each observation's requests
4//! and body reads: an observation still running at the deadline is abandoned,
5//! and no observation starts at or after it. A zero timeout therefore returns
6//! [`WaitOutcome::TimedOut`] without sending a request. A state observed before
7//! the deadline is reported even if the deadline passes while it is evaluated.
8//!
9//! Waiting never retries a failed request: the first error is returned. Dropping
10//! the future cancels the wait.
11use std::{future::Future, time::Duration};
12
13use tokio::time::{Instant, sleep_until, timeout_at};
14
15use crate::Result;
16
17/// How long to wait and how often to check.
18#[derive(Clone, Copy, Debug)]
19pub struct PollOptions {
20    timeout: Duration,
21    interval: Duration,
22}
23
24impl PollOptions {
25    /// Checks every two seconds until `timeout` has passed.
26    pub fn new(timeout: Duration) -> Self {
27        Self {
28            timeout,
29            interval: Duration::from_secs(2),
30        }
31    }
32
33    /// The pause between the end of one observation and the start of the next.
34    pub fn interval(mut self, interval: Duration) -> Self {
35        self.interval = interval;
36        self
37    }
38}
39
40impl Default for PollOptions {
41    /// Three minutes, checking every two seconds.
42    fn default() -> Self {
43        Self::new(Duration::from_secs(180))
44    }
45}
46
47/// The result of waiting. A finished state can still describe a failure, such
48/// as an action where some agents failed; inspect it.
49#[derive(Clone, Debug)]
50#[must_use]
51#[non_exhaustive]
52pub enum WaitOutcome<T> {
53    Finished(T),
54    /// The deadline passed. `last` is the most recent state observed, if any.
55    TimedOut {
56        last: Option<T>,
57    },
58    /// The resource was observed and then could no longer be found, for
59    /// example because it was deleted or fell outside the searched window.
60    /// `last` is the final state observed.
61    Vanished {
62        last: T,
63    },
64}
65
66impl<T> WaitOutcome<T> {
67    /// Returns the state only for [`Self::Finished`]. This says that waiting ended,
68    /// not that the operation succeeded. Use [`Self::last`] to inspect a state
69    /// retained after a timeout or disappearance.
70    pub fn finished(self) -> Option<T> {
71        match self {
72            Self::Finished(value) => Some(value),
73            Self::TimedOut { .. } | Self::Vanished { .. } => None,
74        }
75    }
76
77    /// The most recent state observed, whatever the outcome.
78    pub fn last(&self) -> Option<&T> {
79        match self {
80            Self::Finished(value) | Self::Vanished { last: value } => Some(value),
81            Self::TimedOut { last } => last.as_ref(),
82        }
83    }
84}
85
86/// Calls `observe` until it reports a state accepted by `finished` or the deadline passes.
87/// `observe` returns `None` while the resource is not visible. A resource that
88/// is not visible after having been observed ends the wait as vanished.
89pub(crate) async fn wait<T, F, Fut>(
90    options: PollOptions,
91    mut observe: F,
92    finished: impl Fn(&T) -> bool,
93) -> Result<WaitOutcome<T>>
94where
95    F: FnMut() -> Fut,
96    Fut: Future<Output = Result<Option<T>>>,
97{
98    let deadline = Instant::now() + options.timeout;
99    let mut last = None;
100    loop {
101        if Instant::now() >= deadline {
102            return Ok(WaitOutcome::TimedOut { last });
103        }
104        let Ok(observed) = timeout_at(deadline, observe()).await else {
105            return Ok(WaitOutcome::TimedOut { last });
106        };
107        match (observed?, last) {
108            (Some(state), _) if finished(&state) => return Ok(WaitOutcome::Finished(state)),
109            (Some(state), _) => last = Some(state),
110            (None, Some(previous)) => return Ok(WaitOutcome::Vanished { last: previous }),
111            (None, None) => last = None,
112        }
113        sleep_until(deadline.min(Instant::now() + options.interval)).await;
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use std::{
120        cell::{Cell, RefCell},
121        collections::VecDeque,
122    };
123
124    use tokio::time::sleep;
125
126    use super::*;
127
128    fn options(timeout: u64, interval: u64) -> PollOptions {
129        PollOptions::new(Duration::from_millis(timeout)).interval(Duration::from_millis(interval))
130    }
131
132    /// Replays scripted observations, each after a delay, counting how many started.
133    struct Script {
134        steps: RefCell<VecDeque<(u64, Option<&'static str>)>>,
135        started: Cell<usize>,
136    }
137
138    impl Script {
139        fn new(steps: &[(u64, Option<&'static str>)]) -> Self {
140            Self {
141                steps: RefCell::new(steps.iter().copied().collect()),
142                started: Cell::new(0),
143            }
144        }
145
146        async fn observe(&self) -> Result<Option<&'static str>> {
147            self.started.set(self.started.get() + 1);
148            let (delay, state) = self.steps.borrow_mut().pop_front().unwrap_or((0, None));
149            sleep(Duration::from_millis(delay)).await;
150            Ok(state)
151        }
152    }
153
154    fn done(state: &&str) -> bool {
155        *state == "done"
156    }
157
158    #[tokio::test(start_paused = true)]
159    async fn a_stalled_observation_is_bounded_by_the_deadline() {
160        let script = Script::new(&[(0, Some("running")), (10_000, Some("done"))]);
161        let started = Instant::now();
162        let outcome = wait(options(100, 10), || script.observe(), done)
163            .await
164            .unwrap();
165        assert!(
166            matches!(
167                outcome,
168                WaitOutcome::TimedOut {
169                    last: Some("running")
170                }
171            ),
172            "{outcome:?}"
173        );
174        assert_eq!(started.elapsed(), Duration::from_millis(100));
175    }
176
177    #[tokio::test(start_paused = true)]
178    async fn a_terminal_state_arriving_after_the_deadline_is_not_reported() {
179        let script = Script::new(&[(150, Some("done"))]);
180        let outcome = wait(options(10, 10), || script.observe(), done)
181            .await
182            .unwrap();
183        assert!(
184            matches!(outcome, WaitOutcome::TimedOut { last: None }),
185            "{outcome:?}"
186        );
187    }
188
189    #[tokio::test(start_paused = true)]
190    async fn no_observation_starts_at_or_after_the_deadline() {
191        let script = Script::new(&[(0, Some("running")); 10]);
192        let outcome = wait(options(100, 100), || script.observe(), done)
193            .await
194            .unwrap();
195        assert!(matches!(
196            outcome,
197            WaitOutcome::TimedOut {
198                last: Some("running")
199            }
200        ));
201        assert_eq!(script.started.get(), 1, "the sleep ends at the deadline");
202
203        let script = Script::new(&[(0, Some("done"))]);
204        let outcome = wait(options(0, 10), || script.observe(), done)
205            .await
206            .unwrap();
207        assert!(matches!(outcome, WaitOutcome::TimedOut { last: None }));
208        assert_eq!(script.started.get(), 0, "a zero timeout sends nothing");
209    }
210
211    #[tokio::test(start_paused = true)]
212    async fn a_state_that_disappears_after_being_seen_has_vanished() {
213        let script = Script::new(&[(0, None), (0, Some("running")), (0, None)]);
214        let outcome = wait(options(1_000, 10), || script.observe(), done)
215            .await
216            .unwrap();
217        assert!(
218            matches!(outcome, WaitOutcome::Vanished { last: "running" }),
219            "{outcome:?}"
220        );
221        assert_eq!(outcome.last(), Some(&"running"));
222        assert!(outcome.finished().is_none());
223    }
224
225    #[tokio::test(start_paused = true)]
226    async fn finishing_within_the_deadline_wins_and_errors_end_the_wait() {
227        let script = Script::new(&[(0, Some("running")), (90, Some("done"))]);
228        let outcome = wait(options(100, 10), || script.observe(), done)
229            .await
230            .unwrap();
231        assert_eq!(outcome.finished(), Some("done"));
232
233        let error = wait(
234            options(100, 10),
235            || async { Err::<Option<()>, _>(crate::Error::InvalidRequest("boom".into())) },
236            |()| false,
237        )
238        .await
239        .unwrap_err();
240        assert!(matches!(error, crate::Error::InvalidRequest(_)));
241    }
242
243    #[tokio::test(start_paused = true)]
244    async fn dropping_the_wait_cancels_the_running_observation() {
245        let script = Script::new(&[(10_000, Some("done"))]);
246        let wait = wait(options(60_000, 10), || script.observe(), done);
247        assert!(
248            tokio::time::timeout(Duration::from_millis(50), wait)
249                .await
250                .is_err()
251        );
252        assert_eq!(script.started.get(), 1);
253    }
254}