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