1#![forbid(unsafe_code)]
2#![deny(unsafe_op_in_unsafe_fn)]
3use 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#[derive(Clone, Debug, Default)]
33pub struct CancelToken {
34 cancelled: Arc<AtomicBool>,
35}
36
37impl CancelToken {
38 #[must_use]
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 pub fn cancel(&self) {
46 self.cancelled.store(true, Ordering::Release);
47 }
48
49 #[must_use]
51 pub fn is_cancelled(&self) -> bool {
52 self.cancelled.load(Ordering::Acquire)
53 }
54
55 pub fn check(&self) -> Result<(), Cancelled> {
57 if self.is_cancelled() {
58 Err(Cancelled)
59 } else {
60 Ok(())
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
67#[error("cancelled")]
68pub struct Cancelled;
69
70#[derive(Debug, Clone, PartialEq)]
72pub struct Progress {
73 pub stage: &'static str,
75 pub done: u64,
77 pub total: u64,
79 pub eta: Option<Duration>,
81}
82
83pub trait ProgressSink: Send + Sync + Debug {
85 fn report(&self, progress: &Progress);
87}
88
89#[derive(Debug, Default)]
91pub struct NoProgress;
92
93impl ProgressSink for NoProgress {
94 fn report(&self, _progress: &Progress) {}
95}
96
97pub trait MemoryProbe: Send + Sync + Debug {
99 fn available_bytes(&self) -> u64;
101}
102
103#[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#[derive(Debug)]
120pub struct MemoryBudget {
121 total: u64,
122 state: Mutex<BudgetState>,
123 released: Condvar,
124}
125
126impl MemoryBudget {
127 #[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 #[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 #[must_use]
155 pub fn total(&self) -> u64 {
156 self.total
157 }
158
159 #[must_use]
161 pub fn available(&self) -> u64 {
162 lock_unpoisoned(&self.state).available
163 }
164
165 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
199pub enum MemoryError {
200 #[error("request of {requested} bytes exceeds memory budget of {total} bytes")]
202 TooLarge {
203 requested: u64,
205 total: u64,
207 },
208 #[error("cancelled")]
210 Cancelled,
211}
212
213#[derive(Debug)]
215pub struct MemoryPermit<'a> {
216 budget: &'a MemoryBudget,
217 bytes: u64,
218}
219
220impl MemoryPermit<'_> {
221 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#[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 #[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 #[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 #[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 #[must_use]
320 pub fn cancel(&self) -> &CancelToken {
321 &self.cancel
322 }
323
324 #[must_use]
326 pub fn budget(&self) -> &MemoryBudget {
327 &self.budget
328 }
329
330 #[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 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 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#[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 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 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#[derive(Debug, PartialEq, Eq)]
509pub enum MapError<E> {
510 Cancelled,
512 Item {
514 index: usize,
516 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 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}