Skip to main content

pingora_cache/eviction/
lru.rs

1// Copyright 2026 Cloudflare, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A shared LRU cache manager
16
17use super::{CacheEntryKey, CacheEntryKeyRef, EvictionManager};
18#[cfg(test)]
19use crate::key::CompactCacheKey;
20
21use async_trait::async_trait;
22use pingora_error::{ErrorType::*, OrErr, Result};
23use pingora_lru::{persistence, Lru};
24use serde::de::SeqAccess;
25use serde::{Deserialize, Serialize};
26use std::hash::{Hash, Hasher};
27use std::time::SystemTime;
28
29/// A shared LRU cache manager designed to manage a large volume of assets.
30///
31/// - Space optimized in-memory LRU (see [pingora_lru]).
32/// - Instead of a single giant LRU, this struct shards the assets into `N` independent LRUs.
33///
34/// This allows [EvictionManager::save()] not to lock the entire cache manager while performing
35/// serialization.
36pub struct Manager<const N: usize>(Lru<CacheEntryKey, N>);
37
38#[derive(Debug, Serialize, Deserialize)]
39struct SerdeHelperNode(CacheEntryKey, usize);
40
41impl<const N: usize> Manager<N> {
42    /// Create a [Manager] with the given size limit and estimated per shard capacity.
43    ///
44    /// The `capacity` is for preallocating to avoid reallocation cost when the LRU grows.
45    pub fn with_capacity(limit: usize, capacity: usize) -> Self {
46        Manager(Lru::with_capacity(limit, capacity))
47    }
48
49    /// Create a [Manager] with an optional watermark in addition to weight limit.
50    ///
51    /// When `watermark` is set, the underlying LRU will also evict to keep total item count
52    /// under or equal to that watermark.
53    pub fn with_capacity_and_watermark(
54        limit: usize,
55        capacity: usize,
56        watermark: Option<usize>,
57    ) -> Self {
58        Manager(Lru::with_capacity_and_watermark(limit, capacity, watermark))
59    }
60
61    /// Return the current total cache weight limit.
62    pub fn weight_limit(&self) -> usize {
63        self.0.weight_limit()
64    }
65
66    /// Set the total cache weight limit used by future eviction decisions.
67    pub fn set_weight_limit(&self, limit: usize) {
68        self.0.set_weight_limit(limit);
69    }
70
71    /// Get the number of shards
72    pub fn shards(&self) -> usize {
73        self.0.shards()
74    }
75
76    /// Get the weight (total size) of a specific shard
77    pub fn shard_weight(&self, shard: usize) -> usize {
78        self.0.shard_weight(shard)
79    }
80
81    /// Get the number of items in a specific shard. Best-effort
82    /// lock-free read; see [`pingora_lru::Lru::shard_len`] for the
83    /// consistency semantics.
84    pub fn shard_len(&self, shard: usize) -> usize {
85        self.0.shard_len(shard)
86    }
87
88    /// Get the shard index for a given cache entry
89    ///
90    /// This allows callers to know which shard was affected by an operation
91    /// without acquiring any locks.
92    pub fn get_shard_for_key(&self, key: &CacheEntryKey) -> usize {
93        (u64key(key) % N as u64) as usize
94    }
95
96    /// Peek at the least-recently-used key in the given shard without evicting it.
97    ///
98    /// Returns the cache entry at the LRU tail of the shard, or `None` if empty.
99    /// Useful for reporting the eviction frontier (the age of the next item
100    /// that would be evicted).
101    pub fn peek_lru(&self, shard: usize) -> Option<CacheEntryKey> {
102        self.0.peek_lru(shard).map(|(key, _weight)| key)
103    }
104
105    /// Serialize the given shard
106    pub fn serialize_shard(&self, shard: usize) -> Result<Vec<u8>> {
107        use rmp_serde::encode::Serializer;
108        use serde::ser::SerializeSeq;
109        use serde::ser::Serializer as _;
110
111        assert!(shard < N);
112
113        // NOTE: This could use a lot of memory to buffer the serialized data in memory
114        // NOTE: This for loop could lock the LRU for too long
115        let mut nodes = Vec::with_capacity(self.0.shard_len(shard));
116        self.0.iter_for_each(shard, |(node, size)| {
117            nodes.push(SerdeHelperNode(node.clone(), size));
118        });
119        let mut ser = Serializer::new(vec![]);
120        // Use the captured snapshot length for the MessagePack array header.
121        // Re-reading shard_len() here can race with concurrent mutations and
122        // emit a length that does not match the serialized nodes.
123        let mut seq = ser
124            .serialize_seq(Some(nodes.len()))
125            .or_err(InternalError, "fail to serialize node")?;
126        for node in nodes {
127            seq.serialize_element(&node).unwrap(); // write to vec, safe
128        }
129
130        seq.end().or_err(InternalError, "when serializing LRU")?;
131        Ok(ser.into_inner())
132    }
133
134    /// Deserialize a shard
135    ///
136    /// Shard number is not needed because the key itself will hash to the correct shard.
137    pub fn deserialize_shard(&self, buf: &[u8]) -> Result<()> {
138        use rmp_serde::decode::Deserializer;
139        use serde::de::Deserializer as _;
140
141        let mut de = Deserializer::new(buf);
142        let visitor = InsertToManager { lru: self };
143        de.deserialize_seq(visitor)
144            .or_err(InternalError, "when deserializing LRU")?;
145        Ok(())
146    }
147
148    /// Peek the weight associated with a cache entry without changing its LRU order.
149    pub fn peek_weight(&self, item: &CacheEntryKey) -> Option<usize> {
150        let key = u64key(item);
151        self.0.peek_weight(key)
152    }
153}
154
155struct InsertToManager<'a, const N: usize> {
156    lru: &'a Manager<N>,
157}
158
159impl<'de, const N: usize> serde::de::Visitor<'de> for InsertToManager<'_, N> {
160    type Value = ();
161
162    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
163        formatter.write_str("array of lru nodes")
164    }
165
166    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
167    where
168        A: SeqAccess<'de>,
169    {
170        while let Some(node) = seq.next_element::<SerdeHelperNode>()? {
171            let key = u64key(&node.0);
172            self.lru.0.insert_tail(key, node.0, node.1); // insert in the back
173        }
174        Ok(())
175    }
176}
177
178#[inline]
179fn u64key(key: &impl Hash) -> u64 {
180    // note that std hash is not uniform, I'm not sure if ahash is also the case
181    let mut hasher = ahash::AHasher::default();
182    key.hash(&mut hasher);
183    hasher.finish()
184}
185
186const FILE_NAME: &str = "lru.data";
187
188#[async_trait]
189impl<const N: usize> EvictionManager for Manager<N> {
190    fn total_size(&self) -> usize {
191        self.0.weight()
192    }
193    fn total_items(&self) -> usize {
194        self.0.len()
195    }
196    fn evicted_size(&self) -> usize {
197        self.0.evicted_weight()
198    }
199    fn evicted_items(&self) -> usize {
200        self.0.evicted_len()
201    }
202
203    fn admit(
204        &self,
205        item: CacheEntryKey,
206        size: usize,
207        _fresh_until: SystemTime,
208    ) -> Vec<CacheEntryKey> {
209        let key = u64key(&item);
210        self.0.admit(key, item, size);
211        self.0
212            .evict_to_limit()
213            .into_iter()
214            .map(|(key, _weight)| key)
215            .collect()
216    }
217
218    fn increment_weight(
219        &self,
220        item: &CacheEntryKey,
221        delta: usize,
222        max_weight: Option<usize>,
223    ) -> Vec<CacheEntryKey> {
224        let key = u64key(item);
225        self.0
226            .increment_weight(key, || item.clone(), delta, max_weight);
227        self.0
228            .evict_to_limit()
229            .into_iter()
230            .map(|(key, _weight)| key)
231            .collect()
232    }
233
234    fn remove(&self, item: CacheEntryKeyRef<'_>) {
235        let key = u64key(&item);
236        self.0.remove(key);
237    }
238
239    fn access(&self, item: &CacheEntryKey, size: usize, _fresh_until: SystemTime) -> bool {
240        let key = u64key(item);
241        if !self.0.promote(key) {
242            self.0.admit(key, item.clone(), size);
243            false
244        } else {
245            true
246        }
247    }
248
249    fn peek(&self, item: &CacheEntryKey) -> bool {
250        let key = u64key(item);
251        self.0.peek(key)
252    }
253
254    async fn save(&self, dir_path: &str) -> Result<()> {
255        persistence::save_shards(dir_path, FILE_NAME, N, |i| self.serialize_shard(i))
256            .await
257            .or_err(InternalError, "failed to save LRU")?;
258        Ok(())
259    }
260
261    async fn load(&self, dir_path: &str) -> Result<()> {
262        persistence::load_shards(dir_path, FILE_NAME, N, |_i, data| {
263            self.deserialize_shard(data)
264        })
265        .await;
266        Ok(())
267    }
268}
269
270#[cfg(test)]
271impl<const N: usize> Manager<N> {
272    fn admit(
273        &self,
274        item: CompactCacheKey,
275        size: usize,
276        fresh_until: SystemTime,
277    ) -> Vec<CompactCacheKey> {
278        EvictionManager::admit(self, CacheEntryKey::key_only(item), size, fresh_until)
279            .into_iter()
280            .map(CacheEntryKey::into_key)
281            .collect()
282    }
283
284    fn increment_weight(
285        &self,
286        item: &CompactCacheKey,
287        delta: usize,
288        max_weight: Option<usize>,
289    ) -> Vec<CompactCacheKey> {
290        EvictionManager::increment_weight(
291            self,
292            &CacheEntryKey::key_only(item.clone()),
293            delta,
294            max_weight,
295        )
296        .into_iter()
297        .map(CacheEntryKey::into_key)
298        .collect()
299    }
300
301    fn remove(&self, item: &CompactCacheKey) {
302        EvictionManager::remove(self, CacheEntryKeyRef::from_entry_id(item, None));
303    }
304
305    fn access(&self, item: &CompactCacheKey, size: usize, fresh_until: SystemTime) -> bool {
306        EvictionManager::access(
307            self,
308            &CacheEntryKey::key_only(item.clone()),
309            size,
310            fresh_until,
311        )
312    }
313
314    fn peek(&self, item: &CompactCacheKey) -> bool {
315        EvictionManager::peek(self, &CacheEntryKey::key_only(item.clone()))
316    }
317}
318
319#[cfg(test)]
320mod test {
321    use super::*;
322    use crate::CacheKey;
323    use std::path::Path;
324
325    // we use shard (N) = 1 for eviction consistency in all tests
326
327    #[test]
328    fn test_admission() {
329        let lru = Manager::<1>::with_capacity(4, 10);
330        let key1 = CacheKey::new("a", "1").to_compact();
331        let until = SystemTime::now(); // unused value as a placeholder
332        let v = lru.admit(key1.clone(), 1, until);
333        assert_eq!(v.len(), 0);
334        let key2 = CacheKey::new("b", "1").to_compact();
335        let v = lru.admit(key2.clone(), 2, until);
336        assert_eq!(v.len(), 0);
337        let key3 = CacheKey::new("c", "1").to_compact();
338        let v = lru.admit(key3, 1, until);
339        assert_eq!(v.len(), 0);
340
341        // lru si full (4) now
342
343        let key4 = CacheKey::new("d", "1").to_compact();
344        let v = lru.admit(key4, 2, until);
345        // need to reduce used by at least 2, both key1 and key2 are evicted to make room for 3
346        assert_eq!(v.len(), 2);
347        assert_eq!(v[0], key1);
348        assert_eq!(v[1], key2);
349    }
350
351    #[test]
352    fn test_identified_entries_are_distinct() {
353        let lru = Manager::<1>::with_capacity(1, 2);
354        let key = CacheKey::new("a", "1").to_compact();
355        let first = CacheEntryKey::identified(key.clone(), crate::CacheEntryId::new(1));
356        let second = CacheEntryKey::identified(key, crate::CacheEntryId::new(2));
357        let until = SystemTime::now();
358
359        assert!(EvictionManager::admit(&lru, first.clone(), 1, until).is_empty());
360        assert_eq!(
361            EvictionManager::admit(&lru, second.clone(), 1, until),
362            vec![first]
363        );
364        assert_eq!(lru.peek_lru(0), Some(second));
365    }
366
367    #[test]
368    fn test_set_weight_limit() {
369        let lru = Manager::<1>::with_capacity(4, 10);
370        let until = SystemTime::now();
371        let key1 = CacheKey::new("a", "1").to_compact();
372        let key2 = CacheKey::new("b", "1").to_compact();
373        let key3 = CacheKey::new("c", "1").to_compact();
374        assert_eq!(lru.weight_limit(), 4);
375        assert!(lru.admit(key1.clone(), 1, until).is_empty());
376        assert!(lru.admit(key2.clone(), 1, until).is_empty());
377
378        lru.set_weight_limit(1);
379        assert_eq!(lru.weight_limit(), 1);
380        let evicted = lru.admit(key3, 1, until);
381        assert_eq!(evicted, vec![key1, key2]);
382
383        lru.set_weight_limit(10);
384        assert_eq!(lru.weight_limit(), 10);
385    }
386
387    #[test]
388    fn test_access() {
389        let lru = Manager::<1>::with_capacity(4, 10);
390        let key1 = CacheKey::new("a", "1").to_compact();
391        let until = SystemTime::now(); // unused value as a placeholder
392        let v = lru.admit(key1.clone(), 1, until);
393        assert_eq!(v.len(), 0);
394        let key2 = CacheKey::new("b", "1").to_compact();
395        let v = lru.admit(key2.clone(), 2, until);
396        assert_eq!(v.len(), 0);
397        let key3 = CacheKey::new("c", "1").to_compact();
398        let v = lru.admit(key3, 1, until);
399        assert_eq!(v.len(), 0);
400
401        // lru is full (4) now
402        // make key1 most recently used
403        lru.access(&key1, 1, until);
404        assert_eq!(v.len(), 0);
405
406        let key4 = CacheKey::new("d", "1").to_compact();
407        let v = lru.admit(key4, 2, until);
408        assert_eq!(v.len(), 1);
409        assert_eq!(v[0], key2);
410    }
411
412    #[test]
413    fn test_remove() {
414        let lru = Manager::<1>::with_capacity(4, 10);
415        let key1 = CacheKey::new("a", "1").to_compact();
416        let until = SystemTime::now(); // unused value as a placeholder
417        let v = lru.admit(key1.clone(), 1, until);
418        assert_eq!(v.len(), 0);
419        let key2 = CacheKey::new("b", "1").to_compact();
420        let v = lru.admit(key2.clone(), 2, until);
421        assert_eq!(v.len(), 0);
422        let key3 = CacheKey::new("c", "1").to_compact();
423        let v = lru.admit(key3, 1, until);
424        assert_eq!(v.len(), 0);
425
426        // lru is full (4) now
427        // remove key1
428        lru.remove(&key1);
429
430        // key2 is the least recently used one now
431        let key4 = CacheKey::new("d", "1").to_compact();
432        let v = lru.admit(key4, 2, until);
433        assert_eq!(v.len(), 1);
434        assert_eq!(v[0], key2);
435    }
436
437    #[test]
438    fn test_access_add() {
439        let lru = Manager::<1>::with_capacity(4, 10);
440        let until = SystemTime::now(); // unused value as a placeholder
441
442        let key1 = CacheKey::new("a", "1").to_compact();
443        lru.access(&key1, 1, until);
444        let key2 = CacheKey::new("b", "1").to_compact();
445        lru.access(&key2, 2, until);
446        let key3 = CacheKey::new("c", "1").to_compact();
447        lru.access(&key3, 2, until);
448
449        let key4 = CacheKey::new("d", "1").to_compact();
450        let v = lru.admit(key4, 2, until);
451        // need to reduce used by at least 2, both key1 and key2 are evicted to make room for 3
452        assert_eq!(v.len(), 2);
453        assert_eq!(v[0], key1);
454        assert_eq!(v[1], key2);
455    }
456
457    #[test]
458    fn test_increment_weight_adds_missing_item() {
459        let lru = Manager::<1>::with_capacity(4, 10);
460
461        let key1 = CacheKey::new("a", "1").to_compact();
462        assert!(lru.increment_weight(&key1, 2, None).is_empty());
463        assert!(lru.peek(&key1));
464        assert_eq!(lru.total_size(), 2);
465        assert_eq!(lru.total_items(), 1);
466
467        let key2 = CacheKey::new("b", "1").to_compact();
468        let evicted = lru.increment_weight(&key2, 100, Some(3));
469        assert_eq!(evicted, vec![key1]);
470        assert!(lru.peek(&key2));
471        assert_eq!(lru.total_size(), 3);
472        assert_eq!(lru.total_items(), 1);
473    }
474
475    #[test]
476    fn test_increment_weight_admits_zero_and_does_not_shrink() {
477        let lru = Manager::<1>::with_capacity(10, 10);
478
479        let key1 = CacheKey::new("a", "1").to_compact();
480        assert!(lru.increment_weight(&key1, 0, None).is_empty());
481        assert!(lru.peek(&key1));
482        assert_eq!(lru.total_size(), 1);
483
484        let key2 = CacheKey::new("b", "1").to_compact();
485        assert!(lru.increment_weight(&key2, 3, None).is_empty());
486        assert!(lru.increment_weight(&key2, 100, Some(2)).is_empty());
487        assert_eq!(lru.total_size(), 4);
488        assert_eq!(lru.total_items(), 2);
489    }
490
491    #[test]
492    fn test_admit_update() {
493        let lru = Manager::<1>::with_capacity(4, 10);
494        let key1 = CacheKey::new("a", "1").to_compact();
495        let until = SystemTime::now(); // unused value as a placeholder
496        let v = lru.admit(key1.clone(), 1, until);
497        assert_eq!(v.len(), 0);
498        let key2 = CacheKey::new("b", "1").to_compact();
499        let v = lru.admit(key2.clone(), 2, until);
500        assert_eq!(v.len(), 0);
501        let key3 = CacheKey::new("c", "1").to_compact();
502        let v = lru.admit(key3, 1, until);
503        assert_eq!(v.len(), 0);
504
505        // lru is full (4) now
506        // update key2 to reduce its size by 1
507        let v = lru.admit(key2, 1, until);
508        assert_eq!(v.len(), 0);
509
510        // lru is not full anymore
511        let key4 = CacheKey::new("d", "1").to_compact();
512        let v = lru.admit(key4.clone(), 1, until);
513        assert_eq!(v.len(), 0);
514
515        // make key4 larger
516        let v = lru.admit(key4, 2, until);
517        // need to evict now
518        assert_eq!(v.len(), 1);
519        assert_eq!(v[0], key1);
520    }
521
522    #[test]
523    fn test_peek() {
524        let lru = Manager::<1>::with_capacity(4, 10);
525        let until = SystemTime::now(); // unused value as a placeholder
526
527        let key1 = CacheKey::new("a", "1").to_compact();
528        lru.access(&key1, 1, until);
529        let key2 = CacheKey::new("b", "1").to_compact();
530        lru.access(&key2, 2, until);
531        assert!(lru.peek(&key1));
532        assert!(lru.peek(&key2));
533    }
534
535    #[test]
536    fn test_serde() {
537        let lru = Manager::<1>::with_capacity(4, 10);
538        let key1 = CacheKey::new("a", "1").to_compact();
539        let until = SystemTime::now(); // unused value as a placeholder
540        let v = lru.admit(key1.clone(), 1, until);
541        assert_eq!(v.len(), 0);
542        let key2 = CacheKey::new("b", "1").to_compact();
543        let v = lru.admit(key2.clone(), 2, until);
544        assert_eq!(v.len(), 0);
545        let key3 = CacheKey::new("c", "1").to_compact();
546        let v = lru.admit(key3, 1, until);
547        assert_eq!(v.len(), 0);
548
549        // lru is full (4) now
550        // make key1 most recently used
551        lru.access(&key1, 1, until);
552        assert_eq!(v.len(), 0);
553
554        // load lru2 with lru's data
555        let ser = lru.serialize_shard(0).unwrap();
556        let lru2 = Manager::<1>::with_capacity(4, 10);
557        lru2.deserialize_shard(&ser).unwrap();
558
559        let key4 = CacheKey::new("d", "1").to_compact();
560        let v = lru2.admit(key4, 2, until);
561        assert_eq!(v.len(), 1);
562        assert_eq!(v[0], key2);
563    }
564
565    #[tokio::test]
566    async fn test_save_to_disk() {
567        let until = SystemTime::now(); // unused value as a placeholder
568        let lru = Manager::<2>::with_capacity(10, 10);
569
570        lru.admit(CacheKey::new("a", "1").to_compact(), 1, until);
571        lru.admit(CacheKey::new("b", "1").to_compact(), 2, until);
572        lru.admit(CacheKey::new("c", "1").to_compact(), 1, until);
573        lru.admit(CacheKey::new("d", "1").to_compact(), 1, until);
574        lru.admit(CacheKey::new("e", "1").to_compact(), 2, until);
575        lru.admit(CacheKey::new("f", "1").to_compact(), 1, until);
576
577        // load lru2 with lru's data
578        lru.save("/tmp/test_lru_save").await.unwrap();
579        let lru2 = Manager::<2>::with_capacity(4, 10);
580        lru2.load("/tmp/test_lru_save").await.unwrap();
581
582        let ser0 = lru.serialize_shard(0).unwrap();
583        let ser1 = lru.serialize_shard(1).unwrap();
584
585        assert_eq!(ser0, lru2.serialize_shard(0).unwrap());
586        assert_eq!(ser1, lru2.serialize_shard(1).unwrap());
587    }
588
589    #[tokio::test]
590    async fn test_load_no_shards() {
591        // Loading from an empty directory should succeed with an empty LRU.
592        let test_dir = "/tmp/test_lru_no_shards";
593        let _ = std::fs::remove_dir_all(test_dir);
594        std::fs::create_dir_all(test_dir).unwrap();
595
596        let lru = Manager::<4>::with_capacity(10, 10);
597        lru.load(test_dir).await.unwrap();
598        assert_eq!(lru.total_items(), 0);
599        assert_eq!(lru.total_size(), 0);
600
601        std::fs::remove_dir_all(test_dir).unwrap();
602    }
603
604    #[tokio::test]
605    async fn test_load_partial_shards() {
606        // A subset of shard files is missing on disk. Load should succeed and
607        // populate the LRU from only the shards that exist; missing shards are
608        // treated as empty rather than aborting the load.
609        let test_dir = "/tmp/test_lru_partial_shards";
610        let _ = std::fs::remove_dir_all(test_dir);
611        std::fs::create_dir_all(test_dir).unwrap();
612
613        let until = SystemTime::now();
614        let src = Manager::<4>::with_capacity(100, 100);
615        for i in 0..16 {
616            src.admit(CacheKey::new(format!("k{i}"), "1").to_compact(), 1, until);
617        }
618        src.save(test_dir).await.unwrap();
619        let baseline = src.total_items();
620        assert!(baseline > 0);
621
622        // Remove half the shard files.
623        std::fs::remove_file(format!("{test_dir}/lru.data.1")).unwrap();
624        std::fs::remove_file(format!("{test_dir}/lru.data.3")).unwrap();
625
626        let dst = Manager::<4>::with_capacity(100, 100);
627        dst.load(test_dir).await.unwrap();
628        // Some entries loaded, but fewer than the baseline.
629        assert!(dst.total_items() > 0);
630        assert!(dst.total_items() < baseline);
631
632        std::fs::remove_dir_all(test_dir).unwrap();
633    }
634
635    #[tokio::test]
636    async fn test_save_partial_failure_continues() {
637        // A per-shard rename failure must not abort the whole save. We simulate
638        // by pre-creating a directory at one shard's final path so that shard's
639        // atomic rename fails while the others succeed.
640        let test_dir = "/tmp/test_lru_save_partial_fail";
641        let _ = std::fs::remove_dir_all(test_dir);
642        std::fs::create_dir_all(test_dir).unwrap();
643        std::fs::create_dir(format!("{test_dir}/lru.data.1")).unwrap();
644
645        let until = SystemTime::now();
646        let src = Manager::<4>::with_capacity(100, 100);
647        for i in 0..16 {
648            src.admit(CacheKey::new(format!("k{i}"), "1").to_compact(), 1, until);
649        }
650        src.save(test_dir).await.unwrap();
651
652        // Shards 0, 2, 3 should be regular files; shard 1 is still a directory.
653        for i in [0, 2, 3] {
654            let p = format!("{test_dir}/lru.data.{i}");
655            let meta = std::fs::metadata(&p).unwrap();
656            assert!(meta.is_file(), "shard {i} should be a regular file");
657            assert!(meta.len() > 0, "shard {i} should be non-empty");
658        }
659        assert!(std::fs::metadata(format!("{test_dir}/lru.data.1"))
660            .unwrap()
661            .is_dir());
662
663        std::fs::remove_dir(format!("{test_dir}/lru.data.1")).unwrap();
664        std::fs::remove_dir_all(test_dir).unwrap();
665    }
666
667    #[tokio::test]
668    async fn test_save_total_failure_returns_err() {
669        // If every shard fails to save, the function must return Err so callers
670        // can alarm on the catastrophic case.
671        let test_dir = "/tmp/test_lru_save_total_fail";
672        let _ = std::fs::remove_dir_all(test_dir);
673        std::fs::create_dir_all(test_dir).unwrap();
674        for i in 0..4 {
675            std::fs::create_dir(format!("{test_dir}/lru.data.{i}")).unwrap();
676        }
677
678        let until = SystemTime::now();
679        let src = Manager::<4>::with_capacity(100, 100);
680        src.admit(CacheKey::new("k", "1").to_compact(), 1, until);
681
682        let err = src.save(test_dir).await.unwrap_err();
683        assert!(
684            err.to_string().contains("All 4 shards failed to save"),
685            "unexpected error message: {err}"
686        );
687
688        for i in 0..4 {
689            std::fs::remove_dir(format!("{test_dir}/lru.data.{i}")).unwrap();
690        }
691        std::fs::remove_dir_all(test_dir).unwrap();
692    }
693
694    #[tokio::test]
695    async fn test_load_unreadable_shard_continues() {
696        // A shard path that opens successfully but fails to read (here a
697        // directory at the shard path) should not abort load of other shards.
698        let test_dir = "/tmp/test_lru_unreadable_shard";
699        let _ = std::fs::remove_dir_all(test_dir);
700        std::fs::create_dir_all(test_dir).unwrap();
701
702        let until = SystemTime::now();
703        let src = Manager::<4>::with_capacity(100, 100);
704        for i in 0..16 {
705            src.admit(CacheKey::new(format!("k{i}"), "1").to_compact(), 1, until);
706        }
707        src.save(test_dir).await.unwrap();
708
709        // Replace one shard with a directory so File::open succeeds but
710        // read_to_end returns an error.
711        std::fs::remove_file(format!("{test_dir}/lru.data.2")).unwrap();
712        std::fs::create_dir(format!("{test_dir}/lru.data.2")).unwrap();
713
714        let dst = Manager::<4>::with_capacity(100, 100);
715        dst.load(test_dir).await.unwrap();
716        // Entries from the other 3 shards still loaded.
717        assert!(dst.total_items() > 0);
718
719        std::fs::remove_dir(format!("{test_dir}/lru.data.2")).unwrap();
720        std::fs::remove_dir_all(test_dir).unwrap();
721    }
722
723    #[tokio::test]
724    async fn test_load_corrupt_shard_continues() {
725        // A corrupt shard file should not abort load of the remaining shards.
726        let test_dir = "/tmp/test_lru_corrupt_shard";
727        let _ = std::fs::remove_dir_all(test_dir);
728        std::fs::create_dir_all(test_dir).unwrap();
729
730        let until = SystemTime::now();
731        let src = Manager::<4>::with_capacity(100, 100);
732        for i in 0..16 {
733            src.admit(CacheKey::new(format!("k{i}"), "1").to_compact(), 1, until);
734        }
735        src.save(test_dir).await.unwrap();
736
737        // Truncate one shard to non-empty garbage so deserialize fails.
738        std::fs::write(format!("{test_dir}/lru.data.2"), b"not valid msgpack").unwrap();
739
740        let dst = Manager::<4>::with_capacity(100, 100);
741        dst.load(test_dir).await.unwrap();
742        // We should have loaded entries from the other 3 shards.
743        assert!(dst.total_items() > 0);
744
745        std::fs::remove_dir_all(test_dir).unwrap();
746    }
747
748    #[tokio::test]
749    async fn test_save_then_load_roundtrip_with_remove() {
750        // After load with missing shards, a subsequent save must persist every
751        // shard file again so the next load is complete.
752        let test_dir = "/tmp/test_lru_save_after_partial_load";
753        let _ = std::fs::remove_dir_all(test_dir);
754        std::fs::create_dir_all(test_dir).unwrap();
755
756        let until = SystemTime::now();
757        let src = Manager::<4>::with_capacity(100, 100);
758        for i in 0..16 {
759            src.admit(CacheKey::new(format!("k{i}"), "1").to_compact(), 1, until);
760        }
761        src.save(test_dir).await.unwrap();
762        std::fs::remove_file(format!("{test_dir}/lru.data.1")).unwrap();
763
764        let dst = Manager::<4>::with_capacity(100, 100);
765        dst.load(test_dir).await.unwrap();
766        dst.save(test_dir).await.unwrap();
767
768        for i in 0..4 {
769            assert!(
770                Path::new(&format!("{test_dir}/lru.data.{i}")).exists(),
771                "shard {i} should exist after save"
772            );
773        }
774
775        std::fs::remove_dir_all(test_dir).unwrap();
776    }
777
778    #[tokio::test]
779    async fn test_temp_file_cleanup() {
780        let test_dir = "/tmp/test_lru_cleanup";
781        let dir_path = Path::new(test_dir);
782
783        // Create test directory
784        std::fs::create_dir_all(dir_path).unwrap();
785
786        // Create some fake temp files
787        let temp_files = [
788            "lru.data.0.12345678.tmp",
789            "lru.data.1.abcdef00.tmp",
790            "other_file.tmp", // Should not be removed
791            "lru.data.2",     // Should not be removed
792        ];
793
794        for file in temp_files {
795            let file_path = dir_path.join(file);
796            std::fs::write(&file_path, b"test").unwrap();
797        }
798
799        let lru = Manager::<3>::with_capacity(10, 10);
800        lru.load(test_dir).await.unwrap();
801
802        // Check results
803        assert!(!dir_path.join("lru.data.0.12345678.tmp").exists());
804        assert!(!dir_path.join("lru.data.1.abcdef00.tmp").exists());
805        assert!(dir_path.join("other_file.tmp").exists()); // Should remain
806        assert!(dir_path.join("lru.data.2").exists()); // Should remain
807
808        // Cleanup test directory
809        std::fs::remove_dir_all(dir_path).unwrap();
810    }
811
812    #[test]
813    fn test_peek_lru() {
814        let lru = Manager::<1>::with_capacity(20, 20);
815        let until = SystemTime::now();
816
817        // empty shard returns None
818        assert!(lru.peek_lru(0).is_none());
819
820        let key1 = CacheKey::new("a", "1").to_compact();
821        lru.admit(key1.clone(), 1, until);
822        // single item: it's both the head and the tail
823        assert_eq!(
824            lru.peek_lru(0).unwrap(),
825            CacheEntryKey::key_only(key1.clone())
826        );
827
828        // admit more keys to push key1 to the tail
829        let key2 = CacheKey::new("b", "1").to_compact();
830        lru.admit(key2.clone(), 1, until);
831        for i in 0..5 {
832            lru.admit(CacheKey::new(format!("f{i}"), "1").to_compact(), 1, until);
833        }
834        // key1 is the LRU tail (admitted first)
835        assert_eq!(
836            lru.peek_lru(0).unwrap(),
837            CacheEntryKey::key_only(key1.clone())
838        );
839
840        // promote key1 — now key2 becomes the tail
841        lru.access(&key1, 1, until);
842        assert_eq!(
843            lru.peek_lru(0).unwrap(),
844            CacheEntryKey::key_only(key2.clone())
845        );
846
847        // peek_lru should not remove the item
848        assert_eq!(
849            lru.peek_lru(0).unwrap(),
850            CacheEntryKey::key_only(key2.clone())
851        );
852        assert!(lru.peek(&key2));
853
854        // out-of-bounds shard returns None
855        assert!(lru.peek_lru(999).is_none());
856    }
857}