Skip to main content

ecr_store/
cache.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::SystemTime;
4
5/// A bounded cache keyed by a file's path and mtime.
6///
7/// A parsed message is expensive relative to everything else in a body
8/// request, and a maildir file never changes in place — a new message is a new
9/// file. Keying on mtime as well means the rare rewrite still invalidates.
10pub struct FileCache<T> {
11    inner: Mutex<Inner<T>>,
12    capacity: usize,
13}
14
15struct Inner<T> {
16    entries: HashMap<String, Entry<T>>,
17    /// Insertion order, oldest first, for eviction.
18    order: Vec<String>,
19}
20
21struct Entry<T> {
22    modified: Option<SystemTime>,
23    value: T,
24}
25
26impl<T: Clone> FileCache<T> {
27    pub fn new(capacity: usize) -> Self {
28        Self {
29            inner: Mutex::new(Inner {
30                entries: HashMap::new(),
31                order: Vec::new(),
32            }),
33            capacity: capacity.max(1),
34        }
35    }
36
37    pub fn get(&self, key: &str, modified: Option<SystemTime>) -> Option<T> {
38        let inner = self.inner.lock().ok()?;
39        let entry = inner.entries.get(key)?;
40
41        (entry.modified == modified).then(|| entry.value.clone())
42    }
43
44    pub fn insert(&self, key: String, modified: Option<SystemTime>, value: T) {
45        let Ok(mut inner) = self.inner.lock() else {
46            return;
47        };
48
49        if inner
50            .entries
51            .insert(key.clone(), Entry { modified, value })
52            .is_none()
53        {
54            inner.order.push(key);
55        }
56
57        while inner.order.len() > self.capacity {
58            let oldest = inner.order.remove(0);
59            inner.entries.remove(&oldest);
60        }
61    }
62
63    pub fn len(&self) -> usize {
64        self.inner.lock().map(|i| i.entries.len()).unwrap_or(0)
65    }
66
67    pub fn is_empty(&self) -> bool {
68        self.len() == 0
69    }
70
71    pub fn clear(&self) {
72        if let Ok(mut inner) = self.inner.lock() {
73            inner.entries.clear();
74            inner.order.clear();
75        }
76    }
77}
78
79pub fn modified_at(path: &std::path::Path) -> Option<SystemTime> {
80    std::fs::metadata(path).ok()?.modified().ok()
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::time::Duration;
87
88    fn at(seconds: u64) -> Option<SystemTime> {
89        Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds))
90    }
91
92    #[test]
93    fn returns_what_was_stored() {
94        let cache: FileCache<String> = FileCache::new(4);
95        cache.insert("a".into(), at(1), "value".into());
96
97        assert_eq!(cache.get("a", at(1)), Some("value".to_string()));
98    }
99
100    #[test]
101    fn misses_on_an_unknown_key() {
102        let cache: FileCache<String> = FileCache::new(4);
103        assert_eq!(cache.get("nope", at(1)), None);
104    }
105
106    #[test]
107    fn a_changed_mtime_invalidates_the_entry() {
108        let cache: FileCache<String> = FileCache::new(4);
109        cache.insert("a".into(), at(1), "old".into());
110
111        assert_eq!(cache.get("a", at(2)), None);
112    }
113
114    #[test]
115    fn a_file_that_lost_its_mtime_is_not_served_from_cache() {
116        let cache: FileCache<String> = FileCache::new(4);
117        cache.insert("a".into(), at(1), "value".into());
118
119        assert_eq!(cache.get("a", None), None);
120    }
121
122    #[test]
123    fn evicts_the_oldest_once_full() {
124        let cache: FileCache<String> = FileCache::new(2);
125        cache.insert("a".into(), at(1), "a".into());
126        cache.insert("b".into(), at(1), "b".into());
127        cache.insert("c".into(), at(1), "c".into());
128
129        assert_eq!(cache.len(), 2);
130        assert_eq!(cache.get("a", at(1)), None);
131        assert_eq!(cache.get("c", at(1)), Some("c".to_string()));
132    }
133
134    #[test]
135    fn re_inserting_a_key_does_not_grow_the_order_list() {
136        let cache: FileCache<String> = FileCache::new(2);
137        cache.insert("a".into(), at(1), "one".into());
138        cache.insert("a".into(), at(2), "two".into());
139        cache.insert("b".into(), at(1), "b".into());
140
141        assert_eq!(cache.len(), 2);
142        assert_eq!(cache.get("a", at(2)), Some("two".to_string()));
143    }
144
145    #[test]
146    fn a_zero_capacity_still_holds_one_entry() {
147        let cache: FileCache<String> = FileCache::new(0);
148        cache.insert("a".into(), at(1), "value".into());
149
150        assert_eq!(cache.get("a", at(1)), Some("value".to_string()));
151    }
152
153    #[test]
154    fn clearing_empties_it() {
155        let cache: FileCache<String> = FileCache::new(4);
156        cache.insert("a".into(), at(1), "value".into());
157        cache.clear();
158
159        assert!(cache.is_empty());
160    }
161}