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 metadata (attrs, dirs) in seconds
47    pub ttl_secs: u32,
48    /// TTL for content cache in seconds (defaults to 5x metadata TTL)
49    pub content_ttl_secs: u32,
50    /// TTL for negative cache (non-existent paths) in seconds
51    pub negative_ttl_secs: u32,
52}
53
54impl Default for FileCacheConfig {
55    fn default() -> Self {
56        Self {
57            max_size_mb: 100,
58            ttl_secs: 60,
59            content_ttl_secs: 300,
60            negative_ttl_secs: 10,
61        }
62    }
63}
64
65impl FileCacheConfig {
66    /// Create config from basic parameters, deriving content and negative TTLs
67    pub fn from_basic(max_size_mb: u32, ttl_secs: u32) -> Self {
68        Self {
69            max_size_mb,
70            ttl_secs,
71            content_ttl_secs: ttl_secs.saturating_mul(5),
72            negative_ttl_secs: 10,
73        }
74    }
75}
76
77/// LRU cache for FUSE filesystem
78#[derive(Clone)]
79pub struct FileCache {
80    /// Content cache: path → content
81    content: Cache<String, CachedContent>,
82    /// Attribute cache: path → attributes
83    attrs: Cache<String, CachedAttr>,
84    /// Directory listing cache: path → entries
85    dirs: Cache<String, Vec<CachedDirEntry>>,
86    /// Negative cache: paths confirmed not to exist
87    negative: Cache<String, ()>,
88    /// Configuration
89    config: FileCacheConfig,
90}
91
92impl FileCache {
93    /// Create a new file cache with the given configuration
94    pub fn new(config: FileCacheConfig) -> Self {
95        let metadata_ttl = Duration::from_secs(config.ttl_secs as u64);
96        let content_ttl = Duration::from_secs(config.content_ttl_secs as u64);
97        let negative_ttl = Duration::from_secs(config.negative_ttl_secs as u64);
98        // Estimate ~1KB average per entry for size calculation
99        let max_capacity = (config.max_size_mb as u64) * 1024;
100
101        Self {
102            content: Cache::builder()
103                .time_to_live(content_ttl)
104                .max_capacity(max_capacity)
105                .build(),
106            attrs: Cache::builder()
107                .time_to_live(metadata_ttl)
108                .max_capacity(max_capacity * 10) // Attrs are smaller
109                .build(),
110            dirs: Cache::builder()
111                .time_to_live(metadata_ttl)
112                .max_capacity(max_capacity)
113                .build(),
114            negative: Cache::builder()
115                .time_to_live(negative_ttl)
116                .max_capacity(10_000) // Non-existent paths are tiny
117                .build(),
118            config,
119        }
120    }
121
122    /// Get cached content for a path
123    pub fn get_content(&self, path: &str) -> Option<CachedContent> {
124        self.content.get(&Self::normalize_key(path))
125    }
126
127    /// Cache content for a path
128    pub fn put_content(&self, path: &str, content: CachedContent) {
129        self.content.insert(Self::normalize_key(path), content);
130    }
131
132    /// Get cached attributes for a path
133    pub fn get_attr(&self, path: &str) -> Option<CachedAttr> {
134        self.attrs.get(&Self::normalize_key(path))
135    }
136
137    /// Cache attributes for a path
138    pub fn put_attr(&self, path: &str, attr: CachedAttr) {
139        self.attrs.insert(Self::normalize_key(path), attr);
140    }
141
142    /// Get cached directory listing
143    pub fn get_dir(&self, path: &str) -> Option<Vec<CachedDirEntry>> {
144        self.dirs.get(&Self::normalize_key(path))
145    }
146
147    /// Cache directory listing
148    pub fn put_dir(&self, path: &str, entries: Vec<CachedDirEntry>) {
149        self.dirs.insert(Self::normalize_key(path), entries);
150    }
151
152    /// Check if a path is in the negative cache (known not to exist)
153    pub fn is_negative(&self, path: &str) -> bool {
154        self.negative.contains_key(&Self::normalize_key(path))
155    }
156
157    /// Add a path to the negative cache (mark as non-existent)
158    pub fn put_negative(&self, path: &str) {
159        self.negative.insert(Self::normalize_key(path), ());
160    }
161
162    /// Invalidate a specific path from all caches
163    pub fn invalidate(&self, path: &str) {
164        let key = Self::normalize_key(path);
165        self.content.invalidate(&key);
166        self.attrs.invalidate(&key);
167        self.dirs.invalidate(&key);
168        self.negative.invalidate(&key);
169    }
170
171    /// Invalidate all entries under a path prefix
172    pub fn invalidate_prefix(&self, prefix: &str) {
173        let prefix = Self::normalize_key(prefix);
174
175        // Moka doesn't have prefix invalidation, so we need to iterate
176        // For content cache
177        self.content.run_pending_tasks();
178        // Note: We can't efficiently iterate moka caches, so we just invalidate_all
179        // in practice for prefix invalidations
180        if prefix == "/" {
181            self.invalidate_all();
182        }
183    }
184
185    /// Invalidate all cached entries
186    pub fn invalidate_all(&self) {
187        self.content.invalidate_all();
188        self.attrs.invalidate_all();
189        self.dirs.invalidate_all();
190        self.negative.invalidate_all();
191    }
192
193    /// Get current cache statistics
194    pub fn stats(&self) -> CacheStats {
195        CacheStats {
196            content_count: self.content.entry_count(),
197            attr_count: self.attrs.entry_count(),
198            dir_count: self.dirs.entry_count(),
199            negative_count: self.negative.entry_count(),
200            max_size_mb: self.config.max_size_mb,
201            metadata_ttl_secs: self.config.ttl_secs,
202            content_ttl_secs: self.config.content_ttl_secs,
203            negative_ttl_secs: self.config.negative_ttl_secs,
204        }
205    }
206
207    /// Normalize a path to a consistent cache key
208    fn normalize_key(path: &str) -> String {
209        let path = path.trim();
210        if path.is_empty() || path == "/" {
211            return "/".to_string();
212        }
213
214        let mut key = if path.starts_with('/') {
215            path.to_string()
216        } else {
217            format!("/{}", path)
218        };
219
220        if key.len() > 1 && key.ends_with('/') {
221            key.pop();
222        }
223
224        key
225    }
226}
227
228impl std::fmt::Debug for FileCache {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.debug_struct("FileCache")
231            .field("config", &self.config)
232            .field("content_count", &self.content.entry_count())
233            .field("attr_count", &self.attrs.entry_count())
234            .field("dir_count", &self.dirs.entry_count())
235            .field("negative_count", &self.negative.entry_count())
236            .finish()
237    }
238}
239
240/// Cache statistics
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct CacheStats {
243    pub content_count: u64,
244    pub attr_count: u64,
245    pub dir_count: u64,
246    pub negative_count: u64,
247    pub max_size_mb: u32,
248    pub metadata_ttl_secs: u32,
249    pub content_ttl_secs: u32,
250    pub negative_ttl_secs: u32,
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_content_cache() {
259        let cache = FileCache::new(FileCacheConfig::default());
260
261        let content = CachedContent {
262            data: Arc::new(vec![1, 2, 3]),
263            mime_type: "text/plain".to_string(),
264        };
265
266        cache.put_content("/foo.txt", content.clone());
267
268        let cached = cache.get_content("/foo.txt").unwrap();
269        assert_eq!(cached.data.as_ref(), &[1, 2, 3]);
270        assert_eq!(cached.mime_type, "text/plain");
271    }
272
273    #[test]
274    fn test_attr_cache() {
275        let cache = FileCache::new(FileCacheConfig::default());
276
277        let attr = CachedAttr {
278            size: 100,
279            is_dir: false,
280            mime_type: Some("text/plain".to_string()),
281            mtime: 1234567890,
282        };
283
284        cache.put_attr("/foo.txt", attr.clone());
285
286        let cached = cache.get_attr("/foo.txt").unwrap();
287        assert_eq!(cached.size, 100);
288        assert!(!cached.is_dir);
289    }
290
291    #[test]
292    fn test_dir_cache() {
293        let cache = FileCache::new(FileCacheConfig::default());
294
295        let entries = vec![
296            CachedDirEntry {
297                name: "file.txt".to_string(),
298                is_dir: false,
299            },
300            CachedDirEntry {
301                name: "subdir".to_string(),
302                is_dir: true,
303            },
304        ];
305
306        cache.put_dir("/", entries.clone());
307
308        let cached = cache.get_dir("/").unwrap();
309        assert_eq!(cached.len(), 2);
310        assert_eq!(cached[0].name, "file.txt");
311    }
312
313    #[test]
314    fn test_invalidate() {
315        let cache = FileCache::new(FileCacheConfig::default());
316
317        let content = CachedContent {
318            data: Arc::new(vec![1, 2, 3]),
319            mime_type: "text/plain".to_string(),
320        };
321
322        cache.put_content("/foo.txt", content);
323        assert!(cache.get_content("/foo.txt").is_some());
324
325        cache.invalidate("/foo.txt");
326        assert!(cache.get_content("/foo.txt").is_none());
327    }
328
329    #[test]
330    fn test_invalidate_all() {
331        let cache = FileCache::new(FileCacheConfig::default());
332
333        cache.put_content(
334            "/a.txt",
335            CachedContent {
336                data: Arc::new(vec![1]),
337                mime_type: "text/plain".to_string(),
338            },
339        );
340        cache.put_content(
341            "/b.txt",
342            CachedContent {
343                data: Arc::new(vec![2]),
344                mime_type: "text/plain".to_string(),
345            },
346        );
347
348        cache.invalidate_all();
349
350        assert!(cache.get_content("/a.txt").is_none());
351        assert!(cache.get_content("/b.txt").is_none());
352    }
353
354    #[test]
355    fn test_negative_cache() {
356        let cache = FileCache::new(FileCacheConfig::default());
357
358        assert!(!cache.is_negative("/nonexistent"));
359
360        cache.put_negative("/nonexistent");
361        assert!(cache.is_negative("/nonexistent"));
362
363        // Invalidate should clear negative cache too
364        cache.invalidate("/nonexistent");
365        assert!(!cache.is_negative("/nonexistent"));
366    }
367
368    #[test]
369    fn test_negative_cache_invalidate_all() {
370        let cache = FileCache::new(FileCacheConfig::default());
371
372        cache.put_negative("/a");
373        cache.put_negative("/b");
374        assert!(cache.is_negative("/a"));
375        assert!(cache.is_negative("/b"));
376
377        cache.invalidate_all();
378        assert!(!cache.is_negative("/a"));
379        assert!(!cache.is_negative("/b"));
380    }
381
382    #[test]
383    fn test_normalize_key() {
384        assert_eq!(FileCache::normalize_key(""), "/");
385        assert_eq!(FileCache::normalize_key("/"), "/");
386        assert_eq!(FileCache::normalize_key("foo"), "/foo");
387        assert_eq!(FileCache::normalize_key("/foo"), "/foo");
388        assert_eq!(FileCache::normalize_key("/foo/"), "/foo");
389    }
390}