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#[cfg(feature = "tokio")]
15use bytes::Buf;
16use bytes::Bytes;
17use futures::stream::{self, FuturesUnordered, Stream, StreamExt};
18
19use crate::bmt::DEFAULT_BODY_SIZE;
20use crate::chunk::ChunkAddress;
21
22use super::error::{FileError, Result};
23use super::frontier::{SubtreeNode, expand_frontier, overlapping_children, read_subtree_bodies};
24use super::mode::{JoinMode, PlainMode};
25use super::tree::{ChunkRange, TreeParams};
26use crate::store::{ChunkGet, MaybeSend};
27
28#[cfg(feature = "encryption")]
29use super::mode::EncryptedMode;
30
31/// Generic async joiner parameterized by chunk mode.
32pub struct GenericJoiner<G, M: JoinMode, const BODY_SIZE: usize = DEFAULT_BODY_SIZE>
33where
34    G: ChunkGet<BODY_SIZE>,
35{
36    getter: Arc<G>,
37    root: ChunkAddress,
38    context: M::JoinerContext,
39    span: u64,
40    tree: TreeParams<BODY_SIZE>,
41    /// Pre-expanded frontier for parallel work distribution (computed once at construction).
42    subtrees: Vec<SubtreeNode<M>>,
43    position: u64,
44    concurrency: usize,
45    _mode: PhantomData<M>,
46}
47
48/// Plain (unencrypted) async joiner.
49pub type Joiner<G, const BODY_SIZE: usize = DEFAULT_BODY_SIZE> =
50    GenericJoiner<G, PlainMode, BODY_SIZE>;
51
52/// Encrypted async joiner.
53#[cfg(feature = "encryption")]
54pub type EncryptedJoiner<G, const BODY_SIZE: usize = DEFAULT_BODY_SIZE> =
55    GenericJoiner<G, EncryptedMode, BODY_SIZE>;
56
57impl<G, M, const BODY_SIZE: usize> std::fmt::Debug for GenericJoiner<G, M, BODY_SIZE>
58where
59    G: ChunkGet<BODY_SIZE>,
60    M: JoinMode,
61{
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("GenericJoiner")
64            .field("root", &self.root)
65            .field("span", &self.span)
66            .field("position", &self.position)
67            .field("concurrency", &self.concurrency)
68            .finish_non_exhaustive()
69    }
70}
71
72/// Collect leaf bodies for a set of subtrees with concurrent fetching.
73async fn collect_subtree_bodies<G, M, const BODY_SIZE: usize>(
74    getter: &Arc<G>,
75    subtrees: Vec<SubtreeNode<M>>,
76    chunk_range: ChunkRange,
77    concurrency: usize,
78) -> Result<Vec<Bytes>>
79where
80    G: ChunkGet<BODY_SIZE>,
81    M: JoinMode + MaybeSend + Sync,
82{
83    let bodies: Vec<Bytes> = stream::iter(subtrees)
84        .map(|st| {
85            let getter = Arc::clone(getter);
86            async move { read_subtree_bodies::<G, M, BODY_SIZE>(&*getter, &st, &chunk_range).await }
87        })
88        .buffered(concurrency)
89        .collect::<Vec<_>>()
90        .await
91        .into_iter()
92        .collect::<Result<Vec<Vec<Bytes>>>>()?
93        .into_iter()
94        .flatten()
95        .collect();
96    Ok(bodies)
97}
98
99impl<G, M, const BODY_SIZE: usize> GenericJoiner<G, M, BODY_SIZE>
100where
101    G: ChunkGet<BODY_SIZE>,
102    M: JoinMode + MaybeSend + Sync,
103{
104    /// Create an async joiner from a root reference.
105    pub async fn new(getter: G, input: M::RootRef) -> Result<Self> {
106        const { super::constants::assert_valid_body_size::<BODY_SIZE>() };
107
108        let (root, span, context) =
109            super::mode::joiner_init::<M, G, BODY_SIZE>(&getter, input).await?;
110        let tree = TreeParams::<BODY_SIZE>::new(span);
111
112        let target = DEFAULT_ASYNC_CONCURRENCY * 2;
113        let full_range = tree.chunks_for_range(0, span);
114        let subtrees =
115            expand_frontier::<G, M, BODY_SIZE>(&getter, &root, &context, span, &full_range, target)
116                .await?;
117
118        Ok(Self {
119            getter: Arc::new(getter),
120            root,
121            context,
122            span,
123            tree,
124            subtrees,
125            position: 0,
126            concurrency: DEFAULT_ASYNC_CONCURRENCY,
127            _mode: PhantomData,
128        })
129    }
130
131    /// Set concurrency level for prefetching.
132    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
133        self.concurrency = concurrency.max(1);
134        self
135    }
136
137    /// Total file size.
138    #[inline]
139    pub const fn size(&self) -> u64 {
140        self.span
141    }
142
143    /// Current read position.
144    #[inline]
145    pub const fn position(&self) -> u64 {
146        self.position
147    }
148
149    /// Root address.
150    #[inline]
151    pub const fn root(&self) -> &ChunkAddress {
152        &self.root
153    }
154
155    // crate-private accessors for sibling-module joiner extensions
156    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
157    pub(crate) const fn getter(&self) -> &Arc<G> {
158        &self.getter
159    }
160    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
161    pub(crate) fn subtrees(&self) -> &[SubtreeNode<M>] {
162        &self.subtrees
163    }
164    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
165    pub(crate) const fn tree(&self) -> TreeParams<BODY_SIZE> {
166        self.tree
167    }
168    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
169    pub(crate) const fn concurrency(&self) -> usize {
170        self.concurrency
171    }
172    #[allow(dead_code, reason = "consumed by sibling-module joiner extensions")]
173    pub(crate) const fn context(&self) -> &M::JoinerContext {
174        &self.context
175    }
176
177    /// Read a range of bytes with concurrent fetching using the cached frontier.
178    pub async fn read_range(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
179        Self::read_range_with(
180            &self.getter,
181            &self.subtrees,
182            &self.root,
183            &self.context,
184            self.span,
185            self.tree,
186            self.concurrency,
187            offset,
188            len,
189        )
190        .await
191    }
192
193    /// Read entire file into memory.
194    pub async fn read_all(&self) -> Result<Vec<u8>> {
195        self.read_range(0, self.span as usize).await
196    }
197
198    /// Shared read-range implementation used by both `read_range` and `poll_read`.
199    #[allow(
200        clippy::too_many_arguments,
201        reason = "internal helper threading already-decomposed reader state from two call sites"
202    )]
203    async fn read_range_with(
204        getter: &Arc<G>,
205        subtrees: &[SubtreeNode<M>],
206        root: &ChunkAddress,
207        context: &M::JoinerContext,
208        span: u64,
209        tree: TreeParams<BODY_SIZE>,
210        concurrency: usize,
211        offset: u64,
212        len: usize,
213    ) -> Result<Vec<u8>> {
214        use super::helpers::{ReadRangeCheck, validate_read_range};
215
216        let (offset, actual_len) = match validate_read_range::<BODY_SIZE>(offset, len, span) {
217            ReadRangeCheck::Empty => return Ok(Vec::new()),
218            ReadRangeCheck::SingleChunk { offset, actual_len } => {
219                let chunk = getter.get(root).await.map_err(FileError::getter)?;
220                let chunk = chunk.into_content().ok_or(FileError::InvalidChunkType {
221                    type_name: "non-content",
222                })?;
223                let body = M::decode_body::<BODY_SIZE>(chunk, context, span)?;
224                let start = offset as usize;
225                let end = start + actual_len;
226                return Ok(body[start..end].to_vec());
227            }
228            ReadRangeCheck::MultiChunk { offset, actual_len } => (offset, actual_len),
229        };
230
231        let chunk_range = tree.chunks_for_range(offset, actual_len as u64);
232        let range_start_byte = chunk_range.start * BODY_SIZE as u64;
233        let range_end_byte = chunk_range.end * BODY_SIZE as u64;
234
235        let relevant: Vec<_> = subtrees
236            .iter()
237            .filter(|st| {
238                st.byte_offset < range_end_byte && st.byte_offset + st.span > range_start_byte
239            })
240            .cloned()
241            .collect();
242
243        let bodies =
244            collect_subtree_bodies::<G, M, BODY_SIZE>(getter, relevant, chunk_range, concurrency)
245                .await?;
246
247        Ok(super::tree::assemble_range(
248            &tree,
249            offset,
250            actual_len,
251            &chunk_range,
252            &bodies,
253        ))
254    }
255
256    /// Update read position (synchronous — just updates internal state).
257    pub fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
258        self.position = super::resolve_seek_position(pos, self.position, self.span)?;
259        Ok(self.position)
260    }
261
262    /// Convert into a stream of leaf chunk bodies.
263    pub fn into_stream(self) -> impl Stream<Item = Result<Bytes>> {
264        let getter = self.getter;
265        let chunk_range = self.tree.chunks_for_range(0, self.span);
266
267        struct State<M: JoinMode> {
268            subtrees: std::vec::IntoIter<SubtreeNode<M>>,
269            pending: std::vec::IntoIter<Bytes>,
270        }
271
272        let state = State {
273            subtrees: self.subtrees.into_iter(),
274            pending: Vec::new().into_iter(),
275        };
276
277        stream::unfold(state, move |mut state| {
278            let getter = Arc::clone(&getter);
279            async move {
280                // Drain pending leaf bodies from the last subtree.
281                if let Some(body) = state.pending.next() {
282                    return Some((Ok(body), state));
283                }
284
285                // Fetch the next subtree's leaf bodies.
286                let st = state.subtrees.next()?;
287                match read_subtree_bodies::<G, M, BODY_SIZE>(&*getter, &st, &chunk_range).await {
288                    Ok(bodies) => {
289                        let mut iter = bodies.into_iter();
290                        match iter.next() {
291                            Some(first) => {
292                                state.pending = iter;
293                                Some((Ok(first), state))
294                            }
295                            None => Some((Ok(Bytes::new()), state)),
296                        }
297                    }
298                    Err(e) => Some((Err(e), state)),
299                }
300            }
301        })
302    }
303
304    /// Convert into a stream of `(byte_offset, leaf_body)` pairs fetched with
305    /// bounded concurrency and yielded out of order as each leaf lands.
306    ///
307    /// Unlike [`into_stream`](Self::into_stream), which walks subtrees one at a
308    /// time and emits bytes in file order, this keeps up to `concurrency`
309    /// subtree fetches in flight and yields each leaf the moment its subtree
310    /// resolves, tagged with its absolute byte offset in the file. Reassembling
311    /// the pairs by offset reproduces the file; nothing is buffered into a
312    /// single contiguous result, so peak memory is bounded by the in-flight
313    /// width, not the file size. Set the width with
314    /// [`with_concurrency`](Self::with_concurrency).
315    pub fn into_offset_stream(self) -> impl Stream<Item = Result<(u64, Bytes)>> {
316        let getter = self.getter;
317        let concurrency = self.concurrency;
318        let chunk_range = self.tree.chunks_for_range(0, self.span);
319
320        // Each subtree fetch resolves to its base offset plus its leaves in tree
321        // order; `buffer_unordered` yields whichever subtree finishes first.
322        let subtrees = stream::iter(self.subtrees)
323            .map(move |st| {
324                let getter = Arc::clone(&getter);
325                async move {
326                    let base = st.byte_offset;
327                    let bodies =
328                        read_subtree_bodies::<G, M, BODY_SIZE>(&*getter, &st, &chunk_range).await?;
329                    Ok::<(u64, Vec<Bytes>), FileError>((base, bodies))
330                }
331            })
332            .buffer_unordered(concurrency.max(1));
333
334        // Flatten each resolved subtree into per-leaf `(offset, body)` pairs,
335        // draining one subtree's leaves before polling the next ready subtree.
336        struct State<S> {
337            subtrees: S,
338            base: u64,
339            leaf_index: usize,
340            pending: std::vec::IntoIter<Bytes>,
341        }
342
343        let state = State {
344            subtrees: Box::pin(subtrees),
345            base: 0,
346            leaf_index: 0,
347            pending: Vec::new().into_iter(),
348        };
349
350        stream::unfold(state, move |mut state| async move {
351            loop {
352                // Drain leaves already in hand, offsetting each leaf from its
353                // subtree base by a whole body per preceding leaf.
354                if let Some(body) = state.pending.next() {
355                    let offset = state.base + state.leaf_index as u64 * BODY_SIZE as u64;
356                    state.leaf_index += 1;
357                    return Some((Ok((offset, body)), state));
358                }
359
360                // Pull the next resolved subtree (out of order).
361                match state.subtrees.next().await? {
362                    Ok((base, bodies)) => {
363                        state.base = base;
364                        state.leaf_index = 0;
365                        state.pending = bodies.into_iter();
366                        // Loop back to emit this subtree's first leaf.
367                    }
368                    Err(e) => return Some((Err(e), state)),
369                }
370            }
371        })
372    }
373
374    /// Convert into a stream of `(byte_offset, leaf_body)` pairs fetched with
375    /// per-chunk bounded concurrency and yielded out of order as each leaf lands.
376    ///
377    /// Where [`into_offset_stream`](Self::into_offset_stream) keeps up to
378    /// `concurrency` *subtree* fetches in flight and walks each subtree as a
379    /// sequential descent, this walks the tree at *chunk* granularity: every
380    /// intermediate and leaf fetch competes for the same bounded in-flight pool,
381    /// so up to `concurrency` individual chunks are in flight regardless of how
382    /// few top-level subtrees the tree has. Effective leaf concurrency is
383    /// `min(concurrency, leaf_count)` even for a tree that fans into one wide
384    /// subtree, which the subtree-granular variant cannot reach.
385    ///
386    /// The walk is a bounded producer/worker model expressed without a spawned
387    /// task: a queue of pending tree nodes feeds a [`FuturesUnordered`] pool
388    /// refilled to the width each step. An intermediate node resolves to its
389    /// overlapping children (re-queued); a leaf resolves to its
390    /// `(byte_offset, body)` pair. A leaf fetch that fails is re-enqueued (up to
391    /// a bounded retry budget) and the worker pulls the next node, so a slow or
392    /// flaky chunk retries without holding its slot or gating ready leaves.
393    ///
394    /// Peak memory is bounded by the pool width plus the pending queue, not the
395    /// file size. Set the width with [`with_concurrency`](Self::with_concurrency).
396    /// Reassembling the pairs by offset reproduces the file, byte-for-byte equal
397    /// to [`read_all`](Self::read_all).
398    pub fn into_offset_stream_chunked(self) -> impl Stream<Item = Result<(u64, Bytes)>>
399    where
400        G: 'static,
401    {
402        let getter = self.getter;
403        let width = self.concurrency.max(1);
404        let chunk_range = self.tree.chunks_for_range(0, self.span);
405
406        // One unit of pending work: a tree node plus its remaining retry budget.
407        // The budget only decrements on a failed leaf fetch.
408        struct Pending<M: JoinMode> {
409            node: SubtreeNode<M>,
410            retries: u32,
411        }
412
413        // What a worker future resolves to once its chunk lands.
414        enum Resolved<M: JoinMode> {
415            /// A leaf: its absolute byte offset and decoded body.
416            Leaf(u64, Bytes),
417            /// An intermediate: its overlapping children to re-queue.
418            Children(Vec<SubtreeNode<M>>),
419            /// A leaf fetch failed with retries left: re-queue this node.
420            Retry(Pending<M>),
421            /// A fetch failed terminally (retries exhausted or intermediate error).
422            Failed(FileError),
423        }
424
425        #[cfg(not(target_arch = "wasm32"))]
426        type BoxResolvedFuture<M> =
427            std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>> + Send>>;
428        #[cfg(target_arch = "wasm32")]
429        type BoxResolvedFuture<M> =
430            std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>>>>;
431
432        // Fetch one node: a leaf yields its body, an intermediate yields its
433        // children. A leaf error consumes one retry, then re-queues or fails.
434        async fn fetch_one<G, M, const BS: usize>(
435            getter: &G,
436            chunk_range: &ChunkRange,
437            pending: Pending<M>,
438        ) -> Resolved<M>
439        where
440            G: ChunkGet<BS>,
441            M: JoinMode + MaybeSend + Sync,
442        {
443            let node = &pending.node;
444            let body = match super::mode::read_chunk_body::<M, G, BS>(
445                getter,
446                &node.addr,
447                &node.context,
448                node.span,
449            )
450            .await
451            {
452                Ok(body) => body,
453                Err(e) => {
454                    if node.span <= BS as u64 && pending.retries > 0 {
455                        return Resolved::Retry(Pending {
456                            node: pending.node,
457                            retries: pending.retries - 1,
458                        });
459                    }
460                    return Resolved::Failed(e);
461                }
462            };
463
464            if node.span <= BS as u64 {
465                return Resolved::Leaf(node.byte_offset, body);
466            }
467            match overlapping_children::<M, BS>(&body, node, chunk_range) {
468                Ok(children) => Resolved::Children(children),
469                Err(e) => Resolved::Failed(e),
470            }
471        }
472
473        struct State<G, M: JoinMode, const BS: usize> {
474            getter: Arc<G>,
475            chunk_range: ChunkRange,
476            width: usize,
477            queue: std::collections::VecDeque<Pending<M>>,
478            in_flight: FuturesUnordered<BoxResolvedFuture<M>>,
479        }
480
481        let mut queue = std::collections::VecDeque::new();
482        for st in self.subtrees {
483            queue.push_back(Pending {
484                node: st,
485                retries: DEFAULT_LEAF_RETRIES,
486            });
487        }
488
489        let state = State::<G, M, BODY_SIZE> {
490            getter,
491            chunk_range,
492            width,
493            queue,
494            in_flight: FuturesUnordered::new(),
495        };
496
497        stream::unfold(state, move |mut state| async move {
498            loop {
499                // Refill the in-flight pool from the pending queue up to the
500                // width, so at most `width` chunks are fetching at once.
501                while state.in_flight.len() < state.width {
502                    let Some(pending) = state.queue.pop_front() else {
503                        break;
504                    };
505                    let getter = Arc::clone(&state.getter);
506                    let range = state.chunk_range;
507                    state.in_flight.push(Box::pin(async move {
508                        fetch_one::<G, M, BODY_SIZE>(&*getter, &range, pending).await
509                    }) as BoxResolvedFuture<M>);
510                }
511
512                // Nothing in flight and nothing queued: the tree is drained.
513                let resolved = state.in_flight.next().await?;
514                match resolved {
515                    Resolved::Leaf(offset, body) => {
516                        return Some((Ok((offset, body)), state));
517                    }
518                    Resolved::Children(children) => {
519                        for child in children {
520                            state.queue.push_back(Pending {
521                                node: child,
522                                retries: DEFAULT_LEAF_RETRIES,
523                            });
524                        }
525                    }
526                    Resolved::Retry(pending) => {
527                        // Re-enqueue at the back so the worker pulls fresh work
528                        // first; the failed chunk retries without holding a slot.
529                        state.queue.push_back(pending);
530                    }
531                    Resolved::Failed(e) => return Some((Err(e), state)),
532                }
533            }
534        })
535    }
536
537    /// Like [`into_offset_stream_chunked`](Self::into_offset_stream_chunked) but
538    /// restricted to the byte range `[start, start + len)`.
539    ///
540    /// Only subtrees and intermediate children overlapping the range are walked,
541    /// and the first and last partial leaves are clipped so each emitted
542    /// `(offset, body)` lies inside the range. Offsets stay absolute in the file,
543    /// so reassembling the pairs over `[start, start + len)` reproduces
544    /// [`read_range`](Self::read_range) byte-for-byte. A whole-file range equals
545    /// [`into_offset_stream_chunked`](Self::into_offset_stream_chunked); an empty
546    /// or out-of-bounds range yields an empty stream.
547    pub fn into_offset_stream_chunked_range(
548        self,
549        start: u64,
550        len: u64,
551    ) -> impl Stream<Item = Result<(u64, Bytes)>>
552    where
553        G: 'static,
554    {
555        chunked_range_stream_from::<G, M, BODY_SIZE>(
556            self.getter,
557            self.subtrees,
558            self.tree,
559            self.span,
560            self.concurrency,
561            start,
562            len,
563        )
564    }
565
566    /// Convert into an `AsyncRead` reader.
567    #[cfg(feature = "tokio")]
568    pub fn into_reader(self) -> JoinerReader<G, M, BODY_SIZE> {
569        JoinerReader {
570            joiner: self,
571            buffer: Bytes::new(),
572            future: None,
573        }
574    }
575}
576
577/// Build the chunk-granular range stream from already-decomposed joiner parts.
578///
579/// The single home of the chunk-granular walk: both
580/// [`GenericJoiner::into_offset_stream_chunked_range`] (which moves its parts
581/// in) and consumers that retain reusable joiner state (which clone their parts
582/// in) drive the same implementation. Walks only the subtrees and intermediate
583/// children overlapping `[start, start + len)`, clips boundary leaves to the
584/// range, and keeps absolute offsets, so the returned stream is identical to the
585/// inherent method.
586pub(crate) fn chunked_range_stream_from<G, M, const BODY_SIZE: usize>(
587    getter: Arc<G>,
588    subtrees: Vec<SubtreeNode<M>>,
589    tree: TreeParams<BODY_SIZE>,
590    span: u64,
591    concurrency: usize,
592    start: u64,
593    len: u64,
594) -> impl Stream<Item = Result<(u64, Bytes)>> + 'static
595where
596    G: ChunkGet<BODY_SIZE> + 'static,
597    M: JoinMode + MaybeSend + Sync,
598{
599    let width = concurrency.max(1);
600
601    // Clamp the requested window to the file and derive the chunk range that
602    // seeds the queue and prunes intermediate children, exactly as the full
603    // walk uses the whole-file chunk range.
604    let range_start = start.min(span);
605    let range_end = (start.saturating_add(len)).min(span);
606    let chunk_range = tree.chunks_for_range(range_start, range_end - range_start);
607
608    // One unit of pending work: a tree node plus its remaining retry budget.
609    // The budget only decrements on a failed leaf fetch.
610    struct Pending<M: JoinMode> {
611        node: SubtreeNode<M>,
612        retries: u32,
613    }
614
615    // What a worker future resolves to once its chunk lands.
616    enum Resolved<M: JoinMode> {
617        /// A leaf: its absolute byte offset and decoded body.
618        Leaf(u64, Bytes),
619        /// An intermediate: its overlapping children to re-queue.
620        Children(Vec<SubtreeNode<M>>),
621        /// A leaf fetch failed with retries left: re-queue this node.
622        Retry(Pending<M>),
623        /// A fetch failed terminally (retries exhausted or intermediate error).
624        Failed(FileError),
625    }
626
627    #[cfg(not(target_arch = "wasm32"))]
628    type BoxResolvedFuture<M> =
629        std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>> + Send>>;
630    #[cfg(target_arch = "wasm32")]
631    type BoxResolvedFuture<M> = std::pin::Pin<Box<dyn std::future::Future<Output = Resolved<M>>>>;
632
633    // Fetch one node: a leaf yields its body, an intermediate yields its
634    // overlapping children. A leaf error consumes one retry, then re-queues
635    // or fails.
636    async fn fetch_one<G, M, const BS: usize>(
637        getter: &G,
638        chunk_range: &ChunkRange,
639        pending: Pending<M>,
640    ) -> Resolved<M>
641    where
642        G: ChunkGet<BS>,
643        M: JoinMode + MaybeSend + Sync,
644    {
645        let node = &pending.node;
646        let body = match super::mode::read_chunk_body::<M, G, BS>(
647            getter,
648            &node.addr,
649            &node.context,
650            node.span,
651        )
652        .await
653        {
654            Ok(body) => body,
655            Err(e) => {
656                if node.span <= BS as u64 && pending.retries > 0 {
657                    return Resolved::Retry(Pending {
658                        node: pending.node,
659                        retries: pending.retries - 1,
660                    });
661                }
662                return Resolved::Failed(e);
663            }
664        };
665
666        if node.span <= BS as u64 {
667            return Resolved::Leaf(node.byte_offset, body);
668        }
669        match overlapping_children::<M, BS>(&body, node, chunk_range) {
670            Ok(children) => Resolved::Children(children),
671            Err(e) => Resolved::Failed(e),
672        }
673    }
674
675    struct State<G, M: JoinMode> {
676        getter: Arc<G>,
677        chunk_range: ChunkRange,
678        range_start: u64,
679        range_end: u64,
680        width: usize,
681        queue: std::collections::VecDeque<Pending<M>>,
682        in_flight: FuturesUnordered<BoxResolvedFuture<M>>,
683    }
684
685    // Seed the queue with only the subtrees overlapping the range; an empty
686    // range leaves the queue empty and the stream finishes at once.
687    let mut queue = std::collections::VecDeque::new();
688    if range_end > range_start {
689        let range_start_byte = chunk_range.start * BODY_SIZE as u64;
690        let range_end_byte = chunk_range.end * BODY_SIZE as u64;
691        for st in subtrees {
692            if st.byte_offset < range_end_byte && st.byte_offset + st.span > range_start_byte {
693                queue.push_back(Pending {
694                    node: st,
695                    retries: DEFAULT_LEAF_RETRIES,
696                });
697            }
698        }
699    }
700
701    let state = State::<G, M> {
702        getter,
703        chunk_range,
704        range_start,
705        range_end,
706        width,
707        queue,
708        in_flight: FuturesUnordered::new(),
709    };
710
711    stream::unfold(state, move |mut state| async move {
712        loop {
713            // Refill the in-flight pool from the pending queue up to the
714            // width, so at most `width` chunks are fetching at once.
715            while state.in_flight.len() < state.width {
716                let Some(pending) = state.queue.pop_front() else {
717                    break;
718                };
719                let getter = Arc::clone(&state.getter);
720                let range = state.chunk_range;
721                state.in_flight.push(Box::pin(async move {
722                    fetch_one::<G, M, BODY_SIZE>(&*getter, &range, pending).await
723                }) as BoxResolvedFuture<M>);
724            }
725
726            // Nothing in flight and nothing queued: the range is drained.
727            let resolved = state.in_flight.next().await?;
728            match resolved {
729                Resolved::Leaf(leaf_start, body) => {
730                    // Clip the leaf to the range. Offsets stay absolute, so a
731                    // boundary leaf emits only its in-range slice, and the
732                    // emitted offset is the later of the leaf start and the
733                    // range start.
734                    let leaf_end = leaf_start + body.len() as u64;
735                    if leaf_end <= state.range_start || leaf_start >= state.range_end {
736                        continue;
737                    }
738                    let clip_lo = state.range_start.saturating_sub(leaf_start) as usize;
739                    let clip_hi = (state.range_end - leaf_start).min(body.len() as u64) as usize;
740                    let offset = leaf_start.max(state.range_start);
741                    return Some((Ok((offset, body.slice(clip_lo..clip_hi))), state));
742                }
743                Resolved::Children(children) => {
744                    for child in children {
745                        state.queue.push_back(Pending {
746                            node: child,
747                            retries: DEFAULT_LEAF_RETRIES,
748                        });
749                    }
750                }
751                Resolved::Retry(pending) => {
752                    // Re-enqueue at the back so the worker pulls fresh work
753                    // first; the failed chunk retries without holding a slot.
754                    state.queue.push_back(pending);
755                }
756                Resolved::Failed(e) => return Some((Err(e), state)),
757            }
758        }
759    })
760}
761
762/// Wrapper providing `tokio::io::AsyncRead` over a [`GenericJoiner`].
763///
764/// Created via [`GenericJoiner::into_reader`].
765#[cfg(feature = "tokio")]
766pub struct JoinerReader<G, M: JoinMode, const BODY_SIZE: usize = DEFAULT_BODY_SIZE>
767where
768    G: ChunkGet<BODY_SIZE>,
769{
770    joiner: GenericJoiner<G, M, BODY_SIZE>,
771    buffer: Bytes,
772    #[allow(clippy::type_complexity)]
773    future: Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>>> + Send>>>,
774}
775
776#[cfg(feature = "tokio")]
777impl<G, M, const BODY_SIZE: usize> std::fmt::Debug for JoinerReader<G, M, BODY_SIZE>
778where
779    G: ChunkGet<BODY_SIZE>,
780    M: JoinMode,
781{
782    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783        f.debug_struct("JoinerReader")
784            .field("joiner", &self.joiner)
785            .field("buffer_len", &self.buffer.len())
786            .field("has_pending_future", &self.future.is_some())
787            .finish()
788    }
789}
790
791// Safety: JoinerReader contains no self-referential data.
792// The boxed future is heap-allocated and all other fields are plain data.
793#[cfg(feature = "tokio")]
794impl<G: ChunkGet<BODY_SIZE>, M: JoinMode, const BODY_SIZE: usize> Unpin
795    for JoinerReader<G, M, BODY_SIZE>
796{
797}
798
799#[cfg(feature = "tokio")]
800impl<G, M, const BODY_SIZE: usize> tokio::io::AsyncRead for JoinerReader<G, M, BODY_SIZE>
801where
802    G: ChunkGet<BODY_SIZE> + 'static,
803    M: JoinMode + Send + Sync + 'static,
804{
805    fn poll_read(
806        self: std::pin::Pin<&mut Self>,
807        cx: &mut std::task::Context<'_>,
808        buf: &mut tokio::io::ReadBuf<'_>,
809    ) -> std::task::Poll<std::io::Result<()>> {
810        use std::task::Poll;
811
812        let this = self.get_mut();
813
814        // Drain any leftover buffer first
815        if !this.buffer.is_empty() {
816            let to_copy = this.buffer.len().min(buf.remaining());
817            buf.put_slice(&this.buffer[..to_copy]);
818            this.buffer.advance(to_copy);
819            return Poll::Ready(Ok(()));
820        }
821
822        // EOF check
823        if this.joiner.position >= this.joiner.span {
824            return Poll::Ready(Ok(()));
825        }
826
827        // Create a future for the next read if we don't have one
828        if this.future.is_none() {
829            let position = this.joiner.position;
830            let remaining = (this.joiner.span - position) as usize;
831            let read_len = remaining.min(BODY_SIZE);
832            let getter = Arc::clone(&this.joiner.getter);
833            let root = this.joiner.root;
834            let context = this.joiner.context.clone();
835            let span = this.joiner.span;
836            let tree = this.joiner.tree;
837            let concurrency = this.joiner.concurrency;
838            let subtrees: Vec<SubtreeNode<M>> = this.joiner.subtrees.clone();
839
840            let fut = async move {
841                GenericJoiner::<G, M, BODY_SIZE>::read_range_with(
842                    &getter,
843                    &subtrees,
844                    &root,
845                    &context,
846                    span,
847                    tree,
848                    concurrency,
849                    position,
850                    read_len,
851                )
852                .await
853            };
854            this.future = Some(Box::pin(fut));
855        }
856
857        // Poll the future
858        let fut = this.future.as_mut().unwrap();
859        match fut.as_mut().poll(cx) {
860            Poll::Ready(Ok(data)) => {
861                this.future = None;
862                let bytes = Bytes::from(data);
863                this.joiner.position += bytes.len() as u64;
864                let to_copy = bytes.len().min(buf.remaining());
865                buf.put_slice(&bytes[..to_copy]);
866                if to_copy < bytes.len() {
867                    this.buffer = bytes.slice(to_copy..);
868                }
869                Poll::Ready(Ok(()))
870            }
871            Poll::Ready(Err(e)) => {
872                this.future = None;
873                Poll::Ready(Err(std::io::Error::other(e)))
874            }
875            Poll::Pending => Poll::Pending,
876        }
877    }
878}
879
880#[cfg(feature = "tokio")]
881impl<G, M, const BODY_SIZE: usize> tokio::io::AsyncSeek for JoinerReader<G, M, BODY_SIZE>
882where
883    G: ChunkGet<BODY_SIZE> + 'static,
884    M: JoinMode + Send + Sync + 'static,
885{
886    fn start_seek(self: std::pin::Pin<&mut Self>, pos: SeekFrom) -> std::io::Result<()> {
887        let this = self.get_mut();
888        this.joiner.position =
889            super::resolve_seek_position(pos, this.joiner.position, this.joiner.span)?;
890        this.buffer = Bytes::new();
891        this.future = None;
892        Ok(())
893    }
894
895    fn poll_complete(
896        self: std::pin::Pin<&mut Self>,
897        _cx: &mut std::task::Context<'_>,
898    ) -> std::task::Poll<std::io::Result<u64>> {
899        std::task::Poll::Ready(Ok(self.get_mut().joiner.position))
900    }
901}
902
903#[cfg(all(test, feature = "tokio"))]
904mod tests {
905    use super::*;
906    use crate::chunk::AnyChunk;
907    use crate::file::split;
908    use std::collections::HashMap;
909
910    fn split_and_store(data: &[u8]) -> (ChunkAddress, HashMap<ChunkAddress, AnyChunk>) {
911        let (root, store) = split::<DEFAULT_BODY_SIZE>(data).unwrap();
912        (root, store.into_chunks())
913    }
914
915    // --- Generated shared tests (async variants) ---
916    generate_plain_joiner_tests!(tokio::test, Joiner, [async], [await]);
917
918    // --- Async-only tests: Stream, AsyncRead, AsyncSeek ---
919
920    #[tokio::test]
921    async fn test_joiner_stream() {
922        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3)
923            .map(|i| (i % 256) as u8)
924            .collect();
925        let (root, store) = split_and_store(&data);
926
927        let joiner = Joiner::new(store, root).await.unwrap();
928        let chunks: Vec<Result<Bytes>> = joiner.into_stream().collect().await;
929
930        let mut recovered = Vec::new();
931        for chunk in chunks {
932            recovered.extend_from_slice(&chunk.unwrap());
933        }
934        assert_eq!(recovered, data);
935    }
936
937    /// Drain `into_offset_stream` into an offset-keyed map, asserting every leaf
938    /// arrives exactly once, then reassemble by offset and compare to `read_all`.
939    async fn assert_offset_stream_matches(data: &[u8]) {
940        let (root, store) = split_and_store(data);
941
942        let expected = Joiner::new(store.clone(), root)
943            .await
944            .unwrap()
945            .read_all()
946            .await
947            .unwrap();
948
949        let joiner = Joiner::new(store, root).await.unwrap();
950        let total = joiner.size();
951        let pairs: Vec<Result<(u64, Bytes)>> = joiner.into_offset_stream().collect().await;
952
953        let mut reassembled = vec![0u8; total as usize];
954        let mut covered = 0u64;
955        let mut seen_offsets = std::collections::HashSet::new();
956        for pair in pairs {
957            let (offset, body) = pair.unwrap();
958            assert!(
959                seen_offsets.insert(offset),
960                "offset {offset} yielded more than once"
961            );
962            let start = offset as usize;
963            let end = start + body.len();
964            reassembled[start..end].copy_from_slice(&body);
965            covered += body.len() as u64;
966        }
967
968        assert_eq!(covered, total, "every byte covered exactly once");
969        assert_eq!(reassembled, expected, "offset reassembly equals read_all");
970        assert_eq!(reassembled, data, "offset reassembly equals input");
971    }
972
973    #[tokio::test]
974    async fn test_offset_stream_small() {
975        assert_offset_stream_matches(b"hello world").await;
976    }
977
978    #[tokio::test]
979    async fn test_offset_stream_exact_chunk() {
980        assert_offset_stream_matches(&vec![0xAB; DEFAULT_BODY_SIZE]).await;
981    }
982
983    #[tokio::test]
984    async fn test_offset_stream_multi_chunk() {
985        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 123)
986            .map(|i| (i % 256) as u8)
987            .collect();
988        assert_offset_stream_matches(&data).await;
989    }
990
991    #[tokio::test]
992    async fn test_offset_stream_129_chunks() {
993        let refs_per_chunk = DEFAULT_BODY_SIZE / super::super::constants::REF_SIZE;
994        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * (refs_per_chunk + 1))
995            .map(|i| (i % 256) as u8)
996            .collect();
997        assert_offset_stream_matches(&data).await;
998    }
999
1000    #[tokio::test]
1001    async fn test_offset_stream_concurrency_one() {
1002        // Width 1 still yields every leaf with the right offset (degenerate
1003        // concurrent path), so the reassembly invariant holds independent of fan.
1004        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 7)
1005            .map(|i| (i % 256) as u8)
1006            .collect();
1007        let (root, store) = split_and_store(&data);
1008        let joiner = Joiner::new(store, root).await.unwrap().with_concurrency(1);
1009        let total = joiner.size();
1010        let mut reassembled = vec![0u8; total as usize];
1011        let stream = joiner.into_offset_stream();
1012        futures::pin_mut!(stream);
1013        while let Some(pair) = stream.next().await {
1014            let (offset, body) = pair.unwrap();
1015            let start = offset as usize;
1016            reassembled[start..start + body.len()].copy_from_slice(&body);
1017        }
1018        assert_eq!(reassembled, data);
1019    }
1020
1021    /// Drain `into_offset_stream_chunked` into an offset-keyed buffer, asserting
1022    /// every leaf arrives exactly once, then reassemble and compare to `read_all`.
1023    async fn assert_offset_stream_chunked_matches(data: &[u8]) {
1024        let (root, store) = split_and_store(data);
1025
1026        let expected = Joiner::new(store.clone(), root)
1027            .await
1028            .unwrap()
1029            .read_all()
1030            .await
1031            .unwrap();
1032
1033        let joiner = Joiner::new(store, root).await.unwrap();
1034        let total = joiner.size();
1035        let pairs: Vec<Result<(u64, Bytes)>> = joiner.into_offset_stream_chunked().collect().await;
1036
1037        let mut reassembled = vec![0u8; total as usize];
1038        let mut covered = 0u64;
1039        let mut seen_offsets = std::collections::HashSet::new();
1040        for pair in pairs {
1041            let (offset, body) = pair.unwrap();
1042            assert!(
1043                seen_offsets.insert(offset),
1044                "offset {offset} yielded more than once"
1045            );
1046            let start = offset as usize;
1047            let end = start + body.len();
1048            reassembled[start..end].copy_from_slice(&body);
1049            covered += body.len() as u64;
1050        }
1051
1052        assert_eq!(covered, total, "every byte covered exactly once");
1053        assert_eq!(reassembled, expected, "chunked reassembly equals read_all");
1054        assert_eq!(reassembled, data, "chunked reassembly equals input");
1055    }
1056
1057    #[tokio::test]
1058    async fn test_offset_stream_chunked_small() {
1059        assert_offset_stream_chunked_matches(b"hello world").await;
1060    }
1061
1062    #[tokio::test]
1063    async fn test_offset_stream_chunked_exact_chunk() {
1064        assert_offset_stream_chunked_matches(&vec![0xAB; DEFAULT_BODY_SIZE]).await;
1065    }
1066
1067    #[tokio::test]
1068    async fn test_offset_stream_chunked_multi_chunk() {
1069        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 123)
1070            .map(|i| (i % 256) as u8)
1071            .collect();
1072        assert_offset_stream_chunked_matches(&data).await;
1073    }
1074
1075    #[tokio::test]
1076    async fn test_offset_stream_chunked_three_level_tree() {
1077        // 129 leaves needs a three-level tree, exercising intermediate-node
1078        // re-queueing in the chunk-granular walk.
1079        let refs_per_chunk = DEFAULT_BODY_SIZE / super::super::constants::REF_SIZE;
1080        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * (refs_per_chunk + 1))
1081            .map(|i| (i % 256) as u8)
1082            .collect();
1083        assert_offset_stream_chunked_matches(&data).await;
1084    }
1085
1086    #[tokio::test]
1087    async fn test_offset_stream_chunked_concurrency_one() {
1088        // Width 1 still yields every leaf with the right offset.
1089        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 7)
1090            .map(|i| (i % 256) as u8)
1091            .collect();
1092        let (root, store) = split_and_store(&data);
1093        let joiner = Joiner::new(store, root).await.unwrap().with_concurrency(1);
1094        let total = joiner.size();
1095        let mut reassembled = vec![0u8; total as usize];
1096        let stream = joiner.into_offset_stream_chunked();
1097        futures::pin_mut!(stream);
1098        while let Some(pair) = stream.next().await {
1099            let (offset, body) = pair.unwrap();
1100            let start = offset as usize;
1101            reassembled[start..start + body.len()].copy_from_slice(&body);
1102        }
1103        assert_eq!(reassembled, data);
1104    }
1105
1106    /// A getter that holds each fetch open across several executor yields so the
1107    /// test can observe how many fetches the consumer admits at once, proving the
1108    /// chunk-granular stream reaches per-chunk (not per-subtree) concurrency.
1109    #[derive(Clone)]
1110    struct ConcurrencyProbe {
1111        chunks: Arc<HashMap<ChunkAddress, AnyChunk>>,
1112        in_flight: Arc<std::sync::atomic::AtomicUsize>,
1113        max_in_flight: Arc<std::sync::atomic::AtomicUsize>,
1114    }
1115
1116    impl crate::store::ChunkGet<DEFAULT_BODY_SIZE> for ConcurrencyProbe {
1117        type Error = crate::store::ChunkStoreError;
1118
1119        async fn get(&self, address: &ChunkAddress) -> std::result::Result<AnyChunk, Self::Error> {
1120            use std::sync::atomic::Ordering;
1121            let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1122            self.max_in_flight.fetch_max(now, Ordering::SeqCst);
1123            // Yield several times so concurrently-admitted fetches overlap here
1124            // before any resolves; the peak counter then reflects pool width.
1125            for _ in 0..8 {
1126                tokio::task::yield_now().await;
1127            }
1128            self.in_flight.fetch_sub(1, Ordering::SeqCst);
1129            self.chunks
1130                .get(address)
1131                .cloned()
1132                .ok_or_else(|| crate::store::ChunkStoreError::not_found(address))
1133        }
1134    }
1135
1136    #[tokio::test(flavor = "current_thread")]
1137    async fn test_offset_stream_chunked_per_chunk_concurrency() {
1138        // A flat two-level tree: one intermediate over many leaves. The
1139        // subtree-granular stream would walk it as a single sequential descent
1140        // (in-flight = 1 leaf at a time); the chunk-granular stream fans the
1141        // leaves across the width.
1142        let leaves = 40usize;
1143        let width = 16usize;
1144        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * leaves)
1145            .map(|i| (i % 256) as u8)
1146            .collect();
1147        let (root, store) = split_and_store(&data);
1148
1149        let probe = ConcurrencyProbe {
1150            chunks: Arc::new(store),
1151            in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1152            max_in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1153        };
1154        let max_seen = Arc::clone(&probe.max_in_flight);
1155
1156        let joiner = Joiner::new(probe, root)
1157            .await
1158            .unwrap()
1159            .with_concurrency(width);
1160
1161        let total = joiner.size();
1162        let mut reassembled = vec![0u8; total as usize];
1163        let stream = joiner.into_offset_stream_chunked();
1164        futures::pin_mut!(stream);
1165        while let Some(pair) = stream.next().await {
1166            let (offset, body) = pair.unwrap();
1167            let start = offset as usize;
1168            reassembled[start..start + body.len()].copy_from_slice(&body);
1169        }
1170        assert_eq!(reassembled, data, "concurrency probe still reassembles");
1171
1172        let peak = max_seen.load(std::sync::atomic::Ordering::SeqCst);
1173        assert!(
1174            peak >= width,
1175            "chunk-granular stream should reach width {width} in-flight, saw {peak}"
1176        );
1177    }
1178
1179    /// Drain `into_offset_stream_chunked_range(start, len)`, reassemble the
1180    /// clipped `(offset, bytes)` pairs over `[start, start + len)`, and assert it
1181    /// equals `read_range(start, len)` byte-for-byte. Runs at the default width
1182    /// and at width 1.
1183    async fn assert_offset_stream_chunked_range_matches(data: &[u8], start: u64, len: u64) {
1184        for width in [DEFAULT_ASYNC_CONCURRENCY, 1] {
1185            let (root, store) = split_and_store(data);
1186
1187            let expected = Joiner::new(store.clone(), root)
1188                .await
1189                .unwrap()
1190                .read_range(start, len as usize)
1191                .await
1192                .unwrap();
1193
1194            let joiner = Joiner::new(store, root)
1195                .await
1196                .unwrap()
1197                .with_concurrency(width);
1198            let pairs: Vec<Result<(u64, Bytes)>> = joiner
1199                .into_offset_stream_chunked_range(start, len)
1200                .collect()
1201                .await;
1202
1203            let mut reassembled = vec![0u8; expected.len()];
1204            let mut seen_offsets = std::collections::HashSet::new();
1205            for pair in pairs {
1206                let (offset, body) = pair.unwrap();
1207                assert!(
1208                    offset >= start && offset + body.len() as u64 <= start + len,
1209                    "offset {offset} (+{}) outside [{start}, {})",
1210                    body.len(),
1211                    start + len
1212                );
1213                assert!(
1214                    seen_offsets.insert(offset),
1215                    "offset {offset} yielded more than once (width {width})"
1216                );
1217                let rel = (offset - start) as usize;
1218                reassembled[rel..rel + body.len()].copy_from_slice(&body);
1219            }
1220
1221            assert_eq!(
1222                reassembled, expected,
1223                "range reassembly equals read_range (width {width}, start {start}, len {len})"
1224            );
1225        }
1226    }
1227
1228    #[tokio::test]
1229    async fn test_offset_stream_chunked_range_windows() {
1230        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 321)
1231            .map(|i| (i % 256) as u8)
1232            .collect();
1233        let bs = DEFAULT_BODY_SIZE as u64;
1234        let total = data.len() as u64;
1235
1236        // sub-leaf: start and len both inside one leaf
1237        assert_offset_stream_chunked_range_matches(&data, bs + 10, 50).await;
1238        // leaf-aligned single leaf
1239        assert_offset_stream_chunked_range_matches(&data, bs, bs).await;
1240        // spans several leaves, partial at both ends
1241        assert_offset_stream_chunked_range_matches(&data, bs / 2, bs * 3 + 7).await;
1242        // last partial leaf
1243        assert_offset_stream_chunked_range_matches(&data, bs * 5, total - bs * 5).await;
1244        // whole file (must equal read_all)
1245        assert_offset_stream_chunked_range_matches(&data, 0, total).await;
1246        // zero-len (empty)
1247        assert_offset_stream_chunked_range_matches(&data, bs, 0).await;
1248    }
1249
1250    #[tokio::test]
1251    async fn test_offset_stream_chunked_range_whole_equals_chunked() {
1252        // A whole-file range must reproduce `into_offset_stream_chunked` exactly:
1253        // same leaves, same absolute offsets, same bodies.
1254        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 99)
1255            .map(|i| (i % 256) as u8)
1256            .collect();
1257        let (root, store) = split_and_store(&data);
1258
1259        let full = Joiner::new(store.clone(), root).await.unwrap();
1260        let total = full.size();
1261        let mut from_full: Vec<(u64, Vec<u8>)> = full
1262            .into_offset_stream_chunked()
1263            .collect::<Vec<_>>()
1264            .await
1265            .into_iter()
1266            .map(|p| {
1267                let (o, b) = p.unwrap();
1268                (o, b.to_vec())
1269            })
1270            .collect();
1271        from_full.sort_by_key(|(o, _)| *o);
1272
1273        let ranged = Joiner::new(store, root).await.unwrap();
1274        let mut from_range: Vec<(u64, Vec<u8>)> = ranged
1275            .into_offset_stream_chunked_range(0, total)
1276            .collect::<Vec<_>>()
1277            .await
1278            .into_iter()
1279            .map(|p| {
1280                let (o, b) = p.unwrap();
1281                (o, b.to_vec())
1282            })
1283            .collect();
1284        from_range.sort_by_key(|(o, _)| *o);
1285
1286        assert_eq!(
1287            from_range, from_full,
1288            "whole-file range equals chunked walk"
1289        );
1290
1291        // And the reassembly equals read_all.
1292        let expected = Joiner::new(split_and_store(&data).1, root)
1293            .await
1294            .unwrap()
1295            .read_all()
1296            .await
1297            .unwrap();
1298        let mut reassembled = vec![0u8; total as usize];
1299        for (o, b) in &from_range {
1300            reassembled[*o as usize..*o as usize + b.len()].copy_from_slice(b);
1301        }
1302        assert_eq!(reassembled, expected);
1303    }
1304
1305    #[cfg(feature = "tokio")]
1306    #[tokio::test]
1307    async fn test_reader_small() {
1308        use tokio::io::AsyncReadExt;
1309
1310        let data = b"hello world";
1311        let (root, store) = split_and_store(data);
1312
1313        let joiner = Joiner::new(store, root).await.unwrap();
1314        let mut reader = joiner.into_reader();
1315        let mut result = Vec::new();
1316        reader.read_to_end(&mut result).await.unwrap();
1317        assert_eq!(result, data);
1318    }
1319
1320    #[cfg(feature = "tokio")]
1321    #[tokio::test]
1322    async fn test_reader_multi_chunk() {
1323        use tokio::io::AsyncReadExt;
1324
1325        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3 + 123)
1326            .map(|i| (i % 256) as u8)
1327            .collect();
1328        let (root, store) = split_and_store(&data);
1329
1330        let joiner = Joiner::new(store, root).await.unwrap();
1331        let mut reader = joiner.into_reader();
1332        let mut result = Vec::new();
1333        reader.read_to_end(&mut result).await.unwrap();
1334        assert_eq!(result, data);
1335    }
1336
1337    #[cfg(feature = "tokio")]
1338    #[tokio::test]
1339    async fn test_reader_seek() {
1340        use tokio::io::{AsyncReadExt, AsyncSeekExt};
1341
1342        let data = b"hello world";
1343        let (root, store) = split_and_store(data);
1344
1345        let joiner = Joiner::new(store, root).await.unwrap();
1346        let mut reader = joiner.into_reader();
1347
1348        reader.seek(SeekFrom::Start(6)).await.unwrap();
1349        let mut buf = vec![0u8; 5];
1350        reader.read_exact(&mut buf).await.unwrap();
1351        assert_eq!(&buf, b"world");
1352    }
1353
1354    #[cfg(feature = "tokio")]
1355    #[tokio::test]
1356    async fn test_reader_seek_back_and_forth() {
1357        use tokio::io::{AsyncReadExt, AsyncSeekExt};
1358
1359        let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3)
1360            .map(|i| (i % 256) as u8)
1361            .collect();
1362        let (root, store) = split_and_store(&data);
1363
1364        let joiner = Joiner::new(store, root).await.unwrap();
1365        let mut reader = joiner.into_reader();
1366
1367        // Read from middle
1368        reader
1369            .seek(SeekFrom::Start(DEFAULT_BODY_SIZE as u64))
1370            .await
1371            .unwrap();
1372        let mut buf1 = vec![0u8; 100];
1373        reader.read_exact(&mut buf1).await.unwrap();
1374        assert_eq!(&buf1, &data[DEFAULT_BODY_SIZE..DEFAULT_BODY_SIZE + 100]);
1375
1376        // Seek back to start
1377        reader.seek(SeekFrom::Start(0)).await.unwrap();
1378        let mut buf2 = vec![0u8; 100];
1379        reader.read_exact(&mut buf2).await.unwrap();
1380        assert_eq!(&buf2, &data[..100]);
1381
1382        // Seek to near-end
1383        reader.seek(SeekFrom::End(-50)).await.unwrap();
1384        let mut buf3 = vec![0u8; 50];
1385        reader.read_exact(&mut buf3).await.unwrap();
1386        assert_eq!(&buf3, &data[data.len() - 50..]);
1387    }
1388
1389    #[cfg(feature = "encryption")]
1390    mod encrypted {
1391        use super::*;
1392        use crate::file::split_encrypted;
1393
1394        fn encrypted_split_and_store(
1395            data: &[u8],
1396        ) -> (
1397            crate::chunk::encryption::EncryptedChunkRef,
1398            HashMap<ChunkAddress, AnyChunk>,
1399        ) {
1400            let (root_ref, store) = split_encrypted::<DEFAULT_BODY_SIZE>(data).unwrap();
1401            (root_ref, store.into_chunks())
1402        }
1403
1404        // --- Generated shared tests (async variants) ---
1405        generate_encrypted_joiner_tests!(tokio::test, EncryptedJoiner, [async], [await]);
1406
1407        // --- Async-only tests: Stream ---
1408
1409        #[tokio::test]
1410        async fn test_encrypted_joiner_stream() {
1411            let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 3)
1412                .map(|i| (i % 256) as u8)
1413                .collect();
1414            let (root_ref, store) = encrypted_split_and_store(&data);
1415
1416            let joiner = EncryptedJoiner::new(store, root_ref).await.unwrap();
1417            let chunks: Vec<Result<Bytes>> = joiner.into_stream().collect().await;
1418
1419            let mut recovered = Vec::new();
1420            for chunk in chunks {
1421                recovered.extend_from_slice(&chunk.unwrap());
1422            }
1423            assert_eq!(recovered, data);
1424        }
1425
1426        /// Encrypted analogue of `assert_offset_stream_chunked_range_matches`.
1427        /// Encrypted leaf bodies are shorter than `BODY_SIZE` (the span is
1428        /// stripped), so clipping must key off `body.len()`, never a stride.
1429        async fn assert_encrypted_range_matches(data: &[u8], start: u64, len: u64) {
1430            for width in [DEFAULT_ASYNC_CONCURRENCY, 1] {
1431                let (root_ref, store) = encrypted_split_and_store(data);
1432
1433                let expected = EncryptedJoiner::new(store.clone(), root_ref.clone())
1434                    .await
1435                    .unwrap()
1436                    .read_range(start, len as usize)
1437                    .await
1438                    .unwrap();
1439
1440                let joiner = EncryptedJoiner::new(store, root_ref)
1441                    .await
1442                    .unwrap()
1443                    .with_concurrency(width);
1444                let pairs: Vec<Result<(u64, Bytes)>> = joiner
1445                    .into_offset_stream_chunked_range(start, len)
1446                    .collect()
1447                    .await;
1448
1449                let mut reassembled = vec![0u8; expected.len()];
1450                let mut seen_offsets = std::collections::HashSet::new();
1451                for pair in pairs {
1452                    let (offset, body) = pair.unwrap();
1453                    assert!(
1454                        offset >= start && offset + body.len() as u64 <= start + len,
1455                        "offset {offset} (+{}) outside [{start}, {})",
1456                        body.len(),
1457                        start + len
1458                    );
1459                    assert!(
1460                        seen_offsets.insert(offset),
1461                        "offset {offset} yielded more than once (width {width})"
1462                    );
1463                    let rel = (offset - start) as usize;
1464                    reassembled[rel..rel + body.len()].copy_from_slice(&body);
1465                }
1466
1467                assert_eq!(
1468                    reassembled, expected,
1469                    "encrypted range equals read_range (width {width}, start {start}, len {len})"
1470                );
1471            }
1472        }
1473
1474        #[tokio::test]
1475        async fn test_encrypted_offset_stream_chunked_range_windows() {
1476            let data: Vec<u8> = (0..DEFAULT_BODY_SIZE * 5 + 321)
1477                .map(|i| (i % 256) as u8)
1478                .collect();
1479            let bs = DEFAULT_BODY_SIZE as u64;
1480            let total = data.len() as u64;
1481
1482            // sub-leaf
1483            assert_encrypted_range_matches(&data, bs + 10, 50).await;
1484            // leaf-aligned single leaf
1485            assert_encrypted_range_matches(&data, bs, bs).await;
1486            // spans several leaves
1487            assert_encrypted_range_matches(&data, bs / 2, bs * 3 + 7).await;
1488            // last partial leaf
1489            assert_encrypted_range_matches(&data, bs * 5, total - bs * 5).await;
1490            // whole file
1491            assert_encrypted_range_matches(&data, 0, total).await;
1492            // zero-len
1493            assert_encrypted_range_matches(&data, bs, 0).await;
1494        }
1495    }
1496}