Skip to main content

subx_cli/services/ai/
cache.rs

1//! AI analysis result caching system for performance optimization.
2//!
3//! This module provides a high-performance caching layer for AI analysis results,
4//! reducing the cost and latency of repeated content analysis operations. The cache
5//! uses intelligent key generation and TTL-based expiration to balance performance
6//! with data freshness.
7//!
8//! # Cache Architecture
9//!
10//! ## Key Generation
11//! - **Content-Based Keys**: Hash-based keys derived from request content
12//! - **Deterministic Hashing**: Consistent keys for identical requests
13//! - **Collision Resistance**: Low probability of hash collisions
14//! - **Efficient Lookup**: O(1) average case lookup performance
15//!
16//! ## Storage Strategy
17//! - **In-Memory Storage**: Fast access using HashMap data structure
18//! - **TTL Expiration**: Time-based cache invalidation for freshness
19//! - **LRU Eviction**: Least Recently Used eviction for memory management
20//! - **Concurrent Access**: Thread-safe operations using RwLock
21//!
22//! ## Performance Benefits
23//! - **Cost Reduction**: Avoid expensive AI API calls for repeated requests
24//! - **Latency Improvement**: Sub-millisecond response time for cached results
25//! - **Rate Limit Compliance**: Reduce API usage to stay within provider limits
26//! - **Offline Operation**: Serve cached results when API is unavailable
27//!
28//! # Usage Examples
29//!
30//! ## Basic Caching Operation
31//! ```rust,ignore
32//! use subx_cli::services::ai::{AICache, AnalysisRequest};
33//! use std::time::Duration;
34//!
35//! // Create cache with 1-hour TTL
36//! let cache = AICache::new(Duration::from_secs(3600));
37//!
38//! // Check for cached result
39//! let request = AnalysisRequest { /* ... */ };
40//! if let Some(cached_result) = cache.get(&request).await {
41//!     println!("Using cached result: {:?}", cached_result);
42//!     return Ok(cached_result);
43//! }
44//!
45//! // Perform AI analysis and cache result
46//! let fresh_result = ai_client.analyze_content(request.clone()).await?;
47//! cache.put(request, fresh_result.clone()).await;
48//! ```
49//!
50//! ## Cache Management
51//! ```rust,ignore
52//! use subx_cli::services::ai::AICache;
53//!
54//! let cache = AICache::new(Duration::from_secs(1800)); // 30 minutes
55//!
56//! // Get cache statistics
57//! let stats = cache.stats().await;
58//! println!("Cache hits: {}, misses: {}, size: {}",
59//!     stats.hits, stats.misses, stats.size);
60//!
61//! // Clear expired entries
62//! let expired_count = cache.cleanup_expired().await;
63//! println!("Removed {} expired entries", expired_count);
64//!
65//! // Clear all cache entries
66//! cache.clear().await;
67//! ```
68
69use crate::services::ai::{AnalysisRequest, MatchResult};
70use std::collections::HashMap;
71use std::collections::hash_map::DefaultHasher;
72use std::hash::{Hash, Hasher};
73use std::time::{Duration, SystemTime};
74use tokio::sync::RwLock;
75
76/// AI analysis result cache
77pub struct AICache {
78    cache: RwLock<HashMap<String, CacheEntry>>,
79    ttl: Duration,
80}
81
82#[cfg(test)]
83mod tests {
84    use super::{AICache, AnalysisRequest, MatchResult};
85    use crate::services::ai::ContentSample;
86    use std::time::Duration;
87    use tokio::time::sleep;
88
89    fn make_request() -> AnalysisRequest {
90        AnalysisRequest {
91            video_files: vec!["video1.mp4".to_string()],
92            subtitle_files: vec!["sub1.srt".to_string()],
93            content_samples: vec![ContentSample {
94                subtitle_file_id: "sub_id".to_string(),
95                filename: "sub1.srt".to_string(),
96                content_preview: "test".to_string(),
97                file_size: 123,
98            }],
99        }
100    }
101
102    #[tokio::test]
103    async fn test_cache_get_set_and_generate_key() {
104        let cache = AICache::new(Duration::from_secs(60));
105        let key = AICache::generate_key(&make_request());
106        // cache miss
107        assert!(cache.get(&key).await.is_none());
108
109        let result = MatchResult {
110            matches: vec![],
111            confidence: 0.5,
112            reasoning: "ok".to_string(),
113        };
114        cache.set(key.clone(), result.clone()).await;
115        // cache hit
116        assert_eq!(cache.get(&key).await, Some(result));
117    }
118
119    #[tokio::test]
120    async fn test_cache_expiration() {
121        let cache = AICache::new(Duration::from_millis(50));
122        let key = "expire".to_string();
123        let result = MatchResult {
124            matches: vec![],
125            confidence: 1.0,
126            reasoning: "expire".to_string(),
127        };
128        cache.set(key.clone(), result).await;
129        // immediate hit
130        assert!(cache.get(&key).await.is_some());
131        sleep(Duration::from_millis(100)).await;
132        // after ttl
133        assert!(cache.get(&key).await.is_none());
134    }
135}
136
137struct CacheEntry {
138    data: MatchResult,
139    created_at: SystemTime,
140}
141
142impl AICache {
143    /// Create a cache with the given TTL (time to live)
144    pub fn new(ttl: Duration) -> Self {
145        Self {
146            cache: RwLock::new(HashMap::new()),
147            ttl,
148        }
149    }
150
151    /// Try to read a result from the cache
152    pub async fn get(&self, key: &str) -> Option<MatchResult> {
153        let cache = self.cache.read().await;
154
155        if let Some(entry) = cache.get(key) {
156            if entry.created_at.elapsed().unwrap_or(Duration::MAX) < self.ttl {
157                return Some(entry.data.clone());
158            }
159        }
160        None
161    }
162
163    /// Write a new result into the cache
164    pub async fn set(&self, key: String, data: MatchResult) {
165        let mut cache = self.cache.write().await;
166        cache.insert(
167            key,
168            CacheEntry {
169                data,
170                created_at: SystemTime::now(),
171            },
172        );
173    }
174
175    /// Generate a cache key based on the request
176    pub fn generate_key(request: &AnalysisRequest) -> String {
177        let mut hasher = DefaultHasher::new();
178        request.video_files.hash(&mut hasher);
179        request.subtitle_files.hash(&mut hasher);
180        format!("{:x}", hasher.finish())
181    }
182}