do-memory-core 0.1.31

Core episodic learning system for AI agents with pattern extraction, reward scoring, and dual storage backend
Documentation
//! # Query Cache Implementation
//!
//! LRU cache with TTL for query results to improve retrieval performance.
//! Target: 2-3x speedup for repeated queries with ≥40% cache hit rate.
//!
//! This module was split from retrieval/cache.rs to meet 500 LOC compliance.

use crate::episode::Episode;
use crate::retrieval::cache::types::{
    CacheKey, CacheMetrics, CachedResult, DEFAULT_CACHE_TTL, DEFAULT_MAX_ENTRIES,
};
use lru::LruCache;
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::{Duration, Instant};

/// Query cache with LRU eviction and TTL
///
/// Optimization: Uses `parking_lot::RwLock` instead of `std::sync::RwLock` to reduce
/// lock overhead (faster uncontended acquisition) and memory footprint (24 bytes vs 56 bytes).
/// `parking_lot` locks also provide better performance under high contention and do not
/// suffer from lock poisoning, simplifying the implementation by removing `.expect()` calls.
pub struct QueryCache {
    /// LRU cache storage
    cache: Arc<RwLock<LruCache<u64, CachedResult>>>,
    /// Domain index: maps domain -> set of cache key hashes (`Arc<str>` avoids cloning)
    domain_index: Arc<RwLock<HashMap<Arc<str>, HashSet<u64>>>>,
    /// Lazy invalidation: set of cache key hashes marked for removal
    /// Entries are not removed immediately, but filtered on access
    invalidated_hashes: Arc<RwLock<HashSet<u64>>>,
    /// Cache metrics
    metrics: Arc<RwLock<CacheMetrics>>,
    /// Default TTL for new entries
    default_ttl: Duration,
    /// Maximum number of entries
    max_entries: usize,
}

impl QueryCache {
    /// Create a new query cache with default settings
    #[must_use]
    pub fn new() -> Self {
        Self::with_capacity_and_ttl(DEFAULT_MAX_ENTRIES, DEFAULT_CACHE_TTL)
    }

    /// Create a new query cache with custom capacity and TTL
    #[must_use]
    pub fn with_capacity_and_ttl(capacity: usize, ttl: Duration) -> Self {
        // Ensure capacity is at least 1 to create NonZeroUsize
        let safe_capacity = capacity.max(1);
        let cache = LruCache::new(
            NonZeroUsize::new(safe_capacity)
                .expect("QueryCache: capacity is guaranteed to be non-zero after max(1)"),
        );
        let metrics = CacheMetrics {
            capacity: safe_capacity,
            ..Default::default()
        };

        Self {
            cache: Arc::new(RwLock::new(cache)),
            domain_index: Arc::new(RwLock::new(HashMap::new())),
            invalidated_hashes: Arc::new(RwLock::new(HashSet::new())),
            metrics: Arc::new(RwLock::new(metrics)),
            default_ttl: ttl,
            max_entries: safe_capacity,
        }
    }

    /// Get a cached query result
    #[must_use]
    pub fn get(&self, key: &CacheKey) -> Option<Vec<Arc<Episode>>> {
        let key_hash = key.compute_hash();

        // Fast path: Check if this entry is marked for lazy invalidation
        {
            let invalidated = self.invalidated_hashes.read();
            if invalidated.contains(&key_hash) {
                // Entry is invalidated - count as miss and return None
                let mut metrics = self.metrics.write();
                metrics.misses += 1;
                return None;
            }
        }

        let mut cache = self.cache.write();
        let mut metrics = self.metrics.write();

        // Check if entry exists and is not expired
        if let Some(result) = cache.get(&key_hash) {
            // Check if expired
            if result.is_expired() {
                // Remove expired entry
                cache.pop(&key_hash);
                metrics.misses += 1;
                metrics.evictions += 1;
                metrics.size = cache.len();
                return None;
            }

            // Cache hit - clone the Arc pointers from the slice
            metrics.hits += 1;
            let episodes: Vec<Arc<Episode>> = result.episodes.to_vec();
            Some(episodes)
        } else {
            // Cache miss
            metrics.misses += 1;
            metrics.size = cache.len();
            None
        }
    }

