koth_ms/io/prefetch.rs
1//! Background-thread prefetching for streaming spectrum sources.
2//!
3//! mzML reading is decompression + XML parsing bound (~half of a typical
4//! run is spent inside `miniz_oxide::inflate` and `quick_xml`), and it runs
5//! on the same thread that consumes the spectra for hill detection. Those two
6//! costs are independent, so serializing them wastes wall-clock equal to the
7//! smaller of the two.
8//!
9//! [`prefetch`] moves the source iterator onto a dedicated reader thread that
10//! decodes spectra into a bounded channel while the caller runs `process_scan`
11//! on the previously-decoded one. Spectra are yielded in the exact same order
12//! (single producer → single consumer FIFO), so hill detection stays
13//! byte-for-byte deterministic — this only overlaps the two stages in time.
14
15use std::sync::mpsc::{sync_channel, Receiver};
16use std::thread::JoinHandle;
17
18/// Run a fallible producer on a background thread. Returning `false` from the
19/// supplied emitter means the consumer has stopped; producers must stop too.
20/// Initialization and decode errors are delivered through the same bounded
21/// channel as the data, so they cannot silently truncate a successful result.
22pub fn try_prefetch<T, E, F>(producer: F, capacity: usize) -> Prefetch<Result<T, E>>
23where
24 T: Send + 'static,
25 E: Send + 'static,
26 F: FnOnce(&mut dyn FnMut(T) -> bool) -> Result<(), E> + Send + 'static,
27{
28 let (tx, rx) = sync_channel(capacity);
29 let handle = std::thread::Builder::new()
30 .name("koth-reader".into())
31 .spawn(move || {
32 if let Err(error) = producer(&mut |item| tx.send(Ok(item)).is_ok()) {
33 let _ = tx.send(Err(error));
34 }
35 })
36 .expect("failed to spawn reader thread");
37 Prefetch {
38 rx: Some(rx),
39 handle: Some(handle),
40 }
41}
42
43/// Run `source` on a dedicated background thread, buffering up to `capacity`
44/// items in a bounded channel. The returned iterator yields the same items in
45/// the same order.
46///
47/// The channel is bounded so a fast reader can't run away with memory: once
48/// `capacity` spectra are queued the reader blocks until the consumer drains
49/// one. A producer panic is re-raised on the consumer thread (see
50/// [`Prefetch::next`]) so parse failures still fail loud.
51pub fn prefetch<I>(source: I, capacity: usize) -> Prefetch<I::Item>
52where
53 I: Iterator + Send + 'static,
54 I::Item: Send + 'static,
55{
56 let (tx, rx) = sync_channel(capacity);
57 let handle = std::thread::Builder::new()
58 .name("koth-prefetch".into())
59 .spawn(move || {
60 for item in source {
61 // Err means the consumer was dropped — stop reading early.
62 if tx.send(item).is_err() {
63 break;
64 }
65 }
66 })
67 .expect("failed to spawn prefetch thread");
68 Prefetch {
69 rx: Some(rx),
70 handle: Some(handle),
71 }
72}
73
74/// Iterator over spectra produced by a background reader thread.
75pub struct Prefetch<T> {
76 rx: Option<Receiver<T>>,
77 handle: Option<JoinHandle<()>>,
78}
79
80impl<T> Prefetch<T> {
81 /// Drop the receiver (signalling the producer to stop) and join the reader
82 /// thread, re-raising its panic if it panicked.
83 fn finish(&mut self) {
84 // Drop the receiver first so a producer blocked on a full channel
85 // unblocks (its `send` returns Err); otherwise the join would deadlock.
86 self.rx = None;
87 if let Some(h) = self.handle.take() {
88 if let Err(panic) = h.join() {
89 std::panic::resume_unwind(panic);
90 }
91 }
92 }
93}
94
95impl<T> Iterator for Prefetch<T> {
96 type Item = T;
97
98 fn next(&mut self) -> Option<T> {
99 match self.rx.as_ref().and_then(|rx| rx.recv().ok()) {
100 Some(item) => Some(item),
101 None => {
102 // Sender dropped → source exhausted (or the reader panicked).
103 // Join so a producer panic surfaces here instead of silently
104 // truncating the spectrum stream.
105 self.finish();
106 None
107 }
108 }
109 }
110}
111
112impl<T> Drop for Prefetch<T> {
113 fn drop(&mut self) {
114 // Best-effort cleanup if the consumer abandons iteration early. Ignore
115 // a producer panic here — re-raising during an unwind would abort.
116 self.rx = None;
117 if let Some(h) = self.handle.take() {
118 let _ = h.join();
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use std::sync::{
127 atomic::{AtomicUsize, Ordering},
128 Arc,
129 };
130 use std::time::Duration;
131
132 #[test]
133 fn fallible_producer_is_bounded_and_cancels_on_drop() {
134 let count = Arc::new(AtomicUsize::new(0));
135 let produced = count.clone();
136 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
137 let stream = try_prefetch::<usize, (), _>(
138 move |emit| {
139 for i in 0..1000 {
140 produced.fetch_add(1, Ordering::SeqCst);
141 if i == 4 {
142 ready_tx.send(()).unwrap();
143 }
144 if !emit(i) {
145 break;
146 }
147 }
148 Ok(())
149 },
150 4,
151 );
152 ready_rx.recv_timeout(Duration::from_secs(5)).unwrap();
153 // Four queued values and one blocked send, independent of run length.
154 assert_eq!(count.load(Ordering::SeqCst), 5);
155 drop(stream); // must unblock the send and join the producer
156 assert_eq!(count.load(Ordering::SeqCst), 5);
157 }
158
159 #[test]
160 fn fallible_producer_delivers_ordered_values_then_error() {
161 let mut stream = try_prefetch(
162 |emit| {
163 assert!(emit(1));
164 assert!(emit(2));
165 Err("decode failure")
166 },
167 1,
168 );
169 assert_eq!(stream.next(), Some(Ok(1)));
170 assert_eq!(stream.next(), Some(Ok(2)));
171 assert_eq!(stream.next(), Some(Err("decode failure")));
172 assert_eq!(stream.next(), None);
173 assert_eq!(stream.next(), None);
174 }
175
176 #[test]
177 fn producer_panic_reaches_consumer() {
178 let mut stream = try_prefetch::<(), (), _>(|_| panic!("reader panic"), 1);
179 assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| stream.next())).is_err());
180 }
181}