Skip to main content

pure_magic/readers/
cache.rs

1#![deny(unsafe_code)]
2
3use std::{
4    cmp::{max, min},
5    fs::File,
6    io::{self, Read, Seek, SeekFrom},
7    ops::Range,
8    path::Path,
9};
10
11use crate::readers::DataRead;
12use memmap2::MmapMut;
13
14/// A lazy-loading cache reader with a multi-tiered caching strategy.
15///
16/// Wraps a [`Read`] + [`Seek`] type and provides efficient cached reads using
17/// a hierarchy of caches: hot (head/tail), warm (memory-mapped), and cold (direct).
18///
19/// The cache automatically loads data in blocks as needed, minimizing I/O operations
20/// for sequential and random access patterns.
21///
22/// # Cache Tiers
23///
24/// - **Hot cache**: Small buffers at the head and tail of the source, always available.
25/// - **Warm cache**: Memory-mapped region for frequently accessed data.
26/// - **Cold cache**: Fallback buffer for reads that don't fit in other caches.
27///
28/// See [`LazyCache::from_read_seek`], [`LazyCache::open`], [`LazyCache::with_hot_cache`],
29/// and [`LazyCache::with_warm_cache`] for construction.
30pub struct LazyCache<R>
31where
32    R: Read + Seek,
33{
34    source: R,
35    loaded: Vec<bool>,
36    hot_head: Vec<u8>,
37    hot_tail: Vec<u8>,
38    warm: Option<MmapMut>,
39    cold_range: Range<u64>,
40    cold: Vec<u8>,
41    block_size: u64,
42    warm_size: Option<u64>,
43    stream_pos: u64,
44    pos_end: u64,
45}
46
47const BLOCK_SIZE: usize = 4096;
48
49impl<R> DataRead for LazyCache<R>
50where
51    R: Read + Seek,
52{
53    #[inline(always)]
54    fn stream_position(&self) -> u64 {
55        self.stream_pos
56    }
57
58    #[inline]
59    fn read_range(&mut self, range: Range<u64>) -> Result<&[u8], io::Error> {
60        self.get_range_u64(range)
61    }
62
63    fn read_until_any_delim_or_limit(
64        &mut self,
65        delims: &[u8],
66        limit: u64,
67    ) -> Result<&[u8], io::Error> {
68        self._read_while_or_limit(|b| !delims.contains(&b), limit, true)
69    }
70
71    fn read_until_or_limit(&mut self, byte: u8, limit: u64) -> Result<&[u8], io::Error> {
72        self._read_while_or_limit(|b| b != byte, limit, true)
73    }
74
75    fn read_while_or_limit<F>(&mut self, f: F, limit: u64) -> Result<&[u8], io::Error>
76    where
77        F: Fn(u8) -> bool,
78    {
79        self._read_while_or_limit(f, limit, false)
80    }
81
82    fn read_until_utf16_or_limit(
83        &mut self,
84        utf16_char: &[u8; 2],
85        limit: u64,
86    ) -> Result<&[u8], io::Error> {
87        let start = self.stream_pos;
88        let mut end = 0;
89
90        let even_bs = if self.block_size.is_multiple_of(2) {
91            self.block_size
92        } else {
93            self.block_size.saturating_add(1)
94        };
95
96        'outer: while limit.saturating_sub(end) > 0 {
97            let buf = self.read_count(even_bs)?;
98
99            let even = buf
100                .iter()
101                .enumerate()
102                .filter(|(i, _)| i % 2 == 0)
103                .map(|t| t.1);
104
105            let odd = buf
106                .iter()
107                .enumerate()
108                .filter(|(i, _)| i % 2 != 0)
109                .map(|t| t.1);
110
111            for t in even.zip(odd) {
112                if limit.saturating_sub(end) == 0 {
113                    break 'outer;
114                }
115
116                end += 2;
117
118                // tail check
119                if t.0 == &utf16_char[0] && t.1 == &utf16_char[1] {
120                    // we include char
121                    break 'outer;
122                }
123            }
124
125            // we processed the last chunk
126            if buf.len() as u64 != even_bs {
127                // if we arrive here we reached end of file
128                if buf.len() % 2 != 0 {
129                    // we include last byte missed by zip
130                    end += 1
131                }
132                break;
133            }
134        }
135
136        self.read_exact_range(start..start + end)
137    }
138
139    #[inline]
140    fn data_size(&self) -> u64 {
141        self.pos_end
142    }
143
144    #[inline]
145    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
146        self.stream_pos = self.offset_from_start(pos);
147        Ok(self.stream_pos)
148    }
149}
150
151impl LazyCache<File> {
152    /// Opens a file and creates a new `LazyCache` for it.
153    ///
154    /// This is a convenience constructor equivalent to calling [`LazyCache::from_read_seek`]
155    /// with a [`File`].
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the file cannot be opened.
160    ///
161    /// # Examples
162    ///
163    /// ```no_run
164    /// use pure_magic::readers::LazyCache;
165    /// use std::path::Path;
166    ///
167    /// let cache = LazyCache::<std::fs::File>::open(Path::new("file.bin"))?;
168    /// # Ok::<_, std::io::Error>(())
169    /// ```
170    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
171        Self::from_read_seek(File::open(path)?)
172    }
173}
174
175impl<R> io::Read for LazyCache<R>
176where
177    R: Read + Seek,
178{
179    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
180        let r = self.read_count(buf.len() as u64)?;
181        for (i, b) in r.iter().enumerate() {
182            buf[i] = *b;
183        }
184        Ok(r.len())
185    }
186}
187
188impl<R> LazyCache<R>
189where
190    R: Read + Seek,
191{
192    /// Creates a new `LazyCache` wrapping a [`Read`] + [`Seek`] type.
193    ///
194    /// The cache is initialized with default settings: no hot or warm caches.
195    /// Use [`LazyCache::with_hot_cache`] and [`LazyCache::with_warm_cache`] to enable additional cache tiers.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if seeking to the end of the source fails.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use pure_magic::readers::{LazyCache, DataRead};
205    /// use std::io::Cursor;
206    ///
207    /// let data = b"hello world";
208    /// let cache = LazyCache::from_read_seek(Cursor::new(data)).unwrap();
209    /// assert_eq!(cache.data_size(), data.len() as u64);
210    /// ```
211    pub fn from_read_seek(mut rs: R) -> Result<Self, io::Error> {
212        let block_size = BLOCK_SIZE as u64;
213        let pos_end = rs.seek(SeekFrom::End(0))?;
214        let cache_cap = pos_end.div_ceil(BLOCK_SIZE as u64);
215
216        Ok(Self {
217            source: rs,
218            hot_head: vec![],
219            hot_tail: vec![],
220            warm: None,
221            cold_range: 0..0,
222            cold: vec![0; block_size as usize],
223            loaded: vec![false; cache_cap as usize],
224            block_size,
225            warm_size: None,
226            stream_pos: 0,
227            pos_end,
228        })
229    }
230
231    /// Enables the hot cache with the specified size.
232    ///
233    /// The hot cache maintains two buffers: one at the head (beginning) and one
234    /// at the tail (end) of the source, each with size `size / 2`. This is useful
235    /// for optimizing access to the start and end of files.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if seeking or reading from the source fails.
240    pub fn with_hot_cache(mut self, size: usize) -> Result<Self, io::Error> {
241        let head_tail_size = size / 2;
242
243        let head_size = min(head_tail_size, self.pos_end as usize);
244        self.source.seek(SeekFrom::Start(0))?;
245        self.hot_head.reserve_exact(head_size);
246        self.hot_head.resize(head_size, 0);
247        self.source.read_exact(self.hot_head.as_mut())?;
248
249        if self.pos_end > size as u64 {
250            self.source.seek(SeekFrom::End(-(head_tail_size as i64)))?;
251            self.hot_tail.reserve_exact(head_tail_size);
252            self.hot_tail.resize(head_tail_size, 0);
253            self.source.read_exact(self.hot_tail.as_mut_slice())?;
254        }
255
256        Ok(self)
257    }
258
259    /// Enables the warm cache with the specified size.
260    ///
261    /// The warm cache uses memory-mapped storage for improved performance when
262    /// reading larger regions. The size is clamped to be at least as large as
263    /// the block size to ensure proper alignment.
264    ///
265    /// Note: The memory mapping is performed lazily on first access.
266    pub fn with_warm_cache(mut self, mut warm_size: u64) -> Self {
267        // if warm_size is smaller than block_size we will not
268        // be able to write chunks into the warm cache
269        warm_size = max(warm_size, self.block_size);
270        self.warm_size = Some(warm_size);
271        self
272    }
273
274    #[inline(always)]
275    fn warm(&mut self) -> Result<&mut MmapMut, io::Error> {
276        if self.warm.is_none() && self.warm_size.is_some() {
277            self.warm = Some(MmapMut::map_anon(
278                self.warm_size.unwrap_or_default() as usize
279            )?);
280        }
281        Ok(self
282            .warm
283            .as_mut()
284            .expect("warm cache just initialized above when unset"))
285    }
286
287    #[inline(always)]
288    fn range_warmup(&mut self, range: Range<u64>) -> Result<(), io::Error> {
289        if self.loaded.is_empty() {
290            return Ok(());
291        }
292
293        let start_chunk_id = range.start / self.block_size;
294        let end_chunk_id = (range.end.saturating_sub(1)) / self.block_size;
295
296        self.warm()?;
297        let warm = self
298            .warm
299            .as_mut()
300            .expect("warm() above guarantees the warm cache is initialized");
301
302        let mut chunk_id = start_chunk_id;
303        while chunk_id <= end_chunk_id {
304            if self.loaded[chunk_id as usize] {
305                chunk_id += 1;
306                continue;
307            }
308
309            // Coalesce a contiguous run of not-yet-loaded blocks into a
310            // single seek + read
311            let run_start = chunk_id;
312            let mut run_end = chunk_id;
313            while run_end < end_chunk_id && !self.loaded[(run_end + 1) as usize] {
314                run_end += 1;
315            }
316
317            let start_offset = run_start * self.block_size;
318            let end_offset = min((run_end + 1) * self.block_size, self.pos_end);
319            let len = (end_offset - start_offset) as usize;
320
321            self.source.seek(SeekFrom::Start(start_offset))?;
322            self.source
323                .read_exact(&mut warm[start_offset as usize..start_offset as usize + len])?;
324
325            self.loaded[run_start as usize..=run_end as usize].fill(true);
326
327            chunk_id = run_end + 1;
328        }
329
330        Ok(())
331    }
332
333    #[inline(always)]
334    fn get_range_u64(&mut self, range: Range<u64>) -> Result<&[u8], io::Error> {
335        // we fix range in case we attempt at reading beyond end of file
336        let range = if range.end > self.pos_end {
337            range.start..self.pos_end
338        } else {
339            range
340        };
341
342        let range_len = range.end.saturating_sub(range.start);
343
344        if range.start > self.pos_end || range_len == 0 {
345            Ok(&[])
346        } else if range.start < self.hot_head.len() as u64
347            && range.end <= self.hot_head.len() as u64
348        {
349            self.seek(SeekFrom::Start(range.end))?;
350            Ok(&self.hot_head[range.start as usize..range.end as usize])
351        } else if range.start >= (self.pos_end.saturating_sub(self.hot_tail.len() as u64)) {
352            let tail_base = self.pos_end.saturating_sub(self.hot_tail.len() as u64);
353
354            let start = range.start - tail_base;
355            let end = range.end - tail_base;
356
357            self.seek(SeekFrom::Start(range.end))?;
358
359            Ok(&self.hot_tail[start as usize..end as usize])
360        } else if range.end < self.warm_size.unwrap_or_default() {
361            self.range_warmup(range.clone())?;
362            self.seek(SeekFrom::Start(range.end))?;
363
364            Ok(&self.warm()?[range.start as usize..range.end as usize])
365        } else {
366            if self.cold_range.contains(&range.start)
367                && self.cold_range.contains(&range.end.saturating_sub(1))
368            {
369                let rel_start = range.start - self.cold_range.start;
370                self.seek(SeekFrom::Start(range.end))?;
371
372                Ok(&self.cold[rel_start as usize..(rel_start + range_len) as usize])
373            } else {
374                // we read one block in advance
375                let range_len_ext = range_len.saturating_add(self.block_size);
376                if range_len_ext > self.cold.len() as u64 {
377                    self.cold.resize(range_len_ext as usize, 0);
378                }
379
380                self.source.seek(SeekFrom::Start(range.start))?;
381                let n = self
382                    .source
383                    .read(self.cold[..range_len_ext as usize].as_mut())?;
384                self.cold_range = range.start..range.start + n as u64;
385                self.seek(SeekFrom::Start(range.end))?;
386
387                Ok(&self.cold[..min(range_len as usize, n)])
388            }
389        }
390    }
391
392    // reads while f returns true or we reach limit
393    #[inline(always)]
394    fn _read_while_or_limit<F>(
395        &mut self,
396        f: F,
397        limit: u64,
398        include_last: bool,
399    ) -> Result<&[u8], io::Error>
400    where
401        F: Fn(u8) -> bool,
402    {
403        let start = self.stream_pos;
404        let mut end = 0;
405
406        'outer: while limit - end > 0 {
407            let buf = self.read_count(self.block_size)?;
408
409            for b in buf {
410                if limit - end == 0 {
411                    break 'outer;
412                }
413
414                if !f(*b) {
415                    if include_last && end < self.data_size() {
416                        end += 1;
417                    }
418                    // read_until includes delimiter
419                    break 'outer;
420                }
421
422                end += 1;
423            }
424
425            // we processed last chunk
426            if buf.len() as u64 != self.block_size {
427                break;
428            }
429        }
430
431        self.read_exact_range(start..start + end)
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use std::os::unix::fs::MetadataExt;
438
439    use super::*;
440
441    macro_rules! lazy_cache {
442        ($content: literal) => {
443            LazyCache::from_read_seek(std::io::Cursor::new($content)).unwrap()
444        };
445    }
446
447    /// reads io::Reader `r` by chunks of size `cs` until the end
448    macro_rules! read_to_end {
449        ($r: expr, $cs: literal) => {{
450            let mut buf = [0u8; $cs];
451            let mut out: Vec<u8> = vec![];
452            while let Ok(n) = $r.read(&mut buf[..]) {
453                if n == 0 {
454                    break;
455                }
456                out.extend(&buf[..n]);
457            }
458            out
459        }};
460    }
461
462    #[test]
463    fn test_get_single_block() {
464        let mut cache = lazy_cache!(b"hello world");
465        let data = cache.read_range(0..4).unwrap();
466        assert_eq!(data, b"hell");
467    }
468
469    #[test]
470    fn test_get_across_blocks() {
471        let mut cache = lazy_cache!(b"hello world");
472        let data = cache.read_range(2..7).unwrap();
473        assert_eq!(data, b"llo w");
474    }
475
476    #[test]
477    fn test_get_entire_file() {
478        let mut cache = lazy_cache!(b"hello world");
479        let data = cache.read_range(0..11).unwrap();
480        assert_eq!(data, b"hello world");
481    }
482
483    #[test]
484    fn test_get_empty_range() {
485        let mut cache = lazy_cache!(b"hello world");
486        let data = cache.read_range(0..0).unwrap();
487        assert!(data.is_empty());
488    }
489
490    #[test]
491    fn test_get_out_of_bounds() {
492        let mut cache = lazy_cache!(b"hello world");
493        // This should not panic, but return an error or empty slice depending on your design
494        // Currently, your code will panic due to `unwrap()` on `None`
495        // You may want to handle this case more gracefully
496        assert!(cache.read_range(20..30).unwrap().is_empty());
497    }
498
499    #[test]
500    fn test_cache_eviction() {
501        let mut cache = lazy_cache!(b"0123456789abcdef");
502        // Load blocks 0 and 1
503        let _ = cache.read_range(0..8).unwrap();
504        // Load block 2, which should evict block 0 or 1 due to max_size=8
505        let _ = cache.read_range(8..12).unwrap();
506        // Check that the cache still works
507        let data = cache.read_range(8..12).unwrap();
508        assert_eq!(data, b"89ab");
509    }
510
511    #[test]
512    fn test_chunk_consolidation() {
513        let mut cache = lazy_cache!(b"0123456789abcdef");
514        // Load blocks 0 and 1 separately
515        let _ = cache.read_range(0..4).unwrap();
516        let _ = cache.read_range(4..8).unwrap();
517        // Load block 2, which should not consolidate with 0 or 1
518        let _ = cache.read_range(8..12).unwrap();
519        // Now load block 1 again, which should consolidate with block 0
520        let _ = cache.read_range(2..6).unwrap();
521        // Check that the consolidated chunk is correct
522        let data = cache.read_range(0..8).unwrap();
523        assert_eq!(data, b"01234567");
524    }
525
526    #[test]
527    fn test_overlapping_ranges() {
528        let mut cache = lazy_cache!(b"0123456789abcdef");
529        // Load overlapping ranges
530        let _ = cache.read_range(2..6).unwrap();
531        let _ = cache.read_range(4..10).unwrap();
532        // Check that the data is correct
533        let data = cache.read_range(2..10).unwrap();
534        assert_eq!(data, b"23456789");
535    }
536
537    #[test]
538    fn test_lru_behavior() {
539        let mut cache = lazy_cache!(b"0123456789abcdef");
540        // Load block 0
541        let _ = cache.read_range(0..4).unwrap();
542        // Load block 1
543        let _ = cache.read_range(4..8).unwrap();
544        // Load block 2, which should evict block 0
545        let _ = cache.read_range(8..12).unwrap();
546        // Block 0 should be evicted, so accessing it again should reload it
547        let data = cache.read_range(0..4).unwrap();
548        assert_eq!(data, b"0123");
549    }
550
551    #[test]
552    fn test_small_block_size() {
553        let mut cache = lazy_cache!(b"abc");
554        let data = cache.read_range(0..3).unwrap();
555        assert_eq!(data, b"abc");
556    }
557
558    #[test]
559    fn test_large_block_size() {
560        let mut cache = lazy_cache!(b"hello world");
561        let data = cache.read_range(0..11).unwrap();
562        assert_eq!(data, b"hello world");
563    }
564
565    #[test]
566    fn test_file_smaller_than_block() {
567        let mut cache = lazy_cache!(b"abc");
568        let data = cache.read_range(0..3).unwrap();
569        assert_eq!(data, b"abc");
570    }
571
572    #[test]
573    fn test_multiple_gets_same_block() {
574        let mut cache = lazy_cache!(b"0123456789abcdef");
575        // Get the same block multiple times
576        let _ = cache.read_range(0..4).unwrap();
577        let _ = cache.read_range(0..4).unwrap();
578        let _ = cache.read_range(0..4).unwrap();
579        // The block should still be in the cache
580        let data = cache.read_range(0..4).unwrap();
581        assert_eq!(data, b"0123");
582    }
583
584    #[test]
585    fn test_read_method() {
586        let mut cache = lazy_cache!(b"hello world");
587        let _ = cache.read_count(6).unwrap();
588        let data = cache.read_count(5).unwrap();
589        assert_eq!(data, b"world");
590        // We reached the end so next read should bring an empty slice
591        assert!(cache.read_count(1).unwrap().is_empty());
592    }
593
594    #[test]
595    fn test_read_empty() {
596        let mut cache = lazy_cache!(b"hello world");
597        let data = cache.read_count(0).unwrap();
598        assert!(data.is_empty());
599    }
600
601    #[test]
602    fn test_read_beyond_end() {
603        let mut cache = lazy_cache!(b"hello world");
604        let _ = cache.read_count(11).unwrap();
605        let data = cache.read_count(5).unwrap();
606        assert!(data.is_empty());
607    }
608
609    #[test]
610    fn test_read_exact_range() {
611        let mut cache = lazy_cache!(b"hello world");
612        let data = cache.read_exact_range(0..5).unwrap();
613        assert_eq!(data, b"hello");
614        assert_eq!(cache.read_exact_range(5..11).unwrap(), b" world");
615        assert!(cache.read_exact_range(12..13).is_err());
616    }
617
618    #[test]
619    fn test_read_exact_range_error() {
620        let mut cache = lazy_cache!(b"hello world");
621        let result = cache.read_exact_range(0..20);
622        assert!(result.is_err());
623    }
624
625    #[test]
626    fn test_read_exact() {
627        let mut cache = lazy_cache!(b"hello world");
628        let data = cache.read_exact_count(5).unwrap();
629        assert_eq!(data, b"hello");
630        assert_eq!(cache.read_exact_count(6).unwrap(), b" world");
631        assert!(cache.read_exact_count(0).is_ok());
632        assert!(cache.read_exact_count(1).is_err());
633    }
634
635    #[test]
636    fn test_read_exact_error() {
637        let mut cache = lazy_cache!(b"hello world");
638        let result = cache.read_exact_count(20);
639        assert!(result.is_err());
640    }
641
642    #[test]
643    fn test_read_until_limit() {
644        let mut cache = lazy_cache!(b"hello world");
645        let data = cache.read_until_or_limit(b' ', 10).unwrap();
646        assert_eq!(data, b"hello ");
647        assert_eq!(cache.read_exact_count(5).unwrap(), b"world");
648    }
649
650    #[test]
651    fn test_read_until_limit_not_found() {
652        let mut cache = lazy_cache!(b"hello world");
653        let data = cache.read_until_or_limit(b'\n', 11).unwrap();
654        assert_eq!(data, b"hello world");
655        assert!(cache.read_count(1).unwrap().is_empty());
656    }
657
658    #[test]
659    fn test_read_until_limit_beyond_stream() {
660        let mut cache = lazy_cache!(b"hello world");
661        let data = cache.read_until_or_limit(b'\n', 42).unwrap();
662        assert_eq!(data, b"hello world");
663        assert!(cache.read_count(1).unwrap().is_empty());
664    }
665
666    #[test]
667    fn test_read_until_limit_with_limit() {
668        let mut cache = lazy_cache!(b"hello world");
669        let data = cache.read_until_or_limit(b' ', 42).unwrap();
670        assert_eq!(data, b"hello ");
671
672        let data = cache.read_until_or_limit(b' ', 2).unwrap();
673        assert_eq!(data, b"wo");
674
675        let data = cache.read_until_or_limit(b' ', 42).unwrap();
676        assert_eq!(data, b"rld");
677    }
678
679    #[test]
680    fn test_read_until_utf16_limit() {
681        let mut cache = lazy_cache!(
682            b"\x61\x00\x62\x00\x63\x00\x64\x00\x00\x00\x61\x00\x62\x00\x63\x00\x64\x00\x00"
683        );
684        let data = cache.read_until_utf16_or_limit(b"\x00\x00", 512).unwrap();
685        assert_eq!(data, b"\x61\x00\x62\x00\x63\x00\x64\x00\x00\x00");
686
687        let data = cache.read_until_utf16_or_limit(b"\x00\x00", 1).unwrap();
688        assert_eq!(data, b"\x61\x00");
689
690        assert_eq!(
691            cache.read_until_utf16_or_limit(b"\xff\xff", 64).unwrap(),
692            b"\x62\x00\x63\x00\x64\x00\x00"
693        );
694    }
695
696    #[test]
697    fn test_io_read() {
698        let p = "./src/lib.rs";
699        let mut f = File::open(p).unwrap();
700        let mut lr = LazyCache::from_read_seek(File::open(p).unwrap())
701            .unwrap()
702            .with_hot_cache(512)
703            .unwrap()
704            .with_warm_cache(1024);
705
706        let fb = read_to_end!(f, 32);
707        let lcb = read_to_end!(lr, 16);
708
709        assert_eq!(lcb, fb);
710    }
711
712    #[test]
713    fn test_data_size() {
714        let f = File::open("./src/lib.rs").unwrap();
715        let size = f.metadata().unwrap().size();
716
717        let c = LazyCache::from_read_seek(f).unwrap();
718        assert_eq!(size, c.data_size());
719
720        assert_eq!(
721            LazyCache::from_read_seek(io::Cursor::new(&[]))
722                .unwrap()
723                .data_size(),
724            0
725        );
726    }
727}