reddb_server/runtime/ai/
dedup_cache.rs1use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8
9pub static DEDUP_HITS_TOTAL: AtomicU64 = AtomicU64::new(0);
11pub static DEDUP_MISSES_TOTAL: AtomicU64 = AtomicU64::new(0);
13
14use lru::LruCache;
15use parking_lot::Mutex;
16use sha2::{Digest, Sha256};
17
18pub const CONFIG_DEDUP_ENABLED: &str = "runtime.ai.embedding_dedup_enabled";
19pub const CONFIG_DEDUP_TTL_MS: &str = "runtime.ai.embedding_dedup_ttl_ms";
20pub const CONFIG_DEDUP_LRU_SIZE: &str = "runtime.ai.embedding_dedup_lru_size";
21
22pub const DEFAULT_DEDUP_TTL_MS: u64 = 60_000;
23pub const DEFAULT_DEDUP_LRU_SIZE: usize = 4096;
24
25type HashKey = [u8; 32];
26
27struct Entry {
28 embedding: Vec<f32>,
29 inserted_at: Instant,
30}
31
32pub struct EmbeddingDedupCache {
33 inner: Mutex<LruCache<HashKey, Entry>>,
34 ttl: Duration,
35 hits: AtomicU64,
36 misses: AtomicU64,
37}
38
39impl EmbeddingDedupCache {
40 pub fn new(max_size: usize, ttl: Duration) -> Self {
41 let capacity = std::num::NonZeroUsize::new(max_size.max(1)).expect("max_size >= 1");
42 Self {
43 inner: Mutex::new(LruCache::new(capacity)),
44 ttl,
45 hits: AtomicU64::new(0),
46 misses: AtomicU64::new(0),
47 }
48 }
49
50 pub fn get(&self, text: &str) -> Option<Vec<f32>> {
52 self.get_scoped(&[], text)
53 }
54
55 pub fn get_scoped(&self, scope: &[&str], text: &str) -> Option<Vec<f32>> {
57 let key = hash(scope, text);
58 let mut guard = self.inner.lock();
59 match guard.get(&key) {
60 Some(entry) if entry.inserted_at.elapsed() < self.ttl => {
61 self.hits.fetch_add(1, Ordering::Relaxed);
62 DEDUP_HITS_TOTAL.fetch_add(1, Ordering::Relaxed);
63 Some(entry.embedding.clone())
64 }
65 Some(_expired) => {
66 guard.pop(&key);
68 self.misses.fetch_add(1, Ordering::Relaxed);
69 DEDUP_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
70 None
71 }
72 None => {
73 self.misses.fetch_add(1, Ordering::Relaxed);
74 DEDUP_MISSES_TOTAL.fetch_add(1, Ordering::Relaxed);
75 None
76 }
77 }
78 }
79
80 pub fn insert(&self, text: &str, embedding: Vec<f32>) {
82 self.insert_scoped(&[], text, embedding);
83 }
84
85 pub fn insert_scoped(&self, scope: &[&str], text: &str, embedding: Vec<f32>) {
86 let key = hash(scope, text);
87 self.inner.lock().put(
88 key,
89 Entry {
90 embedding,
91 inserted_at: Instant::now(),
92 },
93 );
94 }
95
96 pub fn hits(&self) -> u64 {
97 self.hits.load(Ordering::Relaxed)
98 }
99
100 pub fn misses(&self) -> u64 {
101 self.misses.load(Ordering::Relaxed)
102 }
103}
104
105fn hash(scope: &[&str], text: &str) -> HashKey {
106 let mut hasher = Sha256::new();
107 for part in scope.iter().copied().chain(std::iter::once(text)) {
108 hasher.update((part.len() as u64).to_le_bytes());
109 hasher.update(part.as_bytes());
110 }
111 hasher.finalize().into()
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn cache(size: usize, ttl_ms: u64) -> EmbeddingDedupCache {
119 EmbeddingDedupCache::new(size, Duration::from_millis(ttl_ms))
120 }
121
122 #[test]
123 fn miss_then_hit() {
124 let c = cache(16, 60_000);
125 assert!(c.get("hello").is_none());
126 c.insert("hello", vec![1.0, 2.0]);
127 let v = c.get("hello").unwrap();
128 assert_eq!(v, vec![1.0, 2.0]);
129 assert_eq!(c.hits(), 1);
130 assert_eq!(c.misses(), 1);
131 }
132
133 #[test]
134 fn lru_eviction() {
135 let c = cache(2, 60_000);
136 c.insert("a", vec![1.0]);
137 c.insert("b", vec![2.0]);
138 c.get("a");
140 c.insert("c", vec![3.0]); assert!(c.get("b").is_none());
142 assert!(c.get("a").is_some());
143 assert!(c.get("c").is_some());
144 }
145
146 #[test]
147 fn ttl_expired_treated_as_miss() {
148 let c = cache(16, 1); c.insert("x", vec![9.9]);
150 std::thread::sleep(Duration::from_millis(5));
151 assert!(c.get("x").is_none());
152 }
153
154 #[test]
155 fn dedup_1000_inputs_10_unique() {
156 let c = cache(1024, 60_000);
158 let unique: Vec<String> = (0..10).map(|i| format!("text {i}")).collect();
159 let inputs: Vec<String> = (0..1000).map(|i| unique[i % 10].clone()).collect();
160
161 let mut miss_count = 0usize;
162 let mut embeddings: Vec<Vec<f32>> = Vec::with_capacity(inputs.len());
163 for text in &inputs {
164 if let Some(cached) = c.get(text) {
165 embeddings.push(cached);
166 } else {
167 miss_count += 1;
168 let emb = vec![miss_count as f32];
169 c.insert(text, emb.clone());
170 embeddings.push(emb);
171 }
172 }
173
174 assert_eq!(miss_count, 10, "only 10 unique texts → 10 provider calls");
175 assert_eq!(embeddings.len(), 1000);
176 assert_eq!(c.misses(), 10);
177 assert_eq!(c.hits(), 990);
178 }
179}