Skip to main content

oxigdal_streaming/io/
writer.rs

1//! Chunked writer for efficient sequential writing.
2
3use super::buffer::{ChunkDescriptor, 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::{debug, info, warn};
11
12/// A writer that processes data in chunks.
13pub struct ChunkedWriter {
14    /// The underlying chunked I/O
15    io: Box<dyn ChunkedIO>,
16
17    /// Chunk buffer (retained for future buffered-write support)
18    #[allow(dead_code)]
19    buffer: ChunkedBuffer,
20
21    /// Current chunk index (equals the number of chunks written so far)
22    current_index: usize,
23
24    /// Total size written
25    bytes_written: u64,
26
27    /// Write semaphore
28    write_semaphore: Arc<Semaphore>,
29
30    /// Chunk strategy (retained for future adaptive-size support)
31    #[allow(dead_code)]
32    strategy: ChunkStrategy,
33
34    /// Optional pre-declared total chunk count.
35    ///
36    /// When set, [`finalize`] checks that exactly this many chunks were written
37    /// and returns [`StreamingError::IncompleteFinalize`] on mismatch.
38    preset_total_chunks: Option<usize>,
39}
40
41impl ChunkedWriter {
42    /// Create a new chunked writer to a file.
43    pub async fn from_file<P: AsRef<Path>>(
44        path: P,
45        strategy: ChunkStrategy,
46        buffer_size: usize,
47        max_concurrent_writes: usize,
48    ) -> Result<Self> {
49        let mut io = FileChunkedIO::new(path, strategy).await?;
50        io.open_write().await?;
51
52        let chunk_size = strategy.chunk_size_for_index(0, 0);
53        let buffer = ChunkedBuffer::new(chunk_size, buffer_size);
54
55        info!("Created chunked writer with chunk size {}", chunk_size);
56
57        Ok(Self {
58            io: Box::new(io),
59            buffer,
60            current_index: 0,
61            bytes_written: 0,
62            write_semaphore: Arc::new(Semaphore::new(max_concurrent_writes)),
63            strategy,
64            preset_total_chunks: None,
65        })
66    }
67
68    /// Declare the expected total number of chunks up front.
69    ///
70    /// When `finalize` is called it verifies that exactly `n` chunks were
71    /// written. A mismatch returns [`StreamingError::IncompleteFinalize`].
72    pub fn set_total_chunks(mut self, n: usize) -> Self {
73        self.preset_total_chunks = Some(n);
74        self
75    }
76
77    /// Write a chunk of data.
78    pub async fn write_chunk(&mut self, data: Bytes) -> Result<()> {
79        let _permit = self
80            .write_semaphore
81            .acquire()
82            .await
83            .map_err(|e| StreamingError::Other(e.to_string()))?;
84
85        let offset = self.bytes_written;
86        let length = data.len();
87
88        // `total_chunks` in the descriptor is set to 0 here because we may not
89        // know the final count yet; `finalize` will write a footer or patch the
90        // header with the real value.
91        let descriptor = ChunkDescriptor::new(
92            offset,
93            length,
94            self.current_index,
95            0, // Updated when finalized (see finalize())
96        );
97
98        self.io.write_chunk(&descriptor, data).await?;
99
100        self.current_index += 1;
101        self.bytes_written += length as u64;
102
103        debug!(
104            "Wrote chunk {} ({} bytes), total: {} bytes",
105            descriptor.index, length, self.bytes_written
106        );
107
108        Ok(())
109    }
110
111    /// Write multiple chunks sequentially.
112    pub async fn write_chunks(&mut self, chunks: Vec<Bytes>) -> Result<()> {
113        for chunk in chunks {
114            self.write_chunk(chunk).await?;
115        }
116        Ok(())
117    }
118
119    /// Flush all pending writes.
120    pub async fn flush(&mut self) -> Result<()> {
121        self.io.flush().await?;
122        info!(
123            "Flushed {} bytes in {} chunks",
124            self.bytes_written, self.current_index
125        );
126        Ok(())
127    }
128
129    /// Finalize the writer, validate chunk counts, and close the stream.
130    ///
131    /// If [`set_total_chunks`] was called previously and the actual number of
132    /// chunks written does not match the preset value,
133    /// [`StreamingError::IncompleteFinalize`] is returned *before* the
134    /// underlying I/O is closed so callers can inspect the partial output.
135    ///
136    /// After a successful finalize a footer descriptor containing the real
137    /// `total_chunks` value is appended so readers can discover the count
138    /// without having to re-scan the file.
139    ///
140    /// [`set_total_chunks`]: ChunkedWriter::set_total_chunks
141    pub async fn finalize(mut self) -> Result<()> {
142        self.flush().await?;
143
144        let total_chunks = self.current_index;
145
146        // Validate against the preset (if any)
147        if let Some(expected) = self.preset_total_chunks
148            && total_chunks != expected
149        {
150            warn!(
151                "Finalize mismatch: expected {} chunks, wrote {}",
152                expected, total_chunks
153            );
154            return Err(StreamingError::IncompleteFinalize {
155                expected,
156                actual: total_chunks,
157            });
158        }
159
160        // Write a footer: a small sentinel chunk whose ChunkDescriptor carries
161        // the definitive `total_chunks` value.  This allows readers that cannot
162        // seek (e.g. network streams) to discover the count at the end.
163        //
164        // The footer payload is a little-endian u64 encoding of `total_chunks`
165        // preceded by the magic bytes `b"CHNK"` (4 bytes) so it is identifiable.
166        let mut footer = Vec::with_capacity(12);
167        footer.extend_from_slice(b"CHNK");
168        footer.extend_from_slice(&(total_chunks as u64).to_le_bytes());
169
170        let footer_descriptor = ChunkDescriptor::new(
171            self.bytes_written,
172            footer.len(),
173            total_chunks, // footer index = total_chunks (one past the last data chunk)
174            total_chunks + 1, // total including the footer itself
175        );
176
177        self.io
178            .write_chunk(&footer_descriptor, Bytes::from(footer))
179            .await?;
180        self.io.flush().await?;
181
182        info!(
183            "Finalized chunked writer: {} data chunks, {} total bytes",
184            total_chunks, self.bytes_written
185        );
186
187        Ok(())
188    }
189
190    /// Get the number of chunks written so far.
191    pub fn chunks_written(&self) -> usize {
192        self.current_index
193    }
194
195    /// Get the total bytes written so far.
196    pub fn bytes_written(&self) -> u64 {
197        self.bytes_written
198    }
199}
200
201#[cfg(test)]
202#[allow(clippy::panic)]
203mod tests {
204    use super::*;
205    use std::env;
206
207    #[tokio::test]
208    async fn test_chunked_writer() {
209        let temp_dir = env::temp_dir();
210        let test_path = temp_dir.join("test_chunked_write.dat");
211
212        let result =
213            ChunkedWriter::from_file(&test_path, ChunkStrategy::FixedSize(1024), 10240, 4).await;
214
215        if let Ok(mut writer) = result {
216            let data = Bytes::from(vec![42u8; 1024]);
217            writer.write_chunk(data).await.ok();
218            writer.finalize().await.ok();
219        }
220
221        // Clean up
222        tokio::fs::remove_file(&test_path).await.ok();
223    }
224
225    /// Verify that `finalize` writes the footer and reports the correct total.
226    #[tokio::test]
227    async fn test_chunked_writer_finalize_patches_total_chunks() {
228        let temp_dir = env::temp_dir();
229        let test_path = temp_dir.join("test_cw_finalize_total.dat");
230        // Clean up before test to avoid stale state
231        tokio::fs::remove_file(&test_path).await.ok();
232
233        let mut writer =
234            ChunkedWriter::from_file(&test_path, ChunkStrategy::FixedSize(512), 10240, 2)
235                .await
236                .expect("writer should be created successfully");
237
238        for i in 0u8..5 {
239            let data = Bytes::from(vec![i; 512]);
240            writer
241                .write_chunk(data)
242                .await
243                .expect("write_chunk should succeed");
244        }
245
246        assert_eq!(writer.chunks_written(), 5);
247
248        writer.finalize().await.expect("finalize should succeed");
249
250        // The footer should have been written; verify the file is larger than 5 * 512
251        let meta = tokio::fs::metadata(&test_path)
252            .await
253            .expect("file metadata should be readable");
254        assert!(meta.len() > 5 * 512, "footer was not written");
255
256        tokio::fs::remove_file(&test_path).await.ok();
257    }
258
259    /// When `set_total_chunks` matches the actual count, `finalize` succeeds.
260    #[tokio::test]
261    async fn test_chunked_writer_with_preset_chunks_matches_on_finalize() {
262        let temp_dir = env::temp_dir();
263        let test_path = temp_dir.join("test_cw_preset_match.dat");
264        tokio::fs::remove_file(&test_path).await.ok();
265
266        let mut writer =
267            ChunkedWriter::from_file(&test_path, ChunkStrategy::FixedSize(256), 10240, 2)
268                .await
269                .expect("writer should be created successfully");
270        // Pre-declare 3 chunks
271        writer = writer.set_total_chunks(3);
272
273        for i in 0u8..3 {
274            writer
275                .write_chunk(Bytes::from(vec![i; 256]))
276                .await
277                .expect("write_chunk should succeed");
278        }
279
280        writer
281            .finalize()
282            .await
283            .expect("finalize should succeed when preset matches actual");
284
285        tokio::fs::remove_file(&test_path).await.ok();
286    }
287
288    /// When `set_total_chunks` does NOT match the actual count, `finalize` errors.
289    #[tokio::test]
290    async fn test_chunked_writer_preset_mismatch_returns_error() {
291        let temp_dir = env::temp_dir();
292        let test_path = temp_dir.join("test_cw_preset_mismatch.dat");
293        tokio::fs::remove_file(&test_path).await.ok();
294
295        let mut writer =
296            ChunkedWriter::from_file(&test_path, ChunkStrategy::FixedSize(256), 10240, 2)
297                .await
298                .expect("writer should be created successfully");
299        // Pre-declare 5 chunks but only write 3
300        writer = writer.set_total_chunks(5);
301
302        for i in 0u8..3 {
303            writer
304                .write_chunk(Bytes::from(vec![i; 256]))
305                .await
306                .expect("write_chunk should succeed");
307        }
308
309        let result = writer.finalize().await;
310        match result {
311            Err(StreamingError::IncompleteFinalize { expected, actual }) => {
312                assert_eq!(expected, 5, "expected preset value");
313                assert_eq!(actual, 3, "actual written count");
314            }
315            other => panic!("expected IncompleteFinalize, got {:?}", other),
316        }
317
318        tokio::fs::remove_file(&test_path).await.ok();
319    }
320}