Skip to main content

rustev_remote_http/
budget.rs

1//! One total budget per attempt (spec 009, 3.4.1 and I-5): the remaining
2//! time at dispatch. Every transport phase draws from it; no phase limit can
3//! outlive it; the remote side is told the budget minus a declared transit
4//! margin.
5
6use std::fmt;
7use std::future::Future;
8use std::time::Duration;
9
10use rustev_contract::time::DurationMs;
11use tokio::time::Instant;
12
13/// The deadline of one attempt (or one exchange), by Tokio's monotonic
14/// clock, fixed at dispatch.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Budget {
17    deadline: Instant,
18}
19
20/// The budget ran out before the phase finished.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Expired;
23
24impl Budget {
25    /// The budget of an attempt dispatched now with `remaining` left.
26    pub fn start(remaining: DurationMs) -> Self {
27        Self::from_ms(remaining.0)
28    }
29
30    pub fn from_ms(ms: u64) -> Self {
31        Self::from_start(Instant::now(), DurationMs(ms))
32    }
33
34    /// Anchor a remaining budget to dispatch, including member preparation time.
35    pub(crate) fn from_start(start: Instant, remaining: DurationMs) -> Self {
36        Budget {
37            deadline: start + Duration::from_millis(remaining.0),
38        }
39    }
40
41    pub fn deadline(&self) -> Instant {
42        self.deadline
43    }
44
45    /// The earlier of two budgets.
46    pub fn min(self, other: Budget) -> Budget {
47        Budget {
48            deadline: self.deadline.min(other.deadline),
49        }
50    }
51
52    pub fn remaining(&self) -> Duration {
53        self.deadline.saturating_duration_since(Instant::now())
54    }
55
56    /// Whole milliseconds left, rounded down.
57    pub fn remaining_ms(&self) -> u64 {
58        u64::try_from(self.remaining().as_millis()).unwrap_or(u64::MAX)
59    }
60
61    pub fn expired(&self) -> bool {
62        Instant::now() >= self.deadline
63    }
64
65    /// What the remote side is told: the time left minus the transit margin.
66    /// Zero means there is no time to give it, and nothing should be sent.
67    pub fn told_ms(&self, transit_margin_ms: u64) -> u64 {
68        self.remaining_ms().saturating_sub(transit_margin_ms)
69    }
70
71    /// The deadline of a phase: the budget's, or `limit` from now when that
72    /// is earlier. A phase limit never extends the budget.
73    pub fn phase_deadline(&self, limit: Option<Duration>) -> Instant {
74        match limit {
75            Some(l) => self.deadline.min(Instant::now() + l),
76            None => self.deadline,
77        }
78    }
79
80    /// Run `fut` until it finishes, the budget runs out, or `limit` (if
81    /// any) passes, whichever is first.
82    pub async fn run<F: Future>(
83        &self,
84        limit: Option<Duration>,
85        fut: F,
86    ) -> Result<F::Output, Expired> {
87        tokio::time::timeout_at(self.phase_deadline(limit), fut)
88            .await
89            .map_err(|_| Expired)
90    }
91
92    /// Resolves when the budget runs out.
93    pub async fn expiry(&self) {
94        tokio::time::sleep_until(self.deadline).await
95    }
96}
97
98/// A configured transport timeout that could outlive the budget it serves
99/// (spec 009, section 6: refused at construction).
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct TimeoutBeyondBudget {
102    pub transport_timeout_ms: u64,
103    pub budget_ms: u64,
104}
105
106impl fmt::Display for TimeoutBeyondBudget {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(
109            f,
110            "a transport timeout of {} ms is longer than the {} ms budget it serves",
111            self.transport_timeout_ms, self.budget_ms
112        )
113    }
114}
115
116impl std::error::Error for TimeoutBeyondBudget {}
117
118/// Refuse a transport timeout longer than the budget available where the
119/// adapter is constructed. Adapters call this before any network use.
120pub fn check_transport_timeout(
121    transport_timeout_ms: Option<u64>,
122    budget_ms: u64,
123) -> Result<(), TimeoutBeyondBudget> {
124    match transport_timeout_ms {
125        Some(t) if t > budget_ms => Err(TimeoutBeyondBudget {
126            transport_timeout_ms: t,
127            budget_ms,
128        }),
129        _ => Ok(()),
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[tokio::test(flavor = "current_thread", start_paused = true)]
138    async fn batch_preparation_does_not_extend_later_member_deadlines() {
139        let start = Instant::now();
140        let first = Budget::from_start(start, DurationMs(100));
141        tokio::time::advance(Duration::from_millis(40)).await;
142        let second = Budget::from_start(start, DurationMs(100));
143        let longer = Budget::from_start(start, DurationMs(150));
144        assert_eq!(second.remaining_ms(), 60);
145        first.expiry().await;
146        assert!(first.expired());
147        assert!(second.expired());
148        assert!(!longer.expired());
149        assert_eq!(longer.remaining_ms(), 50);
150    }
151
152    #[tokio::test(flavor = "current_thread", start_paused = true)]
153    async fn a_phase_limit_never_outlives_the_budget() {
154        let b = Budget::from_ms(100);
155        let t0 = Instant::now();
156        assert_eq!(
157            b.phase_deadline(Some(Duration::from_secs(60))),
158            b.deadline()
159        );
160        assert_eq!(
161            b.phase_deadline(Some(Duration::from_millis(10))),
162            t0 + Duration::from_millis(10)
163        );
164        let r = b
165            .run(Some(Duration::from_secs(60)), std::future::pending::<()>())
166            .await;
167        assert_eq!(r, Err(Expired));
168        assert_eq!(Instant::now(), t0 + Duration::from_millis(100));
169        assert!(b.expired());
170        assert_eq!(b.told_ms(5), 0);
171    }
172
173    #[tokio::test(flavor = "current_thread", start_paused = true)]
174    async fn the_remote_side_is_told_the_budget_minus_the_margin() {
175        let b = Budget::from_ms(1_000);
176        assert_eq!(b.told_ms(50), 950);
177        assert_eq!(b.told_ms(5_000), 0);
178    }
179
180    #[test]
181    fn a_transport_timeout_longer_than_the_budget_is_refused() {
182        assert!(check_transport_timeout(None, 10).is_ok());
183        assert!(check_transport_timeout(Some(10), 10).is_ok());
184        assert_eq!(
185            check_transport_timeout(Some(11), 10),
186            Err(TimeoutBeyondBudget {
187                transport_timeout_ms: 11,
188                budget_ms: 10
189            })
190        );
191    }
192}