Skip to main content

nectar_primitives/file/
joiner.rs

1//! Async joiner with BFS expansion and concurrent chunk fetching.
2
3use std::io::SeekFrom;
4use std::marker::PhantomData;
5use std::sync::Arc;
6
7/// Default number of concurrent chunk fetches for async operations.
8const DEFAULT_ASYNC_CONCURRENCY: usize = 8;
9
10/// Default number of times a failed leaf fetch is re-enqueued before the
11/// chunk-granular offset stream surfaces the error.
12const DEFAULT_LEAF_RETRIES: u32 = 4;
13
14/// Maximum intermediate (non-leaf) chunk fetches kept in flight by the
15/// chunk-granular streaming walks.
16///
17/// Intermediate nodes are rare relative to leaves (one per branching-factor
18/// leaves) and each resolves to a whole chunk of child addresses, so a small
19/// handful keeps leaf discovery far ahead of leaf consumption. Capping them is
20/// what stops a wide subtree frontier from being fetched in full before the
21/// first data leaf: the cap leaves the rest of the configured width free for
22/// leaves, so leaf bytes begin flowing after only a short descent rather than
23/// after the whole frontier is drained. The cap shapes the walk for
24/// time-to-first-byte, sized to keep roughly a subtree descent's worth of
25/// structure fetches concurrent; any reduction in the cold-start fetch burst is
26/// a side effect, not the purpose.
27pub(crate) const MAX_INTERMEDIATE_IN_FLIGHT: usize = 4;
28
29#[cfg(feature = "tokio")]
30use bytes::Buf;
31use bytes::Bytes;
32use futures::stream::{self, FuturesUnordered, Stream, StreamExt};
33
34use crate::bmt::DEFAULT_BODY_SIZE;
35use crate::chunk::ChunkAddress;
36
37use super::error::{FileError, Result};
38use super::frontier::{
39    SubtreeNode, expand_frontier, frontier_seed, overlapping_children, read_subtree_bodies,
40};
41use super::mode::{JoinMode, PlainMode};
42use super::tree::{ChunkRange, TreeParams};
43use crate::store::{ChunkGet, MaybeSend};
44
45#[cfg(feature = "encryption")]
46use super::mode::EncryptedMode;
47
48/// Generic async joiner parameterized by chunk mode.
49pub struct GenericJoiner<G, M: JoinMode, const BODY_SIZE: usize = DEFAULT_BODY_SIZE>
50where
51    G: ChunkGet<BODY_SIZE>,
52{
53    getter: Arc<G>,
54    root: ChunkAddress,
55    context: M::JoinerContext,
56    span: u64,
57    tree: TreeParams<BODY_SIZE>,
58    /// Pre-expanded frontier for parallel work distribution (computed once at construction).
59    subtrees: Vec<SubtreeNode<M>>,
60    position: u64,
61    concurrency: usize,
62    _mode: PhantomData<M>,
63}
64
65/// Plain (unencrypted) async joiner.
66pub type Joiner<G, const BODY_SIZE: usize = DEFAULT_BODY_SIZE> =
67    GenericJoiner<G, PlainMode, BODY_SIZE>;
68
69/// Encrypted async joiner.
70#[cfg(feature = "encryption")]
71pub type EncryptedJoiner<G, const BODY_SIZE: usize = DEFAULT_BODY_SIZE> =
72    GenericJoiner<G, EncryptedMode, BODY_SIZE>;
73
74impl<G, M, const BODY_SIZE: usize> std::fmt::Debug for GenericJoiner<G, M, BODY_SIZE>
75where
76    G: ChunkGet<BODY_SIZE>,
77    M: JoinMode,
78{
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("GenericJoiner")
81            .field("root", &self.root)
82            .field("span", &self.span)
83            .field("position", &self.position)
84            .field("concurrency", &self.concurrency)
85            .finish_non_exhaustive()
86    }
87}
88
89/// Collect leaf bodies for a set of subtrees with concurrent fetching.
90async fn collect_subtree_bodies<G, M, const BODY_SIZE: usize>(
91    getter: &Arc<G>,
92    subtrees: Vec<SubtreeNode<M>>,
93    chunk_range: ChunkRange,
94    concurrency: usize,
95) -> Result<Vec<Bytes>>
96where
97    G: ChunkGet<BODY_SIZE>,
98    M: JoinMode + MaybeSend + Sync,
99{
100    let bodies: Vec<Bytes> = stream::iter(subtrees)
101        .map(|st| {
102            let getter = Arc::clone(getter);
103            async move { read_subtree_bodies::<G, M, BODY_SIZE>(&*getter, &st, &chunk_range).await }
104        })
105        .buffered(concurrency)
106        .collect::<Vec<_>>()
107        .await
108        .into_iter()
109        .collect::<Result<Vec<Vec<Bytes>>>>()?
110        .into_iter()
111        .flatten()
112        .collect();
113    Ok(bodies)
114}
115
116impl<G, M, const BODY_SIZE: usize> GenericJoiner<G, M, BODY_SIZE>
117where
118    G: ChunkGet<BODY_SIZE>,
119    M: JoinMode + MaybeSend + Sync,
120{
121    /// Create an async joiner from a root reference.
122    pub async fn new(getter: G, input: M::RootRef) -> Result<Self> {
123        const { super::constants::assert_valid_body_size::<BODY_SIZE>() };
124
125        let (root, span, context) =
126            super::mode::joiner_init::<M, G, BODY_SIZE>(&getter, input).await?;
127        let tree = TreeParams::<BODY_SIZE>::new(span);
128
129        let target = DEFAULT_ASYNC_CONCURRENCY * 2;
130        let full_range = tree.chunks_for_range(0, span);
131        let subtrees =
132            expand_frontier::<G, M, BODY_SIZE>(&getter, &root, &context, span, &full_range, target)
133                .await?;
134
135        Ok(Self {
136            getter: Arc::new(getter),
137            root,
138            context,
139            span,
140            tree,
141            subtrees,
142            position: 0,
143            concurrency: DEFAULT_ASYNC_CONCURRENCY,
144            _mode: PhantomData,
145        })
146    }
147
148    /// Open for a forward streaming download, skipping the eager frontier
149    /// expansion.
150    ///
151    /// [`new`](Self::new) pre-expands a balanced subtree frontier with a
152    /// level-synchronous BFS, so one slow intermediate stalls every byte until
153    /// its whole level resolves. The chunk-granular offset stream descends the
154    /// tree concurrently from any seed, so a forward download needs no
155    /// pre-expansion: seed it with the root alone and let the bounded pool walk
156    /// the rest with no per-level barrier, so a slow intermediate lags only its
157    /// own subtree rather than the whole download. Use this for
158    /// [`download_into`](Self::download_into) and
159    /// [`into_offset_stream_chunked`](Self::into_offset_stream_chunked); the
160    /// random-access reads ([`read_all`](Self::read_all),
161    /// [`into_windowed_reader`](Self::into_windowed_reader)) want the balanced
162    /// frontier and should open with [`new`](Self::new). The root is re-fetched
163    /// once as the stream descends it (a single chunk).
164    pub async fn open_streaming(getter: G, input: M::RootRef) -> Result<Self> {
165        const { super::constants::assert_valid_body_size::<BODY_SIZE>() };
166
167        let (root, span, context) =
168            super::mode::joiner_init::<M, G, BODY_SIZE>(&getter, input).await?;
169        let tree = TreeParams::<BODY_SIZE>::new(span);
170        let subtrees = vec![frontier_seed::<M>(&root, &context, span)];
171
172        Ok(Self {
173            getter: Arc::new(getter),
174            root,
175            context,
176            span,
177            tree,
178            subtrees,
179            position: 0,
180            concurrency: DEFAULT_ASYNC_CONCURRENCY,
181            _mode: PhantomData,
182        })
183    }
184
185    /// Set concurrency level for prefetching.
186    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
187        self.concurrency = concurrency.max(1);
188        self
189    }
190
191    /// Total file size.
192    #[inline]
193    pub const fn size(&self) -> u64 {
194        self.span
195    }
196
197    /// Current read position.
198    #[inline]
199    pub const fn position(&self) -> u64 {
200        self.position
201    }
202
203    /// Root address.
204    #[inline]
205    pub const fn root(&self) -> &ChunkAddress {
206        &self.root
207    }
208
209    // crate-private accessors for sibling-module joiner extensions
210    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
211    pub(crate) const fn getter(&self) -> &Arc<G> {
212        &self.getter
213    }
214    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
215    pub(crate) fn subtrees(&self) -> &[SubtreeNode<M>] {
216        &self.subtrees
217    }
218    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
219    pub(crate) const fn tree(&self) -> TreeParams<BODY_SIZE> {
220        self.tree
221    }
222    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
223    pub(crate) const fn concurrency(&self) -> usize {
224        self.concurrency
225    }
226    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
227    pub(crate) const fn context(&self) -> &M::JoinerContext {
228        &self.context
229    }
230
231    /// Read a range of bytes with concurrent fetching using the cached frontier.
232    pub async fn read_range(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
233        Self::read_range_with(
234            &self.getter,
235            &self.subtrees,
236            &self.root,
237            &self.context,
238            self.span,
239            self.tree,
240            self.concurrency,
241            offset,
242            len,
243        )
244        .await
245    }
246
247    /// Read entire file into memory.
248    pub async fn read_all(&self) -> Result<Vec<u8>> {
249        self.read_range(0, self.span as usize).await
250    }
251
252    /// Shared read-range implementation used by both `read_range` and `poll_read`.
253    #[allow(
254        clippy::too_many_arguments,
255        reason = "internal helper threading already-decomposed reader state from two call sites"
256    )]
257    async fn read_range_with(
258        getter: &Arc<G>,
259        subtrees: &[SubtreeNode<M>],
260        root: &ChunkAddress,
261        context: &M::JoinerContext,
262        span: u64,
263        tree: TreeParams<BODY_SIZE>,
264        concurrency: usize,
265        offset: u64,
266        len: usize,
267    ) -> Result<Vec<u8>> {
268        use super::helpers::{ReadRangeCheck, validate_read_range};
269
270        let (offset, actual_len) = match validate_read_range::<BODY_SIZE>(offset, len, span) {
271            ReadRangeCheck::Empty => return Ok(Vec::new()),
272            ReadRangeCheck::SingleChunk { offset, actual_len } => {
273                let chunk = getter.get(root).await.map_err(FileError::getter)?;
274                let chunk = chunk.into_content().ok_or(FileError::InvalidChunkType {
275                    type_name: "non-content",
276                })?;
277                let body = M::decode_body::<BODY_SIZE>(chunk, context, span)?;
278                let start = offset as usize;
279                let end = start + actual_len;
280                return Ok(body[start..end].to_vec());
281            }
282            ReadRangeCheck::MultiChunk { offset, actual_len } => (offset, actual_len),
283        };
284
285        let chunk_range = tree.chunks_for_range(offset, actual_len as u64);
286        let range_start_byte = chunk_range.start * BODY_SIZE as u64;
287        let range_end_byte = chunk_range.end * BODY_SIZE as u64;
288
289        let relevant: Vec<_> = subtrees
290            .iter()
291            .filter(|st| {
292                st.byte_offset < range_end_byte && st.byte_offset + st.span > range_start_byte
293            })
294            .cloned()
295            .collect();
296
297        let bodies =
298            collect_subtree_bodies::<G, M, BODY_SIZE>(getter, relevant, chunk_range, concurrency)
299                .await?;
300
301        Ok(super::tree::assemble_range(
302            &tree,
303            offset,
304            actual_len,
305            &chunk_range,
306            &bodies,
307        ))
308    }
309
310    /// Update read position (synchronous — just updates internal state).
311    pub fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
312        self.position = super::resolve_seek_position(pos, self.position, self.span)?;
313        Ok(self.position)
314    }
315
316    /// Convert into a stream of leaf chunk bodies.
317    pub fn into_stream(self) -> impl Stream<Item = Result<Bytes>> {
318        let getter = self.getter;
319        let chunk_range = self.tree.chunks_for_range(0, self.span);
320
321        struct State<M: JoinMode> {
322            subtrees: std::vec::IntoIter<SubtreeNode<M>>,
323            pending: std::vec::IntoIter<Bytes>,
324        }
325
326        let state = State {
327            subtrees: self.subtrees.into_iter(),
328            pending: Vec::new().into_iter(),
329        };
330
331        stream::unfold(state, move |mut state| {
332            let getter = Arc::clone(&getter);
333            async move {
334                // Drain pending leaf bodies from the last subtree.
335                if let Some(body) = state.pending.next() {
336                    return Some((Ok(body), state));
337                }
338
339                // Fetch the next subtree's leaf bodies.
340                let st = state.subtrees.next()?;
341                match read_subtree_bodies::<G, M, BODY_SIZE>(&*getter, &st, &chunk_range).await {
342                    Ok(bodies) => {
343                        let mut iter = bodies.into_iter();
344                        match iter.next() {
345                            Some(first) => {
346                                state.pending = iter;
347                                Some((Ok(first), state))
348                            }
349                            None => Some((Ok(Bytes::new()), state)),
350                        }
351                    }
352                    Err(e) => Some((Err(e), state)),
353                }
354            }
355        })
356    }
357
358    /// Convert into a stream of `(byte_offset, leaf_body)` pairs fetched with
359    /// bounded concurrency and yielded out of order as each leaf lands.
360    ///
361    /// Unlike [`into_stream`](Self::into_stream), which walks subtrees one at a
362    /// time and emits bytes in file order, this keeps up to `concurrency`
363    /// subtree fetches in flight and yields each leaf the moment its subtree
364    /// resolves, tagged with its absolute byte offset in the file. Reassembling
365    /// the pairs by offset reproduces the file; nothing is buffered into a
366    /// single contiguous result, so peak memory is bounded by the in-flight
367    /// width, not the file size. Set the width with
368    /// [`with_concurrency`](Self::with_concurrency).
369    pub fn into_offset_stream(self) -> impl Stream<Item = Result<(u64, Bytes)>> {
370        let getter = self.getter;
371        let concurrency = self.concurrency;
372        let chunk_range = self.tree.chunks_for_range(0, self.span);
373
374        // Each subtree fetch resolves to its base offset plus its leaves in tree
375        // order; `buffer_unordered` yields whichever subtree finishes first.
376        let subtrees = stream::iter(self.subtrees)
377            .map(move |st| {
378                let getter = Arc::clone(&getter);
379                async move {
380                    let base = st.byte_offset;
381                    let bodies =
382                        read_subtree_bodies::<G, M, BODY_SIZE>(&*getter, &st, &chunk_range).await?;
383                    Ok::<(u64, Vec<Bytes>), FileError>((base, bodies))
384                }
385            })
386            .buffer_unordered(concurrency.max(1));
387
388        // Flatten each resolved subtree into per-leaf `(offset, body)` pairs,
389        // draining one subtree's leaves before polling the next ready subtree.
390        struct State<S> {
391            subtrees: S,
392            base: u64,
393            leaf_index: usize,
394            pending: std::vec::IntoIter<Bytes>,
395        }
396
397        let state = State {
398            subtrees: Box::pin(subtrees),
399            base: 0,
400            leaf_index: 0,
401            pending: Vec::new().into_iter(),
402        };
403
404        stream::unfold(state, move |mut state| async move {
405            loop {
406                // Drain leaves already in hand, offsetting each leaf from its
407                // subtree base by a whole body per preceding leaf.
408                if let Some(body) = state.pending.next() {
409                    let offset = state.base + state.leaf_index as u64 * BODY_SIZE as u64;
410                    state.leaf_index += 1;
411                    return Some((Ok((offset, body)), state));
412                }
413
414                // Pull the next resolved subtree (out of order).
415                match state.subtrees.next().await? {
416                    Ok((base, bodies)) => {
417                        state.base = base;
418                        state.leaf_index = 0;
419                        state.pending = bodies.into_iter();
420                        // Loop back to emit this subtree's first leaf.
421                    }
422                    Err(e) => return Some((Err(e), state)),
423                }
424            }
425        })
426    }
427
428    /// Convert into a stream of `(byte_offset, leaf_body)` pairs fetched with
429    /// per-chunk bounded concurrency and yielded out of order as each leaf lands.
430    ///
431    /// Where [`into_offset_stream`](Self::into_offset_stream) keeps up to
432    /// `concurrency` *subtree* fetches in flight and walks each subtree as a
433    /// sequential descent, this walks the tree at *chunk* granularity: every
434    /// intermediate and leaf fetch competes for the same bounded in-flight pool,
435    /// so up to `concurrency` individual chunks are in flight regardless of how
436    /// few top-level subtrees the tree has. Effective leaf concurrency is
437    /// `min(concurrency, leaf_count)` even for a tree that fans into one wide
438    /// subtree, which the subtree-granular variant cannot reach.
439    ///
440    /// The walk is a bounded producer/worker model expressed without a spawned
441    /// task: a queue of pending tree nodes feeds a [`FuturesUnordered`] pool
442    /// refilled to the width each step. An intermediate node resolves to its
443    /// overlapping children (re-queued); a leaf resolves to its
444    /// `(byte_offset, body)` pair. A leaf fetch that fails is re-enqueued (up to
445    /// a bounded retry budget) and the worker pulls the next node, so a slow or
446    /// flaky chunk retries without holding its slot or gating ready leaves.
447    ///
448    /// Peak memory is bounded by the pool width plus the pending queue, not the
449    /// file size. Set the width with [`with_concurrency`](Self::with_concurrency).
450    /// Reassembling the pairs by offset reproduces the file, byte-for-byte equal
451    /// to [`read_all`](Self::read_all).
452    pub fn into_offset_stream_chunked(self) -> impl Stream<Item = Result<(u64, Bytes)>>
453    where
454        G: 'static,
455    {
456        let getter = self.getter;
457        let width = self.concurrency.max(1);
458        let chunk_range = self.tree.chunks_for_range(0, self.span);
459
460        // One unit of pending work: a tree node plus its remaining retry budget.
461        // The budget only decrements on a failed leaf fetch.
462        struct Pending<M: JoinMode> {
463            node: SubtreeNode<M>,
464            retries: u32,
465        }
466
467        // What a worker future resolves to once its chunk lands.
468        enum Resolved<M: JoinMode> {
469            /// A leaf: its absolute byte offset and decoded body.
470            Leaf(u64, Bytes),
471            /// An intermediate: its overlapping children to re-queue.
472            Children(Vec<SubtreeNode<M>>),
473            /// A leaf fetch failed with retries left: re-queue this node.
474            Retry(Pending<M>),
475            /// A fetch failed terminally (retries exhausted or intermediate error).
476            Failed(FileError),
477        }
478
479        #[cfg(not(target_arch = "wasm32"))]
480        type BoxResolvedFuture<M> =
481            std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>> + Send>>;
482        #[cfg(target_arch = "wasm32")]
483        type BoxResolvedFuture<M> =
484            std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>>>>;
485
486        // Fetch one node: a leaf yields its body, an intermediate yields its
487        // children. A leaf error consumes one retry, then re-queues or fails.
488        async fn fetch_one<G, M, const BS: usize>(
489            getter: &G,
490            chunk_range: &ChunkRange,
491            pending: Pending<M>,
492        ) -> Resolved<M>
493        where
494            G: ChunkGet<BS>,
495            M: JoinMode + MaybeSend + Sync,
496        {
497            let node = &pending.node;
498            let body = match super::mode::read_chunk_body::<M, G, BS>(
499                getter,
500                &node.addr,
501                &node.context,
502                node.span,
503            )
504            .await
505            {
506                Ok(body) => body,
507                Err(e) => {
508                    if node.span <= BS as u64 && pending.retries > 0 {
509                        return Resolved::Retry(Pending {
510                            node: pending.node,
511                            retries: pending.retries - 1,
512                        });
513                    }
514                    return Resolved::Failed(e);
515                }
516            };
517
518            if node.span <= BS as u64 {
519                return Resolved::Leaf(node.byte_offset, body);
520            }
521            match overlapping_children::<M, BS>(&body, node, chunk_range) {
522                Ok(children) => Resolved::Children(children),
523                Err(e) => Resolved::Failed(e),
524            }
525        }
526
527        // Intermediates and leaves run on separate budgets out of `width`: at
528        // most `MAX_INTERMEDIATE_IN_FLIGHT` intermediate fetches resolve at once
529        // (each exposes a chunk of leaf addresses), and the rest of the width
530        // fetches leaf data. Leaves are emitted out of order the moment they
531        // land, so no reorder buffer is needed here.
532        struct State<G, M: JoinMode, const BS: usize> {
533            getter: Arc<G>,
534            chunk_range: ChunkRange,
535            width: usize,
536            node_queue: std::collections::VecDeque<Pending<M>>,
537            leaf_queue: std::collections::VecDeque<Pending<M>>,
538            intermediate_in_flight: usize,
539            in_flight: FuturesUnordered<BoxResolvedFuture<M>>,
540        }
541
542        let mut node_queue = std::collections::VecDeque::new();
543        let mut leaf_queue = std::collections::VecDeque::new();
544        for st in self.subtrees {
545            let pending = Pending {
546                node: st,
547                retries: DEFAULT_LEAF_RETRIES,
548            };
549            if pending.node.span <= BODY_SIZE as u64 {
550                leaf_queue.push_back(pending);
551            } else {
552                node_queue.push_back(pending);
553            }
554        }
555
556        let state = State::<G, M, BODY_SIZE> {
557            getter,
558            chunk_range,
559            width,
560            node_queue,
561            leaf_queue,
562            intermediate_in_flight: 0,
563            in_flight: FuturesUnordered::new(),
564        };
565
566        stream::unfold(state, move |mut state| async move {
567            loop {
568                // Refill the in-flight pool. An intermediate is admitted up to
569                // the intermediate cap, reserving at least one slot for a ready
570                // leaf at tiny widths; otherwise the slot fetches leaf data. With
571                // no leaf ready, intermediates may use the slot to drive the
572                // descent. So at most `width` chunks fetch at once and at most
573                // `MAX_INTERMEDIATE_IN_FLIGHT` of them are intermediates.
574                while state.in_flight.len() < state.width {
575                    let leaf_ready = !state.leaf_queue.is_empty();
576                    let can_admit_intermediate = state.intermediate_in_flight
577                        < MAX_INTERMEDIATE_IN_FLIGHT
578                        && !state.node_queue.is_empty();
579                    let admit_intermediate = can_admit_intermediate
580                        && (!leaf_ready || state.intermediate_in_flight + 1 < state.width);
581
582                    let pending = if admit_intermediate {
583                        state.intermediate_in_flight += 1;
584                        state.node_queue.pop_front().expect("node queue non-empty")
585                    } else if let Some(leaf) = state.leaf_queue.pop_front() {
586                        leaf
587                    } else {
588                        break;
589                    };
590
591                    let getter = Arc::clone(&state.getter);
592                    let range = state.chunk_range;
593                    state.in_flight.push(Box::pin(async move {
594                        fetch_one::<G, M, BODY_SIZE>(&*getter, &range, pending).await
595                    }) as BoxResolvedFuture<M>);
596                }
597
598                // Nothing in flight and nothing queued: the tree is drained.
599                let resolved = state.in_flight.next().await?;
600                match resolved {
601                    Resolved::Leaf(offset, body) => {
602                        return Some((Ok((offset, body)), state));
603                    }
604                    Resolved::Children(children) => {
605                        state.intermediate_in_flight -= 1;
606                        for child in children {
607                            let pending = Pending {
608                                node: child,
609                                retries: DEFAULT_LEAF_RETRIES,
610                            };
611                            if pending.node.span <= BODY_SIZE as u64 {
612                                state.leaf_queue.push_back(pending);
613                            } else {
614                                state.node_queue.push_back(pending);
615                            }
616                        }
617                    }
618                    Resolved::Retry(pending) => {
619                        // Re-enqueue at the back so the worker pulls fresh work
620                        // first; the failed chunk retries without holding a slot.
621                        state.leaf_queue.push_back(pending);
622                    }
623                    Resolved::Failed(e) => return Some((Err(e), state)),
624                }
625            }
626        })
627    }
628
629    /// Like [`into_offset_stream_chunked`](Self::into_offset_stream_chunked) but
630    /// restricted to the byte range `[start, start + len)`.
631    ///
632    /// Only subtrees and intermediate children overlapping the range are walked,
633    /// and the first and last partial leaves are clipped so each emitted
634    /// `(offset, body)` lies inside the range. Offsets stay absolute in the file,
635    /// so reassembling the pairs over `[start, start + len)` reproduces
636    /// [`read_range`](Self::read_range) byte-for-byte. A whole-file range equals
637    /// [`into_offset_stream_chunked`](Self::into_offset_stream_chunked); an empty
638    /// or out-of-bounds range yields an empty stream.
639    pub fn into_offset_stream_chunked_range(
640        self,
641        start: u64,
642        len: u64,
643    ) -> impl Stream<Item = Result<(u64, Bytes)>>
644    where
645        G: 'static,
646    {
647        chunked_range_stream_from::<G, M, BODY_SIZE>(
648            self.getter,
649            self.subtrees,
650            self.tree,
651            self.span,
652            self.concurrency,
653            start,
654            len,
655        )
656    }
657
658    /// Convert into an `AsyncRead` reader.
659    #[cfg(feature = "tokio")]
660    pub fn into_reader(self) -> JoinerReader<G, M, BODY_SIZE> {
661        JoinerReader {
662            joiner: self,
663            buffer: Bytes::new(),
664            future: None,
665        }
666    }
667}
668
669/// Build the chunk-granular range stream from already-decomposed joiner parts.
670///
671/// The single home of the chunk-granular walk: both
672/// [`GenericJoiner::into_offset_stream_chunked_range`] (which moves its parts
673/// in) and consumers that retain reusable joiner state (which clone their parts
674/// in) drive the same implementation. Walks only the subtrees and intermediate
675/// children overlapping `[start, start + len)`, clips boundary leaves to the
676/// range, and keeps absolute offsets, so the returned stream is identical to the
677/// inherent method.
678pub(crate) fn chunked_range_stream_from<G, M, const BODY_SIZE: usize>(
679    getter: Arc<G>,
680    subtrees: Vec<SubtreeNode<M>>,
681    tree: TreeParams<BODY_SIZE>,
682    span: u64,
683    concurrency: usize,
684    start: u64,
685    len: u64,
686) -> impl Stream<Item = Result<(u64, Bytes)>> + 'static
687where
688    G: ChunkGet<BODY_SIZE> + 'static,
689    M: JoinMode + MaybeSend + Sync,
690{
691    let width = concurrency.max(1);
692
693    // Clamp the requested window to the file and derive the chunk range that
694    // seeds the queue and prunes intermediate children, exactly as the full
695    // walk uses the whole-file chunk range.
696    let range_start = start.min(span);
697    let range_end = (start.saturating_add(len)).min(span);
698    let chunk_range = tree.chunks_for_range(range_start, range_end - range_start);
699
700    // One unit of pending work: a tree node plus its remaining retry budget.
701    // The budget only decrements on a failed leaf fetch.
702    struct Pending<M: JoinMode> {
703        node: SubtreeNode<M>,
704        retries: u32,
705    }
706
707    // What a worker future resolves to once its chunk lands.
708    enum Resolved<M: JoinMode> {
709        /// A leaf: its absolute byte offset and decoded body.
710        Leaf(u64, Bytes),
711        /// An intermediate: its overlapping children to re-queue.
712        Children(Vec<SubtreeNode<M>>),
713        /// A leaf fetch failed with retries left: re-queue this node.
714        Retry(Pending<M>),
715        /// A fetch failed terminally (retries exhausted or intermediate error).
716        Failed(FileError),
717    }
718
719    #[cfg(not(target_arch = "wasm32"))]
720    type BoxResolvedFuture<M> =
721        std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>> + Send>>;
722    #[cfg(target_arch = "wasm32")]
723    type BoxResolvedFuture<M> = std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>>>>;
724
725    // Fetch one node: a leaf yields its body, an intermediate yields its
726    // overlapping children. A leaf error consumes one retry, then re-queues
727    // or fails.
728    async fn fetch_one<G, M, const BS: usize>(
729        getter: &G,
730        chunk_range: &ChunkRange,
731        pending: Pending<M>,
732    ) -> Resolved<M>
733    where
734        G: ChunkGet<BS>,
735        M: JoinMode + MaybeSend + Sync,
736    {
737        let node = &pending.node;
738        let body = match super::mode::read_chunk_body::<M, G, BS>(
739            getter,
740            &node.addr,
741            &node.context,
742            node.span,
743        )
744        .await
745        {
746            Ok(body) => body,
747            Err(e) => {
748                if node.span <= BS as u64 && pending.retries > 0 {
749                    return Resolved::Retry(Pending {
750                        node: pending.node,
751                        retries: pending.retries - 1,
752                    });
753                }
754                return Resolved::Failed(e);
755            }
756        };
757
758        if node.span <= BS as u64 {
759            return Resolved::Leaf(node.byte_offset, body);
760        }
761        match overlapping_children::<M, BS>(&body, node, chunk_range) {
762            Ok(children) => Resolved::Children(children),
763            Err(e) => Resolved::Failed(e),
764        }
765    }
766
767    // Intermediates and leaves run on separate budgets out of `width`, exactly
768    // as the whole-file walk: at most `MAX_INTERMEDIATE_IN_FLIGHT` intermediate
769    // fetches resolve at once and the rest of the width fetches leaf data, so a
770    // wide frontier no longer front-loads ahead of the first in-range leaf.
771    struct State<G, M: JoinMode> {
772        getter: Arc<G>,
773        chunk_range: ChunkRange,
774        range_start: u64,
775        range_end: u64,
776        width: usize,
777        node_queue: std::collections::VecDeque<Pending<M>>,
778        leaf_queue: std::collections::VecDeque<Pending<M>>,
779        intermediate_in_flight: usize,
780        in_flight: FuturesUnordered<BoxResolvedFuture<M>>,
781    }
782
783    // Seed the queues with only the subtrees overlapping the range, splitting
784    // leaves from intermediates; an empty range leaves both empty and the
785    // stream finishes at once.
786    let mut node_queue = std::collections::VecDeque::new();
787    let mut leaf_queue = std::collections::VecDeque::new();
788    if range_end > range_start {
789        let range_start_byte = chunk_range.start * BODY_SIZE as u64;
790        let range_end_byte = chunk_range.end * BODY_SIZE as u64;
791        for st in subtrees {
792            if st.byte_offset < range_end_byte && st.byte_offset + st.span > range_start_byte {
793                let pending = Pending {
794                    node: st,
795                    retries: DEFAULT_LEAF_RETRIES,
796                };
797                if pending.node.span <= BODY_SIZE as u64 {
798                    leaf_queue.push_back(pending);
799                } else {
800                    node_queue.push_back(pending);
801                }
802            }
803        }
804    }
805
806    let state = State::<G, M> {
807        getter,
808        chunk_range,
809        range_start,
810        range_end,
811        width,
812        node_queue,
813        leaf_queue,
814        intermediate_in_flight: 0,
815        in_flight: FuturesUnordered::new(),
816    };
817
818    stream::unfold(state, move |mut state| async move {
819        loop {
820            // Refill the in-flight pool: intermediates up to the cap (reserving
821            // a leaf slot at tiny widths), the rest of the width for leaf data.
822            while state.in_flight.len() < state.width {
823                let leaf_ready = !state.leaf_queue.is_empty();
824                let can_admit_intermediate = state.intermediate_in_flight
825                    < MAX_INTERMEDIATE_IN_FLIGHT
826                    && !state.node_queue.is_empty();
827                let admit_intermediate = can_admit_intermediate
828                    && (!leaf_ready || state.intermediate_in_flight + 1 < state.width);
829
830                let pending = if admit_intermediate {
831                    state.intermediate_in_flight += 1;
832                    state.node_queue.pop_front().expect("node queue non-empty")
833                } else if let Some(leaf) = state.leaf_queue.pop_front() {
834                    leaf
835                } else {
836                    break;
837                };
838
839                let getter = Arc::clone(&state.getter);
840                let range = state.chunk_range;
841                state.in_flight.push(Box::pin(async move {
842                    fetch_one::<G, M, BODY_SIZE>(&*getter, &range, pending).await
843                }) as BoxResolvedFuture<M>);
844            }
845
846            // Nothing in flight and nothing queued: the range is drained.
847            let resolved = state.in_flight.next().await?;
848            match resolved {
849                Resolved::Leaf(leaf_start, body) => {
850                    // Clip the leaf to the range. Offsets stay absolute, so a
851                    // boundary leaf emits only its in-range slice, and the
852                    // emitted offset is the later of the leaf start and the
853                    // range start.
854                    let leaf_end = leaf_start + body.len() as u64;
855                    if leaf_end <= state.range_start || leaf_start >= state.range_end {
856                        continue;
857                    }
858                    let clip_lo = state.range_start.saturating_sub(leaf_start) as usize;
859                    let clip_hi = (state.range_end - leaf_start).min(body.len() as u64) as usize;
860                    let offset = leaf_start.max(state.range_start);
861                    return Some((Ok((offset, body.slice(clip_lo..clip_hi))), state));
862                }
863                Resolved::Children(children) => {
864                    state.intermediate_in_flight -= 1;
865                    for child in children {
866                        let pending = Pending {
867                            node: child,
868                            retries: DEFAULT_LEAF_RETRIES,
869                        };
870                        if pending.node.span <= BODY_SIZE as u64 {
871                            state.leaf_queue.push_back(pending);
872                        } else {
873                            state.node_queue.push_back(pending);
874                        }
875                    }
876                }
877                Resolved::Retry(pending) => {
878                    // Re-enqueue at the back so the worker pulls fresh work
879                    // first; the failed chunk retries without holding a slot.
880                    state.leaf_queue.push_back(pending);
881                }
882                Resolved::Failed(e) => return Some((Err(e), state)),
883            }
884        }
885    })
886}
887
888/// Wrapper providing `tokio::io::AsyncRead` over a [`GenericJoiner`].
889///
890/// Created via [`GenericJoiner::into_reader`].
891#[cfg(feature = "tokio")]
892pub struct JoinerReader<G, M: JoinMode, const BODY_SIZE: usize = DEFAULT_BODY_SIZE>
893where
894    G: ChunkGet<BODY_SIZE>,
895{
896    joiner: GenericJoiner<G, M, BODY_SIZE>,
897    buffer: Bytes,
898    #[allow(clippy::type_complexity)]
899    future: Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>>> + Send>>>,
900}
901
902#[cfg(feature = "tokio")]
903impl<G, M, const BODY_SIZE: usize> std::fmt::Debug for JoinerReader<G, M, BODY_SIZE>
904where
905    G: ChunkGet<BODY_SIZE>,
906    M: JoinMode,
907{
908    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
909        f.debug_struct("JoinerReader")
910            .field("joiner", &self.joiner)
911            .field("buffer_len", &self.buffer.len())
912            .field("has_pending_future", &self.future.is_some())
913            .finish()
914    }
915}
916
917// Safety: JoinerReader contains no self-referential data.
918// The boxed future is heap-allocated and all other fields are plain data.
919#[cfg(feature = "tokio")]
920impl<G: ChunkGet<BODY_SIZE>, M: JoinMode, const BODY_SIZE: usize> Unpin
921    for JoinerReader<G, M, BODY_SIZE>
922{
923}
924
925#[cfg(feature = "tokio")]
926impl<G, M, const BODY_SIZE: usize> tokio::io::AsyncRead for JoinerReader<G, M, BODY_SIZE>
927where
928    G: ChunkGet<BODY_SIZE> + 'static,
929    M: JoinMode + Send + Sync + 'static,
930{
931    fn poll_read(
932        self: std::pin::Pin<&mut Self>,
933        cx: &mut std::task::Context<'_>,
934        buf: &mut tokio::io::ReadBuf<'_>,
935    ) -> std::task::Poll<std::io::Result<()>> {
936        use std::task::Poll;
937
938        let this = self.get_mut();
939
940        // Drain any leftover buffer first
941        if !this.buffer.is_empty() {
942            let to_copy = this.buffer.len().min(buf.remaining());
943            buf.put_slice(&this.buffer[..to_copy]);
944            this.buffer.advance(to_copy);
945            return Poll::Ready(Ok(()));
946        }
947
948        // EOF check
949        if this.joiner.position >= this.joiner.span {
950            return Poll::Ready(Ok(()));
951        }
952
953        // Create a future for the next read if we don't have one
954        if this.future.is_none() {
955            let position = this.joiner.position;
956            let remaining = (this.joiner.span - position) as usize;
957            let read_len = remaining.min(BODY_SIZE);
958            let getter = Arc::clone(&this.joiner.getter);
959            let root = this.joiner.root;
960            let context = this.joiner.context.clone();
961            let span = this.joiner.span;
962            let tree = this.joiner.tree;
963            let concurrency = this.joiner.concurrency;
964            let subtrees: Vec<SubtreeNode<M>> = this.joiner.subtrees.clone();
965
966            let fut = async move {
967                GenericJoiner::<G, M, BODY_SIZE>::read_range_with(
968                    &getter,
969                    &subtrees,
970                    &root,
971                    &context,
972                    span,
973                    tree,
974                    concurrency,
975                    position,
976                    read_len,
977                )
978                .await
979            };
980            this.future = Some(Box::pin(fut));
981        }
982
983        // Poll the future
984        let fut = this.future.as_mut().unwrap();
985        match fut.as_mut().poll(cx) {
986            Poll::Ready(Ok(data)) => {
987                this.future = None;
988                let bytes = Bytes::from(data);
989                this.joiner.position += bytes.len() as u64;
990                let to_copy = bytes.len().min(buf.remaining());
991                buf.put_slice(&bytes[..to_copy]);
992                if to_copy < bytes.len() {
993                    this.buffer = bytes.slice(to_copy..);
994                }
995                Poll::Ready(Ok(()))
996            }
997            Poll::Ready(Err(e)) => {
998                this.future = None;
999                Poll::Ready(Err(std::io::Error::other(e)))
1000            }
1001            Poll::Pending => Poll::Pending,
1002        }
1003    }
1004}
1005
1006#[cfg(feature = "tokio")]
1007impl<G, M, const BODY_SIZE: usize> tokio::io::AsyncSeek for JoinerReader<G, M, BODY_SIZE>
1008where
1009    G: ChunkGet<BODY_SIZE> + 'static,
1010    M: JoinMode + Send + Sync + 'static,
1011{
1012    fn start_seek(self: std::pin::Pin<&mut Self>, pos: SeekFrom) -> std::io::Result<()> {
1013        let this = self.get_mut();
1014        this.joiner.position =
1015            super::resolve_seek_position(pos, this.joiner.position, this.joiner.span)?;
1016        this.buffer = Bytes::new();
1017        this.future = None;
1018        Ok(())
1019    }
1020
1021    fn poll_complete(
1022        self: std::pin::Pin<&mut Self>,
1023        _cx: &mut std::task::Context<'_>,
1024    ) -> std::task::Poll<std::io::Result<u64>> {
1025        std::task::Poll::Ready(Ok(self.get_mut().joiner.position))
1026    }
1027}
1028
1029#[cfg(all(test, feature = "tokio"))]
1030mod tests {
1031    use super::*;
1032    use crate::chunk::AnyChunk;
1033    use crate::file::split;
1034    use std::collections::HashMap;
1035
1036    fn split_and_store(data: &[u8]) -> (ChunkAddress, HashMap<ChunkAddress, AnyChunk>) {
1037        let (root, store) = split::<DEFAULT_BODY_SIZE>(data).unwrap();
1038        (root, store.into_chunks())
1039    }
1040
1041    // --- Generated shared tests (async variants) ---
1042    generate_plain_joiner_tests!(tokio::test, Joiner, [async], [await]);
1043
1044    // --- Lazy streaming open (`open_streaming`) ---
1045
1046    use std::sync::atomic::{AtomicUsize, Ordering};
1047
1048    /// A store that counts `get` calls and can park one chosen address until a
1049    /// gate is released, so a test can prove the lazy open neither pre-expands
1050    /// the frontier nor lets a slow intermediate stall other subtrees.
1051    #[derive(Clone)]
1052    struct ProbeStore {
1053        inner: Arc<HashMap<ChunkAddress, AnyChunk>>,
1054        gets: Arc<AtomicUsize>,
1055    }
1056
1057    impl ChunkGet<DEFAULT_BODY_SIZE> for ProbeStore {
1058        type Error = crate::store::ChunkStoreError;
1059        async fn get(&self, address: &ChunkAddress) -> std::result::Result<AnyChunk, Self::Error> {
1060            self.gets.fetch_add(1, Ordering::SeqCst);
1061            self.inner
1062                .get(address)
1063                .cloned()
1064                .ok_or_else(|| crate::store::ChunkStoreError::not_found(address))
1065        }
1066    }
1067
1068    async fn drain_chunked_to_buf<G>(joiner: GenericJoiner<G, PlainMode>, total: usize) -> Vec<u8>
1069    where
1070        G: ChunkGet<DEFAULT_BODY_SIZE> + 'static,
1071    {
1072        let stream = joiner.into_offset_stream_chunked();
1073        futures::pin_mut!(stream);
1074        let mut buf = vec![0u8; total];
1075        while let Some(item) = stream.next().await {
1076            let (offset, body) = item.unwrap();
1077            let start = offset as usize;
1078            buf[start..start + body.len()].copy_from_slice(&body);
1079        }
1080        buf
1081    }
1082
1083    /// The lazy open seeds the chunked stream from the root and reproduces a
1084    /// multi-level file byte-for-byte.
1085    #[tokio::test]
1086    async fn streaming_open_assembles_byte_exact() {
1087        // ~600 leaves -> root over an intermediate level, so the lazy seed must
1088        // descend intermediates rather than emit a single leaf.
1089        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 600 + 123)
1090            .map(|i| (i % 256) as u8)
1091            .collect();
1092        let (root, store) = split_and_store(&data);
1093
1094        let joiner = Joiner::open_streaming(store, root).await.unwrap();
1095        let total = joiner.size() as usize;
1096        let got = drain_chunked_to_buf(joiner, total).await;
1097        assert_eq!(got, data);
1098    }
1099
1100    /// The lazy open fetches only the root before returning: no level-synchronous
1101    /// frontier expansion, so no intermediate can stall the open. The eager
1102    /// [`new`](Joiner::new) over the same tree fetches strictly more.
1103    #[tokio::test]
1104    async fn streaming_open_fetches_only_the_root() {
1105        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 600)
1106            .map(|i| (i % 256) as u8)
1107            .collect();
1108        let (root, store) = split_and_store(&data);
1109        let store = Arc::new(store);
1110
1111        let lazy_gets = Arc::new(AtomicUsize::new(0));
1112        let lazy = ProbeStore {
1113            inner: Arc::clone(&store),
1114            gets: Arc::clone(&lazy_gets),
1115        };
1116        let _joiner = Joiner::open_streaming(lazy, root).await.unwrap();
1117        assert_eq!(
1118            lazy_gets.load(Ordering::SeqCst),
1119            1,
1120            "lazy open fetches only the root"
1121        );
1122
1123        let eager_gets = Arc::new(AtomicUsize::new(0));
1124        let eager = ProbeStore {
1125            inner: store,
1126            gets: Arc::clone(&eager_gets),
1127        };
1128        let _joiner = Joiner::new(eager, root).await.unwrap();
1129        assert!(
1130            eager_gets.load(Ordering::SeqCst) > 1,
1131            "eager open pre-expands the frontier (root + intermediates)"
1132        );
1133    }
1134
1135    /// A lazily-opened stream reassembles byte-exact even when every fetch is
1136    /// delayed, proving the descent is correct under latency and reordering.
1137    #[tokio::test]
1138    async fn streaming_open_is_byte_exact_under_latency() {
1139        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 300 + 7)
1140            .map(|i| (i % 256) as u8)
1141            .collect();
1142        let (root, store) = split_and_store(&data);
1143        let joiner = Joiner::open_streaming(store, root)
1144            .await
1145            .unwrap()
1146            .with_concurrency(4);
1147        let total = joiner.size() as usize;
1148        let got = drain_chunked_to_buf(joiner, total).await;
1149        assert_eq!(got, data);
1150    }
1151
1152    // --- Async-only tests: Stream, AsyncRead, AsyncSeek ---
1153
1154    #[tokio::test]
1155    async fn test_joiner_stream() {
1156        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3)
1157            .map(|i| (i % 256) as u8)
1158            .collect();
1159        let (root, store) = split_and_store(&data);
1160
1161        let joiner = Joiner::new(store, root).await.unwrap();
1162        let chunks: Vec<Result<Bytes>> = joiner.into_stream().collect().await;
1163
1164        let mut recovered = Vec::new();
1165        for chunk in chunks {
1166            recovered.extend_from_slice(&chunk.unwrap());
1167        }
1168        assert_eq!(recovered, data);
1169    }
1170
1171    /// Drain `into_offset_stream` into an offset-keyed map, asserting every leaf
1172    /// arrives exactly once, then reassemble by offset and compare to `read_all`.
1173    async fn assert_offset_stream_matches(data: &[u8]) {
1174        let (root, store) = split_and_store(data);
1175
1176        let expected = Joiner::new(store.clone(), root)
1177            .await
1178            .unwrap()
1179            .read_all()
1180            .await
1181            .unwrap();
1182
1183        let joiner = Joiner::new(store, root).await.unwrap();
1184        let total = joiner.size();
1185        let pairs: Vec<Result<(u64, Bytes)>> = joiner.into_offset_stream().collect().await;
1186
1187        let mut reassembled = vec![0u8; total as usize];
1188        let mut covered = 0u64;
1189        let mut seen_offsets = std::collections::HashSet::new();
1190        for pair in pairs {
1191            let (offset, body) = pair.unwrap();
1192            assert!(
1193                seen_offsets.insert(offset),
1194                "offset {offset} yielded more than once"
1195            );
1196            let start = offset as usize;
1197            let end = start + body.len();
1198            reassembled[start..end].copy_from_slice(&body);
1199            covered += body.len() as u64;
1200        }
1201
1202        assert_eq!(covered, total, "every byte covered exactly once");
1203        assert_eq!(reassembled, expected, "offset reassembly equals read_all");
1204        assert_eq!(reassembled, data, "offset reassembly equals input");
1205    }
1206
1207    #[tokio::test]
1208    async fn test_offset_stream_small() {
1209        assert_offset_stream_matches(b"hello world").await;
1210    }
1211
1212    #[tokio::test]
1213    async fn test_offset_stream_exact_chunk() {
1214        assert_offset_stream_matches(&vec![0xAB; DEFAULT_BODY_SIZE]).await;
1215    }
1216
1217    #[tokio::test]
1218    async fn test_offset_stream_multi_chunk() {
1219        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 123)
1220            .map(|i| (i % 256) as u8)
1221            .collect();
1222        assert_offset_stream_matches(&data).await;
1223    }
1224
1225    #[tokio::test]
1226    async fn test_offset_stream_129_chunks() {
1227        let refs_per_chunk = DEFAULT_BODY_SIZE / super::super::constants::REF_SIZE;
1228        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * (refs_per_chunk + 1))
1229            .map(|i| (i % 256) as u8)
1230            .collect();
1231        assert_offset_stream_matches(&data).await;
1232    }
1233
1234    #[tokio::test]
1235    async fn test_offset_stream_concurrency_one() {
1236        // Width 1 still yields every leaf with the right offset (degenerate
1237        // concurrent path), so the reassembly invariant holds independent of fan.
1238        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 7)
1239            .map(|i| (i % 256) as u8)
1240            .collect();
1241        let (root, store) = split_and_store(&data);
1242        let joiner = Joiner::new(store, root).await.unwrap().with_concurrency(1);
1243        let total = joiner.size();
1244        let mut reassembled = vec![0u8; total as usize];
1245        let stream = joiner.into_offset_stream();
1246        futures::pin_mut!(stream);
1247        while let Some(pair) = stream.next().await {
1248            let (offset, body) = pair.unwrap();
1249            let start = offset as usize;
1250            reassembled[start..start + body.len()].copy_from_slice(&body);
1251        }
1252        assert_eq!(reassembled, data);
1253    }
1254
1255    /// Drain `into_offset_stream_chunked` into an offset-keyed buffer, asserting
1256    /// every leaf arrives exactly once, then reassemble and compare to `read_all`.
1257    async fn assert_offset_stream_chunked_matches(data: &[u8]) {
1258        let (root, store) = split_and_store(data);
1259
1260        let expected = Joiner::new(store.clone(), root)
1261            .await
1262            .unwrap()
1263            .read_all()
1264            .await
1265            .unwrap();
1266
1267        let joiner = Joiner::new(store, root).await.unwrap();
1268        let total = joiner.size();
1269        let pairs: Vec<Result<(u64, Bytes)>> = joiner.into_offset_stream_chunked().collect().await;
1270
1271        let mut reassembled = vec![0u8; total as usize];
1272        let mut covered = 0u64;
1273        let mut seen_offsets = std::collections::HashSet::new();
1274        for pair in pairs {
1275            let (offset, body) = pair.unwrap();
1276            assert!(
1277                seen_offsets.insert(offset),
1278                "offset {offset} yielded more than once"
1279            );
1280            let start = offset as usize;
1281            let end = start + body.len();
1282            reassembled[start..end].copy_from_slice(&body);
1283            covered += body.len() as u64;
1284        }
1285
1286        assert_eq!(covered, total, "every byte covered exactly once");
1287        assert_eq!(reassembled, expected, "chunked reassembly equals read_all");
1288        assert_eq!(reassembled, data, "chunked reassembly equals input");
1289    }
1290
1291    #[tokio::test]
1292    async fn test_offset_stream_chunked_small() {
1293        assert_offset_stream_chunked_matches(b"hello world").await;
1294    }
1295
1296    #[tokio::test]
1297    async fn test_offset_stream_chunked_exact_chunk() {
1298        assert_offset_stream_chunked_matches(&vec![0xAB; DEFAULT_BODY_SIZE]).await;
1299    }
1300
1301    #[tokio::test]
1302    async fn test_offset_stream_chunked_multi_chunk() {
1303        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 123)
1304            .map(|i| (i % 256) as u8)
1305            .collect();
1306        assert_offset_stream_chunked_matches(&data).await;
1307    }
1308
1309    #[tokio::test]
1310    async fn test_offset_stream_chunked_three_level_tree() {
1311        // 129 leaves needs a three-level tree, exercising intermediate-node
1312        // re-queueing in the chunk-granular walk.
1313        let refs_per_chunk = DEFAULT_BODY_SIZE / super::super::constants::REF_SIZE;
1314        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * (refs_per_chunk + 1))
1315            .map(|i| (i % 256) as u8)
1316            .collect();
1317        assert_offset_stream_chunked_matches(&data).await;
1318    }
1319
1320    #[tokio::test]
1321    async fn test_offset_stream_chunked_concurrency_one() {
1322        // Width 1 still yields every leaf with the right offset.
1323        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 7)
1324            .map(|i| (i % 256) as u8)
1325            .collect();
1326        let (root, store) = split_and_store(&data);
1327        let joiner = Joiner::new(store, root).await.unwrap().with_concurrency(1);
1328        let total = joiner.size();
1329        let mut reassembled = vec![0u8; total as usize];
1330        let stream = joiner.into_offset_stream_chunked();
1331        futures::pin_mut!(stream);
1332        while let Some(pair) = stream.next().await {
1333            let (offset, body) = pair.unwrap();
1334            let start = offset as usize;
1335            reassembled[start..start + body.len()].copy_from_slice(&body);
1336        }
1337        assert_eq!(reassembled, data);
1338    }
1339
1340    /// A getter that holds each fetch open across several executor yields so the
1341    /// test can observe how many fetches the consumer admits at once, proving the
1342    /// chunk-granular stream reaches per-chunk (not per-subtree) concurrency.
1343    #[derive(Clone)]
1344    struct ConcurrencyProbe {
1345        chunks: Arc<HashMap<ChunkAddress, AnyChunk>>,
1346        in_flight: Arc<std::sync::atomic::AtomicUsize>,
1347        max_in_flight: Arc<std::sync::atomic::AtomicUsize>,
1348    }
1349
1350    impl crate::store::ChunkGet<DEFAULT_BODY_SIZE> for ConcurrencyProbe {
1351        type Error = crate::store::ChunkStoreError;
1352
1353        async fn get(&self, address: &ChunkAddress) -> std::result::Result<AnyChunk, Self::Error> {
1354            use std::sync::atomic::Ordering;
1355            let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1356            self.max_in_flight.fetch_max(now, Ordering::SeqCst);
1357            // Yield several times so concurrently-admitted fetches overlap here
1358            // before any resolves; the peak counter then reflects pool width.
1359            for _ in 0..8 {
1360                tokio::task::yield_now().await;
1361            }
1362            self.in_flight.fetch_sub(1, Ordering::SeqCst);
1363            self.chunks
1364                .get(address)
1365                .cloned()
1366                .ok_or_else(|| crate::store::ChunkStoreError::not_found(address))
1367        }
1368    }
1369
1370    #[tokio::test(flavor = "current_thread")]
1371    async fn test_offset_stream_chunked_per_chunk_concurrency() {
1372        // A flat two-level tree: one intermediate over many leaves. The
1373        // subtree-granular stream would walk it as a single sequential descent
1374        // (in-flight = 1 leaf at a time); the chunk-granular stream fans the
1375        // leaves across the width.
1376        let leaves = 40usize;
1377        let width = 16usize;
1378        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * leaves)
1379            .map(|i| (i % 256) as u8)
1380            .collect();
1381        let (root, store) = split_and_store(&data);
1382
1383        let probe = ConcurrencyProbe {
1384            chunks: Arc::new(store),
1385            in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1386            max_in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1387        };
1388        let max_seen = Arc::clone(&probe.max_in_flight);
1389
1390        let joiner = Joiner::new(probe, root)
1391            .await
1392            .unwrap()
1393            .with_concurrency(width);
1394
1395        let total = joiner.size();
1396        let mut reassembled = vec![0u8; total as usize];
1397        let stream = joiner.into_offset_stream_chunked();
1398        futures::pin_mut!(stream);
1399        while let Some(pair) = stream.next().await {
1400            let (offset, body) = pair.unwrap();
1401            let start = offset as usize;
1402            reassembled[start..start + body.len()].copy_from_slice(&body);
1403        }
1404        assert_eq!(reassembled, data, "concurrency probe still reassembles");
1405
1406        let peak = max_seen.load(std::sync::atomic::Ordering::SeqCst);
1407        assert!(
1408            peak >= width,
1409            "chunk-granular stream should reach width {width} in-flight, saw {peak}"
1410        );
1411    }
1412
1413    // --- Front-load / intermediate-cap guards (small body size = deep tree) ---
1414
1415    /// Branching for a 256-byte body is `256 / REF_SIZE` = 8, so a few hundred
1416    /// leaves build a wide intermediate frontier with little data. This keeps the
1417    /// front-load guards cheap while still exercising a frontier far larger than
1418    /// the intermediate cap.
1419    const TINY_BODY: usize = 256;
1420
1421    /// Content addresses of every data leaf for `data` under `TINY_BODY`, so a
1422    /// probe getter can tell a leaf fetch from an intermediate fetch.
1423    fn tiny_leaf_addresses(data: &[u8]) -> std::collections::HashSet<ChunkAddress> {
1424        use crate::chunk::Chunk;
1425        let mut set = std::collections::HashSet::new();
1426        for block in data.chunks(TINY_BODY) {
1427            let chunk = crate::chunk::ContentChunk::<TINY_BODY>::new(block.to_vec()).unwrap();
1428            set.insert(*chunk.address());
1429        }
1430        set
1431    }
1432
1433    /// A probe getter that records the leaf/intermediate kind of every fetch in
1434    /// start order, tracks peak concurrent intermediate fetches, and can park one
1435    /// chosen intermediate until the consumer has delivered `slow_gate` leaves.
1436    #[derive(Clone)]
1437    struct OrderProbe {
1438        chunks: Arc<HashMap<ChunkAddress, AnyChunk<TINY_BODY>>>,
1439        leaves: Arc<std::collections::HashSet<ChunkAddress>>,
1440        /// One entry per fetch in start order: `true` for a leaf fetch.
1441        kinds: Arc<std::sync::Mutex<Vec<bool>>>,
1442        intermediate_in_flight: Arc<std::sync::atomic::AtomicUsize>,
1443        peak_intermediate_in_flight: Arc<std::sync::atomic::AtomicUsize>,
1444        delivered_leaves: Arc<std::sync::atomic::AtomicUsize>,
1445        slow_addr: Option<ChunkAddress>,
1446        slow_gate: usize,
1447    }
1448
1449    impl OrderProbe {
1450        fn new(store: HashMap<ChunkAddress, AnyChunk<TINY_BODY>>, data: &[u8]) -> Self {
1451            Self {
1452                chunks: Arc::new(store),
1453                leaves: Arc::new(tiny_leaf_addresses(data)),
1454                kinds: Arc::new(std::sync::Mutex::new(Vec::new())),
1455                intermediate_in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1456                peak_intermediate_in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1457                delivered_leaves: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1458                slow_addr: None,
1459                slow_gate: 0,
1460            }
1461        }
1462
1463        /// Drop the recorded order and the peak counter, e.g. after construction
1464        /// so only the streaming walk's fetches are measured.
1465        fn reset(&self) {
1466            use std::sync::atomic::Ordering;
1467            self.kinds.lock().unwrap().clear();
1468            self.peak_intermediate_in_flight.store(0, Ordering::SeqCst);
1469        }
1470
1471        /// Number of intermediate fetches before the first leaf fetch in the
1472        /// recorded order.
1473        fn intermediates_before_first_leaf(&self) -> usize {
1474            let kinds = self.kinds.lock().unwrap();
1475            kinds.iter().take_while(|is_leaf| !**is_leaf).count()
1476        }
1477
1478        fn intermediate_fetches(&self) -> usize {
1479            self.kinds.lock().unwrap().iter().filter(|l| !**l).count()
1480        }
1481    }
1482
1483    impl crate::store::ChunkGet<TINY_BODY> for OrderProbe {
1484        type Error = crate::store::ChunkStoreError;
1485
1486        async fn get(
1487            &self,
1488            address: &ChunkAddress,
1489        ) -> std::result::Result<AnyChunk<TINY_BODY>, Self::Error> {
1490            use std::sync::atomic::Ordering;
1491            let is_leaf = self.leaves.contains(address);
1492            self.kinds.lock().unwrap().push(is_leaf);
1493            if !is_leaf {
1494                let now = self.intermediate_in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1495                self.peak_intermediate_in_flight
1496                    .fetch_max(now, Ordering::SeqCst);
1497            }
1498            if self.slow_addr == Some(*address) {
1499                while self.delivered_leaves.load(Ordering::SeqCst) < self.slow_gate {
1500                    tokio::task::yield_now().await;
1501                }
1502            }
1503            for _ in 0..4 {
1504                tokio::task::yield_now().await;
1505            }
1506            if !is_leaf {
1507                self.intermediate_in_flight.fetch_sub(1, Ordering::SeqCst);
1508            }
1509            self.chunks
1510                .get(address)
1511                .cloned()
1512                .ok_or_else(|| crate::store::ChunkStoreError::not_found(address))
1513        }
1514    }
1515
1516    fn tiny_deep_data(leaves: usize) -> Vec<u8> {
1517        (0..TINY_BODY * leaves).map(|i| (i % 251) as u8).collect()
1518    }
1519
1520    /// The regression guard: on a wide frontier the first data leaf must be
1521    /// fetched after only a short descent (a few intermediates), never after the
1522    /// whole frontier is drained. The breadth-first walk fetched every
1523    /// intermediate first, so the first leaf landed ~frontier-size fetches in.
1524    #[tokio::test(flavor = "current_thread")]
1525    async fn test_offset_stream_chunked_first_leaf_before_frontier() {
1526        let data = tiny_deep_data(900);
1527        let (root, store) = split::<TINY_BODY>(&data).unwrap();
1528        let probe = OrderProbe::new(store.into_chunks(), &data);
1529
1530        let joiner = Joiner::<_, TINY_BODY>::new(probe.clone(), root)
1531            .await
1532            .unwrap();
1533        // Measure only the streaming walk, not the upfront frontier expansion.
1534        probe.reset();
1535
1536        let total = joiner.size();
1537        let mut reassembled = vec![0u8; total as usize];
1538        let stream = joiner.into_offset_stream_chunked();
1539        futures::pin_mut!(stream);
1540        while let Some(pair) = stream.next().await {
1541            let (offset, body) = pair.unwrap();
1542            reassembled[offset as usize..offset as usize + body.len()].copy_from_slice(&body);
1543        }
1544        assert_eq!(reassembled, data, "deep-tree reassembly is byte-exact");
1545
1546        let frontier = probe.intermediate_fetches();
1547        assert!(
1548            frontier >= 40,
1549            "test needs a frontier far larger than the cap, saw {frontier}"
1550        );
1551        let before = probe.intermediates_before_first_leaf();
1552        assert!(
1553            before <= 4 * MAX_INTERMEDIATE_IN_FLIGHT,
1554            "first leaf fetched after {before} intermediates (frontier {frontier}); \
1555             expected a short descent, not the whole frontier"
1556        );
1557    }
1558
1559    /// Intermediate fetches in flight never exceed the cap.
1560    #[tokio::test(flavor = "current_thread")]
1561    async fn test_offset_stream_chunked_intermediate_cap() {
1562        let data = tiny_deep_data(900);
1563        let (root, store) = split::<TINY_BODY>(&data).unwrap();
1564        let probe = OrderProbe::new(store.into_chunks(), &data);
1565
1566        let joiner = Joiner::<_, TINY_BODY>::new(probe.clone(), root)
1567            .await
1568            .unwrap()
1569            .with_concurrency(16);
1570        probe.reset();
1571
1572        let stream = joiner.into_offset_stream_chunked();
1573        futures::pin_mut!(stream);
1574        while let Some(pair) = stream.next().await {
1575            pair.unwrap();
1576        }
1577
1578        let peak = probe
1579            .peak_intermediate_in_flight
1580            .load(std::sync::atomic::Ordering::SeqCst);
1581        assert!(
1582            peak <= MAX_INTERMEDIATE_IN_FLIGHT,
1583            "intermediate in-flight peak {peak} exceeds cap {MAX_INTERMEDIATE_IN_FLIGHT}"
1584        );
1585    }
1586
1587    /// A single slow intermediate must not stall the rest of the stream: leaves
1588    /// from other subtrees keep flowing while it is parked. The slow node parks
1589    /// until the consumer has delivered a batch of leaves, so completion proves
1590    /// those leaves were delivered without it.
1591    #[tokio::test(flavor = "current_thread")]
1592    async fn test_offset_stream_chunked_slow_intermediate_does_not_stall() {
1593        let data = tiny_deep_data(900);
1594        let (root, store) = split::<TINY_BODY>(&data).unwrap();
1595        let store = store.into_chunks();
1596
1597        // Pick an intermediate address (any non-leaf chunk) to slow down.
1598        let leaves = tiny_leaf_addresses(&data);
1599        let slow = *store
1600            .keys()
1601            .find(|a| !leaves.contains(*a) && **a != root)
1602            .expect("an intermediate exists");
1603
1604        let mut probe = OrderProbe::new(store, &data);
1605        probe.slow_addr = Some(slow);
1606        probe.slow_gate = 100;
1607        let delivered = Arc::clone(&probe.delivered_leaves);
1608
1609        let joiner = Joiner::<_, TINY_BODY>::new(probe.clone(), root)
1610            .await
1611            .unwrap();
1612
1613        let total = joiner.size();
1614        let mut reassembled = vec![0u8; total as usize];
1615        let stream = joiner.into_offset_stream_chunked();
1616        futures::pin_mut!(stream);
1617        while let Some(pair) = stream.next().await {
1618            let (offset, body) = pair.unwrap();
1619            reassembled[offset as usize..offset as usize + body.len()].copy_from_slice(&body);
1620            delivered.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1621        }
1622        assert_eq!(
1623            reassembled, data,
1624            "stream completes past the slow intermediate"
1625        );
1626    }
1627
1628    /// Drain `into_offset_stream_chunked_range(start, len)`, reassemble the
1629    /// clipped `(offset, bytes)` pairs over `[start, start + len)`, and assert it
1630    /// equals `read_range(start, len)` byte-for-byte. Runs at the default width
1631    /// and at width 1.
1632    async fn assert_offset_stream_chunked_range_matches(data: &[u8], start: u64, len: u64) {
1633        for width in [DEFAULT_ASYNC_CONCURRENCY, 1] {
1634            let (root, store) = split_and_store(data);
1635
1636            let expected = Joiner::new(store.clone(), root)
1637                .await
1638                .unwrap()
1639                .read_range(start, len as usize)
1640                .await
1641                .unwrap();
1642
1643            let joiner = Joiner::new(store, root)
1644                .await
1645                .unwrap()
1646                .with_concurrency(width);
1647            let pairs: Vec<Result<(u64, Bytes)>> = joiner
1648                .into_offset_stream_chunked_range(start, len)
1649                .collect()
1650                .await;
1651
1652            let mut reassembled = vec![0u8; expected.len()];
1653            let mut seen_offsets = std::collections::HashSet::new();
1654            for pair in pairs {
1655                let (offset, body) = pair.unwrap();
1656                assert!(
1657                    offset >= start && offset + body.len() as u64 <= start + len,
1658                    "offset {offset} (+{}) outside [{start}, {})",
1659                    body.len(),
1660                    start + len
1661                );
1662                assert!(
1663                    seen_offsets.insert(offset),
1664                    "offset {offset} yielded more than once (width {width})"
1665                );
1666                let rel = (offset - start) as usize;
1667                reassembled[rel..rel + body.len()].copy_from_slice(&body);
1668            }
1669
1670            assert_eq!(
1671                reassembled, expected,
1672                "range reassembly equals read_range (width {width}, start {start}, len {len})"
1673            );
1674        }
1675    }
1676
1677    #[tokio::test]
1678    async fn test_offset_stream_chunked_range_windows() {
1679        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 321)
1680            .map(|i| (i % 256) as u8)
1681            .collect();
1682        let bs = DEFAULT_BODY_SIZE as u64;
1683        let total = data.len() as u64;
1684
1685        // sub-leaf: start and len both inside one leaf
1686        assert_offset_stream_chunked_range_matches(&data, bs + 10, 50).await;
1687        // leaf-aligned single leaf
1688        assert_offset_stream_chunked_range_matches(&data, bs, bs).await;
1689        // spans several leaves, partial at both ends
1690        assert_offset_stream_chunked_range_matches(&data, bs / 2, bs * 3 + 7).await;
1691        // last partial leaf
1692        assert_offset_stream_chunked_range_matches(&data, bs * 5, total - bs * 5).await;
1693        // whole file (must equal read_all)
1694        assert_offset_stream_chunked_range_matches(&data, 0, total).await;
1695        // zero-len (empty)
1696        assert_offset_stream_chunked_range_matches(&data, bs, 0).await;
1697    }
1698
1699    #[tokio::test]
1700    async fn test_offset_stream_chunked_range_whole_equals_chunked() {
1701        // A whole-file range must reproduce `into_offset_stream_chunked` exactly:
1702        // same leaves, same absolute offsets, same bodies.
1703        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 99)
1704            .map(|i| (i % 256) as u8)
1705            .collect();
1706        let (root, store) = split_and_store(&data);
1707
1708        let full = Joiner::new(store.clone(), root).await.unwrap();
1709        let total = full.size();
1710        let mut from_full: Vec<(u64, Vec<u8>)> = full
1711            .into_offset_stream_chunked()
1712            .collect::<Vec<_>>()
1713            .await
1714            .into_iter()
1715            .map(|p| {
1716                let (o, b) = p.unwrap();
1717                (o, b.to_vec())
1718            })
1719            .collect();
1720        from_full.sort_by_key(|(o, _)| *o);
1721
1722        let ranged = Joiner::new(store, root).await.unwrap();
1723        let mut from_range: Vec<(u64, Vec<u8>)> = ranged
1724            .into_offset_stream_chunked_range(0, total)
1725            .collect::<Vec<_>>()
1726            .await
1727            .into_iter()
1728            .map(|p| {
1729                let (o, b) = p.unwrap();
1730                (o, b.to_vec())
1731            })
1732            .collect();
1733        from_range.sort_by_key(|(o, _)| *o);
1734
1735        assert_eq!(
1736            from_range, from_full,
1737            "whole-file range equals chunked walk"
1738        );
1739
1740        // And the reassembly equals read_all.
1741        let expected = Joiner::new(split_and_store(&data).1, root)
1742            .await
1743            .unwrap()
1744            .read_all()
1745            .await
1746            .unwrap();
1747        let mut reassembled = vec![0u8; total as usize];
1748        for (o, b) in &from_range {
1749            reassembled[*o as usize..*o as usize + b.len()].copy_from_slice(b);
1750        }
1751        assert_eq!(reassembled, expected);
1752    }
1753
1754    #[cfg(feature = "tokio")]
1755    #[tokio::test]
1756    async fn test_reader_small() {
1757        use tokio::io::AsyncReadExt;
1758
1759        let data = b"hello world";
1760        let (root, store) = split_and_store(data);
1761
1762        let joiner = Joiner::new(store, root).await.unwrap();
1763        let mut reader = joiner.into_reader();
1764        let mut result = Vec::new();
1765        reader.read_to_end(&mut result).await.unwrap();
1766        assert_eq!(result, data);
1767    }
1768
1769    #[cfg(feature = "tokio")]
1770    #[tokio::test]
1771    async fn test_reader_multi_chunk() {
1772        use tokio::io::AsyncReadExt;
1773
1774        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 123)
1775            .map(|i| (i % 256) as u8)
1776            .collect();
1777        let (root, store) = split_and_store(&data);
1778
1779        let joiner = Joiner::new(store, root).await.unwrap();
1780        let mut reader = joiner.into_reader();
1781        let mut result = Vec::new();
1782        reader.read_to_end(&mut result).await.unwrap();
1783        assert_eq!(result, data);
1784    }
1785
1786    #[cfg(feature = "tokio")]
1787    #[tokio::test]
1788    async fn test_reader_seek() {
1789        use tokio::io::{AsyncReadExt, AsyncSeekExt};
1790
1791        let data = b"hello world";
1792        let (root, store) = split_and_store(data);
1793
1794        let joiner = Joiner::new(store, root).await.unwrap();
1795        let mut reader = joiner.into_reader();
1796
1797        reader.seek(SeekFrom::Start(6)).await.unwrap();
1798        let mut buf = vec![0u8; 5];
1799        reader.read_exact(&mut buf).await.unwrap();
1800        assert_eq!(&buf, b"world");
1801    }
1802
1803    #[cfg(feature = "tokio")]
1804    #[tokio::test]
1805    async fn test_reader_seek_back_and_forth() {
1806        use tokio::io::{AsyncReadExt, AsyncSeekExt};
1807
1808        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3)
1809            .map(|i| (i % 256) as u8)
1810            .collect();
1811        let (root, store) = split_and_store(&data);
1812
1813        let joiner = Joiner::new(store, root).await.unwrap();
1814        let mut reader = joiner.into_reader();
1815
1816        // Read from middle
1817        reader
1818            .seek(SeekFrom::Start(DEFAULT_BODY_SIZE as u64))
1819            .await
1820            .unwrap();
1821        let mut buf1 = vec![0u8; 100];
1822        reader.read_exact(&mut buf1).await.unwrap();
1823        assert_eq!(&buf1, &data[DEFAULT_BODY_SIZE..DEFAULT_BODY_SIZE + 100]);
1824
1825        // Seek back to start
1826        reader.seek(SeekFrom::Start(0)).await.unwrap();
1827        let mut buf2 = vec![0u8; 100];
1828        reader.read_exact(&mut buf2).await.unwrap();
1829        assert_eq!(&buf2, &data[..100]);
1830
1831        // Seek to near-end
1832        reader.seek(SeekFrom::End(-50)).await.unwrap();
1833        let mut buf3 = vec![0u8; 50];
1834        reader.read_exact(&mut buf3).await.unwrap();
1835        assert_eq!(&buf3, &data[data.len() - 50..]);
1836    }
1837
1838    #[cfg(feature = "encryption")]
1839    mod encrypted {
1840        use super::*;
1841        use crate::file::split_encrypted;
1842
1843        fn encrypted_split_and_store(
1844            data: &[u8],
1845        ) -> (
1846            crate::chunk::encryption::EncryptedChunkRef,
1847            HashMap<ChunkAddress, AnyChunk>,
1848        ) {
1849            let (root_ref, store) = split_encrypted::<DEFAULT_BODY_SIZE>(data).unwrap();
1850            (root_ref, store.into_chunks())
1851        }
1852
1853        // --- Generated shared tests (async variants) ---
1854        generate_encrypted_joiner_tests!(tokio::test, EncryptedJoiner, [async], [await]);
1855
1856        // --- Async-only tests: Stream ---
1857
1858        #[tokio::test]
1859        async fn test_encrypted_joiner_stream() {
1860            let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3)
1861                .map(|i| (i % 256) as u8)
1862                .collect();
1863            let (root_ref, store) = encrypted_split_and_store(&data);
1864
1865            let joiner = EncryptedJoiner::new(store, root_ref).await.unwrap();
1866            let chunks: Vec<Result<Bytes>> = joiner.into_stream().collect().await;
1867
1868            let mut recovered = Vec::new();
1869            for chunk in chunks {
1870                recovered.extend_from_slice(&chunk.unwrap());
1871            }
1872            assert_eq!(recovered, data);
1873        }
1874
1875        /// Encrypted analogue of `assert_offset_stream_chunked_range_matches`.
1876        /// Encrypted leaf bodies are shorter than `BODY_SIZE` (the span is
1877        /// stripped), so clipping must key off `body.len()`, never a stride.
1878        async fn assert_encrypted_range_matches(data: &[u8], start: u64, len: u64) {
1879            for width in [DEFAULT_ASYNC_CONCURRENCY, 1] {
1880                let (root_ref, store) = encrypted_split_and_store(data);
1881
1882                let expected = EncryptedJoiner::new(store.clone(), root_ref.clone())
1883                    .await
1884                    .unwrap()
1885                    .read_range(start, len as usize)
1886                    .await
1887                    .unwrap();
1888
1889                let joiner = EncryptedJoiner::new(store, root_ref)
1890                    .await
1891                    .unwrap()
1892                    .with_concurrency(width);
1893                let pairs: Vec<Result<(u64, Bytes)>> = joiner
1894                    .into_offset_stream_chunked_range(start, len)
1895                    .collect()
1896                    .await;
1897
1898                let mut reassembled = vec![0u8; expected.len()];
1899                let mut seen_offsets = std::collections::HashSet::new();
1900                for pair in pairs {
1901                    let (offset, body) = pair.unwrap();
1902                    assert!(
1903                        offset >= start && offset + body.len() as u64 <= start + len,
1904                        "offset {offset} (+{}) outside [{start}, {})",
1905                        body.len(),
1906                        start + len
1907                    );
1908                    assert!(
1909                        seen_offsets.insert(offset),
1910                        "offset {offset} yielded more than once (width {width})"
1911                    );
1912                    let rel = (offset - start) as usize;
1913                    reassembled[rel..rel + body.len()].copy_from_slice(&body);
1914                }
1915
1916                assert_eq!(
1917                    reassembled, expected,
1918                    "encrypted range equals read_range (width {width}, start {start}, len {len})"
1919                );
1920            }
1921        }
1922
1923        #[tokio::test]
1924        async fn test_encrypted_offset_stream_chunked_range_windows() {
1925            let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 321)
1926                .map(|i| (i % 256) as u8)
1927                .collect();
1928            let bs = DEFAULT_BODY_SIZE as u64;
1929            let total = data.len() as u64;
1930
1931            // sub-leaf
1932            assert_encrypted_range_matches(&data, bs + 10, 50).await;
1933            // leaf-aligned single leaf
1934            assert_encrypted_range_matches(&data, bs, bs).await;
1935            // spans several leaves
1936            assert_encrypted_range_matches(&data, bs / 2, bs * 3 + 7).await;
1937            // last partial leaf
1938            assert_encrypted_range_matches(&data, bs * 5, total - bs * 5).await;
1939            // whole file
1940            assert_encrypted_range_matches(&data, 0, total).await;
1941            // zero-len
1942            assert_encrypted_range_matches(&data, bs, 0).await;
1943        }
1944    }
1945}