Skip to main content

jax_daemon/fuse/
cache.rs

1//! LRU cache with TTL for FUSE file contents
2//!
3//! This cache stores file content and metadata to reduce HTTP API calls.
4//! It supports time-based expiration and size-based eviction.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use moka::sync::Cache;
10use serde::{Deserialize, Serialize};
11
12/// Cached file content
13#[derive(Debug, Clone)]
14pub struct CachedContent {
15    /// File content bytes
16    pub data: Arc<Vec<u8>>,
17    /// MIME type
18    pub mime_type: String,
19}
20
21/// Cached file/directory attributes
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CachedAttr {
24    /// Size in bytes
25    pub size: u64,
26    /// Is this a directory?
27    pub is_dir: bool,
28    /// MIME type for files
29    pub mime_type: Option<String>,
30    /// Modification time (Unix timestamp)
31    pub mtime: i64,
32}
33
34/// Cached directory listing
35#[derive(Debug, Clone)]
36pub struct CachedDirEntry {
37    pub name: String,
38    pub is_dir: bool,
39}
40
41/// Configuration for the file cache
42#[derive(Debug, Clone)]
43pub struct FileCacheConfig {
44    /// Maximum cache size in megabytes
45    pub max_size_mb: u32,
46    /// TTL for cached entries in seconds
47    pub ttl_secs: u32,
48}
49
50impl Default for FileCacheConfig {
51    fn default() -> Self {
52        Self {
53            max_size_mb: 100,
54            ttl_secs: 60,
55        }
56    }
57}
58
59/// LRU cache for FUSE filesystem
60#[derive(Clone)]
61pub struct FileCache {
62    /// Content cache: path → content
63    content: Cache<String, CachedContent>,
64    /// Attribute cache: path → attributes
65    attrs: Cache<String, CachedAttr>,
66    /// Directory listing cache: path → entries
67    dirs: Cache<String, Vec<CachedDirEntry>>,
68    /// Configuration
69    config: FileCacheConfig,
70}
71
72impl FileCache {
73    /// Create a new file cache with the given configuration
74    pub fn new(config: FileCacheConfig) -> Self {
75        let ttl = Duration::from_secs(config.ttl_secs as u64);
76        // Estimate ~1KB average per entry for size calculation
77        let max_capacity = (config.max_size_mb as u64) * 1024;
78
79        Self {
80            content: Cache::builder()
81                .time_to_live(ttl)
82                .max_capacity(max_capacity)
83                .build(),
84            attrs: Cache::builder()
85                .time_to_live(ttl)
86                .max_capacity(max_capacity * 10) // Attrs are smaller
87                .build(),
88            dirs: Cache::builder()
89                .time_to_live(ttl)
90                .max_capacity(max_capacity)
91                .build(),
92            config,
93        }
94    }
95
96    /// Get cached content for a path
97    pub fn get_content(&self, path: &str) -> Option<CachedContent> {
98        self.content.get(&Self::normalize_key(path))
99    }
100
101    /// Cache content for a path
102    pub fn put_content(&self, path: &str, content: CachedContent) {
103        self.content.insert(Self::normalize_key(path), content);
104    }
105
106    /// Get cached attributes for a path
107    pub fn get_attr(&self, path: &str) -> Option<CachedAttr> {
108        self.attrs.get(&Self::normalize_key(path))
109    }
110
111    /// Cache attributes for a path
112    pub fn put_attr(&self, path: &str, attr: CachedAttr) {
113        self.attrs.insert(Self::normalize_key(path), attr);
114    }
115
116    /// Get cached directory listing
117    pub fn get_dir(&self, path: &str) -> Option<Vec<CachedDirEntry>> {
118        self.dirs.get(&Self::normalize_key(path))
119    }
120
121    /// Cache directory listing
122    pub fn put_dir(&self, path: &str, entries: Vec<CachedDirEntry>) {
123        self.dirs.insert(Self::normalize_key(path), entries);
124    }
125
126    /// Invalidate a specific path from all caches
127    pub fn invalidate(&self, path: &str) {
128        let key = Self::normalize_key(path);
129        self.content.invalidate(&key);
130        self.attrs.invalidate(&key);
131        self.dirs.invalidate(&key);
132    }
133
134    /// Invalidate all entries under a path prefix
135    pub fn invalidate_prefix(&self, prefix: &str) {
136        let prefix = Self::normalize_key(prefix);
137
138        // Moka doesn't have prefix invalidation, so we need to iterate
139        // For content cache
140        self.content.run_pending_tasks();
141        // Note: We can't efficiently iterate moka caches, so we just invalidate_all
142        // in practice for prefix invalidations
143        if prefix == "/" {
144            self.invalidate_all();
145        }
146    }
147
148    /// Invalidate all cached entries
149    pub fn invalidate_all(&self) {
150        self.content.invalidate_all();
151        self.attrs.invalidate_all();
152        self.dirs.invalidate_all();
153    }
154
155    /// Get current cache statistics
156    pub fn stats(&self) -> CacheStats {
157        CacheStats {
158            content_count: self.content.entry_count(),
159            attr_count: self.attrs.entry_count(),
160            dir_count: self.dirs.entry_count(),
161            max_size_mb: self.config.max_size_mb,
162            ttl_secs: self.config.ttl_secs,
163        }
164    }
165
166    /// Normalize a path to a consistent cache key
167    fn normalize_key(path: &str) -> String {
168        let path = path.trim();
169        if path.is_empty() || path == "/" {
170            return "/".to_string();
171        }
172
173        let mut key = if path.starts_with('/') {
174            path.to_string()
175        } else {
176            format!("/{}", path)
177        };
178
179        if key.len() > 1 && key.ends_with('/') {
180            key.pop();
181        }
182
183        key
184    }
185}
186
187impl std::fmt::Debug for FileCache {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("FileCache")
190            .field("config", &self.config)
191            .field("content_count", &self.content.entry_count())
192            .field("attr_count", &self.attrs.entry_count())
193            .field("dir_count", &self.dirs.entry_count())
194            .finish()
195    }
196}
197
198/// Cache statistics
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct CacheStats {
201    pub content_count: u64,
202    pub attr_count: u64,
203    pub dir_count: u64,
204    pub max_size_mb: u32,
205    pub ttl_secs: u32,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_content_cache() {
214        let cache = FileCache::new(FileCacheConfig::default());
215
216        let content = CachedContent {
217            data: Arc::new(vec![1, 2, 3]),
218            mime_type: "text/plain".to_string(),
219        };
220
221        cache.put_content("/foo.txt", content.clone());
222
223        let cached = cache.get_content("/foo.txt").unwrap();
224        assert_eq!(cached.data.as_ref(), &[1, 2, 3]);
225        assert_eq!(cached.mime_type, "text/plain");
226    }
227
228    #[test]
229    fn test_attr_cache() {
230        let cache = FileCache::new(FileCacheConfig::default());
231
232        let attr = CachedAttr {
233            size: 100,
234            is_dir: false,
235            mime_type: Some("text/plain".to_string()),
236            mtime: 1234567890,
237        };
238
239        cache.put_attr("/foo.txt", attr.clone());
240
241        let cached = cache.get_attr("/foo.txt").unwrap();
242        assert_eq!(cached.size, 100);
243        assert!(!cached.is_dir);
244    }
245
246    #[test]
247    fn test_dir_cache() {
248        let cache = FileCache::new(FileCacheConfig::default());
249
250        let entries = vec![
251            CachedDirEntry {
252                name: "file.txt".to_string(),
253                is_dir: false,
254            },
255            CachedDirEntry {
256                name: "subdir".to_string(),
257                is_dir: true,
258            },
259        ];
260
261        cache.put_dir("/", entries.clone());
262
263        let cached = cache.get_dir("/").unwrap();
264        assert_eq!(cached.len(), 2);
265        assert_eq!(cached[0].name, "file.txt");
266    }
267
268    #[test]
269    fn test_invalidate() {
270        let cache = FileCache::new(FileCacheConfig::default());
271
272        let content = CachedContent {
273            data: Arc::new(vec![1, 2, 3]),
274            mime_type: "text/plain".to_string(),
275        };
276
277        cache.put_content("/foo.txt", content);
278        assert!(cache.get_content("/foo.txt").is_some());
279
280        cache.invalidate("/foo.txt");
281        assert!(cache.get_content("/foo.txt").is_none());
282    }
283
284    #[test]
285    fn test_invalidate_all() {
286        let cache = FileCache::new(FileCacheConfig::default());
287
288        cache.put_content(
289            "/a.txt",
290            CachedContent {
291                data: Arc::new(vec![1]),
292                mime_type: "text/plain".to_string(),
293            },
294        );
295        cache.put_content(
296            "/b.txt",
297            CachedContent {
298                data: Arc::new(vec![2]),
299                mime_type: "text/plain".to_string(),
300            },
301        );
302
303        cache.invalidate_all();
304
305        assert!(cache.get_content("/a.txt").is_none());
306        assert!(cache.get_content("/b.txt").is_none());
307    }
308
309    #[test]
310    fn test_normalize_key() {
311        assert_eq!(FileCache::normalize_key(""), "/");
312        assert_eq!(FileCache::normalize_key("/"), "/");
313        assert_eq!(FileCache::normalize_key("foo"), "/foo");
314        assert_eq!(FileCache::normalize_key("/foo"), "/foo");
315        assert_eq!(FileCache::normalize_key("/foo/"), "/foo");
316    }
317}