Skip to main content

mbr_core/
oembed_cache.rs

1//! OEmbed cache module for caching page metadata fetches.
2//!
3//! Provides a thread-safe, size-bounded cache for OEmbed/OpenGraph page info
4//! to avoid redundant network requests when rendering multiple markdown files.
5
6use crate::cache::Entry;
7use crate::oembed::PageInfo;
8use lru::LruCache;
9use std::num::NonZeroUsize;
10use std::sync::Mutex;
11
12/// Maximum number of entries in the oembed LRU cache.
13const OEMBED_CACHE_MAX_ENTRIES: usize = 10_000;
14
15/// Thread-safe cache for OEmbed page information.
16///
17/// Uses an LRU cache with size-based eviction for O(1) get/insert
18/// and bounded memory usage. Unlike the papaya-backed caches built on
19/// [`crate::cache::SizeBoundedMap`], this cache promotes entries to
20/// most-recently-used on access, so it keeps its own LRU storage while
21/// sharing the [`Entry`] weighing/accounting conventions.
22pub struct OembedCache {
23    /// The underlying LRU cache, protected by a mutex.
24    /// Mutex is appropriate here because operations are fast (no I/O inside the lock).
25    cache: Mutex<LruCache<String, Entry<PageInfo>>>,
26    /// Current total size in bytes
27    current_size: Mutex<usize>,
28    /// Maximum allowed size in bytes
29    max_size: usize,
30}
31
32impl OembedCache {
33    /// Creates a new cache with the specified maximum size in bytes.
34    ///
35    /// # Arguments
36    ///
37    /// * `max_size_bytes` - Maximum memory to use for cached entries.
38    ///   Set to 0 to disable caching entirely.
39    pub fn new(max_size_bytes: usize) -> Self {
40        // Use a generous count-based capacity since we manage eviction by size.
41        // The const block makes a zero capacity a compile-time error.
42        let cap = const {
43            match NonZeroUsize::new(OEMBED_CACHE_MAX_ENTRIES) {
44                Some(cap) => cap,
45                None => panic!("OEMBED_CACHE_MAX_ENTRIES must be non-zero"),
46            }
47        };
48        Self {
49            cache: Mutex::new(LruCache::new(cap)),
50            current_size: Mutex::new(0),
51            max_size: max_size_bytes,
52        }
53    }
54
55    /// Retrieves cached page info for a URL if present.
56    ///
57    /// Returns `None` if the URL is not in the cache.
58    /// Promotes the entry to most-recently-used on access.
59    pub fn get(&self, url: &str) -> Option<PageInfo> {
60        if self.max_size == 0 {
61            return None;
62        }
63
64        // Poisoning recovery: the cache holds plain data, so a panicked holder
65        // cannot leave it in an invalid state.
66        let mut cache = self
67            .cache
68            .lock()
69            .unwrap_or_else(std::sync::PoisonError::into_inner);
70        match cache.get(url) {
71            Some(entry) => {
72                tracing::debug!("oembed cache hit: {}", url);
73                Some(entry.value.clone())
74            }
75            None => {
76                tracing::debug!("oembed cache miss: {}", url);
77                None
78            }
79        }
80    }
81
82    /// Inserts page info into the cache.
83    ///
84    /// If the cache exceeds its size limit after insertion, least-recently-used
85    /// entries are evicted until the cache is within bounds.
86    pub fn insert(&self, url: String, info: PageInfo) {
87        if self.max_size == 0 {
88            return;
89        }
90
91        let size_bytes = Entry::<PageInfo>::weigh(info.estimated_size(), url.len());
92
93        let entry = Entry::new(info, size_bytes);
94
95        // Poisoning recovery: both mutexes guard plain data (see get()).
96        let mut cache = self
97            .cache
98            .lock()
99            .unwrap_or_else(std::sync::PoisonError::into_inner);
100        let mut current_size = self
101            .current_size
102            .lock()
103            .unwrap_or_else(std::sync::PoisonError::into_inner);
104
105        // If overwriting an existing entry, subtract its old size
106        if let Some(old) = cache.push(url.clone(), entry) {
107            *current_size -= old.1.size_bytes;
108        }
109        *current_size += size_bytes;
110
111        tracing::debug!("oembed cached: {} ({} bytes)", url, size_bytes);
112
113        // Evict LRU entries until under the size limit
114        while *current_size > self.max_size {
115            if let Some((_evicted_key, evicted_entry)) = cache.pop_lru() {
116                *current_size -= evicted_entry.size_bytes;
117            } else {
118                break;
119            }
120        }
121    }
122
123    /// Returns the current approximate size of the cache in bytes.
124    #[cfg(test)]
125    pub fn current_size(&self) -> usize {
126        *self.current_size.lock().unwrap()
127    }
128
129    /// Returns the number of entries in the cache.
130    #[cfg(test)]
131    pub fn len(&self) -> usize {
132        self.cache.lock().unwrap().len()
133    }
134
135    /// Returns true if the cache is empty.
136    #[cfg(test)]
137    pub fn is_empty(&self) -> bool {
138        self.cache.lock().unwrap().is_empty()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn make_page_info(url: &str) -> PageInfo {
147        PageInfo {
148            url: url.to_string(),
149            title: Some("Test Title".to_string()),
150            description: Some("Test description".to_string()),
151            image: None,
152            embed_html: None,
153        }
154    }
155
156    #[test]
157    fn test_insert_and_retrieve() {
158        let cache = OembedCache::new(1024 * 1024); // 1MB
159
160        let url = "https://example.com/page";
161        let info = make_page_info(url);
162
163        cache.insert(url.to_string(), info.clone());
164
165        let retrieved = cache.get(url);
166        assert!(retrieved.is_some());
167        assert_eq!(retrieved.unwrap().url, url);
168    }
169
170    #[test]
171    fn test_cache_miss() {
172        let cache = OembedCache::new(1024 * 1024);
173
174        let retrieved = cache.get("https://nonexistent.com");
175        assert!(retrieved.is_none());
176    }
177
178    #[test]
179    fn test_disabled_cache() {
180        let cache = OembedCache::new(0); // Disabled
181
182        let url = "https://example.com";
183        let info = make_page_info(url);
184
185        cache.insert(url.to_string(), info);
186
187        // Should not be cached when max_size is 0
188        assert!(cache.get(url).is_none());
189    }
190
191    #[test]
192    fn test_size_tracking() {
193        let cache = OembedCache::new(1024 * 1024);
194
195        assert_eq!(cache.current_size(), 0);
196
197        let url = "https://example.com";
198        let info = make_page_info(url);
199
200        cache.insert(url.to_string(), info);
201
202        assert!(cache.current_size() > 0);
203    }
204
205    #[test]
206    fn test_eviction_on_size_limit() {
207        // Small cache that can only hold a few entries
208        let cache = OembedCache::new(500);
209
210        // Insert several entries to trigger eviction
211        for i in 0..10 {
212            let url = format!("https://example.com/page{}", i);
213            let info = make_page_info(&url);
214            cache.insert(url, info);
215        }
216
217        // Cache should have evicted some entries to stay within bounds
218        assert!(cache.current_size() <= 500);
219    }
220
221    #[test]
222    fn test_multiple_entries() {
223        let cache = OembedCache::new(1024 * 1024);
224
225        let urls = vec![
226            "https://example.com/page1",
227            "https://example.com/page2",
228            "https://example.com/page3",
229        ];
230
231        for url in &urls {
232            let info = make_page_info(url);
233            cache.insert(url.to_string(), info);
234        }
235
236        assert_eq!(cache.len(), 3);
237
238        for url in &urls {
239            assert!(cache.get(url).is_some());
240        }
241    }
242
243    #[test]
244    fn test_overwrite_existing() {
245        let cache = OembedCache::new(1024 * 1024);
246
247        let url = "https://example.com";
248
249        let info1 = PageInfo {
250            url: url.to_string(),
251            title: Some("First Title".to_string()),
252            ..Default::default()
253        };
254
255        let info2 = PageInfo {
256            url: url.to_string(),
257            title: Some("Second Title".to_string()),
258            ..Default::default()
259        };
260
261        cache.insert(url.to_string(), info1);
262        cache.insert(url.to_string(), info2);
263
264        let retrieved = cache.get(url).unwrap();
265        assert_eq!(retrieved.title, Some("Second Title".to_string()));
266    }
267}