apex_sdk_evm/
cache.rs

1//! Caching layer for EVM queries
2//!
3//! This module provides:
4//! - In-memory LRU cache
5//! - Configurable TTL per cache type
6//! - Automatic cache invalidation
7//! - Cache statistics
8
9use std::collections::HashMap;
10use std::hash::Hash;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13use tokio::sync::RwLock;
14
15/// Cache entry with expiration
16#[derive(Clone)]
17struct CacheEntry<V> {
18    value: V,
19    inserted_at: Instant,
20    ttl: Duration,
21}
22
23impl<V> CacheEntry<V> {
24    fn new(value: V, ttl: Duration) -> Self {
25        Self {
26            value,
27            inserted_at: Instant::now(),
28            ttl,
29        }
30    }
31
32    fn is_expired(&self) -> bool {
33        self.inserted_at.elapsed() > self.ttl
34    }
35}
36
37/// Cache statistics
38#[derive(Debug, Clone, Default)]
39pub struct CacheStats {
40    /// Total number of cache hits
41    pub hits: u64,
42    /// Total number of cache misses
43    pub misses: u64,
44    /// Total number of cache sets
45    pub sets: u64,
46    /// Total number of cache evictions
47    pub evictions: u64,
48    /// Current number of entries
49    pub entries: usize,
50}
51
52impl CacheStats {
53    /// Calculate hit rate as a percentage
54    pub fn hit_rate(&self) -> f64 {
55        let total = self.hits + self.misses;
56        if total == 0 {
57            0.0
58        } else {
59            (self.hits as f64 / total as f64) * 100.0
60        }
61    }
62}
63
64/// Simple in-memory cache with TTL support
65pub struct Cache<K, V>
66where
67    K: Eq + Hash + Clone,
68    V: Clone,
69{
70    store: Arc<RwLock<HashMap<K, CacheEntry<V>>>>,
71    max_size: usize,
72    stats: Arc<RwLock<CacheStats>>,
73}
74
75impl<K, V> Cache<K, V>
76where
77    K: Eq + Hash + Clone,
78    V: Clone,
79{
80    /// Create a new cache with a maximum size
81    pub fn new(max_size: usize) -> Self {
82        Self {
83            store: Arc::new(RwLock::new(HashMap::new())),
84            max_size,
85            stats: Arc::new(RwLock::new(CacheStats::default())),
86        }
87    }
88
89    /// Get a value from the cache
90    pub async fn get(&self, key: &K) -> Option<V> {
91        let store = self.store.read().await;
92        let mut stats = self.stats.write().await;
93
94        if let Some(entry) = store.get(key) {
95            if !entry.is_expired() {
96                stats.hits += 1;
97                return Some(entry.value.clone());
98            }
99        }
100
101        stats.misses += 1;
102        None
103    }
104
105    /// Set a value in the cache with TTL
106    pub async fn set(&self, key: K, value: V, ttl: Duration) {
107        let mut store = self.store.write().await;
108        let mut stats = self.stats.write().await;
109
110        // Check if we need to evict entries
111        if store.len() >= self.max_size && !store.contains_key(&key) {
112            // Evict expired entries first
113            let expired_keys: Vec<K> = store
114                .iter()
115                .filter(|(_, entry)| entry.is_expired())
116                .map(|(k, _)| k.clone())
117                .collect();
118
119            for k in expired_keys {
120                store.remove(&k);
121                stats.evictions += 1;
122            }
123
124            // If still at capacity, remove oldest entry
125            if store.len() >= self.max_size {
126                if let Some(oldest_key) = store
127                    .iter()
128                    .min_by_key(|(_, entry)| entry.inserted_at)
129                    .map(|(k, _)| k.clone())
130                {
131                    store.remove(&oldest_key);
132                    stats.evictions += 1;
133                }
134            }
135        }
136
137        store.insert(key, CacheEntry::new(value, ttl));
138        stats.sets += 1;
139        stats.entries = store.len();
140    }
141
142    /// Remove a value from the cache
143    pub async fn remove(&self, key: &K) -> Option<V> {
144        let mut store = self.store.write().await;
145        let mut stats = self.stats.write().await;
146
147        if let Some(entry) = store.remove(key) {
148            stats.entries = store.len();
149            Some(entry.value)
150        } else {
151            None
152        }
153    }
154
155    /// Clear all entries from the cache
156    pub async fn clear(&self) {
157        let mut store = self.store.write().await;
158        let mut stats = self.stats.write().await;
159
160        store.clear();
161        stats.entries = 0;
162    }
163
164    /// Get cache statistics
165    pub async fn stats(&self) -> CacheStats {
166        self.stats.read().await.clone()
167    }
168
169    /// Clean up expired entries
170    pub async fn cleanup_expired(&self) {
171        let mut store = self.store.write().await;
172        let mut stats = self.stats.write().await;
173
174        let expired_keys: Vec<K> = store
175            .iter()
176            .filter(|(_, entry)| entry.is_expired())
177            .map(|(k, _)| k.clone())
178            .collect();
179
180        let count = expired_keys.len();
181        for k in expired_keys {
182            store.remove(&k);
183        }
184
185        if count > 0 {
186            tracing::debug!("Cleaned up {} expired cache entries", count);
187            stats.evictions += count as u64;
188            stats.entries = store.len();
189        }
190    }
191
192    /// Get the current number of entries
193    pub async fn len(&self) -> usize {
194        self.store.read().await.len()
195    }
196
197    /// Check if the cache is empty
198    pub async fn is_empty(&self) -> bool {
199        self.store.read().await.is_empty()
200    }
201}
202
203/// Cache configuration for different query types
204#[derive(Debug, Clone)]
205pub struct CacheConfig {
206    /// TTL for balance queries (default: 30 seconds)
207    pub balance_ttl_secs: u64,
208    /// TTL for transaction status queries (default: 5 minutes)
209    pub transaction_status_ttl_secs: u64,
210    /// TTL for block data (default: immutable, 1 hour)
211    pub block_data_ttl_secs: u64,
212    /// TTL for chain metadata (default: 1 hour)
213    pub chain_metadata_ttl_secs: u64,
214    /// Maximum cache size per type
215    pub max_cache_size: usize,
216    /// Cleanup interval in seconds
217    pub cleanup_interval_secs: u64,
218}
219
220impl Default for CacheConfig {
221    fn default() -> Self {
222        Self {
223            balance_ttl_secs: 30,
224            transaction_status_ttl_secs: 300,
225            block_data_ttl_secs: 3600,
226            chain_metadata_ttl_secs: 3600,
227            max_cache_size: 10000,
228            cleanup_interval_secs: 300,
229        }
230    }
231}
232
233/// Multi-tier cache for different types of EVM data
234pub struct EvmCache {
235    balance_cache: Cache<String, String>,
236    tx_status_cache: Cache<String, String>,
237    block_cache: Cache<u64, String>,
238    config: CacheConfig,
239}
240
241impl EvmCache {
242    /// Create a new EVM cache with default configuration
243    pub fn new() -> Self {
244        Self::with_config(CacheConfig::default())
245    }
246
247    /// Create a new EVM cache with custom configuration
248    pub fn with_config(config: CacheConfig) -> Self {
249        Self {
250            balance_cache: Cache::new(config.max_cache_size),
251            tx_status_cache: Cache::new(config.max_cache_size),
252            block_cache: Cache::new(config.max_cache_size / 10), // Smaller block cache
253            config,
254        }
255    }
256
257    /// Get balance from cache
258    pub async fn get_balance(&self, address: &str) -> Option<String> {
259        self.balance_cache.get(&address.to_string()).await
260    }
261
262    /// Set balance in cache
263    pub async fn set_balance(&self, address: &str, balance: String) {
264        let ttl = Duration::from_secs(self.config.balance_ttl_secs);
265        self.balance_cache
266            .set(address.to_string(), balance, ttl)
267            .await;
268    }
269
270    /// Get transaction status from cache
271    pub async fn get_tx_status(&self, tx_hash: &str) -> Option<String> {
272        self.tx_status_cache.get(&tx_hash.to_string()).await
273    }
274
275    /// Set transaction status in cache
276    pub async fn set_tx_status(&self, tx_hash: &str, status: String) {
277        let ttl = Duration::from_secs(self.config.transaction_status_ttl_secs);
278        self.tx_status_cache
279            .set(tx_hash.to_string(), status, ttl)
280            .await;
281    }
282
283    /// Get block data from cache
284    pub async fn get_block(&self, block_number: u64) -> Option<String> {
285        self.block_cache.get(&block_number).await
286    }
287
288    /// Set block data in cache
289    pub async fn set_block(&self, block_number: u64, data: String) {
290        let ttl = Duration::from_secs(self.config.block_data_ttl_secs);
291        self.block_cache.set(block_number, data, ttl).await;
292    }
293
294    /// Clear all caches
295    pub async fn clear_all(&self) {
296        self.balance_cache.clear().await;
297        self.tx_status_cache.clear().await;
298        self.block_cache.clear().await;
299        tracing::info!("Cleared all caches");
300    }
301
302    /// Get statistics for all caches
303    pub async fn stats(&self) -> HashMap<String, CacheStats> {
304        let mut stats = HashMap::new();
305        stats.insert("balance".to_string(), self.balance_cache.stats().await);
306        stats.insert("tx_status".to_string(), self.tx_status_cache.stats().await);
307        stats.insert("block".to_string(), self.block_cache.stats().await);
308        stats
309    }
310
311    /// Run cleanup on all caches
312    pub async fn cleanup(&self) {
313        self.balance_cache.cleanup_expired().await;
314        self.tx_status_cache.cleanup_expired().await;
315        self.block_cache.cleanup_expired().await;
316    }
317
318    /// Start automatic cache cleanup in the background
319    pub fn start_cleanup_task(self: Arc<Self>) {
320        let cache = self.clone();
321        let interval = Duration::from_secs(self.config.cleanup_interval_secs);
322        let interval_secs = self.config.cleanup_interval_secs;
323
324        tokio::spawn(async move {
325            loop {
326                tokio::time::sleep(interval).await;
327                cache.cleanup().await;
328            }
329        });
330
331        tracing::info!(
332            "Started cache cleanup task with interval: {}s",
333            interval_secs
334        );
335    }
336}
337
338impl Default for EvmCache {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[tokio::test]
349    async fn test_cache_basic_operations() {
350        let cache: Cache<String, String> = Cache::new(100);
351
352        // Set a value
353        cache
354            .set(
355                "key1".to_string(),
356                "value1".to_string(),
357                Duration::from_secs(60),
358            )
359            .await;
360
361        // Get the value
362        let value = cache.get(&"key1".to_string()).await;
363        assert_eq!(value, Some("value1".to_string()));
364
365        // Check stats
366        let stats = cache.stats().await;
367        assert_eq!(stats.hits, 1);
368        assert_eq!(stats.sets, 1);
369    }
370
371    #[tokio::test]
372    async fn test_cache_expiration() {
373        let cache: Cache<String, String> = Cache::new(100);
374
375        // Set a value with very short TTL
376        cache
377            .set(
378                "key1".to_string(),
379                "value1".to_string(),
380                Duration::from_millis(100),
381            )
382            .await;
383
384        // Should be available immediately
385        assert!(cache.get(&"key1".to_string()).await.is_some());
386
387        // Wait for expiration
388        tokio::time::sleep(Duration::from_millis(200)).await;
389
390        // Should be expired
391        assert!(cache.get(&"key1".to_string()).await.is_none());
392    }
393
394    #[tokio::test]
395    async fn test_cache_eviction() {
396        let cache: Cache<String, String> = Cache::new(2);
397
398        // Fill cache to capacity
399        cache
400            .set(
401                "key1".to_string(),
402                "value1".to_string(),
403                Duration::from_secs(60),
404            )
405            .await;
406        cache
407            .set(
408                "key2".to_string(),
409                "value2".to_string(),
410                Duration::from_secs(60),
411            )
412            .await;
413
414        // Add one more, should evict oldest
415        cache
416            .set(
417                "key3".to_string(),
418                "value3".to_string(),
419                Duration::from_secs(60),
420            )
421            .await;
422
423        let stats = cache.stats().await;
424        assert!(stats.evictions > 0);
425    }
426
427    #[tokio::test]
428    async fn test_evm_cache() {
429        let cache = EvmCache::new();
430
431        // Test balance cache
432        cache
433            .set_balance("0x123", "1000000000000000000".to_string())
434            .await;
435        let balance = cache.get_balance("0x123").await;
436        assert_eq!(balance, Some("1000000000000000000".to_string()));
437
438        // Test tx status cache
439        cache.set_tx_status("0xabc", "confirmed".to_string()).await;
440        let status = cache.get_tx_status("0xabc").await;
441        assert_eq!(status, Some("confirmed".to_string()));
442
443        // Check stats
444        let stats = cache.stats().await;
445        assert!(stats.contains_key("balance"));
446        assert!(stats.contains_key("tx_status"));
447    }
448
449    #[test]
450    fn test_cache_stats_hit_rate() {
451        let stats = CacheStats {
452            hits: 80,
453            misses: 20,
454            ..Default::default()
455        };
456
457        assert_eq!(stats.hit_rate(), 80.0);
458    }
459}