Skip to main content

tale_ndjson/readers/
chunked.rs

1//! Memory-efficient chunked file processing with strategy-based adaptation.
2//!
3//! `ChunkedFileReader` processes large files in manageable chunks while
4//! maintaining:
5//! - **Bounded memory usage**: Memory footprint independent of file size
6//! - **Line boundary handling**: Proper JSON line parsing across chunks
7//! - **Adaptive performance**: Strategy pattern for chunk size optimization
8//! - **Metrics collection**: Performance tracking for adaptation decisions
9//!
10//! ## Architecture
11//! - Strategy owns chunk_size (StaticStrategy, AdaptiveStrategy,
12//!   ConservativeStrategy)
13//! - ChunkConfig holds immutable settings (overlap_size, low_memory_mode)
14//! - FileChunk manages data boundaries and line parsing
15//! - ChunkMetrics tracks performance for adaptive strategies
16//!
17//! ## Usage
18//! ```no_run
19//! use tale_ndjson::{readers::ChunkedFileReader, FileProcessor};
20//! use std::path::Path;
21//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
22//! let path = Path::new("logfile.ndjson");
23//! let mut reader = ChunkedFileReader::new(&path)?;
24//! reader.process_lines(|line| {
25//!     // process line
26//!     Ok(())
27//! })?;
28//! # Ok(())
29//! # }
30//! ```
31
32use std::fs::File;
33use std::io::{Read, Seek, SeekFrom};
34use std::path::{Path, PathBuf};
35
36use super::FileProcessor;
37use super::strategies::Strategy;
38use crate::errors::TaleError;
39use crate::memory_budget::{MemoryAllocation, MemoryBudget, MemoryPressure};
40use crate::metrics::*;
41use crate::readers::strategies::ChunkConfig;
42use crate::readers::{IsStrategy, StaticStrategy};
43
44/// A chunk of file data with metadata about its position
45#[derive(Debug)]
46pub struct FileChunk {
47    /// The chunk data
48    pub data: Vec<u8>,
49    /// Starting position in the file
50    pub start_offset: u64,
51    /// End position in the file (exclusive)
52    pub end_offset: u64,
53    /// Whether this chunk starts at a line boundary
54    pub starts_at_line_boundary: bool,
55    /// Whether this chunk ends at a line boundary
56    pub ends_at_line_boundary: bool,
57}
58
59impl FileChunk {
60    /// Create a new FileChunk
61    pub fn new(data: Vec<u8>, start_offset: u64, end_offset: u64) -> Self {
62        let starts_at_line_boundary = start_offset == 0 || data.first() != Some(&b'\n');
63        let ends_at_line_boundary = data.last() == Some(&b'\n');
64
65        Self {
66            data,
67            start_offset,
68            end_offset,
69            starts_at_line_boundary,
70            ends_at_line_boundary,
71        }
72    }
73
74    /// Get the size of this chunk
75    pub fn size(&self) -> usize {
76        self.data.len()
77    }
78
79    /// Check if this chunk is empty
80    pub fn is_empty(&self) -> bool {
81        self.data.is_empty()
82    }
83
84    /// Get lines from this chunk, handling partial lines
85    pub fn lines(&self) -> impl Iterator<Item = &str> {
86        let data_str = std::str::from_utf8(&self.data).unwrap_or("");
87        data_str.lines()
88    }
89
90    /// Find the last complete line boundary in this chunk
91    pub fn find_last_line_boundary(&self) -> Option<usize> {
92        self.data.iter().rposition(|&b| b == b'\n')
93    }
94
95    /// Split this chunk at the last complete line boundary
96    pub fn split_at_last_line(&mut self) -> Option<Vec<u8>> {
97        if let Some(boundary) = self.find_last_line_boundary() {
98            let remainder = self.data.split_off(boundary + 1);
99            self.end_offset = self.start_offset + self.data.len() as u64;
100            self.ends_at_line_boundary = true;
101            Some(remainder)
102        } else {
103            None
104        }
105    }
106}
107
108/// Reader that processes files in chunks with line boundary handling
109#[derive(Debug)]
110pub struct ChunkedFileReader {
111    /// An open file pointer we're reading from.
112    file: File,
113    /// The size of the file we're reading.
114    file_size: u64,
115    /// Our current position in the file we're reading.
116    current_position: u64,
117    /// The path of the file we're reading from
118    _path: PathBuf,
119    /// Data from previous chunk that didn't end at line boundary
120    pending_data: Vec<u8>,
121    /// What's the strategy, Kenneth?
122    strategy: Strategy,
123    /// Tracking how we're doing
124    metrics: ChunkMetrics,
125    /// Memory budget management
126    memory_budget: Option<MemoryBudget>,
127    /// Current chunk allocation
128    current_allocation: Option<MemoryAllocation>,
129    /// Reader ID for memory tracking
130    reader_id: String,
131}
132
133impl ChunkedFileReader {
134    /// Create a new ChunkedFileReader
135    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, TaleError> {
136        let file_size = std::fs::metadata(&path)?.len();
137
138        // Get strategy from global config
139        #[cfg(not(test))]
140        let strategy = Strategy::from_config(crate::config::config(), Some(file_size));
141        #[cfg(test)]
142        let strategy = Strategy::from_config(&crate::config::config(), Some(file_size));
143
144        let path = path.as_ref().to_path_buf();
145        let reader_id = format!(
146            "chunked_reader_{}",
147            path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown")
148        );
149
150        let mut file = File::open(&path)?;
151        file.seek(SeekFrom::End(0))?;
152        file.seek(SeekFrom::Start(0))?;
153
154        // Try to create memory budget from config max_memory
155        let memory_budget = if let Some(max_memory) = crate::config::config().max_memory {
156            Some(MemoryBudget::new(max_memory)?)
157        } else {
158            // Default: use 10% of system memory
159            MemoryBudget::from_system_memory(10.0).ok()
160        };
161
162        Ok(Self {
163            file,
164            file_size,
165            current_position: 0,
166            _path: path,
167            pending_data: Vec::new(),
168            strategy,
169            metrics: ChunkMetrics::new(),
170            memory_budget,
171            current_allocation: None,
172            reader_id,
173        })
174    }
175
176    /// Create with explicit strategy (for testing)
177    pub fn with_strategy<P: AsRef<Path>>(path: P, strategy: Strategy) -> Result<Self, TaleError> {
178        let mut reader = Self::new(path)?;
179        reader.strategy = strategy;
180        Ok(reader)
181    }
182
183    /// Create with explicit memory budget
184    pub fn with_memory_budget<P: AsRef<Path>>(path: P, memory_budget: MemoryBudget) -> Result<Self, TaleError> {
185        let mut reader = Self::new(path)?;
186        reader.memory_budget = Some(memory_budget);
187        Ok(reader)
188    }
189
190    /// Pick the optimal chunk size and stick with it
191    pub fn static_optimal<P: AsRef<Path>>(path: P) -> Result<Self, TaleError> {
192        let mut reader = Self::new(&path)?;
193        let file_size = reader.file_size;
194        let strategy = StaticStrategy::optimal_for_file(file_size);
195        reader.strategy = Strategy::Static(strategy);
196        Ok(reader)
197    }
198
199    pub fn new_with_config<P: AsRef<Path>>(path: P, config: ChunkConfig) -> Result<Self, TaleError> {
200        let mut reader = Self::new(&path)?;
201        let strategy = StaticStrategy::with_config(config);
202        reader.strategy = Strategy::Static(strategy);
203        Ok(reader)
204    }
205
206    /// Create a ChunkedFileReader with optimal configuration for the file
207    pub fn with_optimal_config<P: AsRef<Path>>(path: P) -> Result<Self, TaleError> {
208        Self::static_optimal(path)
209    }
210
211    /// Get the file size
212    pub fn file_size(&self) -> u64 {
213        self.file_size
214    }
215
216    /// Get current position in file
217    pub fn position(&self) -> u64 {
218        self.current_position
219    }
220
221    /// Check if we've reached the end of file
222    pub fn is_at_end(&self) -> bool {
223        self.current_position >= self.file_size
224    }
225
226    /// Read the next chunk from the file
227    pub fn read_chunk(&mut self) -> Result<Option<FileChunk>, TaleError> {
228        // If we have pending data, we need to process it even if we're at EOF
229        if self.is_at_end() && self.pending_data.is_empty() {
230            return Ok(None);
231        }
232
233        // Let strategy adapt if needed
234        if self.metrics.chunks_seen % crate::defaults::processing::ADAPTATION_INTERVAL == 0
235            && self.strategy.should_adapt(&self.metrics)
236        {
237            let current_size = self.strategy.initial_chunk_size();
238            self.strategy.adapt_size(&self.metrics, current_size);
239        }
240
241        // Get base chunk size from strategy
242        let mut chunk_size = self.strategy.initial_chunk_size();
243
244        // Apply memory budget adjustments if available
245        if let Some(ref budget) = self.memory_budget {
246            // Check memory pressure and adjust chunk size
247            if let Ok(pressure) = budget.current_pressure() {
248                let factor = pressure.chunk_size_factor();
249                chunk_size = (chunk_size as f64 * factor) as usize;
250
251                // Don't let it get too small
252                chunk_size = chunk_size.max(4096); // Minimum 4KB
253
254                // Log critical memory pressure
255                if matches!(pressure, MemoryPressure::Critical) {
256                    eprintln!(
257                        "⚠️  Critical memory pressure - reducing chunk size to {} bytes",
258                        chunk_size
259                    );
260                }
261            }
262
263            // Try to allocate memory for this chunk
264            let total_allocation_needed = chunk_size + self.pending_data.len();
265
266            // Release previous allocation first
267            self.current_allocation = None;
268
269            // Try to allocate new chunk
270            match budget.try_allocate(total_allocation_needed, &self.reader_id) {
271                Ok(Some(allocation)) => {
272                    self.current_allocation = Some(allocation);
273                }
274                Ok(None) => {
275                    // Allocation failed - try with smaller chunk size
276                    let emergency_size = chunk_size / 4; // Emergency 25% size
277                    if emergency_size >= 1024 {
278                        // Don't go below 1KB
279                        chunk_size = emergency_size;
280                        let emergency_allocation =
281                            budget.try_allocate(emergency_size + self.pending_data.len(), &self.reader_id)?;
282                        if let Some(allocation) = emergency_allocation {
283                            self.current_allocation = Some(allocation);
284                            eprintln!("🆘 Emergency memory allocation - using {} byte chunks", chunk_size);
285                        } else {
286                            return Err(TaleError::MemoryError(
287                                "Cannot allocate memory even for emergency chunk size".to_string(),
288                            ));
289                        }
290                    } else {
291                        return Err(TaleError::MemoryError(
292                            "Out of memory - chunk size would be too small".to_string(),
293                        ));
294                    }
295                }
296                Err(e) => return Err(e),
297            }
298        }
299
300        // Track how much pending data we have at the start
301        let pending_len = self.pending_data.len();
302
303        let mut buffer = vec![0u8; chunk_size];
304        let bytes_read = if self.is_at_end() {
305            // At EOF, just process any pending data
306            0
307        } else {
308            // Read new data from file
309            let start = std::time::Instant::now();
310            let read = self.file.read(&mut buffer).map_err(TaleError::from)?;
311            let read_duration = start.elapsed();
312
313            // Record read metrics
314            if read > 0 {
315                let line_count = buffer[..read].iter().filter(|&&b| b == b'\n').count();
316                self.metrics.record_chunk_processing(read, read_duration, line_count);
317            }
318
319            read
320        };
321
322        if bytes_read == 0 && self.pending_data.is_empty() {
323            return Ok(None);
324        }
325
326        buffer.truncate(bytes_read);
327
328        // Combine with any pending data from previous chunk
329        if !self.pending_data.is_empty() {
330            let mut combined = std::mem::take(&mut self.pending_data);
331            combined.extend_from_slice(&buffer);
332            buffer = combined;
333        }
334
335        let start_offset = self.current_position - pending_len as u64;
336        self.current_position += bytes_read as u64;
337
338        let mut chunk = FileChunk::new(buffer, start_offset, self.current_position);
339
340        // Handle line boundaries: if chunk doesn't end at a line boundary,
341        // save the partial line for the next chunk
342        if !chunk.ends_at_line_boundary
343            && !self.is_at_end()
344            && let Some(remainder) = chunk.split_at_last_line()
345        {
346            self.pending_data = remainder;
347        }
348
349        // Metrics are now recorded earlier when we actually read from file
350
351        Ok(Some(chunk))
352    }
353
354    /// Seek to a specific position in the file
355    pub fn seek(&mut self, pos: SeekFrom) -> Result<u64, TaleError> {
356        let new_pos = self.file.seek(pos).map_err(TaleError::from)?;
357
358        self.current_position = new_pos;
359        // we've moved and no longer care what we read earlier
360        self.pending_data.clear();
361
362        Ok(new_pos)
363    }
364
365    /// Reset to the beginning of the file
366    pub fn reset(&mut self) -> Result<(), TaleError> {
367        self.seek(SeekFrom::Start(0))?;
368        Ok(())
369    }
370
371    /// Get current memory pressure level
372    pub fn memory_pressure(&self) -> Option<Result<MemoryPressure, TaleError>> {
373        self.memory_budget.as_ref().map(|budget| budget.current_pressure())
374    }
375
376    /// Get memory budget statistics
377    pub fn memory_stats(&self) -> Option<Result<crate::memory_budget::MemoryBudgetStats, TaleError>> {
378        self.memory_budget.as_ref().map(|budget| budget.usage_stats())
379    }
380
381    /// Check if memory budget is active
382    pub fn has_memory_budget(&self) -> bool {
383        self.memory_budget.is_some()
384    }
385}
386
387impl FileProcessor for ChunkedFileReader {
388    fn process_lines<F>(&mut self, mut line_processor: F) -> Result<(), TaleError>
389    where
390        F: FnMut(&str) -> Result<(), TaleError>,
391    {
392        while let Some(chunk) = self.read_chunk()? {
393            for line in chunk.lines() {
394                line_processor(line)?;
395            }
396        }
397        Ok(())
398    }
399
400    fn skip_lines(&mut self, count: u64) -> Result<(), TaleError> {
401        let mut lines_skipped = 0u64;
402
403        while lines_skipped < count {
404            if let Some(chunk) = self.read_chunk()? {
405                // Count lines in this chunk and track position
406                let mut lines_in_chunk = 0u64;
407                let mut last_newline_pos = None;
408
409                for (i, &byte) in chunk.data.iter().enumerate() {
410                    if byte == b'\n' {
411                        lines_in_chunk += 1;
412                        last_newline_pos = Some(i);
413
414                        // Check if we've skipped enough lines
415                        if lines_skipped + lines_in_chunk == count {
416                            // We need to keep the rest of this chunk for processing
417                            // Save the unprocessed portion as pending data
418                            let position_after_newline = i + 1;
419                            if position_after_newline < chunk.data.len() {
420                                self.pending_data = chunk.data[position_after_newline..].to_vec();
421                                // Note: We don't adjust current_position here
422                                // because read_chunk
423                                // already handles the position tracking
424                                // correctly with pending_data
425                            }
426                            return Ok(());
427                        }
428                    }
429                }
430
431                // Entire chunk was consumed
432                lines_skipped += lines_in_chunk;
433
434                // If this chunk didn't end with a newline and we haven't skipped enough lines
435                // yet, we need to keep any partial line for the next iteration
436                if !chunk.ends_at_line_boundary && lines_skipped < count {
437                    if let Some(last_nl) = last_newline_pos {
438                        // Keep everything after the last newline as pending data
439                        let after_last_newline = last_nl + 1;
440                        if after_last_newline < chunk.data.len() {
441                            self.pending_data = chunk.data[after_last_newline..].to_vec();
442                            // Note: We don't adjust current_position here
443                        }
444                    } else {
445                        // No newlines in this chunk, keep the entire chunk as pending
446                        self.pending_data = chunk.data;
447                        // Note: We don't adjust current_position here
448                    }
449                }
450            } else {
451                // EOF reached before skipping all requested lines
452                break;
453            }
454        }
455
456        Ok(())
457    }
458
459    fn file_size(&self) -> u64 {
460        self.file_size
461    }
462
463    fn seek(&mut self, pos: SeekFrom) -> Result<u64, TaleError> {
464        self.seek(pos)
465    }
466
467    fn position(&self) -> u64 {
468        self.current_position
469    }
470}