Skip to main content

leptos_next_metadata/og_image/
cache.rs

1//! Caching system for OG image generation
2//!
3//! This module provides a flexible caching system to improve performance
4//! by avoiding regenerating identical images.
5
6use std::collections::HashMap;
7use std::hash::{Hash, Hasher};
8use std::time::{Duration, Instant};
9
10// Conditional compilation for WASM compatibility
11#[cfg(target_arch = "wasm32")]
12use parking_lot::RwLock as AsyncRwLock;
13
14#[cfg(not(target_arch = "wasm32"))]
15use tokio::sync::RwLock as AsyncRwLock;
16
17use crate::og_image::types::*;
18use crate::Result;
19
20/// Cache entry with metadata
21#[derive(Debug, Clone)]
22pub struct CacheEntry {
23    /// The generated image data
24    pub data: Vec<u8>,
25    /// When this entry was created
26    pub created_at: Instant,
27    /// How many times this entry has been accessed
28    pub access_count: u64,
29    /// Last time this entry was accessed
30    pub last_accessed: Instant,
31}
32
33impl CacheEntry {
34    pub fn new(data: Vec<u8>) -> Self {
35        let now = Instant::now();
36        Self {
37            data,
38            created_at: now,
39            access_count: 1,
40            last_accessed: now,
41        }
42    }
43
44    pub fn access(&mut self) {
45        self.access_count += 1;
46        self.last_accessed = Instant::now();
47    }
48
49    pub fn age(&self) -> Duration {
50        self.created_at.elapsed()
51    }
52
53    pub fn time_since_last_access(&self) -> Duration {
54        self.last_accessed.elapsed()
55    }
56}
57
58/// Cache key for OG image parameters
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct CacheKey {
61    /// Template name
62    pub template: String,
63    /// Serialized template data
64    pub data_hash: u64,
65    /// Image dimensions
66    pub size: (u32, u32),
67    /// Image format
68    pub format: String,
69}
70
71impl CacheKey {
72    pub fn new(params: &OgImageParams) -> Self {
73        let mut hasher = std::collections::hash_map::DefaultHasher::new();
74
75        // Hash the template data by serializing to string
76        for (key, value) in &params.data {
77            key.hash(&mut hasher);
78            // Serialize the value to string for hashing since liquid::Value doesn't implement Hash
79            let value_str = serde_json::to_string(value).unwrap_or_default();
80            value_str.hash(&mut hasher);
81        }
82
83        Self {
84            template: params.template.clone(),
85            data_hash: hasher.finish(),
86            size: params.size.unwrap_or((1200, 630)),
87            format: format!("{:?}", params.format),
88        }
89    }
90}
91
92impl Hash for CacheKey {
93    fn hash<H: Hasher>(&self, state: &mut H) {
94        self.template.hash(state);
95        self.data_hash.hash(state);
96        self.size.hash(state);
97        self.format.hash(state);
98    }
99}
100
101/// Cache provider trait for different caching backends
102#[async_trait::async_trait]
103pub trait CacheProvider: Send + Sync {
104    /// Get a cached image by key
105    async fn get(&self, key: &CacheKey) -> Result<Option<Vec<u8>>>;
106
107    /// Store an image in the cache
108    async fn set(&self, key: &CacheKey, data: &[u8]) -> Result<()>;
109
110    /// Remove an entry from the cache
111    async fn remove(&self, key: &CacheKey) -> Result<()>;
112
113    /// Clear all entries from the cache
114    async fn clear(&self) -> Result<()>;
115
116    /// Get cache statistics
117    async fn stats(&self) -> Result<CacheStats>;
118}
119
120/// In-memory cache implementation
121pub struct MemoryCache {
122    /// The actual cache storage
123    cache: AsyncRwLock<HashMap<CacheKey, CacheEntry>>,
124    /// Maximum number of entries
125    max_entries: usize,
126    /// Maximum age for entries
127    max_age: Duration,
128    /// Maximum time since last access
129    max_idle_time: Duration,
130}
131
132impl MemoryCache {
133    pub fn new(max_entries: usize) -> Self {
134        Self {
135            cache: AsyncRwLock::new(HashMap::new()),
136            max_entries,
137            max_age: Duration::from_secs(3600),       // 1 hour
138            max_idle_time: Duration::from_secs(1800), // 30 minutes
139        }
140    }
141
142    pub fn with_ttl(max_entries: usize, max_age: Duration, max_idle_time: Duration) -> Self {
143        Self {
144            cache: AsyncRwLock::new(HashMap::new()),
145            max_entries,
146            max_age,
147            max_idle_time,
148        }
149    }
150
151    /// Clean up expired entries
152    async fn cleanup(&self) -> Result<()> {
153        let mut cache = self.cache.write().await;
154        let now = Instant::now();
155
156        cache.retain(|_, entry| {
157            let age = now.duration_since(entry.created_at);
158            let idle_time = now.duration_since(entry.last_accessed);
159
160            age < self.max_age && idle_time < self.max_idle_time
161        });
162
163        Ok(())
164    }
165
166    /// Evict least recently used entries if cache is full
167    async fn evict_lru(&self) -> Result<()> {
168        let mut cache = self.cache.write().await;
169
170        if cache.len() < self.max_entries {
171            return Ok(());
172        }
173
174        // Find the entry with the oldest last_accessed time
175        let mut oldest_key = None;
176        let mut oldest_time = Instant::now();
177
178        for (key, entry) in cache.iter() {
179            if entry.last_accessed < oldest_time {
180                oldest_time = entry.last_accessed;
181                oldest_key = Some(key.clone());
182            }
183        }
184
185        if let Some(key) = oldest_key {
186            cache.remove(&key);
187        }
188
189        Ok(())
190    }
191}
192
193#[async_trait::async_trait]
194impl CacheProvider for MemoryCache {
195    async fn get(&self, key: &CacheKey) -> Result<Option<Vec<u8>>> {
196        let mut cache = self.cache.write().await;
197
198        if let Some(entry) = cache.get_mut(key) {
199            // Check if entry is still valid
200            let age = entry.age();
201            let idle_time = entry.time_since_last_access();
202
203            if age < self.max_age && idle_time < self.max_idle_time {
204                entry.access();
205                return Ok(Some(entry.data.clone()));
206            } else {
207                // Entry is expired, remove it
208                cache.remove(key);
209            }
210        }
211
212        Ok(None)
213    }
214
215    async fn set(&self, key: &CacheKey, data: &[u8]) -> Result<()> {
216        // Clean up expired entries first
217        self.cleanup().await?;
218
219        // Evict LRU entries if needed
220        self.evict_lru().await?;
221
222        let mut cache = self.cache.write().await;
223        cache.insert(key.clone(), CacheEntry::new(data.to_vec()));
224
225        Ok(())
226    }
227
228    async fn remove(&self, key: &CacheKey) -> Result<()> {
229        let mut cache = self.cache.write().await;
230        cache.remove(key);
231        Ok(())
232    }
233
234    async fn clear(&self) -> Result<()> {
235        let mut cache = self.cache.write().await;
236        cache.clear();
237        Ok(())
238    }
239
240    async fn stats(&self) -> Result<CacheStats> {
241        let cache = self.cache.read().await;
242
243        let mut total_accesses = 0;
244        let mut total_age = Duration::ZERO;
245        let mut oldest_entry = Instant::now();
246
247        for entry in cache.values() {
248            total_accesses += entry.access_count;
249            total_age += entry.age();
250            if entry.created_at < oldest_entry {
251                oldest_entry = entry.created_at;
252            }
253        }
254
255        Ok(CacheStats {
256            entries: cache.len(),
257            max_entries: self.max_entries,
258            total_accesses,
259            average_age: if cache.is_empty() {
260                Duration::ZERO
261            } else {
262                total_age / cache.len() as u32
263            },
264            oldest_entry_age: oldest_entry.elapsed(),
265            hit_rate: 0.0, // This would need to be tracked separately
266        })
267    }
268}
269
270/// Cache statistics
271#[derive(Debug, Clone)]
272pub struct CacheStats {
273    /// Number of entries in cache
274    pub entries: usize,
275    /// Maximum number of entries allowed
276    pub max_entries: usize,
277    /// Total number of cache accesses
278    pub total_accesses: u64,
279    /// Average age of entries
280    pub average_age: Duration,
281    /// Age of the oldest entry
282    pub oldest_entry_age: Duration,
283    /// Cache hit rate (0.0 to 1.0)
284    pub hit_rate: f64,
285}
286
287/// No-op cache implementation for testing or when caching is disabled
288pub struct NoOpCache;
289
290#[async_trait::async_trait]
291impl CacheProvider for NoOpCache {
292    async fn get(&self, _key: &CacheKey) -> Result<Option<Vec<u8>>> {
293        Ok(None)
294    }
295
296    async fn set(&self, _key: &CacheKey, _data: &[u8]) -> Result<()> {
297        Ok(())
298    }
299
300    async fn remove(&self, _key: &CacheKey) -> Result<()> {
301        Ok(())
302    }
303
304    async fn clear(&self) -> Result<()> {
305        Ok(())
306    }
307
308    async fn stats(&self) -> Result<CacheStats> {
309        Ok(CacheStats {
310            entries: 0,
311            max_entries: 0,
312            total_accesses: 0,
313            average_age: Duration::ZERO,
314            oldest_entry_age: Duration::ZERO,
315            hit_rate: 0.0,
316        })
317    }
318}
319
320/// Cache configuration
321#[derive(Debug, Clone)]
322pub struct CacheConfig {
323    /// Maximum number of entries
324    pub max_entries: usize,
325    /// Maximum age for entries
326    pub max_age: Duration,
327    /// Maximum idle time before eviction
328    pub max_idle_time: Duration,
329    /// Whether to enable caching
330    pub enabled: bool,
331}
332
333impl Default for CacheConfig {
334    fn default() -> Self {
335        Self {
336            max_entries: 100,
337            max_age: Duration::from_secs(3600),       // 1 hour
338            max_idle_time: Duration::from_secs(1800), // 30 minutes
339            enabled: true,
340        }
341    }
342}
343
344impl CacheConfig {
345    pub fn new(max_entries: usize) -> Self {
346        Self {
347            max_entries,
348            ..Default::default()
349        }
350    }
351
352    pub fn with_ttl(max_entries: usize, max_age: Duration, max_idle_time: Duration) -> Self {
353        Self {
354            max_entries,
355            max_age,
356            max_idle_time,
357            enabled: true,
358        }
359    }
360
361    pub fn disabled() -> Self {
362        Self {
363            enabled: false,
364            ..Default::default()
365        }
366    }
367}