Skip to main content

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    /// Block hash
239    pub hash: BlockHash,
240    /// Block height in the chain
241    pub height: u64,
242    /// Block timestamp (Unix epoch seconds)
243    pub time: u64,
244    /// Hash of the previous block
245    pub previous_block_hash: Option<BlockHash>,
246}
247
248/// Cache for block header data indexed by hash and height
249pub struct BlockHeaderCache {
250    by_hash: Arc<RwLock<HashMap<BlockHash, CachedEntry<BlockHeader>>>>,
251    by_height: Arc<RwLock<HashMap<u64, CachedEntry<BlockHeader>>>>,
252    config: CacheConfig,
253}
254
255impl BlockHeaderCache {
256    /// Create a new block header cache
257    pub fn new(config: CacheConfig) -> Self {
258        Self {
259            by_hash: Arc::new(RwLock::new(HashMap::new())),
260            by_height: Arc::new(RwLock::new(HashMap::new())),
261            config,
262        }
263    }
264
265    /// Get a block header by hash
266    pub async fn get_by_hash(&self, hash: &BlockHash) -> Option<BlockHeader> {
267        let cache = self.by_hash.read().await;
268        if let Some(entry) = cache.get(hash) {
269            if !entry.is_expired() {
270                trace!(hash = %hash, "Block header cache hit (by hash)");
271                return Some(entry.value.clone());
272            }
273        }
274        trace!(hash = %hash, "Block header cache miss (by hash)");
275        None
276    }
277
278    /// Get a block header by height
279    pub async fn get_by_height(&self, height: u64) -> Option<BlockHeader> {
280        let cache = self.by_height.read().await;
281        if let Some(entry) = cache.get(&height) {
282            if !entry.is_expired() {
283                trace!(height = height, "Block header cache hit (by height)");
284                return Some(entry.value.clone());
285            }
286        }
287        trace!(height = height, "Block header cache miss (by height)");
288        None
289    }
290
291    /// Insert a block header into cache
292    pub async fn insert(&self, header: BlockHeader) {
293        let mut by_hash = self.by_hash.write().await;
294        let mut by_height = self.by_height.write().await;
295
296        // Evict old entries if we're at capacity
297        if by_hash.len() >= self.config.max_block_headers {
298            Self::evict_expired(&mut by_hash);
299            Self::evict_expired_height(&mut by_height);
300
301            // If still at capacity, remove oldest entry
302            if by_hash.len() >= self.config.max_block_headers {
303                if let Some(oldest_key) = by_hash.keys().next().copied() {
304                    by_hash.remove(&oldest_key);
305                }
306            }
307            if by_height.len() >= self.config.max_block_headers {
308                if let Some(oldest_key) = by_height.keys().next().copied() {
309                    by_height.remove(&oldest_key);
310                }
311            }
312        }
313
314        let hash = header.hash;
315        let height = header.height;
316
317        by_hash.insert(
318            hash,
319            CachedEntry::new(header.clone(), self.config.block_header_ttl),
320        );
321        by_height.insert(
322            height,
323            CachedEntry::new(header, self.config.block_header_ttl),
324        );
325
326        trace!(hash = %hash, height = height, "Block header cached");
327    }
328
329    /// Invalidate a specific block header
330    pub async fn invalidate(&self, hash: &BlockHash, height: u64) {
331        let mut by_hash = self.by_hash.write().await;
332        let mut by_height = self.by_height.write().await;
333
334        by_hash.remove(hash);
335        by_height.remove(&height);
336
337        debug!(hash = %hash, height = height, "Block header cache invalidated");
338    }
339
340    /// Clear all cached block headers
341    pub async fn clear(&self) {
342        let mut by_hash = self.by_hash.write().await;
343        let mut by_height = self.by_height.write().await;
344
345        by_hash.clear();
346        by_height.clear();
347
348        debug!("Block header cache cleared");
349    }
350
351    /// Evict expired entries
352    fn evict_expired(cache: &mut HashMap<BlockHash, CachedEntry<BlockHeader>>) {
353        cache.retain(|_, entry| !entry.is_expired());
354    }
355
356    /// Evict expired entries (by height)
357    fn evict_expired_height(cache: &mut HashMap<u64, CachedEntry<BlockHeader>>) {
358        cache.retain(|_, entry| !entry.is_expired());
359    }
360
361    /// Get cache statistics
362    pub async fn stats(&self) -> CacheStats {
363        let by_hash = self.by_hash.read().await;
364        let expired_count = by_hash.values().filter(|e| e.is_expired()).count();
365
366        CacheStats {
367            total_entries: by_hash.len(),
368            expired_entries: expired_count,
369            active_entries: by_hash.len() - expired_count,
370            max_entries: self.config.max_block_headers,
371        }
372    }
373}
374
375/// Cache statistics
376#[derive(Debug, Clone)]
377pub struct CacheStats {
378    /// Total number of entries (active + expired)
379    pub total_entries: usize,
380    /// Number of entries that have expired but not yet been evicted
381    pub expired_entries: usize,
382    /// Number of non-expired entries
383    pub active_entries: usize,
384    /// Maximum allowed entries
385    pub max_entries: usize,
386}
387
388impl CacheStats {
389    /// Calculate hit rate (requires tracking hits/misses externally)
390    pub fn utilization(&self) -> f64 {
391        if self.max_entries == 0 {
392            0.0
393        } else {
394            self.total_entries as f64 / self.max_entries as f64
395        }
396    }
397}
398
399/// Unified cache manager
400pub struct CacheManager {
401    /// Transaction data cache
402    pub transactions: TransactionCache,
403    /// UTXO data cache
404    pub utxos: UtxoCache,
405    /// Block header cache
406    pub block_headers: BlockHeaderCache,
407    #[allow(dead_code)]
408    config: CacheConfig,
409}
410
411impl CacheManager {
412    /// Create a new cache manager
413    pub fn new(config: CacheConfig) -> Self {
414        Self {
415            transactions: TransactionCache::new(config.clone()),
416            utxos: UtxoCache::new(config.clone()),
417            block_headers: BlockHeaderCache::new(config.clone()),
418            config,
419        }
420    }
421
422    /// Create with default configuration
423    pub fn with_defaults() -> Self {
424        Self::new(CacheConfig::default())
425    }
426
427    /// Clear all caches
428    pub async fn clear_all(&self) {
429        self.transactions.clear().await;
430        self.utxos.clear().await;
431        self.block_headers.clear().await;
432        debug!("All caches cleared");
433    }
434
435    /// Get overall cache statistics
436    pub async fn overall_stats(&self) -> OverallCacheStats {
437        OverallCacheStats {
438            transaction_stats: self.transactions.stats().await,
439            utxo_stats: self.utxos.stats().await,
440            block_header_stats: self.block_headers.stats().await,
441        }
442    }
443}
444
445/// Overall cache statistics
446#[derive(Debug, Clone)]
447pub struct OverallCacheStats {
448    /// Statistics for the transaction cache
449    pub transaction_stats: CacheStats,
450    /// Statistics for the UTXO cache
451    pub utxo_stats: CacheStats,
452    /// Statistics for the block header cache
453    pub block_header_stats: CacheStats,
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use bitcoin::hashes::Hash;
460
461    #[tokio::test]
462    async fn test_cache_config_defaults() {
463        let config = CacheConfig::default();
464        assert!(config.transaction_ttl.as_secs() > 0);
465        assert!(config.max_transactions > 0);
466    }
467
468    #[tokio::test]
469    async fn test_transaction_cache() {
470        let config = CacheConfig {
471            transaction_ttl: Duration::from_secs(1),
472            max_transactions: 2,
473            ..Default::default()
474        };
475
476        let cache = TransactionCache::new(config);
477        let txid = Txid::all_zeros();
478
479        // Initially empty
480        assert!(cache.get(&txid).await.is_none());
481
482        // Insert and retrieve (would need a proper GetRawTransactionResult to test fully)
483        // Skipping full test as it requires complex setup
484    }
485
486    #[tokio::test]
487    async fn test_utxo_cache() {
488        let config = CacheConfig {
489            utxo_ttl: Duration::from_secs(1),
490            max_utxos: 2,
491            ..Default::default()
492        };
493
494        let cache = UtxoCache::new(config);
495        let address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
496
497        // Initially empty
498        assert!(cache.get(address).await.is_none());
499
500        // Insert and retrieve
501        let utxos = vec![];
502        cache.insert(address.to_string(), utxos.clone()).await;
503        assert_eq!(cache.get(address).await, Some(utxos));
504
505        // Invalidate
506        cache.invalidate(address).await;
507        assert!(cache.get(address).await.is_none());
508    }
509
510    #[tokio::test]
511    async fn test_block_header_cache() {
512        let config = CacheConfig {
513            block_header_ttl: Duration::from_secs(1),
514            max_block_headers: 2,
515            ..Default::default()
516        };
517
518        let cache = BlockHeaderCache::new(config);
519        let hash = BlockHash::all_zeros();
520        let header = BlockHeader {
521            hash,
522            height: 100,
523            time: 1234567890,
524            previous_block_hash: None,
525        };
526
527        // Initially empty
528        assert!(cache.get_by_hash(&hash).await.is_none());
529        assert!(cache.get_by_height(100).await.is_none());
530
531        // Insert and retrieve
532        cache.insert(header.clone()).await;
533        assert!(cache.get_by_hash(&hash).await.is_some());
534        assert!(cache.get_by_height(100).await.is_some());
535
536        // Invalidate
537        cache.invalidate(&hash, 100).await;
538        assert!(cache.get_by_hash(&hash).await.is_none());
539        assert!(cache.get_by_height(100).await.is_none());
540    }
541
542    #[tokio::test]
543    async fn test_cache_manager() {
544        let manager = CacheManager::with_defaults();
545        manager.clear_all().await;
546
547        let stats = manager.overall_stats().await;
548        assert_eq!(stats.transaction_stats.total_entries, 0);
549        assert_eq!(stats.utxo_stats.total_entries, 0);
550        assert_eq!(stats.block_header_stats.total_entries, 0);
551    }
552
553    #[test]
554    fn test_cache_stats_utilization() {
555        let stats = CacheStats {
556            total_entries: 50,
557            expired_entries: 10,
558            active_entries: 40,
559            max_entries: 100,
560        };
561
562        assert_eq!(stats.utilization(), 0.5);
563    }
564}