Skip to main content

ma_core/ipfs/
ttl_cache.rs

1//! Generic TTL cache with positive/negative entries and per-key in-flight locks.
2//!
3//! Pure policy: no I/O, no knowledge of DIDs or gateways.
4
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7use web_time::{Duration, Instant};
8
9/// A cached outcome: a successful value or a remembered failure detail.
10#[derive(Clone)]
11pub enum Cached<T> {
12    Hit(T),
13    Miss(String),
14}
15
16#[derive(Clone)]
17struct Entry<T> {
18    expires_at: Instant,
19    value: Cached<T>,
20}
21
22/// String-keyed cache where hits and misses expire on independent TTLs.
23///
24/// A TTL of zero disables caching for that outcome kind. `lock_for` hands out
25/// a per-key async mutex so concurrent fetchers of the same key collapse into
26/// one upstream request.
27pub struct TtlCache<T> {
28    positive_ttl: Mutex<Duration>,
29    negative_ttl: Mutex<Duration>,
30    entries: Mutex<HashMap<String, Entry<T>>>,
31    in_flight: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
32}
33
34impl<T: Clone> TtlCache<T> {
35    #[must_use]
36    pub fn new(positive_ttl: Duration, negative_ttl: Duration) -> Self {
37        Self {
38            positive_ttl: Mutex::new(positive_ttl),
39            negative_ttl: Mutex::new(negative_ttl),
40            entries: Mutex::new(HashMap::new()),
41            in_flight: Mutex::new(HashMap::new()),
42        }
43    }
44
45    pub fn set_ttls(&self, positive_ttl: Duration, negative_ttl: Duration) {
46        if let Ok(mut ttl) = self.positive_ttl.lock() {
47            *ttl = positive_ttl;
48        }
49        if let Ok(mut ttl) = self.negative_ttl.lock() {
50            *ttl = negative_ttl;
51        }
52    }
53
54    #[must_use]
55    pub fn positive_ttl(&self) -> Duration {
56        self.positive_ttl
57            .lock()
58            .map_or(Duration::from_secs(0), |ttl| *ttl)
59    }
60
61    #[must_use]
62    pub fn negative_ttl(&self) -> Duration {
63        self.negative_ttl
64            .lock()
65            .map_or(Duration::from_secs(0), |ttl| *ttl)
66    }
67
68    /// Read a live entry, honouring the current TTL configuration.
69    ///
70    /// Expired entries are evicted. Hits are returned only when the positive
71    /// TTL is non-zero; misses only when the negative TTL is non-zero.
72    #[must_use]
73    pub fn read(&self, key: &str) -> Option<Cached<T>> {
74        let hit_enabled = !self.positive_ttl().is_zero();
75        let miss_enabled = !self.negative_ttl().is_zero();
76        if !hit_enabled && !miss_enabled {
77            return None;
78        }
79
80        let mut entries = self.entries.lock().ok()?;
81        let entry = entries.get(key).cloned()?;
82        if entry.expires_at <= Instant::now() {
83            entries.remove(key);
84            return None;
85        }
86
87        match entry.value {
88            Cached::Hit(value) if hit_enabled => Some(Cached::Hit(value)),
89            Cached::Miss(value) if miss_enabled => Some(Cached::Miss(value)),
90            _ => None,
91        }
92    }
93
94    /// Store a hit under the positive TTL. No-op when the TTL is zero.
95    pub fn write_hit(&self, key: String, value: T) {
96        let ttl = self.positive_ttl();
97        if !ttl.is_zero() {
98            self.write(key, Cached::Hit(value), Instant::now() + ttl);
99        }
100    }
101
102    /// Store a miss under the negative TTL. No-op when the TTL is zero.
103    pub fn write_miss(&self, key: String, detail: String) {
104        let ttl = self.negative_ttl();
105        if !ttl.is_zero() {
106            self.write(key, Cached::Miss(detail), Instant::now() + ttl);
107        }
108    }
109
110    fn write(&self, key: String, value: Cached<T>, expires_at: Instant) {
111        if let Ok(mut entries) = self.entries.lock() {
112            entries.insert(key, Entry { expires_at, value });
113        }
114    }
115
116    /// Get (or create) the in-flight lock for `key`.
117    #[must_use]
118    pub fn lock_for(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
119        let mut in_flight = self
120            .in_flight
121            .lock()
122            .unwrap_or_else(std::sync::PoisonError::into_inner);
123        Arc::clone(
124            in_flight
125                .entry(key.to_string())
126                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
127        )
128    }
129
130    /// Drop the in-flight lock for `key` once no other fetcher holds it.
131    pub fn release_lock(&self, key: &str, lock: &Arc<tokio::sync::Mutex<()>>) {
132        let mut in_flight = self
133            .in_flight
134            .lock()
135            .unwrap_or_else(std::sync::PoisonError::into_inner);
136        if in_flight
137            .get(key)
138            .is_some_and(|current| Arc::ptr_eq(current, lock))
139            && Arc::strong_count(lock) == 2
140        {
141            in_flight.remove(key);
142        }
143    }
144
145    #[cfg(test)]
146    pub(crate) fn has_in_flight(&self, key: &str) -> bool {
147        self.in_flight
148            .lock()
149            .is_ok_and(|in_flight| in_flight.contains_key(key))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{Cached, TtlCache};
156    use std::sync::Arc;
157    use web_time::{Duration, Instant};
158
159    fn cache() -> TtlCache<Vec<u8>> {
160        TtlCache::new(Duration::from_mins(1), Duration::from_secs(10))
161    }
162
163    #[test]
164    fn write_and_read_hit() {
165        let cache = cache();
166        cache.write_hit("did:ma:test".to_string(), vec![1, 2, 3]);
167        let cached = cache.read("did:ma:test");
168        assert!(matches!(cached, Some(Cached::Hit(ref b)) if *b == vec![1, 2, 3]));
169    }
170
171    #[test]
172    fn miss_not_returned_when_negative_ttl_zero() {
173        let cache = cache();
174        cache.write_miss("did:ma:test".to_string(), "some error".to_string());
175        cache.set_ttls(Duration::from_mins(1), Duration::ZERO);
176        assert!(
177            cache.read("did:ma:test").is_none(),
178            "miss should not be returned when miss-cache is disabled"
179        );
180    }
181
182    #[test]
183    fn expired_entry_is_evicted() {
184        let cache = cache();
185        // Bypass write_hit to plant an already-expired entry.
186        let already_expired = Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
187        cache.write("k".to_string(), Cached::Hit(vec![9]), already_expired);
188        assert!(
189            cache.read("k").is_none(),
190            "expired entry must not be returned"
191        );
192    }
193
194    #[test]
195    fn lock_is_shared_per_key_and_released_when_idle() {
196        let cache = cache();
197        let first = cache.lock_for("did:ma:one");
198        let second = cache.lock_for("did:ma:one");
199        let other = cache.lock_for("did:ma:two");
200
201        assert!(Arc::ptr_eq(&first, &second));
202        assert!(!Arc::ptr_eq(&first, &other));
203
204        drop(second);
205        cache.release_lock("did:ma:one", &first);
206        assert!(!cache.has_in_flight("did:ma:one"));
207    }
208}