Skip to main content

weida_runtime/
budget.rs

1//! The bounded close budget.
2
3use std::time::{Duration, Instant};
4
5/// A finite budget for a shutdown, started once and spent by every phase of
6/// it.
7///
8/// **Why the type exists.** A close has phases — stop admitting work, let
9/// what is already finished reach the peer, then close the sockets and wait
10/// for them to go idle — and each phase's wait is decided by somebody else's
11/// network. Without one budget spanning all of them, the length of a close is
12/// whatever the slowest peer makes it: QUIC's closing and draining periods
13/// last about three times the path's probe timeout, and ZeroMQ's `ZMQ_LINGER`
14/// defaults to infinite, which is why `zmq_ctx_term()` is known as a place
15/// processes hang (`docs/research/zeromq.md` §12/P17). A process that must
16/// exit within a budget of its own cannot use an API like that
17/// ([decisions/0009](../../../docs/decisions/0009-drain.md) §4.3, §4.4).
18///
19/// So: one budget, taken at the start, and each phase asks how much is left.
20/// A phase that overruns leaves the next one [`Duration::ZERO`] rather than a
21/// negative number, which is a bound that still holds rather than a wait that
22/// starts over.
23///
24/// ```
25/// # use std::time::Duration;
26/// # use weida_runtime::CloseBudget;
27/// let budget = CloseBudget::start(Duration::from_secs(1));
28/// // First phase waits on `budget.remaining()`, and so does the next.
29/// assert!(budget.remaining() <= Duration::from_secs(1));
30/// assert_eq!(budget.limit(), Duration::from_secs(1));
31/// ```
32#[derive(Clone, Copy, Debug)]
33pub struct CloseBudget {
34    limit: Duration,
35    started: Instant,
36}
37
38impl CloseBudget {
39    /// Starts a budget of `limit`, from now.
40    pub fn start(limit: Duration) -> CloseBudget {
41        CloseBudget {
42            limit,
43            started: Instant::now(),
44        }
45    }
46
47    /// What the whole budget was, regardless of what is left of it.
48    pub const fn limit(&self) -> Duration {
49        self.limit
50    }
51
52    /// What is left, saturating at [`Duration::ZERO`]: the bound the next
53    /// phase of the close gets.
54    pub fn remaining(&self) -> Duration {
55        self.limit.saturating_sub(self.started.elapsed())
56    }
57
58    /// Whether the budget is used up. A spent budget still bounds: the next
59    /// phase gets zero and does not wait.
60    pub fn is_spent(&self) -> bool {
61        self.remaining().is_zero()
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    /// Claim: the phases of one close share one budget — what the second
70    /// phase gets is what the first left, and never more than the limit.
71    #[test]
72    fn a_budget_is_spent_by_the_phases_that_share_it() {
73        let budget = CloseBudget::start(Duration::from_millis(50));
74        assert_eq!(budget.limit(), Duration::from_millis(50));
75        let before = budget.remaining();
76        assert!(before <= Duration::from_millis(50));
77        std::thread::sleep(Duration::from_millis(10));
78        let after = budget.remaining();
79        assert!(after < before, "{after:?} must be less than {before:?}");
80        assert!(!budget.is_spent());
81    }
82
83    /// Claim: an overrun leaves zero rather than wrapping, so the next phase
84    /// is still bounded.
85    #[test]
86    fn an_overrun_budget_is_zero_and_not_negative() {
87        let budget = CloseBudget::start(Duration::from_millis(1));
88        std::thread::sleep(Duration::from_millis(5));
89        assert_eq!(budget.remaining(), Duration::ZERO);
90        assert!(budget.is_spent());
91    }
92}