    /// Store a query result in the cache
    pub fn put(&self, key: CacheKey, episodes: Vec<Arc<Episode>>) {
        let key_hash = key.compute_hash();
        // Convert Vec<Arc<Episode>> to Arc<[Arc<Episode>]>
        // This is zero-copy for the episodes themselves
        let episodes_slice: Arc<[Arc<Episode>]> = episodes.into();
        let cached_result = CachedResult {
            episodes: episodes_slice,
            cached_at: Instant::now(),
            ttl: self.default_ttl,
        };

        let mut cache = self.cache.write();

        // Check if this is an update to an existing entry
        let was_present = cache.contains(&key_hash);

        // Check if cache is at capacity before adding (for eviction tracking)
        let was_at_capacity = cache.len() >= self.max_entries;

        // Add to cache (LruCache automatically evicts oldest if at capacity)
        cache.put(key_hash, cached_result);

        // Update domain index if domain is specified
        if let Some(ref domain) = key.domain {
            let mut domain_index = self.domain_index.write();
            // Arc<str> clone is cheap (just ref count increment)
            domain_index
                .entry(Arc::clone(domain))
                .or_default()
                .insert(key_hash);
        }

        // Update metrics
        let mut metrics = self.metrics.write();
        metrics.size = cache.len();

        // If this was an update (not a new entry), don't count as eviction
        if was_present {
            return;
        }

        // If cache was at capacity and we added a new entry, an eviction occurred
        if was_at_capacity {
            metrics.evictions += 1;
        }
    }

    /// Invalidate all cached entries (use for cross-domain changes)
    pub fn invalidate_all(&self) {
        let mut cache = self.cache.write();
        let count = cache.len();
        cache.clear();

        // Clear domain index
        let mut domain_index = self.domain_index.write();
        domain_index.clear();

        // Clear invalidation set
        let mut invalidated = self.invalidated_hashes.write();
        invalidated.clear();

        // Update metrics
        let mut metrics = self.metrics.write();
        metrics.size = 0;
        metrics.invalidations += count as u64;
    }

    /// Invalidate entries for a specific domain
    ///
    /// This is more efficient than `invalidate_all()` for multi-domain workloads
    /// because it only clears entries for the specified domain.
    ///
    /// # Arguments
    ///
    /// * `domain` - The domain to invalidate
    pub fn invalidate_domain(&self, domain: &str) {
        // First, check if domain exists and get hashes (read lock)
        let hashes_to_invalidate = {
            let domain_index = self.domain_index.read();
            domain_index.get(domain).cloned()
        }; // Read lock released here

        if let Some(hashes) = hashes_to_invalidate {
            let count = hashes.len();

            // Mark entries for lazy invalidation
            {
                let mut invalidated = self.invalidated_hashes.write();
                for hash in &hashes {
                    invalidated.insert(*hash);
                }
            } // Write lock on invalidated_hashes released here

            // Remove from domain index (now safe to acquire write lock)
            {
                let mut domain_index = self.domain_index.write();
                domain_index.remove(domain);
            } // Write lock on domain_index released here

            // Update metrics
            {
                let mut metrics = self.metrics.write();
                metrics.invalidations += count as u64;
            } // Write lock on metrics released here
        }
        // If domain not found, no-op (already cleared or never existed)
    }

    /// Get current cache metrics
    #[must_use]
    pub fn metrics(&self) -> CacheMetrics {
        let metrics = self.metrics.read();
        metrics.clone()
    }

    /// Clear all metrics
    pub fn clear_metrics(&self) {
        let mut metrics = self.metrics.write();
        *metrics = CacheMetrics {
            capacity: self.max_entries,
            ..Default::default()
        };
    }

    /// Get cache size (number of entries)
    ///
    /// Note: This returns the physical size of the cache, which may include
    /// entries that are marked for lazy invalidation. These entries will be
    /// filtered out when accessed via `get()`.
    #[must_use]
    pub fn size(&self) -> usize {
        self.cache.read().len()
    }

    /// Get effective cache size (excluding invalidated entries)
    ///
    /// This returns the logical size of the cache, excluding entries that
    /// are marked for lazy invalidation.
    #[must_use]
    pub fn effective_size(&self) -> usize {
        let cache_size = self.cache.read().len();
        let invalidated_size = self.invalidated_hashes.read().len();
        cache_size.saturating_sub(invalidated_size)
    }

    /// Check if cache is empty
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.cache.read().is_empty()
    }
}

impl Default for QueryCache {
    fn default() -> Self {
        Self::new()
    }
}