pub fn stream_blocking_iter<Init, Iter, Item, Error>(
chunk_size: usize,
init: Init,
) -> impl Stream<Item = Result<Item, Error>> + Send + Unpin + 'staticExpand description
Bridge a blocking, fallible iterator into a bounded Stream, decoding on Tokio’s blocking
pool.
Historical data usually arrives as something that blocks: a Parquet artifact, a compressed
archive, a CSV on a slow disk. Two things then go wrong if such an iterator is simply wrapped in
futures::stream::iter:
- It stalls a runtime worker for the whole decode. Nothing else scheduled on that worker — the engine, another instrument’s decode, a heartbeat — makes progress until the file ends.
- It never yields
Pending, so nothing downstream can pace it. A consumer that forwards into an unbounded channel (the engine feed is one) therefore takes the entire artifact into memory before processing the first event, which is the opposite of the streaming the caller asked for. Laziness alone does not bound memory; the producer has to be made to wait.
This helper fixes both. The decode runs in chunks of chunk_size items, each on a
tokio::task::spawn_blocking task that decodes the chunk, returns it, and gives its thread
back. When a chunk arrives the stream starts the next one, so decoding overlaps consumption, and
it starts no further chunk until the consumer has drained the one it holds. That is the
back-pressure: the decoder runs at most two chunks, 2 × chunk_size items, ahead of whatever
polls this stream, whatever the dataset’s size. init runs in the first chunk’s task, so opening
the source is off the runtime’s workers too, as it usually needs to be.
That bound is local to this hand-off. It constrains nothing further downstream: a caller that forwards this stream into another unbounded queue — a backtest engine feed is exactly that shape — reintroduces unbounded growth there whenever the far end is the slower side. What this helper guarantees is “the decoder will not get more than two chunks ahead of its own consumer”, not an end-to-end memory bound for whatever pipeline it is embedded in.
The bound is also per stream. A merge_time_sorted of N
of these polls every input until each has delivered a chunk, so it holds up to
2 × N × chunk_size decoded items. For a merge of thousands of inputs, lower chunk_size.
§No thread is held while waiting, so any number of these can be merged
A blocking thread is held only while a chunk is being decoded, never while the stream waits for
its consumer. Building a stream starts nothing: the first chunk is started on first poll. Any
number of these can therefore be driven together, for example by
merge_time_sorted, which cannot emit until every input has
buffered an event. Past Tokio’s max_blocking_threads (512 by default), chunks queue for a thread
and every one of them still finishes, so the merge is slower but always progresses.
§Failure model
The item type is Result, because a source that reads incrementally can fail after it opened
successfully. An init that fails yields exactly one Err and ends the stream, so a caller
handles open and mid-stream failures on one path.
A panic is not an Err and is not reported as one: Error is an unconstrained generic, so
there is no value this helper could construct to represent one. It is re-raised on the consumer
instead — see # Panics. What it must never do is end the stream quietly, which would leave a
truncated decode indistinguishable from a source that finished, and drive a backtest to a
normal-looking summary over partial data.
A tokio::task::JoinError that is not a panic ends the stream with None, after a warn!.
This one’s handle is never exposed, so nothing aborts the task, and the only way to reach that is
runtime shutdown cancelling a chunk still queued for a thread. The consumer is normally being torn
down for the same reason; the warn! says the decode was cut short in case it is not.
§Cancellation
Dropping the returned stream starts no further chunk. The chunk already started is not
cancelled, whether it is being decoded or still waiting for a thread: it runs to its end, at most
chunk_size items, and its result is discarded with the iterator. Letting it run keeps the
iterator, and whatever its Drop does, on the blocking pool.
§Panics
Panics if chunk_size is zero (a chunk must deliver at least one item), and, when first polled,
outside a Tokio runtime, like any tokio::task::spawn_blocking caller. chunk_size bounds the
items per chunk and is not allocated up front, so a large value such as usize::MAX is safe.
A panic in init or in the iterator is re-raised on the task that polls this stream, with
the original payload, at the point the stream would otherwise have ended. This mirrors
futures::stream::iter over the same iterator, where the panic surfaces on the poller
directly: moving the decode to another thread changes where the work happens, not whether the
caller hears about it. Items decoded before the panic, including those earlier in the same
chunk, are still yielded first, so the panic arrives after the last successfully decoded item
rather than discarding the batch.
§Polling past the end
None is terminal and repeatable: polling again yields None rather than panicking, so a
consumer that does not track termination itself needs no .fuse(). The returned stream is also
Unpin, so awaiting next() needs no pinning.
Both are part of the contract, not accidents of the current body — a rewrite that broke either
(an async_stream generator breaks both) would break callers. Unpin is therefore in the
return type, so such a rewrite fails to compile here rather than at some downstream call site;
the terminal None cannot be spelled in a signature and is pinned by test instead. FusedStream
is deliberately not implemented: the terminal None above already gives a consumer what
fusing would, the intended consumer
merge_time_sorted fuses its inputs itself, and widening the
return type later is an additive change. That is a design judgement rather than an observation —
this helper has no in-tree production callers, so there is no consumer set to appeal to.
§The iterator must be Send + 'static
The iterator moves from one chunk’s task to the next, so it has to cross threads. A decoder with
!Send internals is not accepted. Holding one thread for the whole decode would allow it, and
is exactly what made a merge of more inputs than the pool has threads deadlock.
§Examples
let _stream = stream_blocking_iter(DEFAULT_BLOCKING_CHUNK_SIZE, decode);