Skip to main content

hdf5_pure/
chunk_cache.rs

1//! Chunk cache with hash-based index and LRU eviction.
2//!
3//! The [`ChunkCache`] avoids re-traversing B-trees on repeated reads of chunked
4//! datasets.  On first access it scans the B-tree once and builds a
5//! `HashMap<ChunkCoord, ChunkInfo>` (the *chunk index*).  Decompressed chunk
6//! data is cached with LRU eviction controlled by a byte-budget.
7
8#[cfg(not(feature = "std"))]
9extern crate alloc;
10
11#[cfg(not(feature = "std"))]
12use alloc::vec::Vec;
13
14#[cfg(not(feature = "std"))]
15use crate::nosync::Mutex;
16#[cfg(feature = "std")]
17use std::sync::Mutex;
18
19#[cfg(not(feature = "std"))]
20use alloc::collections::BTreeMap;
21#[cfg(feature = "std")]
22use std::collections::HashMap;
23
24use crate::chunked_read::ChunkInfo;
25
26/// Coordinate key for a chunk — the N-dimensional offset vector.
27pub type ChunkCoord = Vec<u64>;
28
29/// Default maximum bytes of decompressed chunk data to cache.
30pub const DEFAULT_CACHE_BYTES: usize = 1024 * 1024; // 1 MiB
31
32/// Default maximum number of cached decompressed chunks.
33pub const DEFAULT_MAX_SLOTS: usize = 16;
34
35/// Configuration for a per-dataset chunk cache.
36///
37/// The byte and slot limits are the `hdf5-pure` counterpart of the
38/// `rdcc_nbytes` and `rdcc_nslots` raw-data chunk-cache settings from HDF5's
39/// `H5Pset_cache`. They apply to decompressed raw chunk data. The optional
40/// chunk-index cache controls whether `hdf5-pure` retains the parsed chunk
41/// address index between reads of the same [`crate::Dataset`]. Disabling the
42/// index cache lowers retained metadata memory at the cost of re-scanning the
43/// on-disk chunk index for repeated reads.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct ChunkCacheConfig {
46    max_bytes: usize,
47    max_slots: usize,
48    cache_index: bool,
49}
50
51impl ChunkCacheConfig {
52    /// Create a config matching the historical defaults: 1 MiB of decompressed
53    /// chunks, 16 slots, and retained parsed chunk indexes.
54    pub const fn new() -> Self {
55        Self {
56            max_bytes: DEFAULT_CACHE_BYTES,
57            max_slots: DEFAULT_MAX_SLOTS,
58            cache_index: true,
59        }
60    }
61
62    /// Create a config from HDF5 `H5Pset_cache` raw data chunk-cache values.
63    ///
64    /// `rdcc_nslots` maps to the maximum retained chunk slots and
65    /// `rdcc_nbytes` maps to the maximum retained decompressed chunk bytes.
66    /// Modern HDF5 ignores `H5Pset_cache`'s `mdc_nelmts`; use
67    /// [`crate::MetadataCacheConfig`] for the metadata-cache budget. The
68    /// `rdcc_w0` preemption policy has no direct equivalent because this
69    /// read-only cache uses strict LRU eviction.
70    pub const fn from_h5p_cache(rdcc_nslots: usize, rdcc_nbytes: usize) -> Self {
71        Self {
72            max_bytes: rdcc_nbytes,
73            max_slots: rdcc_nslots,
74            cache_index: true,
75        }
76    }
77
78    /// Disable retained decompressed chunks and parsed chunk indexes.
79    pub const fn disabled() -> Self {
80        Self {
81            max_bytes: 0,
82            max_slots: 0,
83            cache_index: false,
84        }
85    }
86
87    /// Set the maximum decompressed chunk bytes retained per dataset.
88    pub const fn with_max_bytes(mut self, max_bytes: usize) -> Self {
89        self.max_bytes = max_bytes;
90        self
91    }
92
93    /// Set the maximum number of decompressed chunk slots retained per dataset.
94    pub const fn with_max_slots(mut self, max_slots: usize) -> Self {
95        self.max_slots = max_slots;
96        self
97    }
98
99    /// Enable or disable retaining the parsed chunk index between reads.
100    pub const fn with_index_cache(mut self, enabled: bool) -> Self {
101        self.cache_index = enabled;
102        self
103    }
104
105    /// Return the maximum decompressed chunk bytes retained per dataset.
106    pub const fn max_bytes(&self) -> usize {
107        self.max_bytes
108    }
109
110    /// Return the maximum decompressed chunk slots retained per dataset.
111    pub const fn max_slots(&self) -> usize {
112        self.max_slots
113    }
114
115    /// Return whether parsed chunk indexes are retained between reads.
116    pub const fn index_cache_enabled(&self) -> bool {
117        self.cache_index
118    }
119}
120
121impl Default for ChunkCacheConfig {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127/// A read-only snapshot of a dataset's chunk-cache occupancy.
128///
129/// Returned by [`crate::Dataset::chunk_cache_stats`]. Use it to confirm a
130/// chunk-cache configuration is taking effect: after reading a chunked dataset,
131/// an enabled cache reports a loaded index and retained chunks, a disabled one
132/// (or one over its byte/slot budget) reports fewer or none. The counts are a
133/// point-in-time view and change as further reads populate or evict chunks.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub struct ChunkCacheStats {
136    index_loaded: bool,
137    cached_chunks: usize,
138    cached_bytes: usize,
139}
140
141impl ChunkCacheStats {
142    /// Whether the parsed chunk index is currently held in memory.
143    pub const fn index_loaded(&self) -> bool {
144        self.index_loaded
145    }
146
147    /// Number of decompressed chunks currently retained.
148    pub const fn cached_chunks(&self) -> usize {
149        self.cached_chunks
150    }
151
152    /// Total bytes of decompressed chunk data currently retained.
153    pub const fn cached_bytes(&self) -> usize {
154        self.cached_bytes
155    }
156}
157
158// ---------------------------------------------------------------------------
159// LRU entry
160// ---------------------------------------------------------------------------
161
162struct CachedChunk {
163    coord: ChunkCoord,
164    data: Vec<u8>,
165    /// Monotonically increasing access counter for LRU ordering.
166    last_access: u64,
167}
168
169// ---------------------------------------------------------------------------
170// ChunkCache
171// ---------------------------------------------------------------------------
172
173/// A per-dataset chunk cache with hash-based index and LRU eviction.
174///
175/// # Usage
176///
177/// ```ignore
178/// let cache = ChunkCache::new();
179/// // Pass &cache to read_chunked_data — it will populate the index lazily.
180/// ```
181///
182/// The cache is wrapped in `Mutex` internally so it can be mutated through
183/// shared references (thread-safe).
184pub struct ChunkCache {
185    inner: Mutex<CacheInner>,
186}
187
188struct CacheInner {
189    /// Hash index: chunk coordinate → ChunkInfo (offset + size in file).
190    /// Populated once per dataset on first access.
191    #[cfg(feature = "std")]
192    index: Option<HashMap<ChunkCoord, ChunkInfo>>,
193    #[cfg(not(feature = "std"))]
194    index: Option<BTreeMap<ChunkCoord, ChunkInfo>>,
195
196    /// LRU cache of decompressed chunk data.
197    slots: Vec<CachedChunk>,
198
199    /// Current total bytes of cached decompressed data.
200    current_bytes: usize,
201
202    /// Maximum bytes of decompressed data to cache.
203    max_bytes: usize,
204
205    /// Maximum number of slots.
206    max_slots: usize,
207
208    /// Monotonic counter for LRU ordering.
209    tick: u64,
210
211    /// Whether the parsed chunk index should be retained between reads.
212    cache_index: bool,
213}
214
215impl ChunkCache {
216    /// Create a new chunk cache with default limits (1 MiB, 16 slots).
217    pub fn new() -> Self {
218        Self::with_capacity(DEFAULT_CACHE_BYTES, DEFAULT_MAX_SLOTS)
219    }
220
221    /// Create a new chunk cache with custom byte budget and slot count.
222    pub fn with_capacity(max_bytes: usize, max_slots: usize) -> Self {
223        Self::with_config(
224            ChunkCacheConfig::new()
225                .with_max_bytes(max_bytes)
226                .with_max_slots(max_slots),
227        )
228    }
229
230    /// Create a new chunk cache from a full configuration.
231    pub fn with_config(config: ChunkCacheConfig) -> Self {
232        Self {
233            inner: Mutex::new(CacheInner {
234                index: None,
235                slots: Vec::with_capacity(config.max_slots.min(64)),
236                current_bytes: 0,
237                max_bytes: config.max_bytes,
238                max_slots: config.max_slots,
239                tick: 0,
240                cache_index: config.cache_index,
241            }),
242        }
243    }
244
245    /// Snapshot the current chunk-cache occupancy (index loaded, retained
246    /// chunk count, retained bytes).
247    ///
248    /// This is the public, read-only way to observe whether a chunk-cache
249    /// configuration is taking effect. It locks the cache briefly to read a
250    /// consistent snapshot.
251    pub fn stats(&self) -> ChunkCacheStats {
252        let inner = self.inner.lock().unwrap();
253        ChunkCacheStats {
254            index_loaded: inner.index.is_some(),
255            cached_chunks: inner.slots.len(),
256            cached_bytes: inner.current_bytes,
257        }
258    }
259
260    // ----- Index operations -----
261
262    /// Build the chunk index from a pre-collected list of `ChunkInfo`.
263    ///
264    /// The `rank` parameter is used to truncate offsets to spatial dims only
265    /// (B-tree v1 stores rank+1 offsets).
266    pub fn populate_index(&self, chunks: &[ChunkInfo], rank: usize) {
267        let mut inner = self.inner.lock().unwrap();
268        if !inner.cache_index {
269            return;
270        }
271        if inner.index.is_some() {
272            return; // already populated
273        }
274        #[cfg(feature = "std")]
275        let mut map = HashMap::with_capacity(chunks.len());
276        #[cfg(not(feature = "std"))]
277        let mut map = BTreeMap::new();
278
279        for ci in chunks {
280            let coord: ChunkCoord = ci.offsets.iter().take(rank).copied().collect();
281            map.insert(coord, ci.clone());
282        }
283        inner.index = Some(map);
284    }
285
286    /// Return all indexed chunks as a `Vec<ChunkInfo>` (order unspecified).
287    pub fn all_indexed_chunks(&self) -> Option<Vec<ChunkInfo>> {
288        let inner = self.inner.lock().unwrap();
289        inner.index.as_ref().map(|m| m.values().cloned().collect())
290    }
291
292    // ----- Decompressed data cache (LRU) -----
293
294    /// Run `f` over a borrowed view of a cached chunk's decompressed bytes, if
295    /// present, returning its result.
296    ///
297    /// The closure runs while the cache lock is held, which lets the caller copy
298    /// the chunk straight into its output buffer with no intermediate `Vec`
299    /// allocation or clone. The closure must not touch this cache (it would
300    /// deadlock); the chunk-assembly scatter it is used for does not.
301    pub fn with_decompressed<R>(&self, coord: &[u64], f: impl FnOnce(&[u8]) -> R) -> Option<R> {
302        let mut inner = self.inner.lock().unwrap();
303        inner.tick += 1;
304        let tick = inner.tick;
305        for slot in inner.slots.iter_mut() {
306            if slot.coord.as_slice() == coord {
307                slot.last_access = tick;
308                return Some(f(&slot.data));
309            }
310        }
311        None
312    }
313
314    /// Whether a decompressed chunk of `data_len` bytes would be admitted to the
315    /// cache (cache enabled and the chunk within the per-chunk byte budget). Used
316    /// to skip copying a chunk into an owned buffer when it would be rejected.
317    fn accepts_decompressed_len(&self, data_len: usize) -> bool {
318        let inner = self.inner.lock().unwrap();
319        inner.max_bytes != 0 && inner.max_slots != 0 && data_len <= inner.max_bytes
320    }
321
322    /// Insert decompressed chunk data into the LRU cache, taking ownership of the
323    /// buffer (no copy). A chunk too large for the budget, or a disabled cache,
324    /// drops the buffer instead of storing it.
325    pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) {
326        let mut inner = self.inner.lock().unwrap();
327        let data_len = data.len();
328
329        // Don't cache if disabled or if a single chunk exceeds the budget.
330        if inner.max_bytes == 0 || inner.max_slots == 0 || data_len > inner.max_bytes {
331            return;
332        }
333
334        // Check if already present
335        inner.tick += 1;
336        let tick = inner.tick;
337        for slot in inner.slots.iter_mut() {
338            if slot.coord == coord {
339                slot.last_access = tick;
340                return; // already cached
341            }
342        }
343
344        // Evict until we have room
345        while inner.slots.len() >= inner.max_slots
346            || (inner.current_bytes + data_len > inner.max_bytes && !inner.slots.is_empty())
347        {
348            // Find LRU slot
349            let lru_idx = inner
350                .slots
351                .iter()
352                .enumerate()
353                .min_by_key(|(_, s)| s.last_access)
354                .map(|(i, _)| i)
355                .unwrap();
356            let removed = inner.slots.swap_remove(lru_idx);
357            inner.current_bytes -= removed.data.len();
358        }
359
360        inner.current_bytes += data_len;
361        inner.slots.push(CachedChunk {
362            coord,
363            data,
364            last_access: tick,
365        });
366    }
367
368    /// Insert a copy of `data` into the LRU cache, but only if it would actually
369    /// be admitted. This lets the unfiltered read path scatter directly from the
370    /// file buffer and copy into the cache only when caching is enabled and the
371    /// chunk fits the budget (avoiding a throwaway copy otherwise).
372    pub fn put_decompressed_slice(&self, coord: ChunkCoord, data: &[u8]) {
373        if !self.accepts_decompressed_len(data.len()) {
374            return;
375        }
376        self.put_decompressed(coord, data.to_vec());
377    }
378
379    /// Clear the entire cache (index + decompressed data).
380    ///
381    /// Called after a mutation through the owning [`Dataset`](crate::Dataset)
382    /// handle: an append relocates the trailing chunk and adds new index
383    /// entries, so both the cached chunk index and any retained decompressed
384    /// chunks may be stale.
385    pub fn clear(&self) {
386        let mut inner = self.inner.lock().unwrap();
387        inner.index = None;
388        inner.slots.clear();
389        inner.current_bytes = 0;
390        inner.tick = 0;
391    }
392}
393
394impl Default for ChunkCache {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn make_chunk(offsets: Vec<u64>, address: u64, size: u32) -> ChunkInfo {
409        ChunkInfo {
410            chunk_size: size,
411            filter_mask: 0,
412            offsets,
413            address,
414        }
415    }
416
417    #[test]
418    fn index_populate_and_lookup() {
419        let cache = ChunkCache::new();
420        let chunks = vec![
421            make_chunk(vec![0, 0, 0], 0x1000, 80),
422            make_chunk(vec![10, 0, 0], 0x2000, 80),
423        ];
424        cache.populate_index(&chunks, 2); // rank=2, truncate to [0,0] and [10,0]
425        assert!(cache.stats().index_loaded());
426
427        let mut addrs: Vec<u64> = cache
428            .all_indexed_chunks()
429            .unwrap()
430            .iter()
431            .map(|c| c.address)
432            .collect();
433        addrs.sort_unstable();
434        assert_eq!(addrs, vec![0x1000, 0x2000]);
435    }
436
437    /// Test helper: clone a cached chunk's bytes if present (the production
438    /// path uses `with_decompressed` to avoid this copy).
439    fn get_decompressed(cache: &ChunkCache, coord: &[u64]) -> Option<Vec<u8>> {
440        cache.with_decompressed(coord, <[u8]>::to_vec)
441    }
442
443    #[test]
444    fn decompressed_cache_hit() {
445        let cache = ChunkCache::new();
446        cache.put_decompressed(vec![0, 0], vec![1, 2, 3, 4]);
447        let got = get_decompressed(&cache, &[0, 0]).unwrap();
448        assert_eq!(got, vec![1, 2, 3, 4]);
449    }
450
451    #[test]
452    fn lru_eviction_by_slots() {
453        let cache = ChunkCache::with_capacity(1024 * 1024, 2); // max 2 slots
454
455        cache.put_decompressed(vec![0], vec![1; 10]);
456        cache.put_decompressed(vec![1], vec![2; 10]);
457        assert_eq!(cache.stats().cached_chunks(), 2);
458
459        // Access slot 0 to make it more recent
460        get_decompressed(&cache, &[0]);
461
462        // Insert slot 2 — should evict slot 1 (LRU)
463        cache.put_decompressed(vec![2], vec![3; 10]);
464        assert_eq!(cache.stats().cached_chunks(), 2);
465
466        assert!(get_decompressed(&cache, &[0]).is_some());
467        assert!(get_decompressed(&cache, &[1]).is_none()); // evicted
468        assert!(get_decompressed(&cache, &[2]).is_some());
469    }
470
471    #[test]
472    fn lru_eviction_by_bytes() {
473        let cache = ChunkCache::with_capacity(50, 100); // 50 bytes max
474
475        cache.put_decompressed(vec![0], vec![0; 20]);
476        cache.put_decompressed(vec![1], vec![0; 20]);
477        assert_eq!(cache.stats().cached_bytes(), 40);
478
479        // This needs 20 bytes but only 10 free — evict LRU
480        cache.put_decompressed(vec![2], vec![0; 20]);
481        assert!(cache.stats().cached_bytes() <= 50);
482        assert!(get_decompressed(&cache, &[0]).is_none()); // evicted (LRU)
483    }
484
485    #[test]
486    fn put_decompressed_slice_only_copies_when_admitted() {
487        // Disabled cache: the slice is not copied or stored.
488        let cache = ChunkCache::with_config(ChunkCacheConfig::disabled());
489        cache.put_decompressed_slice(vec![0], &[1, 2, 3]);
490        assert_eq!(cache.stats().cached_chunks(), 0);
491
492        // Enabled cache within budget: stored.
493        let cache = ChunkCache::with_capacity(1024, 16);
494        cache.put_decompressed_slice(vec![0], &[1, 2, 3, 4]);
495        assert_eq!(get_decompressed(&cache, &[0]).unwrap(), vec![1, 2, 3, 4]);
496
497        // Over the per-chunk budget: not stored.
498        let cache = ChunkCache::with_capacity(2, 16);
499        cache.put_decompressed_slice(vec![0], &[1, 2, 3, 4]);
500        assert_eq!(cache.stats().cached_chunks(), 0);
501    }
502
503    #[test]
504    fn oversized_chunk_not_cached() {
505        let cache = ChunkCache::with_capacity(10, 16);
506        cache.put_decompressed(vec![0], vec![0; 100]); // too big
507        assert_eq!(cache.stats().cached_chunks(), 0);
508    }
509
510    #[test]
511    fn disabled_cache_retains_no_index_or_chunks() {
512        let cache = ChunkCache::with_config(ChunkCacheConfig::disabled());
513        let chunks = vec![make_chunk(vec![0, 0], 0x1000, 80)];
514        cache.populate_index(&chunks, 1);
515        assert!(!cache.stats().index_loaded());
516
517        cache.put_decompressed(vec![0], vec![1, 2, 3]);
518        assert_eq!(cache.stats().cached_chunks(), 0);
519        assert_eq!(cache.stats().cached_bytes(), 0);
520    }
521
522    #[test]
523    fn h5p_cache_constructor_maps_raw_data_chunk_settings() {
524        let config = ChunkCacheConfig::from_h5p_cache(521, 2 * 1024 * 1024);
525        assert_eq!(config.max_slots(), 521);
526        assert_eq!(config.max_bytes(), 2 * 1024 * 1024);
527        assert!(config.index_cache_enabled());
528    }
529
530    #[test]
531    fn clear_resets_everything() {
532        let cache = ChunkCache::new();
533        let chunks = vec![make_chunk(vec![0, 0], 0x1000, 80)];
534        cache.populate_index(&chunks, 1);
535        cache.put_decompressed(vec![0], vec![1, 2, 3]);
536
537        cache.clear();
538        assert!(!cache.stats().index_loaded());
539        assert_eq!(cache.stats().cached_chunks(), 0);
540        assert_eq!(cache.stats().cached_bytes(), 0);
541    }
542
543    #[test]
544    fn duplicate_insert_is_noop() {
545        let cache = ChunkCache::new();
546        cache.put_decompressed(vec![0], vec![1, 2, 3]);
547        cache.put_decompressed(vec![0], vec![1, 2, 3]); // duplicate
548        assert_eq!(cache.stats().cached_chunks(), 1);
549        assert_eq!(cache.stats().cached_bytes(), 3);
550    }
551}