Skip to main content

secs_compute/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(unsafe_op_in_unsafe_fn)]
3//! Resource rules for heavy computation.
4//!
5//! One place for what every expensive job must obey:
6//!
7//! - **Memory budget.** A job estimates its bytes and takes a weighted permit
8//!   before it allocates; a single job larger than the budget is refused up
9//!   front with the reason, instead of exhausting RAM halfway.
10//! - **Threads.** A rayon pool sized by cores and budget. Parallel work is
11//!   `map` only; floating-point accumulation runs in its original order, so
12//!   results do not depend on the thread count.
13//! - **Cancellation and progress.** A cooperative cancel token checked at
14//!   stage, channel, input and iteration boundaries, and a progress sink that
15//!   reports stage, done/total and an ETA from the observed rate.
16//!
17//! The available-memory probe is a seam (`MemoryProbe`): the app injects the
18//! OS adapter from `secs-sys`; tests inject a fixed value.
19
20use std::fmt::Debug;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Condvar, Mutex, MutexGuard};
23use std::time::{Duration, Instant};
24
25use rayon::prelude::*;
26use rayon::{ThreadPool, ThreadPoolBuilder};
27
28const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
29const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(5);
30
31/// Cooperative cancellation shared by a job and everything it starts.
32#[derive(Clone, Debug, Default)]
33pub struct CancelToken {
34    cancelled: Arc<AtomicBool>,
35}
36
37impl CancelToken {
38    /// Creates a token which has not been cancelled.
39    #[must_use]
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Cancels this token and all of its clones.
45    pub fn cancel(&self) {
46        self.cancelled.store(true, Ordering::Release);
47    }
48
49    /// Returns whether this token has been cancelled.
50    #[must_use]
51    pub fn is_cancelled(&self) -> bool {
52        self.cancelled.load(Ordering::Acquire)
53    }
54
55    /// Returns `Err(Cancelled)` once cancelled.
56    pub fn check(&self) -> Result<(), Cancelled> {
57        if self.is_cancelled() {
58            Err(Cancelled)
59        } else {
60            Ok(())
61        }
62    }
63}
64
65/// A computation was cooperatively cancelled.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
67#[error("cancelled")]
68pub struct Cancelled;
69
70/// One progress report.
71#[derive(Debug, Clone, PartialEq)]
72pub struct Progress {
73    /// Stable stage identifier, such as `engine.ba`.
74    pub stage: &'static str,
75    /// Number of completed items.
76    pub done: u64,
77    /// Total number of items in this stage.
78    pub total: u64,
79    /// Estimated time remaining from this stage's observed rate.
80    pub eta: Option<Duration>,
81}
82
83/// Destination for progress reports.
84pub trait ProgressSink: Send + Sync + Debug {
85    /// Receives one progress snapshot.
86    fn report(&self, progress: &Progress);
87}
88
89/// A sink that drops every progress report.
90#[derive(Debug, Default)]
91pub struct NoProgress;
92
93impl ProgressSink for NoProgress {
94    fn report(&self, _progress: &Progress) {}
95}
96
97/// Reports the memory currently available to the process.
98pub trait MemoryProbe: Send + Sync + Debug {
99    /// Returns available bytes.
100    fn available_bytes(&self) -> u64;
101}
102
103/// A fixed memory reading, useful for tests and deterministic callers.
104#[derive(Debug)]
105pub struct FixedMemory(pub u64);
106
107impl MemoryProbe for FixedMemory {
108    fn available_bytes(&self) -> u64 {
109        self.0
110    }
111}
112
113#[derive(Debug)]
114struct BudgetState {
115    available: u64,
116}
117
118/// A weighted byte budget shared by concurrent work.
119#[derive(Debug)]
120pub struct MemoryBudget {
121    total: u64,
122    state: Mutex<BudgetState>,
123    released: Condvar,
124}
125
126impl MemoryBudget {
127    /// Creates a budget with exactly `total_bytes` bytes.
128    #[must_use]
129    pub fn new(total_bytes: u64) -> Self {
130        Self {
131            total: total_bytes,
132            state: Mutex::new(BudgetState {
133                available: total_bytes,
134            }),
135            released: Condvar::new(),
136        }
137    }
138
139    /// Uses `fraction` of the probed bytes, with `floor_bytes` as a minimum.
140    #[must_use]
141    pub fn from_probe(probe: &dyn MemoryProbe, fraction: f64, floor_bytes: u64) -> Self {
142        let scaled = (probe.available_bytes() as f64 * fraction).max(floor_bytes as f64);
143        let total = if scaled.is_finite() {
144            scaled as u64
145        } else if scaled.is_sign_positive() {
146            u64::MAX
147        } else {
148            floor_bytes
149        };
150        Self::new(total.max(floor_bytes))
151    }
152
153    /// Returns this budget's byte capacity.
154    #[must_use]
155    pub fn total(&self) -> u64 {
156        self.total
157    }
158
159    /// Returns the bytes not reserved at this moment.
160    #[must_use]
161    pub fn available(&self) -> u64 {
162        lock_unpoisoned(&self.state).available
163    }
164
165    /// Waits for and reserves `bytes` until the returned permit is dropped.
166    pub fn acquire(
167        &self,
168        bytes: u64,
169        cancel: &CancelToken,
170    ) -> Result<MemoryPermit<'_>, MemoryError> {
171        if bytes > self.total {
172            return Err(MemoryError::TooLarge {
173                requested: bytes,
174                total: self.total,
175            });
176        }
177
178        let mut state = lock_unpoisoned(&self.state);
179        loop {
180            cancel.check().map_err(|_| MemoryError::Cancelled)?;
181            if state.available >= bytes {
182                state.available -= bytes;
183                return Ok(MemoryPermit {
184                    budget: self,
185                    bytes,
186                });
187            }
188
189            state = match self.released.wait_timeout(state, CANCEL_POLL_INTERVAL) {
190                Ok((guard, _)) => guard,
191                Err(poisoned) => poisoned.into_inner().0,
192            };
193        }
194    }
195}
196
197/// Failure to reserve bytes from a [`MemoryBudget`].
198#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
199pub enum MemoryError {
200    /// One request exceeds the entire budget.
201    #[error("request of {requested} bytes exceeds memory budget of {total} bytes")]
202    TooLarge {
203        /// Bytes requested by the caller.
204        requested: u64,
205        /// Total bytes in the budget.
206        total: u64,
207    },
208    /// Cancellation occurred while waiting.
209    #[error("cancelled")]
210    Cancelled,
211}
212
213/// A byte reservation which releases itself on drop.
214#[derive(Debug)]
215pub struct MemoryPermit<'a> {
216    budget: &'a MemoryBudget,
217    bytes: u64,
218}
219
220impl MemoryPermit<'_> {
221    /// Returns everything above `bytes` to the budget, for buffers this
222    /// reservation covered that are already dropped. Never grows the permit.
223    pub fn shrink_to(&mut self, bytes: u64) {
224        if bytes < self.bytes {
225            let excess = self.bytes - bytes;
226            self.bytes = bytes;
227            self.budget.release(excess);
228        }
229    }
230}
231
232impl MemoryBudget {
233    fn release(&self, bytes: u64) {
234        let mut state = lock_unpoisoned(&self.state);
235        state.available = state
236            .available
237            .checked_add(bytes)
238            .expect("memory permit release cannot exceed u64");
239        debug_assert!(state.available <= self.total);
240        drop(state);
241        self.released.notify_all();
242    }
243}
244
245impl Drop for MemoryPermit<'_> {
246    fn drop(&mut self) {
247        self.budget.release(self.bytes);
248    }
249}
250
251#[derive(Debug)]
252enum Executor {
253    Inline,
254    Pool(ThreadPool),
255}
256
257/// Cancellation, progress, threads and memory available to one heavy job.
258#[derive(Debug)]
259pub struct Context {
260    cancel: CancelToken,
261    progress: Arc<dyn ProgressSink>,
262    executor: Executor,
263    budget: Arc<MemoryBudget>,
264}
265
266impl Context {
267    /// Creates a context. A zero thread request is treated as one thread.
268    #[must_use]
269    pub fn new(
270        threads: usize,
271        budget: MemoryBudget,
272        progress: Arc<dyn ProgressSink>,
273        cancel: CancelToken,
274    ) -> Self {
275        let threads = threads.max(1);
276        let executor = if threads == 1 {
277            Executor::Inline
278        } else {
279            Executor::Pool(
280                ThreadPoolBuilder::new()
281                    .num_threads(threads)
282                    .build()
283                    .expect("building a rayon thread pool should succeed"),
284            )
285        };
286        Self {
287            cancel,
288            progress,
289            executor,
290            budget: Arc::new(budget),
291        }
292    }
293
294    /// A one-thread context for work that runs inside one of this job's
295    /// items: it reserves from the same budget and stops with the same token,
296    /// and reports no progress of its own.
297    #[must_use]
298    pub fn child_serial(&self) -> Context {
299        Self {
300            cancel: self.cancel.clone(),
301            progress: Arc::new(NoProgress),
302            executor: Executor::Inline,
303            budget: Arc::clone(&self.budget),
304        }
305    }
306
307    /// Creates a one-thread context with an unlimited budget and no reporting.
308    #[must_use]
309    pub fn serial() -> Self {
310        Self::new(
311            1,
312            MemoryBudget::new(u64::MAX),
313            Arc::new(NoProgress),
314            CancelToken::new(),
315        )
316    }
317
318    /// Returns this job's cancellation token.
319    #[must_use]
320    pub fn cancel(&self) -> &CancelToken {
321        &self.cancel
322    }
323
324    /// Returns this job's memory budget.
325    #[must_use]
326    pub fn budget(&self) -> &MemoryBudget {
327        &self.budget
328    }
329
330    /// Starts a progress stage and immediately reports its initial state.
331    #[must_use]
332    pub fn stage(&self, stage: &'static str, total: u64) -> StageProgress<'_> {
333        StageProgress::new(stage, total, self.progress.as_ref())
334    }
335
336    /// Applies `f` through this context while preserving input order.
337    pub fn map<T: Send, U: Send>(
338        &self,
339        items: Vec<T>,
340        f: impl Fn(T) -> U + Sync + Send,
341    ) -> Result<Vec<U>, Cancelled> {
342        match &self.executor {
343            Executor::Inline => items
344                .into_iter()
345                .map(|item| {
346                    self.cancel.check()?;
347                    Ok(f(item))
348                })
349                .collect(),
350            Executor::Pool(pool) => pool.install(|| {
351                items
352                    .into_par_iter()
353                    .map(|item| {
354                        self.cancel.check()?;
355                        Ok(f(item))
356                    })
357                    .collect()
358            }),
359        }
360    }
361
362    /// Applies fallible `f` while preserving order and reports the lowest
363    /// failing item index.
364    pub fn try_map<T: Send, U: Send, E: Send>(
365        &self,
366        items: Vec<T>,
367        f: impl Fn(T) -> Result<U, E> + Sync + Send,
368    ) -> Result<Vec<U>, MapError<E>> {
369        match &self.executor {
370            Executor::Inline => {
371                let mut output = Vec::with_capacity(items.len());
372                for (index, item) in items.into_iter().enumerate() {
373                    self.cancel.check().map_err(|_| MapError::Cancelled)?;
374                    output.push(f(item).map_err(|error| MapError::Item { index, error })?);
375                }
376                Ok(output)
377            }
378            Executor::Pool(pool) => {
379                let outcomes: Vec<_> = pool.install(|| {
380                    items
381                        .into_par_iter()
382                        .map(|item| {
383                            if self.cancel.is_cancelled() {
384                                ItemOutcome::Cancelled
385                            } else {
386                                ItemOutcome::Value(f(item))
387                            }
388                        })
389                        .collect()
390                });
391                collect_outcomes(outcomes)
392            }
393        }
394    }
395}
396
397#[derive(Debug)]
398enum ItemOutcome<U, E> {
399    Cancelled,
400    Value(Result<U, E>),
401}
402
403fn collect_outcomes<U, E>(outcomes: Vec<ItemOutcome<U, E>>) -> Result<Vec<U>, MapError<E>> {
404    let mut output = Vec::with_capacity(outcomes.len());
405    for (index, outcome) in outcomes.into_iter().enumerate() {
406        match outcome {
407            ItemOutcome::Cancelled => return Err(MapError::Cancelled),
408            ItemOutcome::Value(Ok(value)) => output.push(value),
409            ItemOutcome::Value(Err(error)) => return Err(MapError::Item { index, error }),
410        }
411    }
412    Ok(output)
413}
414
415#[derive(Debug)]
416struct StageState {
417    done: u64,
418    last_reported: u64,
419    last_report_at: Instant,
420}
421
422/// Progress tracker for one stage.
423#[derive(Debug)]
424pub struct StageProgress<'a> {
425    stage: &'static str,
426    total: u64,
427    started_at: Instant,
428    sink: &'a dyn ProgressSink,
429    state: Mutex<StageState>,
430}
431
432impl<'a> StageProgress<'a> {
433    fn new(stage: &'static str, total: u64, sink: &'a dyn ProgressSink) -> Self {
434        let started_at = Instant::now();
435        let tracker = Self {
436            stage,
437            total,
438            started_at,
439            sink,
440            state: Mutex::new(StageState {
441                done: 0,
442                last_reported: 0,
443                last_report_at: started_at,
444            }),
445        };
446        tracker.sink.report(&Progress {
447            stage,
448            done: 0,
449            total,
450            eta: None,
451        });
452        tracker
453    }
454
455    /// Adds up to `n` completed items, saturating at the stage total.
456    pub fn advance(&self, n: u64) {
457        let mut state = lock_unpoisoned(&self.state);
458        let done = state.done.saturating_add(n).min(self.total);
459        self.update_locked(&mut state, done);
460    }
461
462    /// Moves completion monotonically to `done`, capped at the stage total.
463    pub fn set(&self, done: u64) {
464        let mut state = lock_unpoisoned(&self.state);
465        let done = state.done.max(done.min(self.total));
466        self.update_locked(&mut state, done);
467    }
468
469    fn update_locked(&self, state: &mut StageState, done: u64) {
470        state.done = done;
471        if done == state.last_reported {
472            return;
473        }
474
475        let now = Instant::now();
476        let is_last = done == self.total;
477        if !is_last && now.duration_since(state.last_report_at) < PROGRESS_INTERVAL {
478            return;
479        }
480
481        state.last_reported = done;
482        state.last_report_at = now;
483        self.sink.report(&Progress {
484            stage: self.stage,
485            done,
486            total: self.total,
487            eta: eta(self.started_at, now, done, self.total),
488        });
489    }
490}
491
492fn eta(started_at: Instant, now: Instant, done: u64, total: u64) -> Option<Duration> {
493    if done == 0 {
494        return None;
495    }
496    let remaining = total.saturating_sub(done) as f64;
497    let seconds_per_item = now.duration_since(started_at).as_secs_f64() / done as f64;
498    Some(Duration::from_secs_f64(seconds_per_item * remaining))
499}
500
501fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
502    mutex
503        .lock()
504        .unwrap_or_else(|poisoned| poisoned.into_inner())
505}
506
507/// Failure from an ordered fallible map.
508#[derive(Debug, PartialEq, Eq)]
509pub enum MapError<E> {
510    /// The context was cancelled before an item started.
511    Cancelled,
512    /// The function failed for an item.
513    Item {
514        /// Input index of the failed item.
515        index: usize,
516        /// Error returned for that item.
517        error: E,
518    },
519}
520
521#[cfg(test)]
522mod tests {
523    use std::sync::atomic::{AtomicUsize, Ordering};
524    use std::sync::mpsc;
525    use std::thread;
526
527    use super::*;
528
529    fn context(threads: usize, cancel: CancelToken) -> Context {
530        Context::new(
531            threads,
532            MemoryBudget::new(1024),
533            Arc::new(NoProgress),
534            cancel,
535        )
536    }
537
538    #[test]
539    fn parallel_map_is_ordered_and_thread_count_independent() {
540        let items: Vec<u64> = (0..48).collect();
541        let expected: Vec<_> = items.iter().map(|value| value * value + 3).collect();
542
543        for threads in [1, 2, 8] {
544            let caller = thread::current().id();
545            let output = context(threads, CancelToken::new())
546                .map(items.clone(), |value| {
547                    thread::sleep(Duration::from_millis((7 - value % 7) / 2));
548                    if threads == 1 {
549                        assert_eq!(thread::current().id(), caller);
550                    }
551                    value * value + 3
552                })
553                .unwrap();
554            assert_eq!(output, expected);
555        }
556    }
557
558    #[test]
559    fn cancellation_stops_map_quickly_and_skips_remaining() {
560        let cancel = CancelToken::new();
561        let cancel_from_thread = cancel.clone();
562        let started = Arc::new(AtomicUsize::new(0));
563        let closure_started = Arc::clone(&started);
564        let (cancelled_tx, cancelled_rx) = mpsc::channel();
565
566        let canceller = thread::spawn(move || {
567            thread::sleep(Duration::from_millis(25));
568            let at = Instant::now();
569            cancel_from_thread.cancel();
570            cancelled_tx.send(at).unwrap();
571        });
572        let result = context(2, cancel).map((0..100).collect(), move |_| {
573            closure_started.fetch_add(1, Ordering::SeqCst);
574            thread::sleep(Duration::from_millis(10));
575        });
576        let cancelled_at = cancelled_rx.recv().unwrap();
577        let response_time = cancelled_at.elapsed();
578        canceller.join().unwrap();
579
580        eprintln!("cancellation response: {response_time:?}");
581        assert_eq!(result, Err(Cancelled));
582        // The remaining items would take about 500 ms on two threads; the
583        // bound leaves room for slow CI runners while still proving the skip.
584        assert!(
585            response_time < Duration::from_millis(250),
586            "cancellation took {response_time:?}"
587        );
588        assert!(started.load(Ordering::SeqCst) < 100);
589    }
590
591    #[test]
592    fn try_map_reports_lowest_failing_index() {
593        let result = context(8, CancelToken::new()).try_map((0..12).collect(), |index| {
594            if index == 2 {
595                thread::sleep(Duration::from_millis(20));
596                Err("low")
597            } else if index == 9 {
598                Err("high")
599            } else {
600                Ok(index)
601            }
602        });
603
604        assert_eq!(
605            result,
606            Err(MapError::Item {
607                index: 2,
608                error: "low"
609            })
610        );
611    }
612
613    #[test]
614    fn memory_budget_bounds_concurrent_acquirers() {
615        let budget = MemoryBudget::new(3);
616        let current = AtomicUsize::new(0);
617        let maximum = AtomicUsize::new(0);
618        thread::scope(|scope| {
619            for _ in 0..12 {
620                scope.spawn(|| {
621                    let _permit = budget.acquire(1, &CancelToken::new()).unwrap();
622                    let now = current.fetch_add(1, Ordering::SeqCst) + 1;
623                    maximum.fetch_max(now, Ordering::SeqCst);
624                    thread::sleep(Duration::from_millis(3));
625                    current.fetch_sub(1, Ordering::SeqCst);
626                });
627            }
628        });
629
630        assert_eq!(current.load(Ordering::SeqCst), 0);
631        assert!(maximum.load(Ordering::SeqCst) <= 3);
632    }
633
634    #[test]
635    fn memory_budget_rejects_oversized_requests() {
636        let budget = MemoryBudget::new(10);
637        assert_eq!(
638            budget.acquire(11, &CancelToken::new()).unwrap_err(),
639            MemoryError::TooLarge {
640                requested: 11,
641                total: 10
642            }
643        );
644    }
645
646    #[test]
647    fn memory_budget_waiter_observes_cancellation() {
648        let budget = MemoryBudget::new(1);
649        let holder = budget.acquire(1, &CancelToken::new()).unwrap();
650        let cancel = CancelToken::new();
651        thread::scope(|scope| {
652            let waiter_cancel = cancel.clone();
653            let budget = &budget;
654            let waiter = scope.spawn(move || budget.acquire(1, &waiter_cancel));
655            thread::sleep(Duration::from_millis(15));
656            cancel.cancel();
657            assert_eq!(waiter.join().unwrap().unwrap_err(), MemoryError::Cancelled);
658        });
659        drop(holder);
660    }
661
662    #[test]
663    fn child_serial_shares_budget_and_cancel_without_progress() {
664        let sink = Arc::new(RecordingSink::default());
665        let cancel = CancelToken::new();
666        let parent = Context::new(4, MemoryBudget::new(10), sink.clone(), cancel.clone());
667        let child = parent.child_serial();
668        let caller = thread::current().id();
669        let output = child
670            .map((0..8).collect(), |value: u64| {
671                assert_eq!(thread::current().id(), caller);
672                value * 2
673            })
674            .unwrap();
675        assert_eq!(output, (0..8).map(|v| v * 2).collect::<Vec<_>>());
676
677        let permit = child.budget().acquire(7, child.cancel()).unwrap();
678        assert_eq!(parent.budget().available(), 3);
679        assert_eq!(child.budget().total(), 10);
680        drop(permit);
681        assert_eq!(parent.budget().available(), 10);
682
683        let _stage = child.stage("engine.child", 1);
684        assert!(lock_unpoisoned(&sink.reports).is_empty());
685        cancel.cancel();
686        assert!(child.cancel().is_cancelled());
687    }
688
689    #[test]
690    fn memory_permit_shrink_returns_the_excess() {
691        let budget = MemoryBudget::new(10);
692        let mut permit = budget.acquire(8, &CancelToken::new()).unwrap();
693        assert_eq!(budget.available(), 2);
694        permit.shrink_to(5);
695        assert_eq!(budget.available(), 5);
696        permit.shrink_to(9);
697        assert_eq!(budget.available(), 5);
698        drop(permit);
699        assert_eq!(budget.available(), 10);
700    }
701
702    #[test]
703    fn memory_permit_shrink_wakes_a_waiter() {
704        let budget = MemoryBudget::new(4);
705        let mut holder = budget.acquire(4, &CancelToken::new()).unwrap();
706        thread::scope(|scope| {
707            let waiter = scope.spawn(|| budget.acquire(3, &CancelToken::new()).map(drop));
708            thread::sleep(Duration::from_millis(15));
709            holder.shrink_to(1);
710            assert_eq!(waiter.join().unwrap(), Ok(()));
711        });
712        drop(holder);
713        assert_eq!(budget.available(), 4);
714    }
715
716    #[test]
717    fn memory_permit_releases_after_panic() {
718        let budget = Arc::new(MemoryBudget::new(7));
719        let panicking_budget = Arc::clone(&budget);
720        let panicked = thread::spawn(move || {
721            let _permit = panicking_budget.acquire(7, &CancelToken::new()).unwrap();
722            panic!("intentional panic while holding permit");
723        })
724        .join();
725
726        assert!(panicked.is_err());
727        let _permit = budget.acquire(7, &CancelToken::new()).unwrap();
728    }
729
730    #[derive(Debug, Default)]
731    struct RecordingSink {
732        reports: Mutex<Vec<(Instant, Progress)>>,
733    }
734
735    impl ProgressSink for RecordingSink {
736        fn report(&self, progress: &Progress) {
737            lock_unpoisoned(&self.reports).push((Instant::now(), progress.clone()));
738        }
739    }
740
741    #[test]
742    fn progress_reports_endpoints_monotonically_and_throttles() {
743        let sink = Arc::new(RecordingSink::default());
744        let context = Context::new(1, MemoryBudget::new(1), sink.clone(), CancelToken::new());
745        let progress = context.stage("engine.test", 100);
746        for _ in 0..10 {
747            progress.advance(1);
748        }
749        thread::sleep(Duration::from_millis(110));
750        progress.advance(10);
751        for done in 21..100 {
752            progress.set(done);
753        }
754        progress.set(100);
755
756        let reports = lock_unpoisoned(&sink.reports);
757        let done: Vec<_> = reports.iter().map(|(_, report)| report.done).collect();
758        assert_eq!(done.first(), Some(&0));
759        assert!(done.contains(&20));
760        assert_eq!(done.last(), Some(&100));
761        assert!(done.windows(2).all(|window| window[0] <= window[1]));
762        assert_eq!(reports.first().unwrap().1.eta, None);
763        assert_eq!(reports.last().unwrap().1.eta, Some(Duration::ZERO));
764        for reports in reports.windows(2) {
765            if reports[1].1.done != 100 {
766                assert!(reports[1].0.duration_since(reports[0].0) >= PROGRESS_INTERVAL);
767            }
768        }
769    }
770
771    #[test]
772    fn fixed_memory_and_probe_floor_set_budget() {
773        assert_eq!(
774            MemoryBudget::from_probe(&FixedMemory(1_000), 0.5, 100).total(),
775            500
776        );
777        assert_eq!(
778            MemoryBudget::from_probe(&FixedMemory(10), 0.5, 100).total(),
779            100
780        );
781    }
782}