Skip to main content

reddb_server/runtime/ai/
dedup_cache.rs

1//! Embedding dedup cache — issue #277.
2//!
3//! Optional LRU cache keyed by SHA-256(scope, text) → Vec<f32>.
4//! Off by default; opt-in via `runtime.ai.embedding_dedup_enabled = true`.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8
9/// Global process-wide dedup hit counter (for /metrics).
10pub static DEDUP_HITS_TOTAL: AtomicU64 = AtomicU64::new(0);
11/// Global process-wide dedup miss counter (for /metrics).
12pub 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    /// Look up `text` in the cache. Returns `Some(embedding)` on hit.
51    pub fn get(&self, text: &str) -> Option<Vec<f32>> {
52        self.get_scoped(&[], text)
53    }
54
55    /// Partition entries by connection/model identity; only the digest is stored.
56    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                // TTL expired — remove and treat as miss
67                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    /// Insert `embedding` for `text`.
81    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        // access "a" to make "b" the LRU
139        c.get("a");
140        c.insert("c", vec![3.0]); // evicts "b"
141        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); // 1ms TTL
149        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        // simulate: 1000 inputs with 10 unique texts → only 10 misses
157        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}