Skip to main content

storage/
cache.rs

1//! L1 In-Memory Cache using Moka
2//!
3//! High-performance concurrent cache for vectors with LRU eviction.
4
5use common::Vector;
6use futures_util::{stream::FuturesUnordered, StreamExt};
7use moka::future::Cache;
8use std::sync::Arc;
9use std::time::Duration;
10
11/// Configuration for the L1 cache
12#[derive(Debug, Clone)]
13pub struct CacheConfig {
14    /// Maximum number of vectors to cache
15    pub max_capacity: u64,
16    /// Time-to-live for cached entries
17    pub ttl: Option<Duration>,
18    /// Time-to-idle for cached entries (evict if not accessed)
19    pub tti: Option<Duration>,
20}
21
22impl Default for CacheConfig {
23    fn default() -> Self {
24        Self {
25            max_capacity: 100_000,
26            ttl: Some(Duration::from_secs(3600)), // 1 hour
27            tti: Some(Duration::from_secs(600)),  // 10 minutes idle
28        }
29    }
30}
31
32/// Cache key combining namespace and vector ID
33#[derive(Debug, Clone, Hash, Eq, PartialEq)]
34pub struct CacheKey {
35    pub namespace: Arc<str>,
36    pub vector_id: Arc<str>,
37}
38
39impl CacheKey {
40    pub fn new(namespace: impl AsRef<str>, vector_id: impl AsRef<str>) -> Self {
41        Self {
42            namespace: Arc::from(namespace.as_ref()),
43            vector_id: Arc::from(vector_id.as_ref()),
44        }
45    }
46}
47
48/// L1 in-memory vector cache
49#[derive(Clone)]
50pub struct VectorCache {
51    cache: Cache<CacheKey, Arc<Vector>>,
52    config: CacheConfig,
53}
54
55impl VectorCache {
56    /// Create a new cache with the given configuration
57    pub fn new(config: CacheConfig) -> Self {
58        let mut builder = Cache::builder()
59            .max_capacity(config.max_capacity)
60            .support_invalidation_closures();
61
62        if let Some(ttl) = config.ttl {
63            builder = builder.time_to_live(ttl);
64        }
65
66        if let Some(tti) = config.tti {
67            builder = builder.time_to_idle(tti);
68        }
69
70        let cache = builder.build();
71
72        Self { cache, config }
73    }
74
75    /// Create with default configuration
76    pub fn with_defaults() -> Self {
77        Self::new(CacheConfig::default())
78    }
79
80    /// Get a vector from the cache
81    pub async fn get(&self, namespace: &str, vector_id: &str) -> Option<Arc<Vector>> {
82        let key = CacheKey::new(namespace, vector_id);
83        self.cache.get(&key).await
84    }
85
86    /// Insert a vector into the cache
87    pub async fn insert(&self, namespace: &str, vector: Vector) {
88        let key = CacheKey::new(namespace, &vector.id);
89        self.cache.insert(key, Arc::new(vector)).await;
90    }
91
92    /// Insert multiple vectors into the cache
93    pub async fn insert_batch(&self, namespace: &str, vectors: Vec<Vector>) {
94        let mut futs: FuturesUnordered<_> = vectors
95            .into_iter()
96            .map(|v| self.insert(namespace, v))
97            .collect();
98        while futs.next().await.is_some() {}
99    }
100
101    /// Remove a vector from the cache
102    pub async fn remove(&self, namespace: &str, vector_id: &str) {
103        let key = CacheKey::new(namespace, vector_id);
104        self.cache.remove(&key).await;
105    }
106
107    /// Remove multiple vectors from the cache
108    pub async fn remove_batch(&self, namespace: &str, vector_ids: &[String]) {
109        for id in vector_ids {
110            self.remove(namespace, id).await;
111        }
112    }
113
114    /// Invalidate all entries for a namespace.
115    pub async fn invalidate_namespace(&self, namespace: &str) {
116        let ns: Arc<str> = Arc::from(namespace);
117        if let Err(e) = self
118            .cache
119            .invalidate_entries_if(move |k, _v| *k.namespace == *ns)
120        {
121            // DAK-7428: this runs on the delete_namespace request path. Panicking
122            // here (the previous `.expect`) would abort the request task; instead
123            // fall back to a full invalidation so freshly-deleted entries can never
124            // be served stale — correct, just coarser.
125            tracing::error!(
126                namespace = namespace,
127                error = %e,
128                "namespace cache invalidation failed; clearing entire cache as fallback"
129            );
130            self.cache.invalidate_all();
131            return;
132        }
133        tracing::debug!(namespace = namespace, "Cache namespace invalidated");
134    }
135
136    /// Clear the entire cache
137    pub fn clear(&self) {
138        self.cache.invalidate_all();
139    }
140
141    /// Get cache statistics
142    pub fn stats(&self) -> CacheStats {
143        CacheStats {
144            entry_count: self.cache.entry_count(),
145            weighted_size: self.cache.weighted_size(),
146            max_capacity: self.config.max_capacity,
147        }
148    }
149
150    /// Run pending maintenance tasks (eviction, etc.)
151    pub async fn run_pending_tasks(&self) {
152        self.cache.run_pending_tasks().await;
153    }
154}
155
156/// Cache statistics
157#[derive(Debug, Clone)]
158pub struct CacheStats {
159    /// Number of entries in the cache
160    pub entry_count: u64,
161    /// Weighted size of the cache
162    pub weighted_size: u64,
163    /// Maximum capacity
164    pub max_capacity: u64,
165}
166
167impl CacheStats {
168    /// Cache utilization as a percentage
169    pub fn utilization(&self) -> f64 {
170        if self.max_capacity == 0 {
171            return 0.0;
172        }
173        (self.entry_count as f64 / self.max_capacity as f64) * 100.0
174    }
175}
176
177/// Cached storage wrapper that adds L1 caching to any VectorStorage
178pub struct CachedStorage<S> {
179    inner: S,
180    cache: VectorCache,
181    redis: Option<crate::RedisCache>,
182    /// L2 on-disk (RocksDB) read cache — survives process restarts, cutting cold
183    /// misses to the backing store (DAK-7428). `None` disables L2.
184    disk: Option<Arc<crate::DiskCache>>,
185    /// Optional delta-encoded vector version history (DAK-7428). When set, each
186    /// upsert records a new version so historical versions can be queried.
187    delta: Option<Arc<crate::DeltaStoreManager>>,
188}
189
190impl<S> CachedStorage<S> {
191    pub fn new(inner: S, cache: VectorCache, redis: Option<crate::RedisCache>) -> Self {
192        Self {
193            inner,
194            cache,
195            redis,
196            disk: None,
197            delta: None,
198        }
199    }
200
201    /// Attach an L2 disk cache behind the L1 (Moka) and L1.5 (Redis) tiers.
202    pub fn with_disk_cache(mut self, disk: Arc<crate::DiskCache>) -> Self {
203        self.disk = Some(disk);
204        self
205    }
206
207    /// Attach a delta-encoded version-history store, recorded on every upsert.
208    pub fn with_delta_history(mut self, delta: Arc<crate::DeltaStoreManager>) -> Self {
209        self.delta = Some(delta);
210        self
211    }
212
213    /// Access the delta version-history store, if enabled.
214    pub fn delta(&self) -> Option<&Arc<crate::DeltaStoreManager>> {
215        self.delta.as_ref()
216    }
217
218    pub fn with_default_cache(inner: S) -> Self {
219        Self::new(inner, VectorCache::with_defaults(), None)
220    }
221
222    pub fn cache(&self) -> &VectorCache {
223        &self.cache
224    }
225
226    pub fn inner(&self) -> &S {
227        &self.inner
228    }
229
230    pub fn redis(&self) -> Option<&crate::RedisCache> {
231        self.redis.as_ref()
232    }
233}
234
235#[async_trait::async_trait]
236impl<S: crate::VectorStorage> crate::VectorStorage for CachedStorage<S> {
237    async fn upsert(
238        &self,
239        namespace: &common::NamespaceId,
240        vectors: Vec<common::Vector>,
241    ) -> common::Result<usize> {
242        let count = self.inner.upsert(namespace, vectors.clone()).await?;
243        // Populate L1 cache with upserted vectors
244        self.cache.insert_batch(namespace, vectors.clone()).await;
245        // Populate L2 disk cache
246        if let Some(ref disk) = self.disk {
247            let _ = disk.put_batch(namespace, &vectors);
248        }
249        // Record a new version in the delta history store.
250        if let Some(ref delta) = self.delta {
251            delta.upsert(namespace, &vectors);
252        }
253        // Populate L1.5 Redis and publish invalidation for HA peers
254        if let Some(ref redis) = self.redis {
255            redis.set_batch(namespace, &vectors).await;
256            let ids: Vec<String> = vectors.iter().map(|v| v.id.clone()).collect();
257            redis
258                .publish_invalidation(&crate::CacheInvalidation::Vectors {
259                    namespace: namespace.to_string(),
260                    ids,
261                })
262                .await;
263        }
264        Ok(count)
265    }
266
267    async fn get(
268        &self,
269        namespace: &common::NamespaceId,
270        ids: &[common::VectorId],
271    ) -> common::Result<Vec<common::Vector>> {
272        let mut found = Vec::new();
273        let mut missing_ids: Vec<String> = Vec::new();
274
275        // Check L1 Moka first
276        for id in ids {
277            if let Some(v) = self.cache.get(namespace, id).await {
278                found.push((*v).clone());
279            } else {
280                missing_ids.push(id.clone());
281            }
282        }
283        if missing_ids.is_empty() {
284            return Ok(found);
285        }
286
287        // Check L1.5 Redis
288        if let Some(ref redis) = self.redis {
289            let from_redis = redis.get_multi(namespace, &missing_ids).await;
290            let redis_found_ids: std::collections::HashSet<String> =
291                from_redis.iter().map(|v| v.id.clone()).collect();
292            for v in &from_redis {
293                self.cache.insert(namespace, v.clone()).await; // backfill L1
294            }
295            found.extend(from_redis);
296            missing_ids.retain(|id| !redis_found_ids.contains(id));
297        }
298        if missing_ids.is_empty() {
299            return Ok(found);
300        }
301
302        // Check L2 disk cache (RocksDB), backfilling L1 on hit.
303        if let Some(ref disk) = self.disk {
304            if let Ok(from_disk) = disk.get_batch(namespace, &missing_ids) {
305                let disk_ids: std::collections::HashSet<String> =
306                    from_disk.iter().map(|v| v.id.clone()).collect();
307                for v in &from_disk {
308                    self.cache.insert(namespace, v.clone()).await;
309                }
310                found.extend(from_disk);
311                missing_ids.retain(|id| !disk_ids.contains(id));
312            }
313            if missing_ids.is_empty() {
314                return Ok(found);
315            }
316        }
317
318        // Fall through to backing store
319        let from_store = self.inner.get(namespace, &missing_ids).await?;
320        for v in &from_store {
321            self.cache.insert(namespace, v.clone()).await; // backfill L1
322            if let Some(ref redis) = self.redis {
323                redis.set(namespace, v).await; // backfill L1.5
324            }
325        }
326        // Backfill L2 disk cache.
327        if let Some(ref disk) = self.disk {
328            let _ = disk.put_batch(namespace, &from_store);
329        }
330        found.extend(from_store);
331        Ok(found)
332    }
333
334    async fn get_all(
335        &self,
336        namespace: &common::NamespaceId,
337    ) -> common::Result<Vec<common::Vector>> {
338        let vectors = self.inner.get_all(namespace).await?;
339        // Backfill L1 cache so subsequent individual get() calls will hit
340        for v in &vectors {
341            self.cache.insert(namespace, v.clone()).await;
342        }
343        // Backfill L1.5 Redis if configured
344        if let Some(ref redis) = self.redis {
345            redis.set_batch(namespace, &vectors).await;
346        }
347        Ok(vectors)
348    }
349
350    async fn get_all_meta(
351        &self,
352        namespace: &common::NamespaceId,
353    ) -> common::Result<Vec<common::Vector>> {
354        // DAK-7387: metadata-only projection — delegate straight to the backing
355        // store and skip the L1/L1.5 backfill. Projected vectors carry no
356        // embedding values and must never enter the vector caches.
357        self.inner.get_all_meta(namespace).await
358    }
359
360    async fn delete(
361        &self,
362        namespace: &common::NamespaceId,
363        ids: &[common::VectorId],
364    ) -> common::Result<usize> {
365        let count = self.inner.delete(namespace, ids).await?;
366        self.cache.remove_batch(namespace, ids).await;
367        if let Some(ref disk) = self.disk {
368            let _ = disk.delete_batch(namespace, ids);
369        }
370        if let Some(ref delta) = self.delta {
371            for id in ids {
372                delta.delete(namespace, id);
373            }
374        }
375        if let Some(ref redis) = self.redis {
376            let id_strings: Vec<String> = ids.iter().map(|s| s.to_string()).collect();
377            redis.delete(namespace, &id_strings).await;
378            redis
379                .publish_invalidation(&crate::CacheInvalidation::Vectors {
380                    namespace: namespace.to_string(),
381                    ids: id_strings,
382                })
383                .await;
384        }
385        Ok(count)
386    }
387
388    async fn namespace_exists(&self, namespace: &common::NamespaceId) -> common::Result<bool> {
389        self.inner.namespace_exists(namespace).await
390    }
391
392    async fn ensure_namespace(&self, namespace: &common::NamespaceId) -> common::Result<()> {
393        self.inner.ensure_namespace(namespace).await
394    }
395
396    async fn count(&self, namespace: &common::NamespaceId) -> common::Result<usize> {
397        self.inner.count(namespace).await
398    }
399
400    async fn dimension(&self, namespace: &common::NamespaceId) -> common::Result<Option<usize>> {
401        self.inner.dimension(namespace).await
402    }
403
404    async fn list_namespaces(&self) -> common::Result<Vec<common::NamespaceId>> {
405        self.inner.list_namespaces().await
406    }
407
408    async fn delete_namespace(&self, namespace: &common::NamespaceId) -> common::Result<bool> {
409        let result = self.inner.delete_namespace(namespace).await?;
410        self.cache.invalidate_namespace(namespace).await;
411        if let Some(ref disk) = self.disk {
412            let _ = disk.clear_namespace(namespace);
413        }
414        if let Some(ref delta) = self.delta {
415            delta.delete_namespace(namespace);
416        }
417        if let Some(ref redis) = self.redis {
418            redis.invalidate_namespace(namespace).await;
419            redis
420                .publish_invalidation(&crate::CacheInvalidation::Namespace(namespace.to_string()))
421                .await;
422        }
423        Ok(result)
424    }
425
426    async fn cleanup_expired(&self, namespace: &common::NamespaceId) -> common::Result<usize> {
427        self.inner.cleanup_expired(namespace).await
428    }
429
430    async fn cleanup_all_expired(&self) -> common::Result<usize> {
431        self.inner.cleanup_all_expired().await
432    }
433
434    /// DAK-7337: drop the entire Moka L1 (write-through over `inner`, so every
435    /// entry is repopulatable on demand) and force moka's deferred eviction to
436    /// run so the freed entries are actually released now, then let the inner
437    /// stack reclaim its own derived layers. Redis is intentionally untouched —
438    /// it lives out-of-process and does not contribute to this process's RSS.
439    async fn reclaim_derived_caches(&self) {
440        let dropped = self.cache.stats().entry_count;
441        self.cache.clear();
442        self.cache.run_pending_tasks().await;
443        tracing::info!(
444            dropped_entries = dropped,
445            "memory reclaim: Moka L1 vector cache cleared"
446        );
447        self.inner.reclaim_derived_caches().await;
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[tokio::test]
456    async fn test_cache_insert_and_get() {
457        let cache = VectorCache::with_defaults();
458
459        let vector = Vector {
460            id: "v1".to_string(),
461            values: vec![1.0, 2.0, 3.0],
462            metadata: None,
463            ttl_seconds: None,
464            expires_at: None,
465        };
466
467        cache.insert("test_ns", vector.clone()).await;
468
469        let retrieved = cache.get("test_ns", "v1").await;
470        assert!(retrieved.is_some());
471
472        let retrieved = retrieved.unwrap();
473        assert_eq!(retrieved.id, "v1");
474        assert_eq!(retrieved.values, vec![1.0, 2.0, 3.0]);
475    }
476
477    #[tokio::test]
478    async fn test_cache_miss() {
479        let cache = VectorCache::with_defaults();
480
481        let retrieved = cache.get("test_ns", "nonexistent").await;
482        assert!(retrieved.is_none());
483    }
484
485    #[tokio::test]
486    async fn test_cache_remove() {
487        let cache = VectorCache::with_defaults();
488
489        let vector = Vector {
490            id: "v1".to_string(),
491            values: vec![1.0, 2.0, 3.0],
492            metadata: None,
493            ttl_seconds: None,
494            expires_at: None,
495        };
496
497        cache.insert("test_ns", vector).await;
498        assert!(cache.get("test_ns", "v1").await.is_some());
499
500        cache.remove("test_ns", "v1").await;
501        cache.run_pending_tasks().await;
502
503        assert!(cache.get("test_ns", "v1").await.is_none());
504    }
505
506    #[tokio::test]
507    async fn test_cache_batch_operations() {
508        let cache = VectorCache::with_defaults();
509
510        let vectors = vec![
511            Vector {
512                id: "v1".to_string(),
513                values: vec![1.0],
514                metadata: None,
515                ttl_seconds: None,
516                expires_at: None,
517            },
518            Vector {
519                id: "v2".to_string(),
520                values: vec![2.0],
521                metadata: None,
522                ttl_seconds: None,
523                expires_at: None,
524            },
525            Vector {
526                id: "v3".to_string(),
527                values: vec![3.0],
528                metadata: None,
529                ttl_seconds: None,
530                expires_at: None,
531            },
532        ];
533
534        cache.insert_batch("test_ns", vectors).await;
535
536        assert!(cache.get("test_ns", "v1").await.is_some());
537        assert!(cache.get("test_ns", "v2").await.is_some());
538        assert!(cache.get("test_ns", "v3").await.is_some());
539
540        cache
541            .remove_batch("test_ns", &["v1".to_string(), "v2".to_string()])
542            .await;
543        cache.run_pending_tasks().await;
544
545        assert!(cache.get("test_ns", "v1").await.is_none());
546        assert!(cache.get("test_ns", "v2").await.is_none());
547        assert!(cache.get("test_ns", "v3").await.is_some());
548    }
549
550    #[tokio::test]
551    async fn test_cache_stats() {
552        let cache = VectorCache::new(CacheConfig {
553            max_capacity: 1000,
554            ttl: None,
555            tti: None,
556        });
557
558        for i in 0..10 {
559            let vector = Vector {
560                id: format!("v{}", i),
561                values: vec![i as f32],
562                metadata: None,
563                ttl_seconds: None,
564                expires_at: None,
565            };
566            cache.insert("test_ns", vector).await;
567        }
568
569        // Verify entries are retrievable
570        for i in 0..10 {
571            assert!(cache.get("test_ns", &format!("v{}", i)).await.is_some());
572        }
573
574        let stats = cache.stats();
575        assert_eq!(stats.max_capacity, 1000);
576    }
577
578    #[tokio::test]
579    async fn test_cache_namespace_isolation() {
580        let cache = VectorCache::with_defaults();
581
582        let v1 = Vector {
583            id: "same_id".to_string(),
584            values: vec![1.0],
585            metadata: None,
586            ttl_seconds: None,
587            expires_at: None,
588        };
589
590        let v2 = Vector {
591            id: "same_id".to_string(),
592            values: vec![2.0],
593            metadata: None,
594            ttl_seconds: None,
595            expires_at: None,
596        };
597
598        cache.insert("ns1", v1).await;
599        cache.insert("ns2", v2).await;
600
601        let from_ns1 = cache.get("ns1", "same_id").await.unwrap();
602        let from_ns2 = cache.get("ns2", "same_id").await.unwrap();
603
604        assert_eq!(from_ns1.values, vec![1.0]);
605        assert_eq!(from_ns2.values, vec![2.0]);
606    }
607
608    #[tokio::test]
609    async fn test_cache_clear() {
610        let cache = VectorCache::with_defaults();
611
612        for i in 0..5 {
613            let vector = Vector {
614                id: format!("v{}", i),
615                values: vec![i as f32],
616                metadata: None,
617                ttl_seconds: None,
618                expires_at: None,
619            };
620            cache.insert("test_ns", vector).await;
621        }
622
623        // Verify entries exist before clear
624        for i in 0..5 {
625            assert!(cache.get("test_ns", &format!("v{}", i)).await.is_some());
626        }
627
628        cache.clear();
629        cache.run_pending_tasks().await;
630
631        // Verify entries are gone after clear
632        for i in 0..5 {
633            assert!(cache.get("test_ns", &format!("v{}", i)).await.is_none());
634        }
635    }
636
637    // DAK-7337: the pressure-reclaim path must empty the L1 without losing durable data —
638    // entries are transparently repopulated from the inner storage on the next read.
639    #[tokio::test]
640    async fn test_reclaim_derived_caches_clears_l1_keeps_durable() {
641        use crate::VectorStorage;
642
643        let inner = crate::InMemoryStorage::new();
644        let storage = CachedStorage::with_default_cache(inner);
645        let namespace = "test_ns".to_string();
646        storage.ensure_namespace(&namespace).await.unwrap();
647        storage
648            .upsert(
649                &namespace,
650                vec![Vector {
651                    id: "v1".to_string(),
652                    values: vec![1.0, 2.0, 3.0],
653                    metadata: None,
654                    ttl_seconds: None,
655                    expires_at: None,
656                }],
657            )
658            .await
659            .unwrap();
660
661        // Warm the L1, then reclaim.
662        storage.get(&namespace, &["v1".to_string()]).await.unwrap();
663        storage.reclaim_derived_caches().await;
664        assert_eq!(
665            storage.cache().stats().entry_count,
666            0,
667            "reclaim must empty the Moka L1 synchronously (DAK-7337)"
668        );
669
670        // Durable read still works (repopulates from inner).
671        let got = storage.get(&namespace, &["v1".to_string()]).await.unwrap();
672        assert_eq!(got.len(), 1);
673        assert_eq!(got[0].id, "v1");
674    }
675}