kaccy_bitcoin/
cache.rs

1//! Caching layer for Bitcoin data to reduce RPC calls
2
3use bitcoin::{BlockHash, Txid};
4use bitcoincore_rpc::json::GetRawTransactionResult;
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::RwLock;
9use tracing::{debug, trace};
10
11use crate::utxo::Utxo;
12
13/// Configuration for the cache
14#[derive(Debug, Clone)]
15pub struct CacheConfig {
16    /// Time-to-live for transaction cache entries
17    pub transaction_ttl: Duration,
18    /// Time-to-live for UTXO cache entries
19    pub utxo_ttl: Duration,
20    /// Time-to-live for block header cache entries
21    pub block_header_ttl: Duration,
22    /// Maximum number of transactions to cache
23    pub max_transactions: usize,
24    /// Maximum number of UTXOs to cache
25    pub max_utxos: usize,
26    /// Maximum number of block headers to cache
27    pub max_block_headers: usize,
28}
29
30impl Default for CacheConfig {
31    fn default() -> Self {
32        Self {
33            transaction_ttl: Duration::from_secs(300),  // 5 minutes
34            utxo_ttl: Duration::from_secs(60),          // 1 minute
35            block_header_ttl: Duration::from_secs(600), // 10 minutes
36            max_transactions: 1000,
37            max_utxos: 5000,
38            max_block_headers: 500,
39        }
40    }
41}
42
43/// A cached entry with expiration time
44#[derive(Debug, Clone)]
45struct CachedEntry<T> {
46    value: T,
47    expires_at: Instant,
48}
49
50impl<T> CachedEntry<T> {
51    fn new(value: T, ttl: Duration) -> Self {
52        Self {
53            value,
54            expires_at: Instant::now() + ttl,
55        }
56    }
57
58    fn is_expired(&self) -> bool {
59        Instant::now() > self.expires_at
60    }
61}
62
63/// Transaction cache
64pub struct TransactionCache {
65    cache: Arc<RwLock<HashMap<Txid, CachedEntry<GetRawTransactionResult>>>>,
66    config: CacheConfig,
67}
68
69impl TransactionCache {
70    /// Create a new transaction cache
71    pub fn new(config: CacheConfig) -> Self {
72        Self {
73            cache: Arc::new(RwLock::new(HashMap::new())),
74            config,
75        }
76    }
77
78    /// Get a transaction from cache
79    pub async fn get(&self, txid: &Txid) -> Option<GetRawTransactionResult> {
80        let cache = self.cache.read().await;
81        if let Some(entry) = cache.get(txid) {
82            if !entry.is_expired() {
83                trace!(txid = %txid, "Transaction cache hit");
84                return Some(entry.value.clone());
85            }
86        }
87        trace!(txid = %txid, "Transaction cache miss");
88        None
89    }
90
91    /// Insert a transaction into cache
92    pub async fn insert(&self, txid: Txid, tx: GetRawTransactionResult) {
93        let mut cache = self.cache.write().await;
94
95        // Evict old entries if we're at capacity
96        if cache.len() >= self.config.max_transactions {
97            self.evict_expired(&mut cache);
98
99            // If still at capacity, remove oldest entry
100            if cache.len() >= self.config.max_transactions {
101                if let Some(oldest_key) = cache.keys().next().copied() {
102                    cache.remove(&oldest_key);
103                }
104            }
105        }
106
107        cache.insert(txid, CachedEntry::new(tx, self.config.transaction_ttl));
108        trace!(txid = %txid, "Transaction cached");
109    }
110
111    /// Invalidate a specific transaction
112    pub async fn invalidate(&self, txid: &Txid) {
113        let mut cache = self.cache.write().await;
114        cache.remove(txid);
115        debug!(txid = %txid, "Transaction cache invalidated");
116    }
117
118    /// Clear all cached transactions
119    pub async fn clear(&self) {
120        let mut cache = self.cache.write().await;
121        cache.clear();
122        debug!("Transaction cache cleared");
123    }
124
125    /// Evict expired entries
126    fn evict_expired(&self, cache: &mut HashMap<Txid, CachedEntry<GetRawTransactionResult>>) {
127        cache.retain(|_, entry| !entry.is_expired());
128    }
129
130    /// Get cache statistics
131    pub async fn stats(&self) -> CacheStats {
132        let cache = self.cache.read().await;
133        let expired_count = cache.values().filter(|e| e.is_expired()).count();
134
135        CacheStats {
136            total_entries: cache.len(),
137            expired_entries: expired_count,
138            active_entries: cache.len() - expired_count,
139            max_entries: self.config.max_transactions,
140        }
141    }
142}
143
144/// UTXO cache
145pub struct UtxoCache {
146    cache: Arc<RwLock<HashMap<String, CachedEntry<Vec<Utxo>>>>>,
147    config: CacheConfig,
148}
149
150impl UtxoCache {
151    /// Create a new UTXO cache
152    pub fn new(config: CacheConfig) -> Self {
153        Self {
154            cache: Arc::new(RwLock::new(HashMap::new())),
155            config,
156        }
157    }
158
159    /// Get UTXOs for an address from cache
160    pub async fn get(&self, address: &str) -> Option<Vec<Utxo>> {
161        let cache = self.cache.read().await;
162        if let Some(entry) = cache.get(address) {
163            if !entry.is_expired() {
164                trace!(address = address, "UTXO cache hit");
165                return Some(entry.value.clone());
166            }
167        }
168        trace!(address = address, "UTXO cache miss");
169        None
170    }
171
172    /// Insert UTXOs for an address into cache
173    pub async fn insert(&self, address: String, utxos: Vec<Utxo>) {
174        let mut cache = self.cache.write().await;
175
176        // Evict old entries if we're at capacity
177        if cache.len() >= self.config.max_utxos {
178            self.evict_expired(&mut cache);
179
180            // If still at capacity, remove oldest entry
181            if cache.len() >= self.config.max_utxos {
182                if let Some(oldest_key) = cache.keys().next().cloned() {
183                    cache.remove(&oldest_key);
184                }
185            }
186        }
187
188        cache.insert(
189            address.clone(),
190            CachedEntry::new(utxos, self.config.utxo_ttl),
191        );
192        trace!(address = address, "UTXOs cached");
193    }
194
195    /// Invalidate UTXOs for a specific address
196    pub async fn invalidate(&self, address: &str) {
197        let mut cache = self.cache.write().await;
198        cache.remove(address);
199        debug!(address = address, "UTXO cache invalidated");
200    }
201
202    /// Invalidate all UTXOs (e.g., on new block)
203    pub async fn invalidate_all(&self) {
204        let mut cache = self.cache.write().await;
205        cache.clear();
206        debug!("All UTXO cache invalidated");
207    }
208
209    /// Clear all cached UTXOs
210    pub async fn clear(&self) {
211        let mut cache = self.cache.write().await;
212        cache.clear();
213        debug!("UTXO cache cleared");
214    }
215
216    /// Evict expired entries
217    fn evict_expired(&self, cache: &mut HashMap<String, CachedEntry<Vec<Utxo>>>) {
218        cache.retain(|_, entry| !entry.is_expired());
219    }
220
221    /// Get cache statistics
222    pub async fn stats(&self) -> CacheStats {
223        let cache = self.cache.read().await;
224        let expired_count = cache.values().filter(|e| e.is_expired()).count();
225
226        CacheStats {
227            total_entries: cache.len(),
228            expired_entries: expired_count,
229            active_entries: cache.len() - expired_count,
230            max_entries: self.config.max_utxos,
231        }
232    }
233}
234
235/// Block header cache
236#[derive(Debug, Clone)]
237pub struct BlockHeader {
238    pub hash: BlockHash,
239    pub height: u64,
240    pub time: u64,
241    pub previous_block_hash: Option<BlockHash>,
242}
243
244pub struct BlockHeaderCache {
245    by_hash: Arc<RwLock<HashMap<BlockHash, CachedEntry<BlockHeader>>>>,
246    by_height: Arc<RwLock<HashMap<u64, CachedEntry<BlockHeader>>>>,
247    config: CacheConfig,
248}
249
250impl BlockHeaderCache {
251    /// Create a new block header cache
252    pub fn new(config: CacheConfig) -> Self {
253        Self {
254            by_hash: Arc::new(RwLock::new(HashMap::new())),
255            by_height: Arc::new(RwLock::new(HashMap::new())),
256            config,
257        }
258    }
259
260    /// Get a block header by hash
261    pub async fn get_by_hash(&self, hash: &BlockHash) -> Option<BlockHeader> {
262        let cache = self.by_hash.read().await;
263        if let Some(entry) = cache.get(hash) {
264            if !entry.is_expired() {
265                trace!(hash = %hash, "Block header cache hit (by hash)");
266                return Some(entry.value.clone());
267            }
268        }
269        trace!(hash = %hash, "Block header cache miss (by hash)");
270        None
271    }
272
273    /// Get a block header by height
274    pub async fn get_by_height(&self, height: u64) -> Option<BlockHeader> {
275        let cache = self.by_height.read().await;
276        if let Some(entry) = cache.get(&height) {
277            if !entry.is_expired() {
278                trace!(height = height, "Block header cache hit (by height)");
279                return Some(entry.value.clone());
280            }
281        }
282        trace!(height = height, "Block header cache miss (by height)");
283        None
284    }
285
286    /// Insert a block header into cache
287    pub async fn insert(&self, header: BlockHeader) {
288        let mut by_hash = self.by_hash.write().await;
289        let mut by_height = self.by_height.write().await;
290
291        // Evict old entries if we're at capacity
292        if by_hash.len() >= self.config.max_block_headers {
293            Self::evict_expired(&mut by_hash);
294            Self::evict_expired_height(&mut by_height);
295
296            // If still at capacity, remove oldest entry
297            if by_hash.len() >= self.config.max_block_headers {
298                if let Some(oldest_key) = by_hash.keys().next().copied() {
299                    by_hash.remove(&oldest_key);
300                }
301            }
302            if by_height.len() >= self.config.max_block_headers {
303                if let Some(oldest_key) = by_height.keys().next().copied() {
304                    by_height.remove(&oldest_key);
305                }
306            }
307        }
308
309        let hash = header.hash;
310        let height = header.height;
311
312        by_hash.insert(
313            hash,
314            CachedEntry::new(header.clone(), self.config.block_header_ttl),
315        );
316        by_height.insert(
317            height,
318            CachedEntry::new(header, self.config.block_header_ttl),
319        );
320
321        trace!(hash = %hash, height = height, "Block header cached");
322    }
323
324    /// Invalidate a specific block header
325    pub async fn invalidate(&self, hash: &BlockHash, height: u64) {
326        let mut by_hash = self.by_hash.write().await;
327        let mut by_height = self.by_height.write().await;
328
329        by_hash.remove(hash);
330        by_height.remove(&height);
331
332        debug!(hash = %hash, height = height, "Block header cache invalidated");
333    }
334
335    /// Clear all cached block headers
336    pub async fn clear(&self) {
337        let mut by_hash = self.by_hash.write().await;
338        let mut by_height = self.by_height.write().await;
339
340        by_hash.clear();
341        by_height.clear();
342
343        debug!("Block header cache cleared");
344    }
345
346    /// Evict expired entries
347    fn evict_expired(cache: &mut HashMap<BlockHash, CachedEntry<BlockHeader>>) {
348        cache.retain(|_, entry| !entry.is_expired());
349    }
350
351    /// Evict expired entries (by height)
352    fn evict_expired_height(cache: &mut HashMap<u64, CachedEntry<BlockHeader>>) {
353        cache.retain(|_, entry| !entry.is_expired());
354    }
355
356    /// Get cache statistics
357    pub async fn stats(&self) -> CacheStats {
358        let by_hash = self.by_hash.read().await;
359        let expired_count = by_hash.values().filter(|e| e.is_expired()).count();
360
361        CacheStats {
362            total_entries: by_hash.len(),
363            expired_entries: expired_count,
364            active_entries: by_hash.len() - expired_count,
365            max_entries: self.config.max_block_headers,
366        }
367    }
368}
369
370/// Cache statistics
371#[derive(Debug, Clone)]
372pub struct CacheStats {
373    pub total_entries: usize,
374    pub expired_entries: usize,
375    pub active_entries: usize,
376    pub max_entries: usize,
377}
378
379impl CacheStats {
380    /// Calculate hit rate (requires tracking hits/misses externally)
381    pub fn utilization(&self) -> f64 {
382        if self.max_entries == 0 {
383            0.0
384        } else {
385            self.total_entries as f64 / self.max_entries as f64
386        }
387    }
388}
389
390/// Unified cache manager
391pub struct CacheManager {
392    pub transactions: TransactionCache,
393    pub utxos: UtxoCache,
394    pub block_headers: BlockHeaderCache,
395    #[allow(dead_code)]
396    config: CacheConfig,
397}
398
399impl CacheManager {
400    /// Create a new cache manager
401    pub fn new(config: CacheConfig) -> Self {
402        Self {
403            transactions: TransactionCache::new(config.clone()),
404            utxos: UtxoCache::new(config.clone()),
405            block_headers: BlockHeaderCache::new(config.clone()),
406            config,
407        }
408    }
409
410    /// Create with default configuration
411    pub fn with_defaults() -> Self {
412        Self::new(CacheConfig::default())
413    }
414
415    /// Clear all caches
416    pub async fn clear_all(&self) {
417        self.transactions.clear().await;
418        self.utxos.clear().await;
419        self.block_headers.clear().await;
420        debug!("All caches cleared");
421    }
422
423    /// Get overall cache statistics
424    pub async fn overall_stats(&self) -> OverallCacheStats {
425        OverallCacheStats {
426            transaction_stats: self.transactions.stats().await,
427            utxo_stats: self.utxos.stats().await,
428            block_header_stats: self.block_headers.stats().await,
429        }
430    }
431}
432
433/// Overall cache statistics
434#[derive(Debug, Clone)]
435pub struct OverallCacheStats {
436    pub transaction_stats: CacheStats,
437    pub utxo_stats: CacheStats,
438    pub block_header_stats: CacheStats,
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use bitcoin::hashes::Hash;
445
446    #[tokio::test]
447    async fn test_cache_config_defaults() {
448        let config = CacheConfig::default();
449        assert!(config.transaction_ttl.as_secs() > 0);
450        assert!(config.max_transactions > 0);
451    }
452
453    #[tokio::test]
454    async fn test_transaction_cache() {
455        let config = CacheConfig {
456            transaction_ttl: Duration::from_secs(1),
457            max_transactions: 2,
458            ..Default::default()
459        };
460
461        let cache = TransactionCache::new(config);
462        let txid = Txid::all_zeros();
463
464        // Initially empty
465        assert!(cache.get(&txid).await.is_none());
466
467        // Insert and retrieve (would need a proper GetRawTransactionResult to test fully)
468        // Skipping full test as it requires complex setup
469    }
470
471    #[tokio::test]
472    async fn test_utxo_cache() {
473        let config = CacheConfig {
474            utxo_ttl: Duration::from_secs(1),
475            max_utxos: 2,
476            ..Default::default()
477        };
478
479        let cache = UtxoCache::new(config);
480        let address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
481
482        // Initially empty
483        assert!(cache.get(address).await.is_none());
484
485        // Insert and retrieve
486        let utxos = vec![];
487        cache.insert(address.to_string(), utxos.clone()).await;
488        assert_eq!(cache.get(address).await, Some(utxos));
489
490        // Invalidate
491        cache.invalidate(address).await;
492        assert!(cache.get(address).await.is_none());
493    }
494
495    #[tokio::test]
496    async fn test_block_header_cache() {
497        let config = CacheConfig {
498            block_header_ttl: Duration::from_secs(1),
499            max_block_headers: 2,
500            ..Default::default()
501        };
502
503        let cache = BlockHeaderCache::new(config);
504        let hash = BlockHash::all_zeros();
505        let header = BlockHeader {
506            hash,
507            height: 100,
508            time: 1234567890,
509            previous_block_hash: None,
510        };
511
512        // Initially empty
513        assert!(cache.get_by_hash(&hash).await.is_none());
514        assert!(cache.get_by_height(100).await.is_none());
515
516        // Insert and retrieve
517        cache.insert(header.clone()).await;
518        assert!(cache.get_by_hash(&hash).await.is_some());
519        assert!(cache.get_by_height(100).await.is_some());
520
521        // Invalidate
522        cache.invalidate(&hash, 100).await;
523        assert!(cache.get_by_hash(&hash).await.is_none());
524        assert!(cache.get_by_height(100).await.is_none());
525    }
526
527    #[tokio::test]
528    async fn test_cache_manager() {
529        let manager = CacheManager::with_defaults();
530        manager.clear_all().await;
531
532        let stats = manager.overall_stats().await;
533        assert_eq!(stats.transaction_stats.total_entries, 0);
534        assert_eq!(stats.utxo_stats.total_entries, 0);
535        assert_eq!(stats.block_header_stats.total_entries, 0);
536    }
537
538    #[test]
539    fn test_cache_stats_utilization() {
540        let stats = CacheStats {
541            total_entries: 50,
542            expired_entries: 10,
543            active_entries: 40,
544            max_entries: 100,
545        };
546
547        assert_eq!(stats.utilization(), 0.5);
548    }
549}