Skip to main content

ipfrs_storage/
block_stream.rs

1//! Block stream iterator with backpressure for large CAR/tensor exports.
2//!
3//! Provides [`BlockStreamIterator`] for memory-efficient iteration over large
4//! block exports.  Backpressure is applied synchronously: when the internal
5//! buffer is full, [`BlockStreamIterator::next_chunk`] returns `None` until
6//! the consumer calls [`BlockStreamIterator::drain_one`].
7
8use std::collections::VecDeque;
9
10// ── BlockChunk ────────────────────────────────────────────────────────────────
11
12/// A single unit of work produced by [`BlockStreamIterator`].
13///
14/// `cids` and `data` are parallel vectors: `cids[i]` is the CID of `data[i]`.
15/// When [`StreamConfig::include_data`] is `false`, `data` contains only empty
16/// `Vec`s (but `cids` is still populated).
17#[derive(Debug, Clone)]
18pub struct BlockChunk {
19    /// CID strings in this chunk.
20    pub cids: Vec<String>,
21    /// Block data corresponding 1-to-1 with `cids`.
22    pub data: Vec<Vec<u8>>,
23    /// Zero-based index of this chunk in the stream.
24    pub chunk_index: u64,
25    /// `true` for the last chunk of the stream.
26    pub is_final: bool,
27}
28
29impl BlockChunk {
30    /// Total bytes across all data vectors in this chunk.
31    #[inline]
32    pub fn total_bytes(&self) -> usize {
33        self.data.iter().map(|d| d.len()).sum()
34    }
35
36    /// Number of blocks in this chunk.
37    #[inline]
38    pub fn len(&self) -> usize {
39        self.cids.len()
40    }
41
42    /// Returns `true` when the chunk contains no blocks.
43    #[inline]
44    pub fn is_empty(&self) -> bool {
45        self.cids.is_empty()
46    }
47}
48
49// ── StreamConfig ──────────────────────────────────────────────────────────────
50
51/// Configuration for a [`BlockStreamIterator`].
52#[derive(Debug, Clone)]
53pub struct StreamConfig {
54    /// Number of blocks per chunk (default: 64).
55    pub chunk_size: usize,
56    /// Maximum number of chunks held in the internal buffer before backpressure
57    /// kicks in (default: 4).
58    pub max_buffer_chunks: usize,
59    /// When `false`, `data` vectors in every [`BlockChunk`] are left empty;
60    /// only CIDs are streamed (default: `true`).
61    pub include_data: bool,
62}
63
64impl Default for StreamConfig {
65    fn default() -> Self {
66        Self {
67            chunk_size: 64,
68            max_buffer_chunks: 4,
69            include_data: true,
70        }
71    }
72}
73
74// ── BlockStreamState ──────────────────────────────────────────────────────────
75
76/// Current state of a [`BlockStreamIterator`].
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum BlockStreamState {
79    /// Chunks are being produced normally.
80    Ready,
81    /// Source blocks are exhausted; buffer is being drained.
82    Draining,
83    /// Buffer is full; backpressure applied until a chunk is drained.
84    Paused,
85    /// All blocks have been produced and drained.
86    Exhausted,
87    /// An error occurred.
88    Error(String),
89}
90
91// ── StreamStats ───────────────────────────────────────────────────────────────
92
93/// Cumulative statistics for a [`BlockStreamIterator`].
94#[derive(Debug, Clone, Default)]
95pub struct StreamStats {
96    /// Total chunks pushed into the buffer.
97    pub chunks_produced: u64,
98    /// Total chunks removed from the buffer by the consumer.
99    pub chunks_drained: u64,
100    /// Total bytes that have left the buffer (sum of `total_bytes` of drained
101    /// chunks).
102    pub bytes_streamed: u64,
103    /// Number of times `next_chunk` returned `None` due to a full buffer.
104    pub backpressure_events: u64,
105}
106
107// ── BlockStreamIterator ───────────────────────────────────────────────────────
108
109/// Synchronous, single-threaded streaming iterator over a block collection.
110///
111/// # Backpressure
112///
113/// If the internal buffer reaches [`StreamConfig::max_buffer_chunks`] chunks,
114/// [`next_chunk`](BlockStreamIterator::next_chunk) returns `None` and
115/// increments [`StreamStats::backpressure_events`].  The caller must call
116/// [`drain_one`](BlockStreamIterator::drain_one) before production can resume.
117///
118/// # Example
119///
120/// ```rust
121/// use ipfrs_storage::block_stream::{BlockStreamIterator, StreamConfig};
122///
123/// let blocks: Vec<(String, Vec<u8>)> = (0u8..10)
124///     .map(|i| (format!("cid-{i}"), vec![i; 32]))
125///     .collect();
126/// let mut iter = BlockStreamIterator::new(blocks, StreamConfig::default());
127///
128/// while !iter.is_exhausted() {
129///     if let Some(chunk) = iter.next_chunk() {
130///         println!("chunk {}: {} blocks", chunk.chunk_index, chunk.len());
131///     } else {
132///         // backpressure — drain before continuing
133///         iter.drain_one();
134///     }
135/// }
136/// // drain any remaining buffered chunks
137/// while let Some(_chunk) = iter.drain_one() {}
138/// ```
139#[derive(Debug)]
140pub struct BlockStreamIterator {
141    /// Source (CID, data) pairs.
142    pub all_blocks: Vec<(String, Vec<u8>)>,
143    /// Current read position in `all_blocks`.
144    pub position: usize,
145    /// Stream configuration.
146    pub config: StreamConfig,
147    /// Buffered but not yet consumed chunks.
148    pub buffer: VecDeque<BlockChunk>,
149    /// Current state of the iterator.
150    pub state: BlockStreamState,
151    /// Accumulated statistics.
152    pub stats: StreamStats,
153}
154
155impl BlockStreamIterator {
156    /// Construct a new iterator over `blocks` with the given `config`.
157    pub fn new(blocks: Vec<(String, Vec<u8>)>, config: StreamConfig) -> Self {
158        let state = if blocks.is_empty() {
159            BlockStreamState::Exhausted
160        } else {
161            BlockStreamState::Ready
162        };
163        Self {
164            all_blocks: blocks,
165            position: 0,
166            config,
167            buffer: VecDeque::new(),
168            state,
169            stats: StreamStats::default(),
170        }
171    }
172
173    // ── Public API ────────────────────────────────────────────────────────────
174
175    /// Attempt to produce the next chunk.
176    ///
177    /// Returns `None` when:
178    /// - The buffer is already at capacity (backpressure — call [`drain_one`](Self::drain_one)).
179    /// - The iterator is exhausted.
180    ///
181    /// On success, advances `position`, pushes a [`BlockChunk`] onto the
182    /// buffer, and returns a clone of that chunk.
183    pub fn next_chunk(&mut self) -> Option<BlockChunk> {
184        // Cannot produce if already exhausted.
185        if matches!(self.state, BlockStreamState::Exhausted) {
186            return None;
187        }
188        // Backpressure: buffer is full.
189        if self.buffer.len() >= self.config.max_buffer_chunks {
190            self.stats.backpressure_events += 1;
191            self.state = BlockStreamState::Paused;
192            return None;
193        }
194        // Nothing left to produce.
195        if self.position >= self.all_blocks.len() {
196            // Transition to Draining / Exhausted.
197            if self.buffer.is_empty() {
198                self.state = BlockStreamState::Exhausted;
199            } else {
200                self.state = BlockStreamState::Draining;
201            }
202            return None;
203        }
204
205        // Slice the next chunk out of all_blocks.
206        let start = self.position;
207        let end = (start + self.config.chunk_size).min(self.all_blocks.len());
208        let is_final = end >= self.all_blocks.len();
209
210        let mut cids = Vec::with_capacity(end - start);
211        let mut data = Vec::with_capacity(end - start);
212
213        for (cid, block_data) in &self.all_blocks[start..end] {
214            cids.push(cid.clone());
215            if self.config.include_data {
216                data.push(block_data.clone());
217            } else {
218                data.push(Vec::new());
219            }
220        }
221
222        self.position = end;
223
224        let chunk_index = self.stats.chunks_produced;
225        let chunk = BlockChunk {
226            cids,
227            data,
228            chunk_index,
229            is_final,
230        };
231
232        self.stats.chunks_produced += 1;
233        self.buffer.push_back(chunk.clone());
234
235        // Update state.
236        if is_final {
237            self.state = BlockStreamState::Draining;
238        } else if self.buffer.len() >= self.config.max_buffer_chunks {
239            self.state = BlockStreamState::Paused;
240        } else {
241            self.state = BlockStreamState::Ready;
242        }
243
244        Some(chunk)
245    }
246
247    /// Remove and return the oldest buffered chunk.
248    ///
249    /// Updates [`StreamStats::chunks_drained`] and [`StreamStats::bytes_streamed`].
250    /// If the buffer becomes empty and all source blocks have been consumed,
251    /// transitions to [`BlockStreamState::Exhausted`].
252    pub fn drain_one(&mut self) -> Option<BlockChunk> {
253        let chunk = self.buffer.pop_front()?;
254        self.stats.chunks_drained += 1;
255        self.stats.bytes_streamed += chunk.total_bytes() as u64;
256
257        // After draining, transition out of Paused if we were blocked.
258        let buffer_was_full = matches!(self.state, BlockStreamState::Paused);
259        if buffer_was_full && self.buffer.len() < self.config.max_buffer_chunks {
260            if self.position < self.all_blocks.len() {
261                self.state = BlockStreamState::Ready;
262            } else if self.buffer.is_empty() {
263                self.state = BlockStreamState::Exhausted;
264            } else {
265                self.state = BlockStreamState::Draining;
266            }
267        } else if self.buffer.is_empty() && self.position >= self.all_blocks.len() {
268            self.state = BlockStreamState::Exhausted;
269        }
270
271        Some(chunk)
272    }
273
274    /// Fill the buffer from source blocks up to `max_buffer_chunks`.
275    ///
276    /// Calls [`next_chunk`](Self::next_chunk) repeatedly until the buffer is
277    /// full, backpressure fires, or the source is exhausted.
278    pub fn fill_buffer(&mut self) {
279        while self.buffer.len() < self.config.max_buffer_chunks
280            && self.position < self.all_blocks.len()
281            && !matches!(
282                self.state,
283                BlockStreamState::Exhausted | BlockStreamState::Error(_)
284            )
285        {
286            if self.next_chunk().is_none() {
287                break;
288            }
289        }
290    }
291
292    /// Returns `true` when all blocks have been produced **and** the buffer
293    /// has been fully drained.
294    #[inline]
295    pub fn is_exhausted(&self) -> bool {
296        matches!(self.state, BlockStreamState::Exhausted)
297    }
298
299    /// Number of source blocks not yet chunked.
300    #[inline]
301    pub fn remaining_blocks(&self) -> usize {
302        self.all_blocks.len().saturating_sub(self.position)
303    }
304
305    /// Number of chunks currently sitting in the buffer.
306    #[inline]
307    pub fn buffered_chunks(&self) -> usize {
308        self.buffer.len()
309    }
310}
311
312// ── Tests ─────────────────────────────────────────────────────────────────────
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    /// Build a simple block list of `n` entries.
319    fn make_blocks(n: usize) -> Vec<(String, Vec<u8>)> {
320        (0..n)
321            .map(|i| (format!("cid-{i:04}"), vec![i as u8; 32]))
322            .collect()
323    }
324
325    // ── 1. next_chunk produces correct chunk sizes ────────────────────────────
326
327    #[test]
328    fn test_chunk_size_exact_multiple() {
329        let blocks = make_blocks(64);
330        let config = StreamConfig {
331            chunk_size: 16,
332            max_buffer_chunks: 10,
333            include_data: true,
334        };
335        let mut iter = BlockStreamIterator::new(blocks, config);
336
337        let chunk = iter.next_chunk().expect("should produce chunk");
338        assert_eq!(chunk.len(), 16);
339        assert_eq!(chunk.cids.len(), chunk.data.len());
340    }
341
342    #[test]
343    fn test_chunk_size_remainder() {
344        // 70 blocks with chunk_size=32 → first two chunks of 32, last of 6.
345        let blocks = make_blocks(70);
346        let config = StreamConfig {
347            chunk_size: 32,
348            max_buffer_chunks: 10,
349            include_data: true,
350        };
351        let mut iter = BlockStreamIterator::new(blocks, config);
352
353        let c0 = iter.next_chunk().expect("chunk 0");
354        assert_eq!(c0.len(), 32);
355        assert!(!c0.is_final);
356
357        let c1 = iter.next_chunk().expect("chunk 1");
358        assert_eq!(c1.len(), 32);
359        assert!(!c1.is_final);
360
361        let c2 = iter.next_chunk().expect("chunk 2");
362        assert_eq!(c2.len(), 6);
363        assert!(c2.is_final);
364    }
365
366    // ── 2. Final chunk has is_final = true ───────────────────────────────────
367
368    #[test]
369    fn test_is_final_flag() {
370        let blocks = make_blocks(10);
371        let config = StreamConfig {
372            chunk_size: 5,
373            max_buffer_chunks: 10,
374            include_data: true,
375        };
376        let mut iter = BlockStreamIterator::new(blocks, config);
377
378        let c0 = iter.next_chunk().expect("chunk 0");
379        assert!(!c0.is_final);
380
381        let c1 = iter.next_chunk().expect("chunk 1");
382        assert!(c1.is_final);
383    }
384
385    // ── 3. is_exhausted after all chunks produced and drained ────────────────
386
387    #[test]
388    fn test_is_exhausted_after_drain() {
389        let blocks = make_blocks(5);
390        let config = StreamConfig {
391            chunk_size: 5,
392            max_buffer_chunks: 4,
393            include_data: true,
394        };
395        let mut iter = BlockStreamIterator::new(blocks, config);
396
397        iter.next_chunk().expect("chunk");
398        assert!(!iter.is_exhausted());
399
400        iter.drain_one().expect("drain");
401        assert!(iter.is_exhausted());
402    }
403
404    // ── 4. Backpressure: next_chunk returns None when buffer is full ─────────
405
406    #[test]
407    fn test_backpressure_returns_none() {
408        // max_buffer_chunks = 2, chunk_size = 1 so we can fill fast
409        let blocks = make_blocks(10);
410        let config = StreamConfig {
411            chunk_size: 1,
412            max_buffer_chunks: 2,
413            include_data: true,
414        };
415        let mut iter = BlockStreamIterator::new(blocks, config);
416
417        // Fill buffer to capacity.
418        iter.next_chunk().expect("chunk 0");
419        iter.next_chunk().expect("chunk 1");
420        assert_eq!(iter.buffered_chunks(), 2);
421
422        // Now the buffer is full → backpressure.
423        let result = iter.next_chunk();
424        assert!(result.is_none());
425        assert_eq!(iter.stats.backpressure_events, 1);
426        assert!(matches!(iter.state, BlockStreamState::Paused));
427    }
428
429    // ── 5. drain_one releases backpressure ───────────────────────────────────
430
431    #[test]
432    fn test_drain_releases_backpressure() {
433        let blocks = make_blocks(10);
434        let config = StreamConfig {
435            chunk_size: 1,
436            max_buffer_chunks: 2,
437            include_data: true,
438        };
439        let mut iter = BlockStreamIterator::new(blocks, config);
440
441        iter.next_chunk();
442        iter.next_chunk();
443        // Trigger backpressure.
444        assert!(iter.next_chunk().is_none());
445
446        // Drain one → state should become Ready again.
447        iter.drain_one().expect("should drain");
448        assert!(matches!(iter.state, BlockStreamState::Ready));
449
450        // Now we can produce again.
451        let chunk = iter.next_chunk().expect("should produce after drain");
452        assert_eq!(chunk.chunk_index, 2);
453    }
454
455    // ── 6. include_data = false produces empty data vecs ────────────────────
456
457    #[test]
458    fn test_include_data_false() {
459        let blocks = make_blocks(8);
460        let config = StreamConfig {
461            chunk_size: 8,
462            max_buffer_chunks: 4,
463            include_data: false,
464        };
465        let mut iter = BlockStreamIterator::new(blocks, config);
466        let chunk = iter.next_chunk().expect("chunk");
467
468        assert_eq!(chunk.cids.len(), 8);
469        for d in &chunk.data {
470            assert!(d.is_empty());
471        }
472        assert_eq!(chunk.total_bytes(), 0);
473    }
474
475    // ── 7. remaining_blocks decreases as chunks are produced ─────────────────
476
477    #[test]
478    fn test_remaining_blocks_decreases() {
479        let blocks = make_blocks(20);
480        let config = StreamConfig {
481            chunk_size: 10,
482            max_buffer_chunks: 4,
483            include_data: true,
484        };
485        let mut iter = BlockStreamIterator::new(blocks, config);
486
487        assert_eq!(iter.remaining_blocks(), 20);
488        iter.next_chunk();
489        assert_eq!(iter.remaining_blocks(), 10);
490        iter.next_chunk();
491        assert_eq!(iter.remaining_blocks(), 0);
492    }
493
494    // ── 8. Empty input → immediate exhaustion ────────────────────────────────
495
496    #[test]
497    fn test_empty_input_exhausted() {
498        let mut iter = BlockStreamIterator::new(vec![], StreamConfig::default());
499        assert!(iter.is_exhausted());
500        assert!(iter.next_chunk().is_none());
501        assert!(iter.drain_one().is_none());
502        assert_eq!(iter.remaining_blocks(), 0);
503        assert_eq!(iter.buffered_chunks(), 0);
504    }
505
506    // ── 9. Stats accumulate correctly ────────────────────────────────────────
507
508    #[test]
509    fn test_stats_accumulate() {
510        let blocks = make_blocks(6);
511        let config = StreamConfig {
512            chunk_size: 3,
513            max_buffer_chunks: 4,
514            include_data: true,
515        };
516        let mut iter = BlockStreamIterator::new(blocks, config);
517
518        iter.next_chunk().expect("c0");
519        iter.next_chunk().expect("c1");
520
521        assert_eq!(iter.stats.chunks_produced, 2);
522        assert_eq!(iter.stats.chunks_drained, 0);
523
524        iter.drain_one().expect("drain c0");
525        // Each block is 32 bytes, 3 blocks per chunk → 96 bytes.
526        assert_eq!(iter.stats.bytes_streamed, 96);
527        assert_eq!(iter.stats.chunks_drained, 1);
528
529        iter.drain_one().expect("drain c1");
530        assert_eq!(iter.stats.bytes_streamed, 192);
531        assert_eq!(iter.stats.chunks_drained, 2);
532    }
533
534    // ── 10. chunk_index increments monotonically ─────────────────────────────
535
536    #[test]
537    fn test_chunk_index_monotonic() {
538        let blocks = make_blocks(30);
539        let config = StreamConfig {
540            chunk_size: 10,
541            max_buffer_chunks: 4,
542            include_data: true,
543        };
544        let mut iter = BlockStreamIterator::new(blocks, config);
545
546        for expected in 0u64..3 {
547            let chunk = iter.next_chunk().expect("chunk");
548            assert_eq!(chunk.chunk_index, expected);
549        }
550    }
551
552    // ── 11. fill_buffer fills up to max_buffer_chunks ────────────────────────
553
554    #[test]
555    fn test_fill_buffer() {
556        let blocks = make_blocks(100);
557        let config = StreamConfig {
558            chunk_size: 10,
559            max_buffer_chunks: 4,
560            include_data: true,
561        };
562        let mut iter = BlockStreamIterator::new(blocks, config);
563
564        iter.fill_buffer();
565        assert_eq!(iter.buffered_chunks(), 4);
566        assert_eq!(iter.remaining_blocks(), 60);
567    }
568
569    // ── 12. CIDs in chunks match source order ────────────────────────────────
570
571    #[test]
572    fn test_cid_order_preserved() {
573        let blocks = make_blocks(5);
574        let config = StreamConfig {
575            chunk_size: 5,
576            max_buffer_chunks: 4,
577            include_data: true,
578        };
579        let mut iter = BlockStreamIterator::new(blocks.clone(), config);
580        let chunk = iter.next_chunk().expect("chunk");
581
582        for (i, cid) in chunk.cids.iter().enumerate() {
583            assert_eq!(*cid, blocks[i].0);
584        }
585    }
586
587    // ── 13. Single-block input ────────────────────────────────────────────────
588
589    #[test]
590    fn test_single_block() {
591        let blocks = vec![("cid-single".to_string(), vec![0xAB; 64])];
592        let config = StreamConfig::default();
593        let mut iter = BlockStreamIterator::new(blocks, config);
594
595        let chunk = iter.next_chunk().expect("chunk");
596        assert!(chunk.is_final);
597        assert_eq!(chunk.len(), 1);
598        assert_eq!(chunk.total_bytes(), 64);
599
600        // Producing another chunk yields nothing.
601        assert!(iter.next_chunk().is_none());
602
603        // Not exhausted until buffer drained.
604        assert!(!iter.is_exhausted());
605        iter.drain_one().expect("drain");
606        assert!(iter.is_exhausted());
607    }
608
609    // ── 14. Full drain loop exhausts the iterator ────────────────────────────
610
611    #[test]
612    fn test_full_drain_loop() {
613        let n = 50usize;
614        let blocks = make_blocks(n);
615        let config = StreamConfig {
616            chunk_size: 7,
617            max_buffer_chunks: 3,
618            include_data: true,
619        };
620        let mut iter = BlockStreamIterator::new(blocks, config);
621
622        let mut total_blocks_seen = 0usize;
623        while !iter.is_exhausted() {
624            if let Some(chunk) = iter.next_chunk() {
625                total_blocks_seen += chunk.len();
626            } else {
627                // Either backpressure or source done — drain one.
628                if let Some(chunk) = iter.drain_one() {
629                    let _ = chunk;
630                } else {
631                    // Buffer empty and nothing to produce → force exhausted.
632                    break;
633                }
634            }
635        }
636        // Drain anything left in buffer.
637        while iter.drain_one().is_some() {}
638
639        assert!(iter.is_exhausted());
640        assert_eq!(iter.stats.chunks_produced, iter.stats.chunks_drained);
641        // total blocks seen from next_chunk == n
642        assert_eq!(total_blocks_seen, n);
643    }
644
645    // ── 15. backpressure_events counter increments correctly ─────────────────
646
647    #[test]
648    fn test_backpressure_events_counter() {
649        let blocks = make_blocks(20);
650        let config = StreamConfig {
651            chunk_size: 1,
652            max_buffer_chunks: 2,
653            include_data: false,
654        };
655        let mut iter = BlockStreamIterator::new(blocks, config);
656
657        iter.next_chunk();
658        iter.next_chunk();
659        // Trigger 3 backpressure events without draining.
660        iter.next_chunk(); // backpressure #1
661        iter.next_chunk(); // backpressure #2
662        iter.next_chunk(); // backpressure #3
663
664        assert_eq!(iter.stats.backpressure_events, 3);
665    }
666}