Skip to main content

rustrade_data/streams/
blocking.rs

1use futures::Stream;
2use std::future::Future;
3use std::pin::Pin;
4use std::task::{Context, Poll};
5use tokio::sync::mpsc::Receiver;
6use tokio::task::JoinHandle;
7
8/// Channel depth [`stream_blocking_iter`] is documented for, and a sensible default for a decoder
9/// feeding a backtest.
10///
11/// Large enough that the producer is not woken per item, small enough that the buffered events are
12/// an accounting rounding error next to the dataset. At ~160 bytes per market event this is ~160 KiB
13/// in flight.
14pub const DEFAULT_BLOCKING_CHANNEL_CAPACITY: usize = 1024;
15
16/// Bridge a **blocking**, fallible iterator into a bounded [`Stream`], decoding on a blocking thread.
17///
18/// Historical data usually arrives as something that blocks: a Parquet artifact, a compressed
19/// archive, a CSV on a slow disk. Two things then go wrong if such an iterator is simply wrapped in
20/// [`futures::stream::iter`]:
21///
22/// 1. **It stalls a runtime worker for the whole decode.** Nothing else scheduled on that worker —
23///    the engine, another instrument's decode, a heartbeat — makes progress until the file ends.
24/// 2. **It never yields `Pending`, so nothing downstream can pace it.** A consumer that forwards
25///    into an unbounded channel (the engine feed is one) therefore takes the *entire* artifact into
26///    memory before processing the first event, which is the opposite of the streaming the caller
27///    asked for. Laziness alone does not bound memory; the producer has to be made to wait.
28///
29/// This helper fixes both: `init` runs on a [`tokio::task::spawn_blocking`] thread — so opening the
30/// source is blocking too, as it usually is — and each item is handed over a channel of `capacity`
31/// items. When the channel is full the blocking thread parks, which is the back-pressure: the decoder
32/// runs at most `capacity` items ahead of whatever polls this stream, whatever the dataset's size,
33/// and decoding overlaps consumption instead of preceding it.
34///
35/// **That bound is local to this hand-off.** It constrains nothing further downstream: a caller that
36/// forwards this stream into another *unbounded* queue — a backtest engine feed is exactly that shape
37/// — reintroduces unbounded growth there whenever the far end is the slower side. What this helper
38/// guarantees is "the decoder will not get more than `capacity` items ahead of its own consumer", not
39/// an end-to-end memory bound for whatever pipeline it is embedded in.
40///
41/// # Failure model
42/// The item type is `Result`, because a source that reads incrementally can fail *after* it opened
43/// successfully. An `init` that fails yields exactly one `Err` and ends the stream, so a caller
44/// handles open and mid-stream failures on one path.
45///
46/// A **panic** is not an `Err` and is not reported as one: `Error` is an unconstrained generic, so
47/// there is no value this helper could construct to represent one. It is re-raised on the consumer
48/// instead — see `# Panics`. What it must never do is end the stream quietly, which would leave a
49/// truncated decode indistinguishable from a source that finished, and drive a backtest to a
50/// normal-looking summary over partial data.
51///
52/// A [`tokio::task::JoinError`] that is *not* a panic does end the stream as a clean `None`. A
53/// `spawn_blocking` task cannot be aborted and this one's handle is never exposed, so the only way
54/// to reach that is runtime shutdown — where the consumer is being torn down for the same reason and
55/// has nothing left to do with the news.
56///
57/// # Cancellation
58/// Dropping the returned stream drops the receiver, the next hand-off fails, and the blocking task
59/// returns at its next item boundary. A blocking task cannot be pre-empted, so an iterator that
60/// blocks for a long time between items keeps its thread until it comes back — bounded by the
61/// iterator's own step, not by the dataset.
62///
63/// # Panics
64/// Panics if `capacity` is zero (a zero-capacity channel cannot deliver), and requires a Tokio
65/// runtime with the `rt` feature, like any [`tokio::task::spawn_blocking`] caller.
66///
67/// **A panic in `init` or in the iterator is re-raised on the task that polls this stream**, with
68/// the original payload, at the point the stream would otherwise have ended. This mirrors
69/// [`futures::stream::iter`] over the same iterator, where the panic surfaces on the poller
70/// directly: moving the decode to another thread changes where the work happens, not whether the
71/// caller hears about it. Items already handed over are still yielded first, so the panic arrives
72/// after the last successfully decoded item rather than discarding the batch.
73///
74/// # Polling past the end
75/// `None` is terminal and repeatable: polling again yields `None` rather than panicking, so a
76/// consumer that does not track termination itself needs no `.fuse()`. The returned stream is also
77/// [`Unpin`], so awaiting `next()` needs no pinning.
78///
79/// Both are part of the contract, not accidents of the current body — a rewrite that broke either
80/// (an `async_stream` generator breaks both) would break callers. [`Unpin`] is therefore in the
81/// return type, so such a rewrite fails to compile here rather than at some downstream call site;
82/// the terminal `None` cannot be spelled in a signature and is pinned by test instead. `FusedStream`
83/// is deliberately *not* implemented: the terminal `None` above already gives a consumer what
84/// fusing would, the intended consumer
85/// [`merge_time_sorted`](super::merge::merge_time_sorted) fuses its inputs itself, and widening the
86/// return type later is an additive change. That is a design judgement rather than an observation —
87/// this helper has no in-tree production callers, so there is no consumer set to appeal to.
88///
89/// # ⚠️ One blocking thread per call, held for the whole decode
90/// [`tokio::task::spawn_blocking`] fires **at call time, not at first poll**, so building N of
91/// these starts N blocking tasks immediately, before anything is polled. Each holds its thread for
92/// the whole decode: `blocking_send` parks the thread while the channel is full rather than
93/// returning it to the pool, which is precisely the back-pressure described above.
94///
95/// Tokio's blocking pool defaults to `max_blocking_threads: 512`. Past that, further tasks are
96/// queued and get no thread until a running one returns — and a decode only returns when its
97/// consumer drains it.
98///
99/// **That combination deadlocks under a k-way merge.**
100/// [`merge_time_sorted`](super::merge::merge_time_sorted) collects its inputs eagerly and cannot
101/// emit until *every* input has buffered an event or ended (see its `# Pace` section). With more
102/// than 512 inputs, the queued tasks never run, so their streams stay `Pending`, so the merge stays
103/// `Pending`, so none of the *running* decodes is drained and none releases its thread. Nothing
104/// times out and nothing logs: the backtest simply stops.
105///
106/// A 512-instrument universe is not exotic, so a caller merging more inputs than the pool has
107/// threads must either raise
108/// [`max_blocking_threads`](tokio::runtime::Builder::max_blocking_threads) above the input count,
109/// or merge in batches whose size it does not exceed.
110///
111/// # Examples
112/// ```no_run
113/// # use rustrade_data::streams::blocking::{stream_blocking_iter, DEFAULT_BLOCKING_CHANNEL_CAPACITY};
114/// # fn decode() -> Result<std::vec::IntoIter<Result<u64, std::io::Error>>, std::io::Error> {
115/// #     unimplemented!()
116/// # }
117/// let _stream = stream_blocking_iter(DEFAULT_BLOCKING_CHANNEL_CAPACITY, decode);
118/// ```
119pub fn stream_blocking_iter<Init, Iter, Item, Error>(
120    capacity: usize,
121    init: Init,
122) -> impl Stream<Item = Result<Item, Error>> + Send + Unpin + 'static
123where
124    Init: FnOnce() -> Result<Iter, Error> + Send + 'static,
125    // No `Send`/`'static` on `Iter`: it is built and drained entirely inside the `spawn_blocking`
126    // closure, never captured by it and never returned, so it does not cross a thread boundary. A
127    // decoder with `!Send` internals is therefore usable here.
128    Iter: Iterator<Item = Result<Item, Error>>,
129    Item: Send + 'static,
130    Error: Send + 'static,
131{
132    assert!(
133        capacity > 0,
134        "stream_blocking_iter requires a non-zero channel capacity"
135    );
136
137    let (tx, rx) = tokio::sync::mpsc::channel(capacity);
138
139    // The handle is retained, not detached: it is the only thing that distinguishes an iterator
140    // that finished from one that unwound. `spawn_blocking` tasks are not abortable, so it buys
141    // nothing for cancellation -- dropping the receiver is still what ends the task -- but a
142    // dropped handle would discard the panic along with it. See `BlockingIterStream::poll_next`.
143    let handle = tokio::task::spawn_blocking(move || {
144        let iter = match init() {
145            Ok(iter) => iter,
146            // The consumer is gone; there is nobody to report the failure to.
147            Err(error) => {
148                let _ = tx.blocking_send(Err(error));
149                return;
150            }
151        };
152
153        for item in iter {
154            // Parks this thread while the channel is full -- the back-pressure the bound exists for.
155            // An `Err` means the receiver was dropped, so the remaining items have no destination.
156            if tx.blocking_send(item).is_err() {
157                break;
158            }
159        }
160    });
161
162    BlockingIterStream {
163        rx,
164        handle: Some(handle),
165    }
166}
167
168/// The [`Stream`] returned by [`stream_blocking_iter`].
169///
170/// Private: it exists to add one behaviour to a plain receiver stream — deciding *why* the channel
171/// closed — and nothing about that needs to be nameable by a caller.
172struct BlockingIterStream<Item, Error> {
173    rx: Receiver<Result<Item, Error>>,
174    /// Taken once resolved, so a stream polled past its end does not poll a completed handle.
175    handle: Option<JoinHandle<()>>,
176}
177
178impl<Item, Error> Stream for BlockingIterStream<Item, Error> {
179    type Item = Result<Item, Error>;
180
181    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
182        // Every field is `Unpin`, so the struct is too and needs no projection.
183        let this = self.get_mut();
184
185        match this.rx.poll_recv(cx) {
186            Poll::Pending => return Poll::Pending,
187            Poll::Ready(Some(item)) => return Poll::Ready(Some(item)),
188            // Every sender is gone. That is either the iterator finishing or the thread unwinding,
189            // and the two are indistinguishable from here -- the join handle is what tells them
190            // apart, so fall through rather than reporting an end the caller would have to trust.
191            Poll::Ready(None) => {}
192        }
193
194        let Some(handle) = this.handle.as_mut() else {
195            return Poll::Ready(None);
196        };
197
198        // The task drops its sender as it returns *or* as it unwinds, so the channel can close a
199        // moment before the task is marked complete. Polling registers a waker for that gap rather
200        // than spinning on it.
201        let result = std::task::ready!(Pin::new(handle).poll(cx));
202        this.handle = None;
203
204        match result {
205            Ok(()) => Poll::Ready(None),
206            // Re-raised with the original payload, on the task that polls this stream. Losing it
207            // here is the silent truncation the `# Failure model` exists to rule out.
208            Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
209            // `spawn_blocking` tasks cannot be aborted, so the only way to reach this is runtime
210            // shutdown, where the consumer is going away too and has nothing to do with the news.
211            Err(_) => Poll::Ready(None),
212        }
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
218mod tests {
219    use super::*;
220    use futures::StreamExt;
221    use std::{
222        io::{Error, ErrorKind},
223        sync::{
224            Arc, Mutex,
225            atomic::{AtomicUsize, Ordering},
226        },
227        time::Duration,
228    };
229
230    fn ok_iter(count: usize) -> Result<std::vec::IntoIter<Result<usize, Error>>, Error> {
231        Ok((0..count).map(Ok).collect::<Vec<_>>().into_iter())
232    }
233
234    #[tokio::test]
235    async fn forwards_every_item_in_order() {
236        let stream = stream_blocking_iter(4, || ok_iter(50));
237
238        let items = stream.map(|item| item.unwrap()).collect::<Vec<_>>().await;
239
240        assert_eq!(items, (0..50).collect::<Vec<_>>());
241    }
242
243    #[tokio::test]
244    async fn an_init_failure_becomes_a_single_err_item() {
245        let stream = stream_blocking_iter(4, || {
246            Err::<std::vec::IntoIter<Result<usize, Error>>, _>(Error::new(
247                ErrorKind::NotFound,
248                "no such artifact",
249            ))
250        });
251
252        let items = stream.collect::<Vec<_>>().await;
253        assert_eq!(items.len(), 1);
254        assert_eq!(items[0].as_ref().unwrap_err().kind(), ErrorKind::NotFound);
255    }
256
257    #[tokio::test]
258    async fn a_mid_stream_failure_is_surfaced_not_skipped() {
259        let stream = stream_blocking_iter(4, || {
260            Ok(vec![
261                Ok(1_usize),
262                Err(Error::new(ErrorKind::InvalidData, "bad row")),
263                Ok(3),
264            ]
265            .into_iter())
266        });
267
268        let items = stream.collect::<Vec<_>>().await;
269        assert_eq!(items.len(), 3);
270        assert!(items[1].is_err());
271    }
272
273    /// A panicking decoder must not look like a decoder that reached the end of its data. Silently
274    /// ending here would let a truncated artifact drive a backtest to a normal-looking summary.
275    #[tokio::test]
276    #[should_panic(expected = "corrupt page header")]
277    async fn a_panic_in_the_iterator_reaches_the_consumer() {
278        let stream = stream_blocking_iter(4, || {
279            Ok((0..10).map(|index: usize| {
280                assert!(index < 3, "corrupt page header");
281                Ok::<usize, Error>(index)
282            }))
283        });
284
285        let _items = stream.collect::<Vec<_>>().await;
286    }
287
288    #[tokio::test]
289    #[should_panic(expected = "unreadable footer")]
290    async fn a_panic_in_init_reaches_the_consumer() {
291        let stream = stream_blocking_iter(4, || {
292            panic!("unreadable footer");
293            #[allow(unreachable_code)] // Pins the closure's return type for inference.
294            ok_iter(0)
295        });
296
297        let _items = stream.collect::<Vec<_>>().await;
298    }
299
300    /// The items decoded before the panic are real data and are still delivered; the panic arrives
301    /// after them, not instead of them.
302    #[tokio::test]
303    async fn items_before_a_panic_are_still_yielded() {
304        // Collected through a handle that OUTLIVES the unwind. A `Vec` local to the caught future
305        // is destroyed by the panic along with the rest of that frame, so nothing can be asserted
306        // about it afterwards -- which left the "items before the panic are still yielded" half of
307        // this contract untested, and only the "the panic escapes" half pinned.
308        let delivered = Arc::new(Mutex::new(Vec::new()));
309
310        let sink = Arc::clone(&delivered);
311        let collect = std::panic::AssertUnwindSafe(async move {
312            let mut stream = Box::pin(stream_blocking_iter(4, || {
313                Ok((0..10).map(|index: usize| {
314                    assert!(index < 3, "boom");
315                    Ok::<usize, Error>(index)
316                }))
317            }));
318
319            while let Some(item) = stream.next().await {
320                // The lock is never held across the await, so the panic cannot poison it.
321                sink.lock().unwrap().push(item.unwrap());
322            }
323        });
324
325        let outcome = futures::FutureExt::catch_unwind(collect).await;
326
327        assert!(outcome.is_err(), "the panic must not be swallowed");
328        assert_eq!(
329            *delivered.lock().unwrap(),
330            vec![0, 1, 2],
331            "every item decoded before the panic is real data and must still reach the consumer"
332        );
333    }
334
335    /// The clean path must stay clean: a normal end is still a normal end, not a panic.
336    #[tokio::test]
337    async fn a_normal_end_of_stream_does_not_panic() {
338        let mut stream = Box::pin(stream_blocking_iter(4, || ok_iter(3)));
339
340        for expected in 0..3 {
341            assert_eq!(stream.next().await.unwrap().unwrap(), expected);
342        }
343
344        // Polled past the end twice: the join handle is consumed on the first, and the second must
345        // not poll it again.
346        assert!(stream.next().await.is_none());
347        assert!(stream.next().await.is_none());
348    }
349
350    /// The property the type exists for: the producer must not run ahead of the consumer without
351    /// bound. With a capacity of 2 and nothing drained, no more than a handful of items can have
352    /// been produced -- emphatically not all 10,000.
353    #[tokio::test]
354    async fn the_producer_is_bounded_by_the_channel_capacity() {
355        const CAPACITY: usize = 2;
356        let produced = Arc::new(AtomicUsize::new(0));
357
358        let counter = Arc::clone(&produced);
359        let mut stream = Box::pin(stream_blocking_iter(CAPACITY, move || {
360            Ok((0..10_000).map(move |index| {
361                counter.fetch_add(1, Ordering::SeqCst);
362                Ok::<usize, Error>(index)
363            }))
364        }));
365
366        // Take one item, then let the blocking thread run as far as it can.
367        assert_eq!(stream.next().await.unwrap().unwrap(), 0);
368        tokio::task::yield_now().await;
369
370        // One consumed, `CAPACITY` buffered, and at most one more parked mid-hand-off.
371        let produced = produced.load(Ordering::SeqCst);
372        assert!(
373            produced <= CAPACITY + 2,
374            "producer ran {produced} items ahead of a {CAPACITY}-item channel"
375        );
376    }
377
378    #[tokio::test]
379    async fn dropping_the_stream_stops_the_producer() {
380        const ITEMS: usize = 10_000;
381        let produced = Arc::new(AtomicUsize::new(0));
382
383        let counter = Arc::clone(&produced);
384        let mut stream = Box::pin(stream_blocking_iter(2, move || {
385            Ok((0..ITEMS).map(move |index| {
386                counter.fetch_add(1, Ordering::SeqCst);
387                Ok::<usize, Error>(index)
388            }))
389        }));
390
391        assert!(stream.next().await.is_some());
392        drop(stream);
393
394        // Wait for the count to STOP climbing, rather than assuming it already has. The producer
395        // runs on its own OS thread and stops at its next hand-off, so a `yield_now` on this side
396        // proves nothing about where that thread has got to -- sampling twice around one yield can
397        // catch it mid-iteration and fail spuriously. Polling until two samples separated by a real
398        // delay agree is the same assertion made soundly.
399        let mut settled = produced.load(Ordering::SeqCst);
400        let mut stopped = false;
401        for _ in 0..50 {
402            tokio::time::sleep(Duration::from_millis(10)).await;
403            let current = produced.load(Ordering::SeqCst);
404            if current == settled {
405                stopped = true;
406                break;
407            }
408            settled = current;
409        }
410
411        assert!(
412            stopped,
413            "the producer was still running 500ms after the stream was dropped ({settled} items)"
414        );
415        // The property that matters, and the one a "count stopped changing" assertion alone does
416        // not establish: it stopped *early*, rather than draining the whole iterator into a channel
417        // nobody was left to read. The tight bound belongs to the sibling capacity test; here the
418        // question is only whether the drop was observed at all.
419        assert!(
420            settled < ITEMS,
421            "the producer ran to completion ({settled} items) despite the stream being dropped"
422        );
423    }
424}