Skip to main content

asupersync/time/
budget_ext.rs

1//! Budget extensions for time operations.
2
3use crate::cx::Cx;
4use crate::time::{Elapsed, Sleep, sleep_until};
5use crate::types::{Budget, Time};
6use std::future::Future;
7use std::marker::Unpin;
8use std::time::Duration;
9
10/// Extension trait for Budget deadline operations.
11pub trait BudgetTimeExt {
12    /// Get remaining time until deadline.
13    fn remaining_duration(&self, now: Time) -> Option<Duration>;
14
15    /// Create sleep that respects budget deadline.
16    fn deadline_sleep(&self) -> Option<Sleep>;
17
18    /// Check if deadline has passed.
19    fn deadline_elapsed(&self, now: Time) -> bool;
20}
21
22impl BudgetTimeExt for Budget {
23    #[inline]
24    fn remaining_duration(&self, now: Time) -> Option<Duration> {
25        self.deadline.map(|d| {
26            if now >= d {
27                Duration::ZERO
28            } else {
29                let diff_nanos = d.as_nanos().saturating_sub(now.as_nanos());
30                Duration::from_nanos(diff_nanos)
31            }
32        })
33    }
34
35    #[inline]
36    fn deadline_sleep(&self) -> Option<Sleep> {
37        self.deadline.map(sleep_until)
38    }
39
40    #[inline]
41    fn deadline_elapsed(&self, now: Time) -> bool {
42        self.deadline.is_some_and(|d| d <= now)
43    }
44}
45
46/// Sleep that integrates with the provided context's budget.
47///
48/// This sleeps for the shorter of the requested duration or the remaining budget.
49/// If the budget runs out, it returns `Err(Elapsed)`.
50pub async fn budget_sleep(cx: &Cx, duration: Duration, now: Time) -> Result<(), Elapsed> {
51    let budget = cx.budget();
52
53    // Use shorter of requested duration or remaining budget
54    // Use BudgetTimeExt::remaining_duration explicit call
55    let remaining = BudgetTimeExt::remaining_duration(&budget, now);
56
57    let effective_duration = match remaining {
58        Some(rem) if rem < duration => rem,
59        _ => duration,
60    };
61
62    if effective_duration.is_zero() && BudgetTimeExt::deadline_elapsed(&budget, now) {
63        let deadline = budget.deadline.unwrap_or(now);
64        return Err(Elapsed::new(deadline));
65    }
66
67    crate::time::sleep(now, effective_duration).await;
68
69    // Check if we were cut short by budget
70    if effective_duration < duration {
71        // We slept for 'remaining', which means deadline is hit.
72        let deadline = budget.deadline.unwrap_or(now);
73        return Err(Elapsed::new(deadline));
74    }
75
76    Ok(())
77}
78
79/// Timeout that respects budget deadline.
80pub async fn budget_timeout<F: Future + Unpin>(
81    cx: &Cx,
82    duration: Duration,
83    future: F,
84    now: Time,
85) -> Result<F::Output, Elapsed> {
86    let budget = cx.budget();
87
88    // Use shorter of requested timeout or remaining budget
89    let remaining = BudgetTimeExt::remaining_duration(&budget, now);
90    let effective_timeout = match remaining {
91        Some(rem) if rem < duration => rem,
92        _ => duration,
93    };
94
95    crate::time::timeout(now, effective_timeout, future).await
96}
97
98#[cfg(test)]
99mod tests {
100    #![allow(
101        clippy::pedantic,
102        clippy::nursery,
103        clippy::expect_fun_call,
104        clippy::map_unwrap_or,
105        clippy::cast_possible_wrap,
106        clippy::future_not_send
107    )]
108    use super::*;
109    use crate::cx::Cx;
110    use crate::test_utils::init_test_logging;
111    use crate::types::{Budget, RegionId, TaskId};
112    use crate::util::ArenaIndex;
113    use proptest::prelude::*;
114    use std::future::{pending, ready};
115    use std::time::Duration;
116
117    fn init_test(name: &str) {
118        init_test_logging();
119        crate::test_phase!(name);
120    }
121
122    fn test_cx(budget: Budget) -> Cx {
123        Cx::new(
124            RegionId::from_arena(ArenaIndex::new(0, 1)),
125            TaskId::from_arena(ArenaIndex::new(0, 0)),
126            budget,
127        )
128    }
129
130    #[test]
131    fn budget_time_ext_deadline_boundaries() {
132        init_test("budget_time_ext_deadline_boundaries");
133
134        let unconstrained = Budget::new();
135        assert_eq!(
136            BudgetTimeExt::remaining_duration(&unconstrained, Time::from_secs(5)),
137            None
138        );
139        assert!(!BudgetTimeExt::deadline_elapsed(
140            &unconstrained,
141            Time::from_secs(5)
142        ));
143        assert!(BudgetTimeExt::deadline_sleep(&unconstrained).is_none());
144
145        let deadline = Time::from_secs(10);
146        let budget = Budget::new().with_deadline(deadline);
147
148        assert_eq!(
149            BudgetTimeExt::remaining_duration(&budget, Time::from_secs(4)),
150            Some(Duration::from_secs(6))
151        );
152        assert!(!BudgetTimeExt::deadline_elapsed(
153            &budget,
154            Time::from_secs(4)
155        ));
156        assert!(BudgetTimeExt::deadline_sleep(&budget).is_some());
157
158        assert_eq!(
159            BudgetTimeExt::remaining_duration(&budget, deadline),
160            Some(Duration::ZERO)
161        );
162        assert!(BudgetTimeExt::deadline_elapsed(&budget, deadline));
163
164        assert_eq!(
165            BudgetTimeExt::remaining_duration(&budget, Time::from_secs(12)),
166            Some(Duration::ZERO)
167        );
168        assert!(BudgetTimeExt::deadline_elapsed(
169            &budget,
170            Time::from_secs(12)
171        ));
172        crate::test_complete!("budget_time_ext_deadline_boundaries");
173    }
174
175    proptest! {
176        #[test]
177        fn budget_remaining_duration_metamorphic_monotonic_as_now_advances(
178            deadline_nanos in 0u64..1_000_000_000_000,
179            first_now_nanos in 0u64..1_000_000_000_000,
180            second_now_nanos in 0u64..1_000_000_000_000,
181        ) {
182            let budget = Budget::new().with_deadline(Time::from_nanos(deadline_nanos));
183            let earlier_now_nanos = first_now_nanos.min(second_now_nanos);
184            let later_now_nanos = first_now_nanos.max(second_now_nanos);
185
186            let earlier_remaining = BudgetTimeExt::remaining_duration(
187                &budget,
188                Time::from_nanos(earlier_now_nanos),
189            )
190            .expect("budget has a deadline");
191            let later_remaining = BudgetTimeExt::remaining_duration(
192                &budget,
193                Time::from_nanos(later_now_nanos),
194            )
195            .expect("budget has a deadline");
196            let elapsed_between_reads =
197                Duration::from_nanos(later_now_nanos - earlier_now_nanos);
198
199            prop_assert!(
200                later_remaining <= earlier_remaining,
201                "remaining duration must not increase as now advances",
202            );
203            prop_assert_eq!(
204                later_remaining,
205                earlier_remaining.saturating_sub(elapsed_between_reads),
206                "advancing now must reduce remaining duration by the elapsed interval, floored at zero",
207            );
208            prop_assert_eq!(
209                BudgetTimeExt::deadline_elapsed(&budget, Time::from_nanos(later_now_nanos)),
210                later_now_nanos >= deadline_nanos,
211                "deadline_elapsed must agree with remaining_duration's zero boundary",
212            );
213        }
214    }
215
216    #[test]
217    fn budget_timeout_respects_exhausted_deadline_boundary() {
218        init_test("budget_timeout_respects_exhausted_deadline_boundary");
219        let cx = test_cx(Budget::new().with_deadline(Time::ZERO));
220
221        futures_lite::future::block_on(async {
222            let elapsed = budget_timeout(&cx, Duration::from_secs(10), pending::<()>(), Time::ZERO)
223                .await
224                .expect_err("pending work must time out at an exhausted budget deadline");
225            assert_eq!(elapsed.deadline(), Time::ZERO);
226
227            let completed = budget_timeout(
228                &cx,
229                Duration::from_secs(10),
230                ready("already-complete"),
231                Time::ZERO,
232            )
233            .await
234            .expect("ready work wins the timeout boundary");
235            assert_eq!(completed, "already-complete");
236        });
237        crate::test_complete!("budget_timeout_respects_exhausted_deadline_boundary");
238    }
239
240    #[test]
241    fn test_budget_sleep() {
242        init_test("test_budget_sleep");
243        // `Sleep`'s fallback time source starts at `Time::ZERO` on first poll.
244        // Use a small deadline in the same time basis so this test remains fast.
245        let now = Time::ZERO;
246        let deadline = now.saturating_add_nanos(5_000_000); // 5ms
247        let budget = Budget::new().with_deadline(deadline);
248        let cx = test_cx(budget);
249
250        // Request longer sleep than budget allows
251        futures_lite::future::block_on(async {
252            let result = budget_sleep(&cx, Duration::from_secs(10), now).await;
253            let is_err = result.is_err();
254            crate::assert_with_log!(is_err, "budget sleep errors", true, is_err);
255        });
256        crate::test_complete!("test_budget_sleep");
257    }
258}