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 filename: "sub1.srt".to_string(),
95 content_preview: "test".to_string(),
96 file_size: 123,
97 }],
98 }
99 }
100
101 #[tokio::test]
102 async fn test_cache_get_set_and_generate_key() {
103 let cache = AICache::new(Duration::from_secs(60));
104 let key = AICache::generate_key(&make_request());
105 // cache miss
106 assert!(cache.get(&key).await.is_none());
107
108 let result = MatchResult {
109 matches: vec![],
110 confidence: 0.5,
111 reasoning: "ok".to_string(),
112 };
113 cache.set(key.clone(), result.clone()).await;
114 // cache hit
115 assert_eq!(cache.get(&key).await, Some(result));
116 }
117
118 #[tokio::test]
119 async fn test_cache_expiration() {
120 let cache = AICache::new(Duration::from_millis(50));
121 let key = "expire".to_string();
122 let result = MatchResult {
123 matches: vec![],
124 confidence: 1.0,
125 reasoning: "expire".to_string(),
126 };
127 cache.set(key.clone(), result).await;
128 // immediate hit
129 assert!(cache.get(&key).await.is_some());
130 sleep(Duration::from_millis(100)).await;
131 // after ttl
132 assert!(cache.get(&key).await.is_none());
133 }
134}
135
136struct CacheEntry {
137 data: MatchResult,
138 created_at: SystemTime,
139}
140
141impl AICache {
142 /// Create a cache with the given TTL (time to live)
143 pub fn new(ttl: Duration) -> Self {
144 Self {
145 cache: RwLock::new(HashMap::new()),
146 ttl,
147 }
148 }
149
150 /// Try to read a result from the cache
151 pub async fn get(&self, key: &str) -> Option<MatchResult> {
152 let cache = self.cache.read().await;
153
154 if let Some(entry) = cache.get(key) {
155 if entry.created_at.elapsed().unwrap_or(Duration::MAX) < self.ttl {
156 return Some(entry.data.clone());
157 }
158 }
159 None
160 }
161
162 /// Write a new result into the cache
163 pub async fn set(&self, key: String, data: MatchResult) {
164 let mut cache = self.cache.write().await;
165 cache.insert(
166 key,
167 CacheEntry {
168 data,
169 created_at: SystemTime::now(),
170 },
171 );
172 }
173
174 /// Generate a cache key based on the request
175 pub fn generate_key(request: &AnalysisRequest) -> String {
176 let mut hasher = DefaultHasher::new();
177 request.video_files.hash(&mut hasher);
178 request.subtitle_files.hash(&mut hasher);
179 format!("{:x}", hasher.finish())
180 }
181}