ma_core/ipfs/
ttl_cache.rs1use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7use web_time::{Duration, Instant};
8
9#[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
22pub 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 #[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 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 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 #[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 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 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}