Skip to main content

xos_storage/
cache.rs

1//! Local file cache with size-based eviction.
2//!
3//! Stores fetched IPFS content on disk to avoid redundant downloads.
4//! When the cache exceeds the configured size limit, the oldest
5//! entries are evicted first.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::{Result, StorageError};
12
13/// Entry metadata tracked in memory.
14#[derive(Debug, Clone)]
15struct CacheEntry {
16    size: u64,
17    last_access: u64,
18}
19
20/// Disk-backed LRU cache for IPFS content.
21pub struct FileCache {
22    dir: PathBuf,
23    max_size: u64,
24    entries: BTreeMap<String, CacheEntry>,
25    current_size: u64,
26}
27
28impl FileCache {
29    /// Create a new cache at the given directory with a max size in bytes.
30    pub fn new(dir: &Path, max_size: u64) -> Result<Self> {
31        std::fs::create_dir_all(dir)
32            .map_err(|e| StorageError::Cache(format!("create cache dir failed: {e}")))?;
33
34        Ok(Self {
35            dir: dir.to_path_buf(),
36            max_size,
37            entries: BTreeMap::new(),
38            current_size: 0,
39        })
40    }
41
42    /// Get cached content by CID. Returns `None` on miss.
43    pub fn get(&mut self, cid: &str) -> Option<Vec<u8>> {
44        if !self.entries.contains_key(cid) {
45            return None;
46        }
47
48        let path = self.path_for(cid);
49        match std::fs::read(&path) {
50            Ok(data) => {
51                // Update access time
52                if let Some(entry) = self.entries.get_mut(cid) {
53                    entry.last_access = now_secs();
54                }
55                Some(data)
56            }
57            Err(_) => {
58                // File disappeared — remove from index
59                self.entries.remove(cid);
60                None
61            }
62        }
63    }
64
65    /// Insert content into the cache, evicting old entries if needed.
66    pub fn put(&mut self, cid: &str, data: &[u8]) -> Result<()> {
67        let size = data.len() as u64;
68
69        // Don't cache if single item exceeds limit
70        if size > self.max_size {
71            return Ok(());
72        }
73
74        // Evict until there's room
75        while self.current_size + size > self.max_size {
76            if !self.evict_oldest() {
77                break;
78            }
79        }
80
81        let path = self.path_for(cid);
82        std::fs::write(&path, data)
83            .map_err(|e| StorageError::Cache(format!("write cache file failed: {e}")))?;
84
85        self.current_size += size;
86        self.entries.insert(
87            cid.to_string(),
88            CacheEntry {
89                size,
90                last_access: now_secs(),
91            },
92        );
93
94        Ok(())
95    }
96
97    /// Remove a specific entry from the cache.
98    pub fn remove(&mut self, cid: &str) -> Result<()> {
99        if let Some(entry) = self.entries.remove(cid) {
100            self.current_size = self.current_size.saturating_sub(entry.size);
101            let path = self.path_for(cid);
102            let _ = std::fs::remove_file(path);
103        }
104        Ok(())
105    }
106
107    /// Check if a CID is cached.
108    pub fn contains(&self, cid: &str) -> bool {
109        self.entries.contains_key(cid)
110    }
111
112    /// Number of cached entries.
113    pub fn len(&self) -> usize {
114        self.entries.len()
115    }
116
117    /// Whether the cache is empty.
118    pub fn is_empty(&self) -> bool {
119        self.entries.is_empty()
120    }
121
122    /// Current total size of cached data.
123    pub fn size(&self) -> u64 {
124        self.current_size
125    }
126
127    /// Clear all cached entries and files.
128    pub fn clear(&mut self) -> Result<()> {
129        for cid in self.entries.keys().cloned().collect::<Vec<_>>() {
130            let path = self.path_for(&cid);
131            let _ = std::fs::remove_file(path);
132        }
133        self.entries.clear();
134        self.current_size = 0;
135        Ok(())
136    }
137
138    /// File path for a given CID.
139    fn path_for(&self, cid: &str) -> PathBuf {
140        // Replace any path-unsafe characters
141        let safe_name: String = cid.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
142        self.dir.join(safe_name)
143    }
144
145    /// Evict the oldest entry. Returns false if nothing to evict.
146    fn evict_oldest(&mut self) -> bool {
147        let oldest_cid = self
148            .entries
149            .iter()
150            .min_by_key(|(_, e)| e.last_access)
151            .map(|(k, _)| k.clone());
152
153        if let Some(cid) = oldest_cid {
154            if let Some(entry) = self.entries.remove(&cid) {
155                self.current_size = self.current_size.saturating_sub(entry.size);
156                let path = self.path_for(&cid);
157                let _ = std::fs::remove_file(path);
158                return true;
159            }
160        }
161        false
162    }
163}
164
165fn now_secs() -> u64 {
166    SystemTime::now()
167        .duration_since(UNIX_EPOCH)
168        .unwrap_or_default()
169        .as_secs()
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use tempfile::TempDir;
176
177    fn test_cache(max_size: u64) -> (FileCache, TempDir) {
178        let tmp = TempDir::new().unwrap();
179        let cache = FileCache::new(tmp.path(), max_size).unwrap();
180        (cache, tmp)
181    }
182
183    #[test]
184    fn put_and_get() {
185        let (mut cache, _tmp) = test_cache(1_000_000);
186        cache.put("QmTest1", b"hello world").unwrap();
187        assert_eq!(cache.get("QmTest1").unwrap(), b"hello world");
188    }
189
190    #[test]
191    fn miss_returns_none() {
192        let (mut cache, _tmp) = test_cache(1_000_000);
193        assert!(cache.get("QmNope").is_none());
194    }
195
196    #[test]
197    fn contains_check() {
198        let (mut cache, _tmp) = test_cache(1_000_000);
199        assert!(!cache.contains("Qm1"));
200        cache.put("Qm1", b"data").unwrap();
201        assert!(cache.contains("Qm1"));
202    }
203
204    #[test]
205    fn remove_entry() {
206        let (mut cache, _tmp) = test_cache(1_000_000);
207        cache.put("Qm1", b"data").unwrap();
208        cache.remove("Qm1").unwrap();
209        assert!(!cache.contains("Qm1"));
210        assert!(cache.is_empty());
211    }
212
213    #[test]
214    fn eviction_when_full() {
215        // 50-byte limit: first entry is 30 bytes, second is 30 bytes
216        // -> first should be evicted
217        let (mut cache, _tmp) = test_cache(50);
218        cache.put("Qm1", &[0u8; 30]).unwrap();
219        cache.put("Qm2", &[1u8; 30]).unwrap();
220        assert!(!cache.contains("Qm1")); // evicted
221        assert!(cache.contains("Qm2"));
222    }
223
224    #[test]
225    fn skip_oversized() {
226        let (mut cache, _tmp) = test_cache(10);
227        cache.put("QmBig", &[0u8; 100]).unwrap(); // silently skipped
228        assert!(!cache.contains("QmBig"));
229    }
230
231    #[test]
232    fn clear_all() {
233        let (mut cache, _tmp) = test_cache(1_000_000);
234        cache.put("Qm1", b"a").unwrap();
235        cache.put("Qm2", b"b").unwrap();
236        cache.clear().unwrap();
237        assert!(cache.is_empty());
238        assert_eq!(cache.size(), 0);
239    }
240
241    #[test]
242    fn size_tracking() {
243        let (mut cache, _tmp) = test_cache(1_000_000);
244        cache.put("Qm1", &[0u8; 100]).unwrap();
245        cache.put("Qm2", &[0u8; 200]).unwrap();
246        assert_eq!(cache.size(), 300);
247        cache.remove("Qm1").unwrap();
248        assert_eq!(cache.size(), 200);
249    }
250}