Skip to main content

hermes_core/directories/
slice_cache.rs

1//! Slice-level caching directory with overlap management
2//!
3//! Caches byte ranges from files, merging overlapping ranges and
4//! evicting least-recently-used slices when the cache limit is reached.
5//!
6//! Concurrency and complexity:
7//! - Lazy-handle hits take the state `read()` lock only. Recency is a
8//!   per-slice atomic stamp drawn from small thread-local blocks, and hit
9//!   accounting is sharded by thread, so readers do not contend on one
10//!   global counter. Direct range reads retain the faster serialized path.
11//! - Slices of one file never overlap, so a range is either fully contained
12//!   in its predecessor slice (`BTreeMap::range(..=start).next_back()`) or it
13//!   is a miss; overlap detection on insert walks backwards from the last
14//!   slice starting before the new end and stops at the first disjoint one.
15//! - Eviction is bounded-approximate LRU through a lazily maintained min-heap
16//!   of `(stamp, file, start)` entries: ordering is exact within a thread and
17//!   may differ by at most one 64-stamp reservation block across threads. A
18//!   popped stale entry is re-pushed with its current stamp. This is
19//!   amortized `O(log n)` per operation and never scans all slices (the heap
20//!   is rebuilt from live slices only when stale entries outnumber live ones).
21
22use async_trait::async_trait;
23use parking_lot::RwLock;
24use std::cell::Cell;
25use std::cmp::Reverse;
26use std::collections::{BTreeMap, BinaryHeap, HashMap};
27use std::io::{self, Read, Write};
28use std::ops::Range;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
32
33use super::{Directory, FileHandle, OwnedBytes, RangeReadFn};
34
35/// File extension for slice cache files
36pub const SLICE_CACHE_EXTENSION: &str = "slicecache";
37
38/// Magic bytes for slice cache file format
39const SLICE_CACHE_MAGIC: &[u8; 8] = b"HRMSCACH";
40
41/// Current version of the slice cache format
42/// v2: Added file size caching
43const SLICE_CACHE_VERSION: u32 = 2;
44
45/// Flush in-process hit counts to the `metrics` facade every this many hits.
46/// Misses and evictions flush unconditionally (they are already slow paths).
47const HIT_METRICS_FLUSH_INTERVAL: u64 = 1024;
48
49/// Reserve recency stamps in small per-thread blocks. This removes the
50/// globally contended atomic increment from every cache hit while bounding
51/// cross-thread LRU ordering error to at most one block.
52const STAMP_BLOCK_SIZE: u64 = 64;
53const COUNTER_SHARDS: usize = 64;
54
55static NEXT_STAMP_BLOCK: AtomicU64 = AtomicU64::new(0);
56static NEXT_COUNTER_SHARD: AtomicUsize = AtomicUsize::new(0);
57
58thread_local! {
59    static STAMP_BLOCK: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
60    static COUNTER_SHARD: usize = NEXT_COUNTER_SHARD.fetch_add(1, Ordering::Relaxed)
61        % COUNTER_SHARDS;
62}
63
64#[inline]
65fn next_stamp() -> u64 {
66    STAMP_BLOCK.with(|block| {
67        let (next, end) = block.get();
68        if next < end {
69            block.set((next + 1, end));
70            return next;
71        }
72        let start = NEXT_STAMP_BLOCK.fetch_add(STAMP_BLOCK_SIZE, Ordering::Relaxed) + 1;
73        block.set((start + 1, start + STAMP_BLOCK_SIZE));
74        start
75    })
76}
77
78/// Keep frequently updated counters on separate cache lines. Assigning a
79/// thread to a shard costs one global increment for the lifetime of that
80/// thread, rather than one for every cache hit.
81#[repr(align(64))]
82struct CounterShard(AtomicU64);
83
84struct ShardedCounter {
85    shards: [CounterShard; COUNTER_SHARDS],
86}
87
88impl ShardedCounter {
89    fn new() -> Self {
90        Self {
91            shards: std::array::from_fn(|_| CounterShard(AtomicU64::new(0))),
92        }
93    }
94
95    #[inline]
96    fn increment(&self) -> u64 {
97        COUNTER_SHARD.with(|&shard| self.shards[shard].0.fetch_add(1, Ordering::Relaxed) + 1)
98    }
99
100    fn load(&self) -> u64 {
101        self.shards
102            .iter()
103            .map(|shard| shard.0.load(Ordering::Relaxed))
104            .sum()
105    }
106}
107
108/// A cached slice of a file
109#[derive(Debug)]
110struct CachedSlice {
111    /// Byte range in the file
112    range: Range<u64>,
113    /// Arc-backed cached data. Cache hits return cheap sub-slices instead of
114    /// allocating and copying the requested range.
115    data: OwnedBytes,
116    /// Recency stamp for LRU eviction. Updated by hits under the shared
117    /// read lock, hence atomic.
118    access_count: AtomicU64,
119}
120
121impl CachedSlice {
122    #[inline]
123    fn stamp(&self) -> u64 {
124        self.access_count.load(Ordering::Relaxed)
125    }
126}
127
128/// Per-file slice cache: non-overlapping slices keyed by start offset.
129struct FileSliceCache {
130    /// Stable identity used by LRU heap entries (paths can be renamed).
131    id: u64,
132    /// Slices sorted by start offset for efficient overlap detection
133    slices: BTreeMap<u64, CachedSlice>,
134    /// Total bytes cached for this file
135    total_bytes: usize,
136}
137
138impl FileSliceCache {
139    fn new(id: u64) -> Self {
140        Self {
141            id,
142            slices: BTreeMap::new(),
143            total_bytes: 0,
144        }
145    }
146
147    /// Serialize this file cache to bytes
148    fn serialize(&self) -> Vec<u8> {
149        let mut buf = Vec::new();
150        // Number of slices
151        buf.extend_from_slice(&(self.slices.len() as u32).to_le_bytes());
152        for slice in self.slices.values() {
153            // Range start and end
154            buf.extend_from_slice(&slice.range.start.to_le_bytes());
155            buf.extend_from_slice(&slice.range.end.to_le_bytes());
156            // Data length and data
157            buf.extend_from_slice(&(slice.data.len() as u32).to_le_bytes());
158            buf.extend_from_slice(slice.data.as_slice());
159        }
160        buf
161    }
162
163    /// Deserialize from bytes, returns (cache, bytes_consumed)
164    fn deserialize(
165        data: &[u8],
166        id: u64,
167        access_counter: u64,
168        max_bytes: usize,
169    ) -> io::Result<(Self, usize)> {
170        let mut pos = 0;
171        if data.len() < 4 {
172            return Err(io::Error::new(
173                io::ErrorKind::InvalidData,
174                "truncated slice cache",
175            ));
176        }
177        let num_slices = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
178        pos += 4;
179
180        let mut cache = FileSliceCache::new(id);
181        for _ in 0..num_slices {
182            if pos + 20 > data.len() {
183                return Err(io::Error::new(
184                    io::ErrorKind::InvalidData,
185                    "truncated slice entry",
186                ));
187            }
188            let range_start = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
189            pos += 8;
190            let range_end = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
191            pos += 8;
192            let data_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
193            pos += 4;
194
195            let data_end = pos.checked_add(data_len).ok_or_else(|| {
196                io::Error::new(io::ErrorKind::InvalidData, "slice data length overflow")
197            })?;
198            if data_end > data.len() {
199                return Err(io::Error::new(
200                    io::ErrorKind::InvalidData,
201                    "truncated slice data",
202                ));
203            }
204            if range_end < range_start || range_end - range_start != data_len as u64 {
205                return Err(io::Error::new(
206                    io::ErrorKind::InvalidData,
207                    "slice range and data length are inconsistent",
208                ));
209            }
210            let slice_range = range_start..range_end;
211            pos = data_end;
212
213            // Do not duplicate an oversized serialized entry just to evict it
214            // after the complete cache has been reconstructed. Retain at most
215            // one cache budget while parsing each file.
216            if data_len <= max_bytes {
217                let bytes_to_free = cache
218                    .total_bytes
219                    .saturating_add(data_len)
220                    .saturating_sub(max_bytes);
221                cache.evict_lru(bytes_to_free);
222                cache.insert(
223                    slice_range,
224                    OwnedBytes::new(data[data_end - data_len..data_end].to_vec()),
225                    access_counter,
226                );
227                debug_assert!(cache.total_bytes <= max_bytes);
228            }
229        }
230        Ok((cache, pos))
231    }
232
233    /// Try to read from cache; `None` if the range is not fully cached.
234    ///
235    /// Slices never overlap, so only the slice starting at or before
236    /// `range.start` can contain the range. Recency is recorded through the
237    /// slice's atomic stamp, which is why this takes `&self`.
238    fn try_read(&self, range: Range<u64>) -> Option<OwnedBytes> {
239        let start = range.start;
240        let end = range.end;
241        let (&slice_start, slice) = self.slices.range(..=start).next_back()?;
242        if slice.range.end < end {
243            return None;
244        }
245        let stamp = next_stamp();
246        slice.access_count.store(stamp, Ordering::Relaxed);
247        let offset = (start - slice_start) as usize;
248        let len = (end - start) as usize;
249        Some(slice.data.slice(offset..offset + len))
250    }
251
252    /// Insert a slice, merging with overlapping slices.
253    ///
254    /// Returns the net change in bytes (negative when the merge shrinks the
255    /// footprint) and the start offset of the (possibly merged) slice.
256    fn insert(&mut self, range: Range<u64>, data: OwnedBytes, access_counter: u64) -> (isize, u64) {
257        let start = range.start;
258        let end = range.end;
259        let data_len = data.len();
260
261        // Overlapping slices all start before `end`; walking backwards from
262        // there, the first slice that ends at or before `start` is disjoint
263        // and so is everything before it (slices are sorted and disjoint).
264        let mut to_remove: Vec<u64> = Vec::new();
265        let mut merged_start = start;
266        let mut merged_end = end;
267        for (&slice_start, slice) in self.slices.range(..end).rev() {
268            if slice.range.end <= start {
269                break;
270            }
271            to_remove.push(slice_start);
272            merged_start = merged_start.min(slice_start);
273            merged_end = merged_end.max(slice.range.end);
274        }
275
276        let mut bytes_removed: usize = 0;
277        let (final_start, final_data) = if to_remove.is_empty() {
278            (start, data)
279        } else {
280            let merged_len = (merged_end - merged_start) as usize;
281            let mut new_data = vec![0u8; merged_len];
282
283            // Copy existing slices, then the new data over any overlap.
284            for &slice_start in &to_remove {
285                if let Some(slice) = self.slices.remove(&slice_start) {
286                    let offset = (slice_start - merged_start) as usize;
287                    new_data[offset..offset + slice.data.len()]
288                        .copy_from_slice(slice.data.as_slice());
289                    bytes_removed += slice.data.len();
290                    self.total_bytes -= slice.data.len();
291                }
292            }
293            let offset = (start - merged_start) as usize;
294            new_data[offset..offset + data_len].copy_from_slice(data.as_slice());
295            (merged_start, OwnedBytes::new(new_data))
296        };
297
298        let bytes_added = final_data.len();
299        self.total_bytes += bytes_added;
300        self.slices.insert(
301            final_start,
302            CachedSlice {
303                range: final_start..final_start + bytes_added as u64,
304                data: final_data,
305                access_count: AtomicU64::new(access_counter),
306            },
307        );
308
309        (bytes_added as isize - bytes_removed as isize, final_start)
310    }
311
312    /// Evict least recently used slices of this file to free up space.
313    /// Used while reconstructing a single file from a serialized cache; the
314    /// live cache evicts through the global LRU heap instead.
315    fn evict_lru(&mut self, bytes_to_free: usize) -> usize {
316        if bytes_to_free == 0 || self.slices.is_empty() {
317            return 0;
318        }
319        let mut order: Vec<(u64, u64)> = self
320            .slices
321            .iter()
322            .map(|(&start, slice)| (slice.stamp(), start))
323            .collect();
324        order.sort_unstable();
325
326        let mut freed = 0;
327        for (_, start) in order {
328            if freed >= bytes_to_free {
329                break;
330            }
331            if let Some(slice) = self.slices.remove(&start) {
332                freed += slice.data.len();
333                self.total_bytes -= slice.data.len();
334            }
335        }
336        freed
337    }
338}
339
340/// Lazily maintained LRU heap entry. Ordered by stamp first so the heap
341/// minimum is the least recently used candidate.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
343struct LruEntry {
344    stamp: u64,
345    file_id: u64,
346    start: u64,
347}
348
349/// Everything protected by the cache lock.
350struct CacheState {
351    files: HashMap<Arc<Path>, FileSliceCache>,
352    /// file id → path, so heap entries survive renames.
353    paths: HashMap<u64, Arc<Path>>,
354    lru: BinaryHeap<Reverse<LruEntry>>,
355    current_bytes: usize,
356    total_slices: usize,
357    next_file_id: u64,
358}
359
360impl CacheState {
361    fn new() -> Self {
362        Self {
363            files: HashMap::new(),
364            paths: HashMap::new(),
365            lru: BinaryHeap::new(),
366            current_bytes: 0,
367            total_slices: 0,
368            next_file_id: 0,
369        }
370    }
371
372    fn file_mut(&mut self, path: &Path) -> &mut FileSliceCache {
373        if !self.files.contains_key(path) {
374            let id = self.next_file_id;
375            self.next_file_id += 1;
376            let shared: Arc<Path> = Arc::from(path);
377            self.paths.insert(id, Arc::clone(&shared));
378            self.files.insert(shared, FileSliceCache::new(id));
379        }
380        self.files.get_mut(path).expect("file cache just inserted")
381    }
382
383    fn remove_file(&mut self, path: &Path) -> Option<FileSliceCache> {
384        let file = self.files.remove(path)?;
385        self.paths.remove(&file.id);
386        self.current_bytes = self.current_bytes.saturating_sub(file.total_bytes);
387        self.total_slices = self.total_slices.saturating_sub(file.slices.len());
388        // Heap entries of the removed file are discarded lazily on pop.
389        Some(file)
390    }
391
392    fn rename_file(&mut self, from: &Path, to: &Path) {
393        // Any cache already present under the destination is superseded.
394        self.remove_file(to);
395        if let Some(file) = self.files.remove(from) {
396            let id = file.id;
397            let shared: Arc<Path> = Arc::from(to);
398            self.paths.insert(id, Arc::clone(&shared));
399            self.files.insert(shared, file);
400        }
401    }
402
403    /// Replace (or add) a whole file cache, registering every slice in the
404    /// LRU heap. Returns the previous cache, if any.
405    fn replace_file(&mut self, path: &Path, mut cache: FileSliceCache) {
406        self.remove_file(path);
407        let id = self.next_file_id;
408        self.next_file_id += 1;
409        cache.id = id;
410        for (&start, slice) in &cache.slices {
411            self.lru.push(Reverse(LruEntry {
412                stamp: slice.stamp(),
413                file_id: id,
414                start,
415            }));
416        }
417        self.current_bytes = self.current_bytes.saturating_add(cache.total_bytes);
418        self.total_slices += cache.slices.len();
419        let shared: Arc<Path> = Arc::from(path);
420        self.paths.insert(id, Arc::clone(&shared));
421        self.files.insert(shared, cache);
422        self.compact_lru_if_bloated();
423    }
424
425    /// Insert one slice into a file cache and account for it globally.
426    fn insert_slice(&mut self, path: &Path, range: Range<u64>, data: OwnedBytes, stamp: u64) {
427        let file = self.file_mut(path);
428        let slices_before = file.slices.len();
429        let (net_change, start) = file.insert(range, data, stamp);
430        let file_id = file.id;
431        let slices_after = file.slices.len();
432        self.total_slices = (self.total_slices + slices_after).saturating_sub(slices_before);
433        if net_change >= 0 {
434            self.current_bytes += net_change as usize;
435        } else {
436            self.current_bytes = self.current_bytes.saturating_sub((-net_change) as usize);
437        }
438        self.lru.push(Reverse(LruEntry {
439            stamp,
440            file_id,
441            start,
442        }));
443        self.compact_lru_if_bloated();
444    }
445
446    /// Stale heap entries (merged or evicted slices, superseded stamps) are
447    /// discarded lazily; rebuild when they clearly dominate.
448    fn compact_lru_if_bloated(&mut self) {
449        if self.lru.len() > 2 * self.total_slices + 1024 {
450            self.rebuild_lru();
451        }
452    }
453
454    fn rebuild_lru(&mut self) {
455        let mut entries = Vec::with_capacity(self.total_slices);
456        for file in self.files.values() {
457            for (&start, slice) in &file.slices {
458                entries.push(Reverse(LruEntry {
459                    stamp: slice.stamp(),
460                    file_id: file.id,
461                    start,
462                }));
463            }
464        }
465        self.lru = BinaryHeap::from(entries);
466    }
467
468    /// Evict least recently used slices until `needed` more bytes fit under
469    /// `max_bytes`. Returns `(evicted_slices, evicted_bytes)`.
470    fn evict_for(&mut self, max_bytes: usize, needed: usize) -> (u64, usize) {
471        let target = self
472            .current_bytes
473            .saturating_add(needed)
474            .saturating_sub(max_bytes);
475        if target == 0 {
476            return (0, 0);
477        }
478        let mut freed = 0usize;
479        let mut evicted = 0u64;
480        let mut rebuilt = false;
481        while freed < target {
482            let Some(Reverse(entry)) = self.lru.pop() else {
483                // Every live slice owns at least one heap entry, so an empty
484                // heap with live slices means the index is inconsistent.
485                // Rebuild once and keep going; give up only when truly empty.
486                if self.total_slices == 0 || rebuilt {
487                    break;
488                }
489                self.rebuild_lru();
490                rebuilt = true;
491                continue;
492            };
493            let Some(path) = self.paths.get(&entry.file_id) else {
494                continue; // file removed
495            };
496            let Some(file) = self.files.get_mut(path.as_ref()) else {
497                continue;
498            };
499            let Some(slice) = file.slices.get(&entry.start) else {
500                continue; // slice merged away or already evicted
501            };
502            let current = slice.stamp();
503            if current != entry.stamp {
504                // Touched since this entry was recorded: not the LRU anymore.
505                self.lru.push(Reverse(LruEntry {
506                    stamp: current,
507                    ..entry
508                }));
509                continue;
510            }
511            let slice = file
512                .slices
513                .remove(&entry.start)
514                .expect("slice present under lock");
515            file.total_bytes -= slice.data.len();
516            freed += slice.data.len();
517            evicted += 1;
518            self.total_slices -= 1;
519        }
520        self.current_bytes = self.current_bytes.saturating_sub(freed);
521        (evicted, freed)
522    }
523
524    fn clear(&mut self) {
525        self.files.clear();
526        self.paths.clear();
527        self.lru.clear();
528        self.current_bytes = 0;
529        self.total_slices = 0;
530    }
531}
532
533/// Lock-protected cache state plus lock-free counters, shared between the
534/// directory and every lazy file handle it hands out.
535struct SliceCacheShared {
536    state: RwLock<CacheState>,
537    /// Maximum total bytes to cache
538    max_bytes: usize,
539    hits: ShardedCounter,
540    misses: AtomicU64,
541    evicted_slices: AtomicU64,
542    evicted_bytes: AtomicU64,
543    /// Index name for Directory-layer metric labels (also forwarded to inner)
544    label: super::IndexLabel,
545}
546
547impl SliceCacheShared {
548    fn new(max_bytes: usize) -> Self {
549        Self {
550            state: RwLock::new(CacheState::new()),
551            max_bytes,
552            hits: ShardedCounter::new(),
553            misses: AtomicU64::new(0),
554            evicted_slices: AtomicU64::new(0),
555            evicted_bytes: AtomicU64::new(0),
556            label: super::IndexLabel::default(),
557        }
558    }
559
560    /// Hit path: shared lock, atomic stamp update, and sharded accounting.
561    fn try_read(&self, path: &Path, range: Range<u64>) -> Option<OwnedBytes> {
562        let hit = {
563            let state = self.state.read();
564            state.files.get(path).and_then(|file| file.try_read(range))
565        };
566        self.record_lookup(&hit);
567        hit
568    }
569
570    /// The direct `Directory::read_range` entry point has no reusable file
571    /// handle and its sub-microsecond critical section is faster when
572    /// serialized than when many readers bounce the RwLock reader count.
573    /// Lazy handles use `try_read` above and remain concurrent.
574    fn try_read_direct(&self, path: &Path, range: Range<u64>) -> Option<OwnedBytes> {
575        let hit = {
576            let state = self.state.write();
577            state.files.get(path).and_then(|file| file.try_read(range))
578        };
579        self.record_lookup(&hit);
580        hit
581    }
582
583    #[inline]
584    fn record_lookup(&self, result: &Option<OwnedBytes>) {
585        match result {
586            Some(data) => {
587                let hits = self.hits.increment();
588                if hits.is_multiple_of(HIT_METRICS_FLUSH_INTERVAL) {
589                    crate::observe::slice_cache_hits(
590                        &self.label.get(),
591                        HIT_METRICS_FLUSH_INTERVAL,
592                        data.len(),
593                    );
594                }
595            }
596            None => {
597                self.misses.fetch_add(1, Ordering::Relaxed);
598            }
599        }
600    }
601
602    /// Miss path: exclusive lock, single eviction pass, merge-insert.
603    fn insert(&self, path: &Path, range: Range<u64>, data: OwnedBytes) {
604        let data_len = data.len();
605        crate::observe::slice_cache_miss(&self.label.get(), data_len);
606        // An individual entry larger than the entire cache can never fit.
607        // Bypass it instead of evicting useful data and exceeding the cap.
608        if data_len > self.max_bytes {
609            return;
610        }
611        let stamp = next_stamp();
612        let (evicted_slices, evicted_bytes) = {
613            let mut state = self.state.write();
614            // Free enough space before merging. Besides keeping the retained
615            // size bounded, this avoids constructing a large merged
616            // allocation only to evict it immediately afterward. Merging
617            // never grows the footprint beyond `data_len` (overlap is
618            // replaced, not duplicated), so one pass suffices.
619            let evicted = state.evict_for(self.max_bytes, data_len);
620            state.insert_slice(path, range, data, stamp);
621            debug_assert!(state.current_bytes <= self.max_bytes);
622            evicted
623        };
624        if evicted_slices > 0 {
625            self.evicted_slices
626                .fetch_add(evicted_slices, Ordering::Relaxed);
627            self.evicted_bytes
628                .fetch_add(evicted_bytes as u64, Ordering::Relaxed);
629            crate::observe::slice_cache_evicted(&self.label.get(), evicted_slices, evicted_bytes);
630        }
631    }
632}
633
634/// Slice-caching directory wrapper
635///
636/// Caches byte ranges from the inner directory, with:
637/// - Overlap detection and merging
638/// - LRU eviction when cache limit is reached
639/// - Bounded total memory usage
640/// - File size caching to avoid HEAD requests
641pub struct SliceCachingDirectory<D: Directory> {
642    inner: Arc<D>,
643    shared: Arc<SliceCacheShared>,
644    /// Cached file sizes (avoids HEAD requests on lazy open)
645    file_sizes: Arc<RwLock<HashMap<PathBuf, u64>>>,
646}
647
648impl<D: Directory> SliceCachingDirectory<D> {
649    /// Create a new slice-caching directory with the given memory limit
650    pub fn new(inner: D, max_bytes: usize) -> Self {
651        Self {
652            inner: Arc::new(inner),
653            shared: Arc::new(SliceCacheShared::new(max_bytes)),
654            file_sizes: Arc::new(RwLock::new(HashMap::new())),
655        }
656    }
657
658    /// Get a reference to the inner directory
659    pub fn inner(&self) -> &D {
660        &self.inner
661    }
662
663    /// Try to read from cache
664    fn try_cache_read(&self, path: &Path, range: Range<u64>) -> Option<OwnedBytes> {
665        self.shared.try_read_direct(path, range)
666    }
667
668    /// Insert into cache, evicting if necessary
669    fn cache_insert(&self, path: &Path, range: Range<u64>, data: OwnedBytes) {
670        self.shared.insert(path, range, data)
671    }
672
673    fn invalidate(&self, path: &Path) {
674        {
675            let mut state = self.shared.state.write();
676            state.remove_file(path);
677        }
678        self.file_sizes.write().remove(path);
679    }
680
681    /// Get cache statistics
682    pub fn stats(&self) -> SliceCacheStats {
683        let state = self.shared.state.read();
684        let mut total_slices = 0;
685        let mut files_cached = 0;
686
687        for fc in state.files.values() {
688            if !fc.slices.is_empty() {
689                files_cached += 1;
690                total_slices += fc.slices.len();
691            }
692        }
693
694        SliceCacheStats {
695            total_bytes: state.current_bytes,
696            max_bytes: self.shared.max_bytes,
697            total_slices,
698            files_cached,
699            hits: self.shared.hits.load(),
700            misses: self.shared.misses.load(Ordering::Relaxed),
701            evicted_slices: self.shared.evicted_slices.load(Ordering::Relaxed),
702            evicted_bytes: self.shared.evicted_bytes.load(Ordering::Relaxed),
703        }
704    }
705
706    /// Serialize the entire cache to a single binary blob
707    ///
708    /// Format (v2):
709    /// - Magic: 8 bytes "HRMSCACH"
710    /// - Version: 4 bytes (u32 LE)
711    /// - Num files: 4 bytes (u32 LE)
712    /// - For each file:
713    ///   - Path length: 4 bytes (u32 LE)
714    ///   - Path: UTF-8 bytes
715    ///   - File cache data (see FileSliceCache::serialize)
716    /// - Num file sizes: 4 bytes (u32 LE) [v2+]
717    /// - For each file size: [v2+]
718    ///   - Path length: 4 bytes (u32 LE)
719    ///   - Path: UTF-8 bytes
720    ///   - File size: 8 bytes (u64 LE)
721    pub fn serialize(&self) -> Vec<u8> {
722        let state = self.shared.state.read();
723        let file_sizes = self.file_sizes.read();
724        let mut buf = Vec::new();
725
726        // Magic and version
727        buf.extend_from_slice(SLICE_CACHE_MAGIC);
728        buf.extend_from_slice(&SLICE_CACHE_VERSION.to_le_bytes());
729
730        // Count non-empty caches
731        let non_empty: Vec<_> = state
732            .files
733            .iter()
734            .filter(|(_, fc)| !fc.slices.is_empty())
735            .collect();
736        buf.extend_from_slice(&(non_empty.len() as u32).to_le_bytes());
737
738        for (path, file_cache) in non_empty {
739            // Path
740            let path_str = path.to_string_lossy();
741            let path_bytes = path_str.as_bytes();
742            buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
743            buf.extend_from_slice(path_bytes);
744
745            // File cache data
746            let cache_data = file_cache.serialize();
747            buf.extend_from_slice(&cache_data);
748        }
749
750        // v2: File sizes section
751        buf.extend_from_slice(&(file_sizes.len() as u32).to_le_bytes());
752        for (path, &size) in file_sizes.iter() {
753            let path_str = path.to_string_lossy();
754            let path_bytes = path_str.as_bytes();
755            buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
756            buf.extend_from_slice(path_bytes);
757            buf.extend_from_slice(&size.to_le_bytes());
758        }
759
760        buf
761    }
762
763    /// Deserialize and prefill the cache from a binary blob
764    ///
765    /// This loads cached slices from a previously serialized cache file.
766    /// Existing cache entries are preserved; new entries are merged in.
767    pub fn deserialize(&self, data: &[u8]) -> io::Result<()> {
768        let mut pos = 0;
769
770        // Check magic
771        if data.len() < 16 {
772            return Err(io::Error::new(
773                io::ErrorKind::InvalidData,
774                "slice cache too short",
775            ));
776        }
777        if &data[pos..pos + 8] != SLICE_CACHE_MAGIC {
778            return Err(io::Error::new(
779                io::ErrorKind::InvalidData,
780                "invalid slice cache magic",
781            ));
782        }
783        pos += 8;
784
785        // Check version (v2 only)
786        let version = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
787        pos += 4;
788        if version != 2 {
789            return Err(io::Error::new(
790                io::ErrorKind::InvalidData,
791                format!("unsupported slice cache version: {} (expected 2)", version),
792            ));
793        }
794
795        // Number of files
796        let num_files = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
797        pos += 4;
798
799        let max_bytes = self.shared.max_bytes;
800        let counter = next_stamp();
801        let mut state = self.shared.state.write();
802
803        for _ in 0..num_files {
804            // Path length
805            if pos + 4 > data.len() {
806                return Err(io::Error::new(
807                    io::ErrorKind::InvalidData,
808                    "truncated path length",
809                ));
810            }
811            let path_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
812            pos += 4;
813
814            // Path
815            if pos + path_len > data.len() {
816                return Err(io::Error::new(io::ErrorKind::InvalidData, "truncated path"));
817            }
818            let path_str = std::str::from_utf8(&data[pos..pos + path_len])
819                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
820            let path = PathBuf::from(path_str);
821            pos += path_len;
822
823            // File cache (the id is reassigned on insertion)
824            let (file_cache, consumed) =
825                FileSliceCache::deserialize(&data[pos..], 0, counter, max_bytes)?;
826            pos += consumed;
827
828            state.replace_file(&path, file_cache);
829            state.evict_for(max_bytes, 0);
830        }
831
832        // Recompute once after loading as a consistency check for serialized
833        // caches containing duplicate paths or overlapping ranges.
834        state.current_bytes = state.files.values().map(|cache| cache.total_bytes).sum();
835        state.total_slices = state.files.values().map(|cache| cache.slices.len()).sum();
836        state.evict_for(max_bytes, 0);
837        drop(state);
838
839        // Load file sizes
840        if pos + 4 <= data.len() {
841            let num_sizes = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
842            pos += 4;
843
844            let mut file_sizes = self.file_sizes.write();
845            for _ in 0..num_sizes {
846                if pos + 4 > data.len() {
847                    break;
848                }
849                let path_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
850                pos += 4;
851
852                if pos + path_len > data.len() {
853                    break;
854                }
855                let path_str = match std::str::from_utf8(&data[pos..pos + path_len]) {
856                    Ok(s) => s,
857                    Err(_) => break,
858                };
859                let path = PathBuf::from(path_str);
860                pos += path_len;
861
862                if pos + 8 > data.len() {
863                    break;
864                }
865                let size = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
866                pos += 8;
867
868                file_sizes.insert(path, size);
869            }
870        }
871
872        Ok(())
873    }
874
875    /// Serialize the cache to a writer
876    pub fn serialize_to_writer<W: Write>(&self, mut writer: W) -> io::Result<()> {
877        let data = self.serialize();
878        writer.write_all(&data)
879    }
880
881    /// Deserialize the cache from a reader
882    pub fn deserialize_from_reader<R: Read>(&self, mut reader: R) -> io::Result<()> {
883        let mut data = Vec::new();
884        reader.read_to_end(&mut data)?;
885        self.deserialize(&data)
886    }
887
888    /// Check if the cache is empty
889    pub fn is_empty(&self) -> bool {
890        self.shared.state.read().current_bytes == 0
891    }
892
893    /// Clear all cached data
894    pub fn clear(&self) {
895        self.shared.state.write().clear();
896    }
897}
898
899/// Cache statistics
900#[derive(Debug, Clone)]
901pub struct SliceCacheStats {
902    pub total_bytes: usize,
903    pub max_bytes: usize,
904    pub total_slices: usize,
905    pub files_cached: usize,
906    /// Range reads served from cache since creation.
907    pub hits: u64,
908    /// Range reads that went to the inner directory since creation.
909    pub misses: u64,
910    /// Slices dropped by LRU eviction since creation.
911    pub evicted_slices: u64,
912    /// Bytes dropped by LRU eviction since creation.
913    pub evicted_bytes: u64,
914}
915
916#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
917#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
918impl<D: Directory> Directory for SliceCachingDirectory<D> {
919    async fn exists(&self, path: &Path) -> io::Result<bool> {
920        self.inner.exists(path).await
921    }
922
923    async fn file_size(&self, path: &Path) -> io::Result<u64> {
924        // Check cache first
925        {
926            let file_sizes = self.file_sizes.read();
927            if let Some(&size) = file_sizes.get(path) {
928                return Ok(size);
929            }
930        }
931
932        // Fetch from inner and cache
933        let size = self.inner.file_size(path).await?;
934        {
935            let mut file_sizes = self.file_sizes.write();
936            file_sizes.insert(path.to_path_buf(), size);
937        }
938        Ok(size)
939    }
940
941    async fn open_read(&self, path: &Path) -> io::Result<FileHandle> {
942        // Check if we have the full file cached (use our caching file_size)
943        let file_size = self.file_size(path).await?;
944        let full_range = 0..file_size;
945
946        // Try cache first for full file
947        if let Some(data) = self.try_cache_read(path, full_range.clone()) {
948            return Ok(FileHandle::from_bytes(data));
949        }
950
951        // Read from inner
952        let handle = self.inner.open_read(path).await?;
953        let bytes = handle.read_bytes().await?;
954
955        // Cache the full file
956        self.cache_insert(path, full_range, bytes.clone());
957
958        Ok(FileHandle::from_bytes(bytes))
959    }
960
961    async fn read_range(&self, path: &Path, range: Range<u64>) -> io::Result<OwnedBytes> {
962        // Try cache first
963        if let Some(data) = self.try_cache_read(path, range.clone()) {
964            return Ok(data);
965        }
966
967        // Read from inner
968        let data = self.inner.read_range(path, range.clone()).await?;
969
970        // Cache the result
971        self.cache_insert(path, range, data.clone());
972
973        Ok(data)
974    }
975
976    async fn list_files(&self, prefix: &Path) -> io::Result<Vec<PathBuf>> {
977        self.inner.list_files(prefix).await
978    }
979
980    async fn open_lazy(&self, path: &Path) -> io::Result<FileHandle> {
981        // Get file size (uses cache to avoid HEAD requests)
982        let file_size = self.file_size(path).await?;
983
984        // Create a caching wrapper around the inner directory's read_range.
985        // The path is shared, not cloned, per read.
986        let path: Arc<Path> = Arc::from(path);
987        let shared = Arc::clone(&self.shared);
988        let inner = Arc::clone(&self.inner);
989
990        let read_fn: RangeReadFn = Arc::new(move |range: Range<u64>| {
991            let path = Arc::clone(&path);
992            let shared = Arc::clone(&shared);
993            let inner = Arc::clone(&inner);
994
995            Box::pin(async move {
996                // Try cache first
997                if let Some(data) = shared.try_read(&path, range.clone()) {
998                    return Ok(data);
999                }
1000
1001                // Read from inner
1002                let data = inner.read_range(&path, range.clone()).await?;
1003
1004                // Cache the result
1005                shared.insert(&path, range, data.clone());
1006
1007                Ok(data)
1008            })
1009        });
1010
1011        Ok(FileHandle::lazy_labeled(
1012            file_size,
1013            read_fn,
1014            self.shared.label.get(),
1015        ))
1016    }
1017
1018    fn local_path(&self, path: &Path) -> Option<PathBuf> {
1019        self.inner.local_path(path)
1020    }
1021
1022    fn set_index_label(&self, label: &str) {
1023        self.shared.label.set(label);
1024        self.inner.set_index_label(label);
1025    }
1026}
1027
1028/// DirectoryWriter implementation for SliceCachingDirectory
1029/// Delegates to inner directory and invalidates cache entries as needed
1030#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1031#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1032impl<D: super::DirectoryWriter> super::DirectoryWriter for SliceCachingDirectory<D> {
1033    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1034        // Invalidate cache and file size for this file
1035        self.invalidate(path);
1036        // Delegate to inner
1037        self.inner.write(path, data).await
1038    }
1039
1040    async fn delete(&self, path: &Path) -> io::Result<()> {
1041        self.invalidate(path);
1042        // Delegate to inner
1043        self.inner.delete(path).await
1044    }
1045
1046    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1047        // Move cache entries from old path to new path
1048        {
1049            let mut state = self.shared.state.write();
1050            state.rename_file(from, to);
1051        }
1052        // Move file size cache
1053        {
1054            let mut file_sizes = self.file_sizes.write();
1055            if let Some(size) = file_sizes.remove(from) {
1056                file_sizes.insert(to.to_path_buf(), size);
1057            }
1058        }
1059        // Delegate to inner
1060        self.inner.rename(from, to).await
1061    }
1062
1063    async fn link(&self, from: &Path, to: &Path) -> io::Result<()> {
1064        // A link creates an immutable alias. Do not copy cache entries: the
1065        // destination starts cold and is populated under its own path.
1066        self.inner.link(from, to).await
1067    }
1068
1069    async fn sync(&self) -> io::Result<()> {
1070        self.inner.sync().await
1071    }
1072
1073    async fn streaming_writer(&self, path: &Path) -> io::Result<Box<dyn super::StreamingWriter>> {
1074        // Invalidate cache for this file before writing
1075        self.invalidate(path);
1076        self.inner.streaming_writer(path).await
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083    use crate::directories::{DirectoryWriter, RamDirectory};
1084
1085    #[tokio::test]
1086    async fn test_slice_cache_basic() {
1087        let ram = RamDirectory::new();
1088        ram.write(Path::new("test.bin"), &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
1089            .await
1090            .unwrap();
1091
1092        let cached = SliceCachingDirectory::new(ram, 1024);
1093
1094        // First read - cache miss
1095        let data = cached
1096            .read_range(Path::new("test.bin"), 2..5)
1097            .await
1098            .unwrap();
1099        assert_eq!(data.as_slice(), &[2, 3, 4]);
1100
1101        // Second read - should be cache hit
1102        let data = cached
1103            .read_range(Path::new("test.bin"), 2..5)
1104            .await
1105            .unwrap();
1106        assert_eq!(data.as_slice(), &[2, 3, 4]);
1107
1108        let stats = cached.stats();
1109        assert_eq!(stats.total_slices, 1);
1110        assert_eq!(stats.total_bytes, 3);
1111        assert_eq!(stats.hits, 1);
1112        assert_eq!(stats.misses, 1);
1113    }
1114
1115    #[tokio::test]
1116    async fn slice_cache_hits_reuse_the_cached_backing_allocation() {
1117        let ram = RamDirectory::new();
1118        ram.write(Path::new("test.bin"), &[7; 64]).await.unwrap();
1119        let cached = SliceCachingDirectory::new(ram, 64);
1120
1121        let miss = cached
1122            .read_range(Path::new("test.bin"), 8..56)
1123            .await
1124            .unwrap();
1125        let hit = cached
1126            .read_range(Path::new("test.bin"), 8..56)
1127            .await
1128            .unwrap();
1129
1130        assert_eq!(miss.as_slice(), hit.as_slice());
1131        assert_eq!(miss.as_slice().as_ptr(), hit.as_slice().as_ptr());
1132    }
1133
1134    #[tokio::test]
1135    async fn oversized_slice_bypasses_cache_instead_of_exceeding_limit() {
1136        let ram = RamDirectory::new();
1137        ram.write(Path::new("test.bin"), &[3; 32]).await.unwrap();
1138        let cached = SliceCachingDirectory::new(ram, 8);
1139
1140        let data = cached
1141            .read_range(Path::new("test.bin"), 0..32)
1142            .await
1143            .unwrap();
1144        assert_eq!(data.len(), 32);
1145        assert_eq!(cached.stats().total_bytes, 0);
1146    }
1147
1148    #[tokio::test]
1149    async fn test_slice_cache_overlap_merge() {
1150        let ram = RamDirectory::new();
1151        ram.write(Path::new("test.bin"), &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
1152            .await
1153            .unwrap();
1154
1155        let cached = SliceCachingDirectory::new(ram, 1024);
1156
1157        // Read [2..5]
1158        cached
1159            .read_range(Path::new("test.bin"), 2..5)
1160            .await
1161            .unwrap();
1162
1163        // Read [4..7] - overlaps with previous
1164        cached
1165            .read_range(Path::new("test.bin"), 4..7)
1166            .await
1167            .unwrap();
1168
1169        let stats = cached.stats();
1170        // Should be merged into one slice [2..7]
1171        assert_eq!(stats.total_slices, 1);
1172        assert_eq!(stats.total_bytes, 5); // bytes 2,3,4,5,6
1173
1174        // Reading from merged range should work
1175        let data = cached
1176            .read_range(Path::new("test.bin"), 3..6)
1177            .await
1178            .unwrap();
1179        assert_eq!(data.as_slice(), &[3, 4, 5]);
1180    }
1181
1182    /// Overlap detection walks backwards from the last slice starting before
1183    /// the new range's end. Pins the edge cases of that walk: a new range
1184    /// that bridges several slices, one that ends exactly where a slice
1185    /// starts (adjacent, not overlapping), one that starts exactly where a
1186    /// slice ends, and one fully inside an existing slice.
1187    #[tokio::test]
1188    async fn overlap_merge_bridges_multiple_slices_and_keeps_adjacent_ones_apart() {
1189        let ram = RamDirectory::new();
1190        let bytes: Vec<u8> = (0..64).collect();
1191        ram.write(Path::new("test.bin"), &bytes).await.unwrap();
1192        let cached = SliceCachingDirectory::new(ram, 1024);
1193        let path = Path::new("test.bin");
1194
1195        // Three disjoint slices: [4..8), [12..16), [20..24), plus [40..44)
1196        for range in [4..8, 12..16, 20..24, 40..44] {
1197            cached.read_range(path, range).await.unwrap();
1198        }
1199        assert_eq!(cached.stats().total_slices, 4);
1200
1201        // Adjacent on both sides but not overlapping: [8..12) stays separate
1202        // from [4..8) and [12..16).
1203        cached.read_range(path, 8..12).await.unwrap();
1204        assert_eq!(cached.stats().total_slices, 5);
1205        assert_eq!(cached.stats().total_bytes, 20);
1206
1207        // A range bridging [4..8), [8..12), [12..16) and [20..24) merges all
1208        // four into one [4..24) slice; [40..44) is untouched.
1209        cached.read_range(path, 6..22).await.unwrap();
1210        let stats = cached.stats();
1211        assert_eq!(stats.total_slices, 2);
1212        assert_eq!(stats.total_bytes, 20 + 4);
1213
1214        // Fully contained range is a hit and does not change the layout.
1215        let misses_before = cached.stats().misses;
1216        let data = cached.read_range(path, 10..14).await.unwrap();
1217        assert_eq!(data.as_slice(), &[10, 11, 12, 13]);
1218        assert_eq!(cached.stats().misses, misses_before);
1219        assert_eq!(cached.stats().total_slices, 2);
1220
1221        // Every byte of the merged slice reads back correctly.
1222        let data = cached.read_range(path, 4..24).await.unwrap();
1223        assert_eq!(data.as_slice(), &bytes[4..24]);
1224        let data = cached.read_range(path, 40..44).await.unwrap();
1225        assert_eq!(data.as_slice(), &bytes[40..44]);
1226    }
1227
1228    #[tokio::test]
1229    async fn test_slice_cache_eviction() {
1230        let ram = RamDirectory::new();
1231        ram.write(Path::new("test.bin"), &[0; 100]).await.unwrap();
1232
1233        // Small cache limit
1234        let cached = SliceCachingDirectory::new(ram, 50);
1235
1236        // Fill cache
1237        cached
1238            .read_range(Path::new("test.bin"), 0..30)
1239            .await
1240            .unwrap();
1241
1242        // This should trigger eviction
1243        cached
1244            .read_range(Path::new("test.bin"), 50..80)
1245            .await
1246            .unwrap();
1247
1248        let stats = cached.stats();
1249        assert!(stats.total_bytes <= 50);
1250        assert_eq!(stats.evicted_slices, 1);
1251        assert_eq!(stats.evicted_bytes, 30);
1252    }
1253
1254    /// Eviction is exact LRU even though hits only bump an atomic stamp: a
1255    /// slice touched after its heap entry was recorded must survive an older
1256    /// untouched slice, across files.
1257    #[tokio::test]
1258    async fn eviction_is_lru_across_files_after_hits_refresh_recency() {
1259        let ram = RamDirectory::new();
1260        ram.write(Path::new("a.bin"), &[1; 64]).await.unwrap();
1261        ram.write(Path::new("b.bin"), &[2; 64]).await.unwrap();
1262        let cached = SliceCachingDirectory::new(ram, 32);
1263
1264        cached.read_range(Path::new("a.bin"), 0..10).await.unwrap(); // oldest
1265        cached.read_range(Path::new("b.bin"), 0..10).await.unwrap();
1266        cached.read_range(Path::new("a.bin"), 20..30).await.unwrap();
1267        // Refresh the oldest slice; b.bin[0..10) is now the LRU.
1268        cached.read_range(Path::new("a.bin"), 2..8).await.unwrap();
1269
1270        // Needs 10 more bytes: exactly one slice must go, and it must be b.
1271        cached.read_range(Path::new("a.bin"), 40..50).await.unwrap();
1272        let stats = cached.stats();
1273        assert_eq!(stats.total_bytes, 30);
1274        assert_eq!(stats.evicted_slices, 1);
1275
1276        let misses = cached.stats().misses;
1277        cached.read_range(Path::new("a.bin"), 0..10).await.unwrap();
1278        cached.read_range(Path::new("a.bin"), 20..30).await.unwrap();
1279        assert_eq!(cached.stats().misses, misses, "refreshed slices survived");
1280        cached.read_range(Path::new("b.bin"), 0..10).await.unwrap();
1281        assert_eq!(cached.stats().misses, misses + 1, "LRU slice was evicted");
1282    }
1283
1284    /// Renaming a file keeps its slices (and their LRU entries) usable.
1285    #[tokio::test]
1286    async fn rename_keeps_cached_slices_evictable_and_readable() {
1287        let ram = RamDirectory::new();
1288        ram.write(Path::new("old.bin"), &[9; 64]).await.unwrap();
1289        let cached = SliceCachingDirectory::new(ram, 16);
1290        cached.read_range(Path::new("old.bin"), 0..8).await.unwrap();
1291        cached
1292            .rename(Path::new("old.bin"), Path::new("new.bin"))
1293            .await
1294            .unwrap();
1295
1296        let misses = cached.stats().misses;
1297        let data = cached.read_range(Path::new("new.bin"), 0..8).await.unwrap();
1298        assert_eq!(data.as_slice(), &[9; 8]);
1299        assert_eq!(cached.stats().misses, misses);
1300
1301        // Filling the cache must be able to evict the renamed slice.
1302        cached
1303            .read_range(Path::new("new.bin"), 16..24)
1304            .await
1305            .unwrap();
1306        cached
1307            .read_range(Path::new("new.bin"), 32..40)
1308            .await
1309            .unwrap();
1310        let stats = cached.stats();
1311        assert!(stats.total_bytes <= 16);
1312        assert_eq!(stats.evicted_slices, 1);
1313    }
1314
1315    #[tokio::test]
1316    async fn test_slice_cache_serialize_deserialize() {
1317        let ram = RamDirectory::new();
1318        ram.write(Path::new("file1.bin"), &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
1319            .await
1320            .unwrap();
1321        ram.write(Path::new("file2.bin"), &[10, 11, 12, 13, 14, 15])
1322            .await
1323            .unwrap();
1324
1325        let cached = SliceCachingDirectory::new(ram.clone(), 1024);
1326
1327        // Read some ranges to populate cache
1328        cached
1329            .read_range(Path::new("file1.bin"), 2..6)
1330            .await
1331            .unwrap();
1332        cached
1333            .read_range(Path::new("file2.bin"), 1..4)
1334            .await
1335            .unwrap();
1336
1337        let stats = cached.stats();
1338        assert_eq!(stats.files_cached, 2);
1339        assert_eq!(stats.total_bytes, 7); // 4 + 3
1340
1341        // Serialize
1342        let serialized = cached.serialize();
1343        assert!(!serialized.is_empty());
1344
1345        // Create new cache and deserialize
1346        let cached2 = SliceCachingDirectory::new(ram.clone(), 1024);
1347        assert!(cached2.is_empty());
1348
1349        cached2.deserialize(&serialized).unwrap();
1350
1351        let stats2 = cached2.stats();
1352        assert_eq!(stats2.files_cached, 2);
1353        assert_eq!(stats2.total_bytes, 7);
1354
1355        // Verify cached data is correct by reading (should be cache hits)
1356        let data = cached2
1357            .read_range(Path::new("file1.bin"), 2..6)
1358            .await
1359            .unwrap();
1360        assert_eq!(data.as_slice(), &[2, 3, 4, 5]);
1361
1362        let data = cached2
1363            .read_range(Path::new("file2.bin"), 1..4)
1364            .await
1365            .unwrap();
1366        assert_eq!(data.as_slice(), &[11, 12, 13]);
1367        assert_eq!(cached2.stats().misses, 0);
1368    }
1369
1370    #[tokio::test]
1371    async fn test_slice_cache_serialize_empty() {
1372        let ram = RamDirectory::new();
1373        let cached = SliceCachingDirectory::new(ram, 1024);
1374
1375        // Serialize empty cache
1376        let serialized = cached.serialize();
1377        assert!(!serialized.is_empty()); // Should have header
1378
1379        // Deserialize into new cache
1380        let cached2 = SliceCachingDirectory::new(RamDirectory::new(), 1024);
1381        cached2.deserialize(&serialized).unwrap();
1382        assert!(cached2.is_empty());
1383    }
1384
1385    #[tokio::test]
1386    async fn deserialization_enforces_the_destination_cache_limit() {
1387        let ram = RamDirectory::new();
1388        ram.write(Path::new("test.bin"), &[1; 64]).await.unwrap();
1389        let source = SliceCachingDirectory::new(ram.clone(), 64);
1390        source
1391            .read_range(Path::new("test.bin"), 0..64)
1392            .await
1393            .unwrap();
1394
1395        let destination = SliceCachingDirectory::new(ram, 8);
1396        destination.deserialize(&source.serialize()).unwrap();
1397        assert!(destination.stats().total_bytes <= 8);
1398    }
1399
1400    /// The lazy handle path (segment readers) shares hit/miss accounting and
1401    /// eviction with `read_range`.
1402    #[tokio::test]
1403    async fn lazy_handle_reads_hit_the_shared_cache() {
1404        let ram = RamDirectory::new();
1405        ram.write(Path::new("test.bin"), &[4; 128]).await.unwrap();
1406        let cached = SliceCachingDirectory::new(ram, 1024);
1407
1408        cached
1409            .read_range(Path::new("test.bin"), 0..64)
1410            .await
1411            .unwrap();
1412        let handle = cached.open_lazy(Path::new("test.bin")).await.unwrap();
1413        let data = handle.read_bytes_range(8..40).await.unwrap();
1414        assert_eq!(data.len(), 32);
1415        assert_eq!(cached.stats().hits, 1);
1416        assert_eq!(cached.stats().misses, 1);
1417
1418        let data = handle.read_bytes_range(64..128).await.unwrap();
1419        assert_eq!(data.len(), 64);
1420        assert_eq!(cached.stats().misses, 2);
1421        assert_eq!(cached.stats().total_bytes, 128);
1422    }
1423
1424    /// Sharding the hit counter must not make the public total approximate:
1425    /// every hit from every reader thread is included in `stats()`.
1426    #[tokio::test]
1427    async fn concurrent_lazy_handle_hits_are_counted_exactly() {
1428        const THREADS: usize = 8;
1429        const HITS_PER_THREAD: usize = 250;
1430
1431        let ram = RamDirectory::new();
1432        ram.write(Path::new("test.bin"), &[7; 4096]).await.unwrap();
1433        let cached = SliceCachingDirectory::new(ram, 4096);
1434        cached
1435            .read_range(Path::new("test.bin"), 0..4096)
1436            .await
1437            .unwrap();
1438        let handle = cached.open_lazy(Path::new("test.bin")).await.unwrap();
1439
1440        std::thread::scope(|scope| {
1441            for thread in 0..THREADS {
1442                let handle = &handle;
1443                scope.spawn(move || {
1444                    for hit in 0..HITS_PER_THREAD {
1445                        let start = ((thread * 31 + hit * 7) % (4096 - 64)) as u64;
1446                        let bytes =
1447                            futures::executor::block_on(handle.read_bytes_range(start..start + 64))
1448                                .unwrap();
1449                        assert_eq!(bytes.len(), 64);
1450                    }
1451                });
1452            }
1453        });
1454
1455        assert_eq!(cached.stats().hits, (THREADS * HITS_PER_THREAD) as u64);
1456        assert_eq!(cached.stats().misses, 1);
1457    }
1458}