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}