Skip to main content

goldrush_sdk/
cache.rs

1use crate::{Error, Result};
2use std::collections::HashMap;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5use tokio::sync::RwLock;
6use tracing::{debug, instrument};
7
8/// Cache entry with expiration time.
9#[derive(Debug, Clone)]
10pub struct CacheEntry<T> {
11    pub value: T,
12    pub expires_at: Instant,
13    pub created_at: Instant,
14}
15
16impl<T> CacheEntry<T> {
17    pub fn new(value: T, ttl: Duration) -> Self {
18        let now = Instant::now();
19        Self {
20            value,
21            expires_at: now + ttl,
22            created_at: now,
23        }
24    }
25    
26    pub fn is_expired(&self) -> bool {
27        Instant::now() > self.expires_at
28    }
29    
30    pub fn age(&self) -> Duration {
31        Instant::now().duration_since(self.created_at)
32    }
33}
34
35/// In-memory cache with TTL and size limits.
36#[derive(Debug)]
37pub struct MemoryCache<T> {
38    store: Arc<RwLock<HashMap<String, CacheEntry<T>>>>,
39    default_ttl: Duration,
40    max_entries: usize,
41}
42
43impl<T: Clone + Send + Sync + 'static> MemoryCache<T> {
44    pub fn new(default_ttl: Duration, max_entries: usize) -> Self {
45        Self {
46            store: Arc::new(RwLock::new(HashMap::new())),
47            default_ttl,
48            max_entries,
49        }
50    }
51
52    /// Get a value from the cache.
53    #[instrument(skip(self), fields(key = %key))]
54    pub async fn get(&self, key: &str) -> Option<T> {
55        let mut store = self.store.write().await;
56        
57        if let Some(entry) = store.get(key) {
58            if entry.is_expired() {
59                debug!("Cache entry expired, removing");
60                store.remove(key);
61                None
62            } else {
63                debug!(age_ms = %entry.age().as_millis(), "Cache hit");
64                Some(entry.value.clone())
65            }
66        } else {
67            debug!("Cache miss");
68            None
69        }
70    }
71
72    /// Set a value in the cache with default TTL.
73    #[instrument(skip(self, value), fields(key = %key))]
74    pub async fn set(&self, key: String, value: T) {
75        self.set_with_ttl(key, value, self.default_ttl).await;
76    }
77
78    /// Set a value in the cache with custom TTL.
79    #[instrument(skip(self, value), fields(key = %key, ttl_secs = %ttl.as_secs()))]
80    pub async fn set_with_ttl(&self, key: String, value: T, ttl: Duration) {
81        let mut store = self.store.write().await;
82        
83        // Evict expired entries if we're at capacity
84        if store.len() >= self.max_entries {
85            self.evict_expired_entries(&mut store).await;
86            
87            // If still at capacity, remove oldest entry
88            if store.len() >= self.max_entries {
89                if let Some(oldest_key) = self.find_oldest_entry(&store).await {
90                    debug!(evicted_key = %oldest_key, "Evicting oldest cache entry");
91                    store.remove(&oldest_key);
92                }
93            }
94        }
95        
96        let entry = CacheEntry::new(value, ttl);
97        store.insert(key, entry);
98        debug!("Value cached successfully");
99    }
100
101    /// Remove a value from the cache.
102    #[instrument(skip(self), fields(key = %key))]
103    pub async fn remove(&self, key: &str) -> Option<T> {
104        let mut store = self.store.write().await;
105        store.remove(key).map(|entry| {
106            debug!("Cache entry removed");
107            entry.value
108        })
109    }
110
111    /// Clear all entries from the cache.
112    #[instrument(skip(self))]
113    pub async fn clear(&self) {
114        let mut store = self.store.write().await;
115        let count = store.len();
116        store.clear();
117        debug!(cleared_entries = %count, "Cache cleared");
118    }
119
120    /// Get cache statistics.
121    pub async fn stats(&self) -> CacheStats {
122        let store = self.store.read().await;
123        let total_entries = store.len();
124        let expired_entries = store.values().filter(|entry| entry.is_expired()).count();
125        
126        CacheStats {
127            total_entries,
128            expired_entries,
129            active_entries: total_entries - expired_entries,
130            max_entries: self.max_entries,
131        }
132    }
133
134    /// Remove all expired entries from the cache.
135    async fn evict_expired_entries(&self, store: &mut HashMap<String, CacheEntry<T>>) {
136        let expired_keys: Vec<String> = store
137            .iter()
138            .filter_map(|(key, entry)| {
139                if entry.is_expired() {
140                    Some(key.clone())
141                } else {
142                    None
143                }
144            })
145            .collect();
146        
147        let expired_count = expired_keys.len();
148        
149        for key in expired_keys {
150            store.remove(&key);
151        }
152        
153        if expired_count > 0 {
154            debug!(expired_count = %expired_count, "Evicted expired cache entries");
155        }
156    }
157
158    /// Find the oldest entry in the cache for LRU eviction.
159    async fn find_oldest_entry(&self, store: &HashMap<String, CacheEntry<T>>) -> Option<String> {
160        store
161            .iter()
162            .min_by_key(|(_, entry)| entry.created_at)
163            .map(|(key, _)| key.clone())
164    }
165}
166
167/// Cache statistics.
168#[derive(Debug, Clone)]
169pub struct CacheStats {
170    pub total_entries: usize,
171    pub expired_entries: usize,
172    pub active_entries: usize,
173    pub max_entries: usize,
174}
175
176/// Cache configuration for different endpoint types.
177#[derive(Debug, Clone)]
178pub struct CacheConfig {
179    /// TTL for balance data (relatively static)
180    pub balance_ttl: Duration,
181    /// TTL for transaction data (immutable once confirmed)
182    pub transaction_ttl: Duration,
183    /// TTL for NFT metadata (mostly static)
184    pub nft_metadata_ttl: Duration,
185    /// TTL for NFT collections (mostly static)
186    pub nft_collection_ttl: Duration,
187    /// Maximum number of cached entries
188    pub max_entries: usize,
189    /// Enable caching
190    pub enabled: bool,
191}
192
193impl Default for CacheConfig {
194    fn default() -> Self {
195        Self {
196            balance_ttl: Duration::from_secs(30),      // 30 seconds for balances
197            transaction_ttl: Duration::from_secs(300), // 5 minutes for transactions
198            nft_metadata_ttl: Duration::from_secs(3600), // 1 hour for NFT metadata
199            nft_collection_ttl: Duration::from_secs(3600), // 1 hour for NFT collections
200            max_entries: 1000,
201            enabled: true,
202        }
203    }
204}
205
206/// Generate cache keys for different types of requests.
207pub fn cache_key_for_balances(chain_name: &str, address: &str, options: &str) -> String {
208    format!("balances:{}:{}:{}", chain_name, address, options)
209}
210
211pub fn cache_key_for_transactions(chain_name: &str, address: &str, options: &str) -> String {
212    format!("transactions:{}:{}:{}", chain_name, address, options)
213}
214
215pub fn cache_key_for_transaction(chain_name: &str, tx_hash: &str) -> String {
216    format!("transaction:{}:{}", chain_name, tx_hash)
217}
218
219pub fn cache_key_for_nfts(chain_name: &str, address: &str, options: &str) -> String {
220    format!("nfts:{}:{}:{}", chain_name, address, options)
221}
222
223pub fn cache_key_for_nft_metadata(chain_name: &str, address: &str, token_id: &str) -> String {
224    format!("nft_metadata:{}:{}:{}", chain_name, address, token_id)
225}