tale_ndjson/readers/
chunked.rs1use 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#[derive(Debug)]
46pub struct FileChunk {
47 pub data: Vec<u8>,
49 pub start_offset: u64,
51 pub end_offset: u64,
53 pub starts_at_line_boundary: bool,
55 pub ends_at_line_boundary: bool,
57}
58
59impl FileChunk {
60 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 pub fn size(&self) -> usize {
76 self.data.len()
77 }
78
79 pub fn is_empty(&self) -> bool {
81 self.data.is_empty()
82 }
83
84 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 pub fn find_last_line_boundary(&self) -> Option<usize> {
92 self.data.iter().rposition(|&b| b == b'\n')
93 }
94
95 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#[derive(Debug)]
110pub struct ChunkedFileReader {
111 file: File,
113 file_size: u64,
115 current_position: u64,
117 _path: PathBuf,
119 pending_data: Vec<u8>,
121 strategy: Strategy,
123 metrics: ChunkMetrics,
125 memory_budget: Option<MemoryBudget>,
127 current_allocation: Option<MemoryAllocation>,
129 reader_id: String,
131}
132
133impl ChunkedFileReader {
134 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, TaleError> {
136 let file_size = std::fs::metadata(&path)?.len();
137
138 #[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 let memory_budget = if let Some(max_memory) = crate::config::config().max_memory {
156 Some(MemoryBudget::new(max_memory)?)
157 } else {
158 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 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 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 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 pub fn with_optimal_config<P: AsRef<Path>>(path: P) -> Result<Self, TaleError> {
208 Self::static_optimal(path)
209 }
210
211 pub fn file_size(&self) -> u64 {
213 self.file_size
214 }
215
216 pub fn position(&self) -> u64 {
218 self.current_position
219 }
220
221 pub fn is_at_end(&self) -> bool {
223 self.current_position >= self.file_size
224 }
225
226 pub fn read_chunk(&mut self) -> Result<Option<FileChunk>, TaleError> {
228 if self.is_at_end() && self.pending_data.is_empty() {
230 return Ok(None);
231 }
232
233 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 let mut chunk_size = self.strategy.initial_chunk_size();
243
244 if let Some(ref budget) = self.memory_budget {
246 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 chunk_size = chunk_size.max(4096); if matches!(pressure, MemoryPressure::Critical) {
256 eprintln!(
257 "⚠️ Critical memory pressure - reducing chunk size to {} bytes",
258 chunk_size
259 );
260 }
261 }
262
263 let total_allocation_needed = chunk_size + self.pending_data.len();
265
266 self.current_allocation = None;
268
269 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 let emergency_size = chunk_size / 4; if emergency_size >= 1024 {
278 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 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 0
307 } else {
308 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 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 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 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 Ok(Some(chunk))
352 }
353
354 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 self.pending_data.clear();
361
362 Ok(new_pos)
363 }
364
365 pub fn reset(&mut self) -> Result<(), TaleError> {
367 self.seek(SeekFrom::Start(0))?;
368 Ok(())
369 }
370
371 pub fn memory_pressure(&self) -> Option<Result<MemoryPressure, TaleError>> {
373 self.memory_budget.as_ref().map(|budget| budget.current_pressure())
374 }
375
376 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 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 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 if lines_skipped + lines_in_chunk == count {
416 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 }
426 return Ok(());
427 }
428 }
429 }
430
431 lines_skipped += lines_in_chunk;
433
434 if !chunk.ends_at_line_boundary && lines_skipped < count {
437 if let Some(last_nl) = last_newline_pos {
438 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 }
444 } else {
445 self.pending_data = chunk.data;
447 }
449 }
450 } else {
451 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}