Skip to main content

oxigeo_streaming/io/
reader.rs

1//! Chunked reader for efficient sequential reading.
2
3use super::buffer::ChunkedBuffer;
4use super::chunked::{ChunkStrategy, ChunkedIO, FileChunkedIO};
5use crate::error::{Result, StreamingError};
6use bytes::Bytes;
7use std::path::Path;
8use std::sync::Arc;
9use tokio::sync::Semaphore;
10use tracing::info;
11
12/// A reader that processes data in chunks.
13pub struct ChunkedReader {
14    /// The underlying chunked I/O
15    io: Box<dyn ChunkedIO>,
16
17    /// Chunk buffer
18    buffer: ChunkedBuffer,
19
20    /// Current chunk index
21    current_index: usize,
22
23    /// Total number of chunks
24    total_chunks: usize,
25
26    /// Total size in bytes
27    total_size: u64,
28
29    /// Prefetch semaphore
30    prefetch_semaphore: Arc<Semaphore>,
31
32    /// Number of chunks to prefetch
33    prefetch_count: usize,
34}
35
36impl ChunkedReader {
37    /// Create a new chunked reader from a file.
38    pub async fn from_file<P: AsRef<Path>>(
39        path: P,
40        strategy: ChunkStrategy,
41        buffer_size: usize,
42        prefetch_count: usize,
43    ) -> Result<Self> {
44        let mut io = FileChunkedIO::new(path, strategy).await?;
45        io.open_read().await?;
46
47        let total_size = io.total_size().await?;
48        let chunk_size = strategy.chunk_size_for_index(0, 0);
49        let buffer = ChunkedBuffer::new(chunk_size, buffer_size);
50        let total_chunks = buffer.calculate_chunks(total_size);
51
52        info!(
53            "Created chunked reader: {} chunks, {} bytes total",
54            total_chunks, total_size
55        );
56
57        Ok(Self {
58            io: Box::new(io),
59            buffer,
60            current_index: 0,
61            total_chunks,
62            total_size,
63            prefetch_semaphore: Arc::new(Semaphore::new(prefetch_count)),
64            prefetch_count,
65        })
66    }
67
68    /// Read the next chunk.
69    pub async fn read_chunk(&mut self) -> Result<Option<Bytes>> {
70        if self.current_index >= self.total_chunks {
71            return Ok(None);
72        }
73
74        // Serve from the read-ahead buffer, but only when it actually holds the
75        // chunk we are about to deliver.
76        //
77        // This used to be an unconditional `self.buffer.pop().await?`. On a
78        // fresh reader the buffer is empty and not yet write-complete, which
79        // `ChunkedBuffer::pop` reports as `Other("No chunks available")` — so
80        // `?` turned "nothing prefetched yet" into a hard error and the direct
81        // read below was unreachable. The very first `read_chunk()` on any
82        // `ChunkedReader` therefore failed, always.
83        if self
84            .buffer
85            .peek()
86            .await?
87            .is_some_and(|descriptor| descriptor.index == self.current_index)
88            && let Some((_, data)) = self.buffer.pop().await?
89        {
90            self.current_index += 1;
91            self.start_prefetch().await?;
92            return Ok(Some(data));
93        }
94
95        // Read directly
96        let descriptor = self
97            .buffer
98            .descriptor_for_index(self.current_index, self.total_size);
99        let data = self.io.read_chunk(&descriptor).await?;
100
101        self.current_index += 1;
102        self.start_prefetch().await?;
103
104        Ok(Some(data))
105    }
106
107    /// Fill the read-ahead buffer with a contiguous run of chunks starting at
108    /// the next index the caller will ask for.
109    ///
110    /// Read-ahead only runs once the buffer has drained, so the buffered chunks
111    /// are always contiguous and in stream order — which is what
112    /// [`ChunkedBuffer::push`] requires. The previous version pushed from
113    /// `current_index` *after* it had been advanced past a chunk that had been
114    /// read directly (bypassing the buffer), so the buffer's write cursor was
115    /// still at 0 while the pushed index was 1 and every prefetch failed with
116    /// `InvalidOperation("Expected chunk 0, got 1")`.
117    async fn start_prefetch(&mut self) -> Result<()> {
118        // Refill only on a drained buffer; otherwise the chunks already queued
119        // are still ahead of the caller and nothing needs doing.
120        if !self.buffer.is_empty().await {
121            return Ok(());
122        }
123
124        let start_index = self.current_index;
125        let end_index = (start_index + self.prefetch_count).min(self.total_chunks);
126        if start_index >= end_index {
127            return Ok(());
128        }
129
130        // Direct reads bypass the buffer, so its write cursor lags the stream.
131        // Re-base it now that the buffer is drained, or `push` will reject the
132        // run below.
133        if !self.buffer.rebase_if_empty(start_index).await {
134            return Ok(());
135        }
136
137        for index in start_index..end_index {
138            if self.prefetch_semaphore.available_permits() == 0 {
139                break;
140            }
141
142            let descriptor = self.buffer.descriptor_for_index(index, self.total_size);
143
144            // Prefetch this chunk
145            let _permit = self
146                .prefetch_semaphore
147                .try_acquire()
148                .map_err(|_| StreamingError::Other("Failed to acquire permit".to_string()))?;
149
150            let data = self.io.read_chunk(&descriptor).await?;
151            self.buffer.push(descriptor, data).await?;
152        }
153
154        Ok(())
155    }
156
157    /// Get the total number of chunks.
158    pub fn total_chunks(&self) -> usize {
159        self.total_chunks
160    }
161
162    /// Get the current chunk index.
163    pub fn current_index(&self) -> usize {
164        self.current_index
165    }
166
167    /// Check if there are more chunks to read.
168    pub fn has_more(&self) -> bool {
169        self.current_index < self.total_chunks
170    }
171
172    /// Get progress percentage.
173    pub fn progress(&self) -> f64 {
174        if self.total_chunks == 0 {
175            100.0
176        } else {
177            (self.current_index as f64 / self.total_chunks as f64) * 100.0
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::env;
186    use std::sync::atomic::{AtomicU64, Ordering};
187    use tokio::fs::File;
188    use tokio::io::AsyncWriteExt;
189
190    /// Per-test scratch fixture inside the system temp dir (house policy: no
191    /// hardcoded absolute paths).
192    ///
193    /// The leaf name embeds the process id and a monotonic counter, so no two
194    /// test binaries — nor two concurrent runs of this one — can ever land on
195    /// the same file.  Dropping the guard removes the fixture, so a panicking
196    /// test leaks nothing.
197    struct TempPath(std::path::PathBuf);
198
199    impl TempPath {
200        fn new(name: &str) -> Self {
201            static COUNTER: AtomicU64 = AtomicU64::new(0);
202            let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
203            Self(env::temp_dir().join(format!(
204                "oxigeo_streaming_io_reader_{}_{seq}_{name}",
205                std::process::id()
206            )))
207        }
208    }
209
210    impl std::ops::Deref for TempPath {
211        type Target = std::path::Path;
212
213        fn deref(&self) -> &std::path::Path {
214            &self.0
215        }
216    }
217
218    impl AsRef<std::path::Path> for TempPath {
219        fn as_ref(&self) -> &std::path::Path {
220            &self.0
221        }
222    }
223
224    impl Drop for TempPath {
225        fn drop(&mut self) {
226            let _ = std::fs::remove_file(&self.0);
227        }
228    }
229
230    #[tokio::test]
231    async fn test_chunked_reader() {
232        let test_path = TempPath::new("chunked_read.dat");
233
234        // Create a 10 KiB test file of known content.
235        let mut f = File::create(&test_path)
236            .await
237            .expect("fixture file should be creatable");
238        let data = vec![42u8; 10240];
239        f.write_all(&data)
240            .await
241            .expect("fixture should be writable");
242        f.flush().await.expect("fixture should flush");
243        drop(f);
244
245        // 10240 bytes at 1024 bytes per chunk is exactly 10 chunks.
246        let reader = ChunkedReader::from_file(&test_path, ChunkStrategy::FixedSize(1024), 10240, 2)
247            .await
248            .expect("a 10 KiB readable file must open as a ChunkedReader");
249
250        let mut reader = reader;
251        assert_eq!(
252            reader.total_chunks(),
253            10,
254            "10240 bytes at 1024 bytes/chunk is 10 chunks"
255        );
256        assert_eq!(reader.current_index(), 0, "nothing read yet");
257        assert!(reader.has_more(), "a fresh reader has chunks pending");
258
259        // Drain the reader and check both the framing and the bytes.
260        let mut chunks = 0usize;
261        let mut total = 0usize;
262        while let Some(chunk) = reader
263            .read_chunk()
264            .await
265            .expect("reading a well-formed chunk must succeed")
266        {
267            assert_eq!(
268                chunk.len(),
269                1024,
270                "chunk {chunks} must be a full 1024 bytes"
271            );
272            assert!(
273                chunk.iter().all(|&b| b == 42),
274                "chunk {chunks} must return the bytes that were written"
275            );
276            chunks += 1;
277            total += chunk.len();
278        }
279
280        assert_eq!(chunks, 10, "the reader must yield every chunk exactly once");
281        assert_eq!(total, 10240, "the reader must yield every byte of the file");
282        assert!(!reader.has_more(), "a drained reader has nothing pending");
283        assert!(
284            (reader.progress() - 100.0).abs() < 1e-9,
285            "a drained reader must report 100% progress, got {}",
286            reader.progress()
287        );
288    }
289}