Skip to main content

gwseq_io/
progress.rs

1//! Progress reporting.
2//!
3//! A push model: a worker that has just
4//! finished some work calls [`ProgressTracker::add`], and that is where the
5//! callback fires — when the fraction done has advanced by at least
6//! [`REPORT_INTERVAL`] since the last report.
7//!
8//! # Why the callback runs on the worker
9//!
10//! Because it can. In the Python build the callback is a Python object and the
11//! workers are rayon threads, but the thread that owns the request has released
12//! the GIL for the whole extraction, so a worker is free to take it. The
13//! alternative — accumulating silently and having the owning thread poll —
14//! reports nothing at all while a single long window is being read, which is
15//! exactly when a caller wants to see progress.
16//!
17//! The report lock is held **across** the callback. That is deliberate: every thread takes this lock before the GIL and never
18//! the other way round, so there is one acquisition order and no cycle to
19//! deadlock on.
20
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::Arc;
23
24use parking_lot::Mutex;
25
26/// Called with (done, total), both in base pairs.
27///
28/// `Fn` rather than `FnMut`, and behind an `Arc`: a reader's methods take
29/// `&self`, and several workers call this at once.
30pub type ProgressFn = Arc<dyn Fn(u64, u64) + Send + Sync>;
31
32/// Minimum fractional progress between two callbacks.
33pub const REPORT_INTERVAL: f64 = 0.01;
34
35/// A flag a caller sets to stop a long operation between two of its steps.
36///
37/// Only the converters take one, and only because their progress callback is
38/// the one piece of Python a conversion runs — so a `KeyboardInterrupt` out of
39/// it is how an interrupt surfaces at all. The callback is a `Fn` returning
40/// nothing, so it cannot report the interrupt itself; the binding sets this
41/// flag and the conversion checks it after every report.
42#[derive(Debug, Clone, Default)]
43pub struct CancelFlag(Arc<std::sync::atomic::AtomicBool>);
44
45impl CancelFlag {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn cancel(&self) {
51        self.0.store(true, Ordering::Relaxed);
52    }
53
54    pub fn is_cancelled(&self) -> bool {
55        self.0.load(Ordering::Relaxed)
56    }
57}
58
59pub struct ProgressTracker {
60    done: AtomicU64,
61    total: u64,
62    callback: Option<ProgressFn>,
63    /// Fraction last reported. Also the lock that serialises reports.
64    last_reported: Mutex<f64>,
65}
66
67impl std::fmt::Debug for ProgressTracker {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("ProgressTracker")
70            .field("done", &self.done())
71            .field("total", &self.total)
72            .field("reporting", &self.callback.is_some())
73            .finish()
74    }
75}
76
77impl ProgressTracker {
78    /// A tracker that counts but reports nothing.
79    pub fn new(total: u64) -> Self {
80        Self::with_callback(total, None)
81    }
82
83    pub fn with_callback(total: u64, callback: Option<ProgressFn>) -> Self {
84        Self {
85            done: AtomicU64::new(0),
86            total,
87            callback,
88            last_reported: Mutex::new(0.0),
89        }
90    }
91
92    /// Add completed work, firing the callback if the threshold is crossed.
93    ///
94    /// The counter is a relaxed atomic and the lock is only taken when there is
95    /// a callback to fire — a tracker nobody is listening to costs one atomic
96    /// add per locus.
97    pub fn add(&self, value: u64) {
98        let current = self.done.fetch_add(value, Ordering::Relaxed) + value;
99        let Some(callback) = &self.callback else {
100            return;
101        };
102        let mut last = self.last_reported.lock();
103        let progress = if self.total > 0 {
104            current as f64 / self.total as f64
105        } else {
106            0.0
107        };
108        if progress >= *last + REPORT_INTERVAL {
109            *last = progress;
110            callback(current, self.total);
111        }
112    }
113
114    /// Fire the callback with (total, total) unless 100% was already reported.
115    ///
116    /// Called once all the work is finished, so a request always ends on a full
117    /// bar however its windows fell.
118    pub fn done_report(&self) {
119        let Some(callback) = &self.callback else {
120            return;
121        };
122        let mut last = self.last_reported.lock();
123        if *last < 1.0 {
124            *last = 1.0;
125            callback(self.total, self.total);
126        }
127    }
128
129    pub fn done(&self) -> u64 {
130        self.done.load(Ordering::Relaxed)
131    }
132
133    pub fn total(&self) -> u64 {
134        self.total
135    }
136}
137
138/// The terminal bar installed when `progress=True`.
139///
140/// Writes nothing when stderr is not a terminal: a carriage-return bar in a log
141/// file or a pipe is noise, and a caller redirecting output has said as much.
142pub fn default_progress() -> ProgressFn {
143    Arc::new(|done, total| {
144        use std::io::{IsTerminal, Write};
145        let mut err = std::io::stderr();
146        if !err.is_terminal() {
147            return;
148        }
149        const WIDTH: usize = 40;
150        let fraction = if total == 0 {
151            1.0
152        } else {
153            (done as f64 / total as f64).clamp(0.0, 1.0)
154        };
155        let filled = (fraction * WIDTH as f64).round() as usize;
156        let _ = write!(
157            err,
158            "\r[{}{}] {:5.1}%  {done}/{total} bp",
159            "#".repeat(filled),
160            "-".repeat(WIDTH - filled),
161            fraction * 100.0,
162        );
163        if done >= total {
164            let _ = writeln!(err);
165        }
166        let _ = err.flush();
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    /// A callback and the log it appends to.
175    type Recorder = (ProgressFn, Arc<Mutex<Vec<(u64, u64)>>>);
176
177    fn recording() -> Recorder {
178        let seen = Arc::new(Mutex::new(Vec::new()));
179        let sink = seen.clone();
180        (Arc::new(move |d, t| sink.lock().push((d, t))), seen)
181    }
182
183    #[test]
184    fn a_tracker_with_no_callback_just_counts() {
185        let tracker = ProgressTracker::new(100);
186        tracker.add(30);
187        tracker.add(20);
188        assert_eq!(tracker.done(), 50);
189        assert_eq!(tracker.total(), 100);
190        tracker.done_report(); // must not panic
191    }
192
193    #[test]
194    fn reports_only_once_the_interval_is_crossed() {
195        let (callback, seen) = recording();
196        let tracker = ProgressTracker::with_callback(1000, Some(callback));
197        // Under 1% each: nine of these still say nothing.
198        for _ in 0..9 {
199            tracker.add(1);
200        }
201        assert!(seen.lock().is_empty());
202        tracker.add(1); // now at 1%
203        assert_eq!(&*seen.lock(), &[(10, 1000)]);
204    }
205
206    #[test]
207    fn the_final_report_fires_once_and_says_the_total() {
208        let (callback, seen) = recording();
209        let tracker = ProgressTracker::with_callback(1000, Some(callback));
210        tracker.add(500);
211        tracker.done_report();
212        tracker.done_report();
213        assert_eq!(seen.lock().last(), Some(&(1000, 1000)));
214        assert_eq!(seen.lock().iter().filter(|(d, _)| *d == 1000).count(), 1);
215    }
216
217    #[test]
218    fn a_request_that_finished_exactly_does_not_report_twice() {
219        let (callback, seen) = recording();
220        let tracker = ProgressTracker::with_callback(100, Some(callback));
221        tracker.add(100);
222        assert_eq!(&*seen.lock(), &[(100, 100)]);
223        tracker.done_report();
224        assert_eq!(seen.lock().len(), 1);
225    }
226
227    #[test]
228    fn a_zero_total_never_divides_and_still_completes() {
229        let (callback, seen) = recording();
230        let tracker = ProgressTracker::with_callback(0, Some(callback));
231        tracker.add(5);
232        assert!(seen.lock().is_empty());
233        tracker.done_report();
234        assert_eq!(&*seen.lock(), &[(0, 0)]);
235    }
236
237    #[test]
238    fn concurrent_adds_are_not_lost_and_reports_are_serialised() {
239        let (callback, seen) = recording();
240        let tracker = Arc::new(ProgressTracker::with_callback(8000, Some(callback)));
241        let threads: Vec<_> = (0..8)
242            .map(|_| {
243                let tracker = tracker.clone();
244                std::thread::spawn(move || {
245                    for _ in 0..1000 {
246                        tracker.add(1);
247                    }
248                })
249            })
250            .collect();
251        for t in threads {
252            t.join().unwrap();
253        }
254        assert_eq!(tracker.done(), 8000);
255        let seen = seen.lock();
256        assert!(!seen.is_empty());
257        // At most one report per percent, and each says more than the last.
258        assert!(seen.len() <= 100, "{} reports", seen.len());
259        assert!(seen.windows(2).all(|w| w[0].0 <= w[1].0));
260    }
261}