๐Ÿ“ฆ RightNow-AI / openfang

๐Ÿ“„ web_cache.rs ยท 146 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146//! In-memory TTL cache for web search and fetch results.
//!
//! Thread-safe via `DashMap`. Lazy eviction on `get()` โ€” expired entries
//! are only cleaned up when accessed. A `Duration::ZERO` TTL disables
//! caching entirely (zero-cost passthrough).

use dashmap::DashMap;
use std::time::{Duration, Instant};

/// A cached entry with its insertion timestamp.
struct CacheEntry {
    value: String,
    inserted_at: Instant,
}

/// Thread-safe in-memory cache with configurable TTL.
pub struct WebCache {
    entries: DashMap<String, CacheEntry>,
    ttl: Duration,
}

impl WebCache {
    /// Create a new cache with the given TTL. A TTL of `Duration::ZERO` disables caching.
    pub fn new(ttl: Duration) -> Self {
        Self {
            entries: DashMap::new(),
            ttl,
        }
    }

    /// Get a cached value by key. Returns `None` if missing or expired.
    /// Expired entries are lazily evicted on access.
    pub fn get(&self, key: &str) -> Option<String> {
        if self.ttl.is_zero() {
            return None;
        }
        let entry = self.entries.get(key)?;
        if entry.inserted_at.elapsed() > self.ttl {
            drop(entry); // release read lock before removing
            self.entries.remove(key);
            None
        } else {
            Some(entry.value.clone())
        }
    }

    /// Store a value in the cache. No-op if TTL is zero.
    pub fn put(&self, key: String, value: String) {
        if self.ttl.is_zero() {
            return;
        }
        self.entries.insert(
            key,
            CacheEntry {
                value,
                inserted_at: Instant::now(),
            },
        );
    }

    /// Remove all expired entries. Called periodically or on demand.
    pub fn evict_expired(&self) {
        self.entries
            .retain(|_, entry| entry.inserted_at.elapsed() <= self.ttl);
    }

    /// Number of entries currently in the cache (including possibly expired).
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the cache is empty.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_put_and_get() {
        let cache = WebCache::new(Duration::from_secs(60));
        cache.put("key1".to_string(), "value1".to_string());
        assert_eq!(cache.get("key1"), Some("value1".to_string()));
    }

    #[test]
    fn test_cache_miss() {
        let cache = WebCache::new(Duration::from_secs(60));
        assert_eq!(cache.get("nonexistent"), None);
    }

    #[test]
    fn test_expired_entry() {
        let cache = WebCache::new(Duration::from_millis(1));
        cache.put("key1".to_string(), "value1".to_string());
        std::thread::sleep(Duration::from_millis(10));
        assert_eq!(cache.get("key1"), None);
    }

    #[test]
    fn test_evict_expired() {
        let cache = WebCache::new(Duration::from_millis(1));
        cache.put("a".to_string(), "1".to_string());
        cache.put("b".to_string(), "2".to_string());
        std::thread::sleep(Duration::from_millis(10));
        cache.evict_expired();
        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_zero_ttl_disables_caching() {
        let cache = WebCache::new(Duration::ZERO);
        cache.put("key1".to_string(), "value1".to_string());
        assert_eq!(cache.get("key1"), None);
        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_overwrite() {
        let cache = WebCache::new(Duration::from_secs(60));
        cache.put("key1".to_string(), "old".to_string());
        cache.put("key1".to_string(), "new".to_string());
        assert_eq!(cache.get("key1"), Some("new".to_string()));
    }

    #[test]
    fn test_len() {
        let cache = WebCache::new(Duration::from_secs(60));
        assert_eq!(cache.len(), 0);
        cache.put("a".to_string(), "1".to_string());
        cache.put("b".to_string(), "2".to_string());
        assert_eq!(cache.len(), 2);
    }

    #[test]
    fn test_is_empty() {
        let cache = WebCache::new(Duration::from_secs(60));
        assert!(cache.is_empty());
        cache.put("a".to_string(), "1".to_string());
        assert!(!cache.is_empty());
    }
}