Skip to main content

rdap_cache/
lib.rs

1//! In-memory response cache with TTL expiry.
2//!
3//! Uses [`DashMap`] for lock-free concurrent reads.
4//! Entries are evicted lazily (on read) and eagerly (on `clear()`).
5
6#![forbid(unsafe_code)]
7
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use dashmap::DashMap;
12use serde_json::Value;
13
14// ── Cache entry ───────────────────────────────────────────────────────────────
15
16#[derive(Debug, Clone)]
17struct Entry {
18    value: Value,
19    inserted_at: Instant,
20    ttl: Duration,
21}
22
23impl Entry {
24    fn is_expired(&self) -> bool {
25        self.inserted_at.elapsed() > self.ttl
26    }
27}
28
29// ── Cache configuration ───────────────────────────────────────────────────────
30
31/// Configuration for the response cache.
32#[derive(Debug, Clone)]
33pub struct CacheConfig {
34    /// Default TTL for cached entries.
35    pub ttl: Duration,
36    /// Maximum number of entries to keep in the cache.
37    pub max_entries: usize,
38}
39
40impl Default for CacheConfig {
41    fn default() -> Self {
42        Self {
43            ttl: Duration::from_secs(300), // 5 minutes
44            max_entries: 1_000,
45        }
46    }
47}
48
49// ── Cache ─────────────────────────────────────────────────────────────────────
50
51/// Thread-safe in-memory RDAP response cache.
52///
53/// Cache keys are the full query URL strings.
54#[derive(Debug, Clone)]
55pub struct MemoryCache {
56    store: Arc<DashMap<String, Entry>>,
57    config: CacheConfig,
58}
59
60impl MemoryCache {
61    /// Creates a cache with default configuration.
62    pub fn new() -> Self {
63        Self::with_config(CacheConfig::default())
64    }
65
66    /// Creates a cache with custom configuration.
67    pub fn with_config(config: CacheConfig) -> Self {
68        Self {
69            store: Arc::new(DashMap::new()),
70            config,
71        }
72    }
73
74    /// Retrieves a cached value if it exists and has not expired.
75    pub fn get(&self, key: &str) -> Option<Value> {
76        let entry = self.store.get(key)?;
77        if entry.is_expired() {
78            drop(entry);
79            self.store.remove(key);
80            return None;
81        }
82        Some(entry.value.clone())
83    }
84
85    /// Inserts a value with the default TTL.
86    pub fn set(&self, key: String, value: Value) {
87        self.set_with_ttl(key, value, self.config.ttl);
88    }
89
90    /// Inserts a value with a custom TTL.
91    pub fn set_with_ttl(&self, key: String, value: Value, ttl: Duration) {
92        if self.store.len() >= self.config.max_entries {
93            self.evict_oldest();
94        }
95
96        self.store.insert(
97            key,
98            Entry {
99                value,
100                inserted_at: Instant::now(),
101                ttl,
102            },
103        );
104    }
105
106    /// Removes all entries from the cache.
107    pub fn clear(&self) {
108        self.store.clear();
109    }
110
111    /// Returns the number of entries currently in the cache.
112    pub fn len(&self) -> usize {
113        self.store.len()
114    }
115
116    /// Returns `true` if the cache is empty.
117    pub fn is_empty(&self) -> bool {
118        self.store.is_empty()
119    }
120
121    /// Removes all expired entries.
122    pub fn evict_expired(&self) {
123        self.store.retain(|_, entry| !entry.is_expired());
124    }
125
126    fn evict_oldest(&self) {
127        let oldest_key = self
128            .store
129            .iter()
130            .min_by_key(|entry| entry.value().inserted_at)
131            .map(|entry| entry.key().clone());
132
133        if let Some(key) = oldest_key {
134            self.store.remove(&key);
135        }
136    }
137}
138
139impl Default for MemoryCache {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145// ── Tests ─────────────────────────────────────────────────────────────────────
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use serde_json::json;
151
152    #[test]
153    fn basic_get_set() {
154        let cache = MemoryCache::new();
155        assert!(cache.get("https://rdap.example.com/domain/foo").is_none());
156
157        cache.set(
158            "https://rdap.example.com/domain/foo".to_string(),
159            json!({ "ldhName": "foo.example" }),
160        );
161
162        assert!(cache.get("https://rdap.example.com/domain/foo").is_some());
163    }
164
165    #[test]
166    fn expired_entry_is_evicted() {
167        let cache = MemoryCache::with_config(CacheConfig {
168            ttl: Duration::from_millis(1),
169            max_entries: 100,
170        });
171
172        cache.set("key".to_string(), json!({}));
173        std::thread::sleep(Duration::from_millis(5));
174        assert!(cache.get("key").is_none());
175    }
176
177    #[test]
178    fn max_entries_evicts_oldest() {
179        let cache = MemoryCache::with_config(CacheConfig {
180            ttl: Duration::from_secs(60),
181            max_entries: 2,
182        });
183
184        cache.set("a".to_string(), json!(1));
185        cache.set("b".to_string(), json!(2));
186        cache.set("c".to_string(), json!(3));
187
188        assert_eq!(cache.len(), 2);
189        assert!(cache.get("a").is_none());
190    }
191
192    #[test]
193    fn clear_empties_cache() {
194        let cache = MemoryCache::new();
195        cache.set("x".to_string(), json!({}));
196        cache.clear();
197        assert!(cache.is_empty());
198    }
199}