llm-memory-graph 0.1.0

Graph-based context-tracking and prompt-lineage database for LLM systems
Documentation
//! High-performance caching layer for storage operations
//!
//! This module provides an async-safe, thread-safe caching layer using moka
//! to dramatically reduce read latency for frequently accessed nodes and edges.

use crate::types::{Edge, EdgeId, Node, NodeId};
use moka::future::Cache;
use std::time::Duration;

/// Multi-level cache for nodes and edges
///
/// Provides LRU-based caching with automatic eviction and TTL support.
/// All operations are async and thread-safe.
#[derive(Clone)]
pub struct StorageCache {
    /// Cache for node lookups by ID
    node_cache: Cache<NodeId, Node>,
    /// Cache for edge lookups by ID
    edge_cache: Cache<EdgeId, Edge>,
}

impl StorageCache {
    /// Create a new storage cache with default settings
    ///
    /// Default configuration:
    /// - Node cache: 10,000 entries
    /// - Edge cache: 50,000 entries
    /// - TTL: 5 minutes
    pub fn new() -> Self {
        Self::with_capacity(10_000, 50_000)
    }

    /// Create a cache with custom capacities
    pub fn with_capacity(node_capacity: u64, edge_capacity: u64) -> Self {
        let node_cache = Cache::builder()
            .max_capacity(node_capacity)
            .time_to_live(Duration::from_secs(300)) // 5 minutes
            .build();

        let edge_cache = Cache::builder()
            .max_capacity(edge_capacity)
            .time_to_live(Duration::from_secs(300))
            .build();

        Self {
            node_cache,
            edge_cache,
        }
    }

    /// Create a cache with custom TTL
    pub fn with_ttl(ttl_secs: u64) -> Self {
        let node_cache = Cache::builder()
            .max_capacity(10_000)
            .time_to_live(Duration::from_secs(ttl_secs))
            .build();

        let edge_cache = Cache::builder()
            .max_capacity(50_000)
            .time_to_live(Duration::from_secs(ttl_secs))
            .build();

        Self {
            node_cache,
            edge_cache,
        }
    }

    /// Get a node from cache
    pub async fn get_node(&self, id: &NodeId) -> Option<Node> {
        self.node_cache.get(id).await
    }

    /// Insert a node into cache
    pub async fn insert_node(&self, id: NodeId, node: Node) {
        self.node_cache.insert(id, node).await;
    }

    /// Remove a node from cache
    pub async fn invalidate_node(&self, id: &NodeId) {
        self.node_cache.invalidate(id).await;
    }

    /// Get an edge from cache
    pub async fn get_edge(&self, id: &EdgeId) -> Option<Edge> {
        self.edge_cache.get(id).await
    }

    /// Insert an edge into cache
    pub async fn insert_edge(&self, id: EdgeId, edge: Edge) {
        self.edge_cache.insert(id, edge).await;
    }

    /// Remove an edge from cache
    pub async fn invalidate_edge(&self, id: &EdgeId) {
        self.edge_cache.invalidate(id).await;
    }

    /// Get cache statistics
    ///
    /// Returns current cache sizes. Note that hit/miss rates are not tracked
    /// by default in moka cache to minimize performance overhead.
    pub async fn stats(&self) -> CacheStats {
        // Sync pending tasks to get accurate counts
        self.node_cache.run_pending_tasks().await;
        self.edge_cache.run_pending_tasks().await;

        CacheStats {
            node_cache_size: self.node_cache.entry_count(),
            edge_cache_size: self.edge_cache.entry_count(),
            node_cache_hits: 0, // Hit tracking not enabled by default
            node_cache_misses: 0,
            edge_cache_hits: 0,
            edge_cache_misses: 0,
        }
    }

    /// Clear all caches
    pub fn clear(&self) {
        self.node_cache.invalidate_all();
        self.edge_cache.invalidate_all();
    }
}

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

/// Cache statistics
#[derive(Debug, Clone)]
pub struct CacheStats {
    /// Number of nodes in cache
    pub node_cache_size: u64,
    /// Number of edges in cache
    pub edge_cache_size: u64,
    /// Node cache hits
    pub node_cache_hits: u64,
    /// Node cache misses
    pub node_cache_misses: u64,
    /// Edge cache hits
    pub edge_cache_hits: u64,
    /// Edge cache misses
    pub edge_cache_misses: u64,
}

impl CacheStats {
    /// Calculate node cache hit rate
    pub fn node_hit_rate(&self) -> f64 {
        let total = self.node_cache_hits + self.node_cache_misses;
        if total == 0 {
            0.0
        } else {
            self.node_cache_hits as f64 / total as f64
        }
    }

    /// Calculate edge cache hit rate
    pub fn edge_hit_rate(&self) -> f64 {
        let total = self.edge_cache_hits + self.edge_cache_misses;
        if total == 0 {
            0.0
        } else {
            self.edge_cache_hits as f64 / total as f64
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{ConversationSession, PromptNode, SessionId};

    #[tokio::test]
    async fn test_cache_creation() {
        let cache = StorageCache::new();
        let stats = cache.stats().await;

        assert_eq!(stats.node_cache_size, 0);
        assert_eq!(stats.edge_cache_size, 0);
    }

    #[tokio::test]
    async fn test_node_cache() {
        let cache = StorageCache::new();

        let session = ConversationSession::new();
        let node = Node::Session(session.clone());
        let node_id = node.id();

        // Cache miss initially
        assert!(cache.get_node(&node_id).await.is_none());

        // Insert into cache
        cache.insert_node(node_id, node.clone()).await;

        // Cache hit
        let cached = cache.get_node(&node_id).await;
        assert!(cached.is_some());
        assert_eq!(cached.unwrap().id(), node_id);
    }

    #[tokio::test]
    async fn test_node_cache_invalidation() {
        let cache = StorageCache::new();

        let session = ConversationSession::new();
        let node = Node::Session(session);
        let node_id = node.id();

        cache.insert_node(node_id, node).await;
        assert!(cache.get_node(&node_id).await.is_some());

        cache.invalidate_node(&node_id).await;
        // Give cache time to process invalidation
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;

        // Note: Moka might still return the value briefly after invalidation
        // This is expected behavior for async caches
    }

    #[tokio::test]
    async fn test_cache_stats() {
        let cache = StorageCache::new();

        let session = ConversationSession::new();
        let node = Node::Session(session);
        let node_id = node.id();

        // Miss - no entry yet
        let result = cache.get_node(&node_id).await;
        assert!(result.is_none());

        // Insert
        cache.insert_node(node_id, node.clone()).await;

        // Hit - entry should be present
        let result = cache.get_node(&node_id).await;
        assert!(result.is_some());

        // stats() now syncs pending tasks before returning
        let stats = cache.stats().await;
        assert_eq!(stats.node_cache_size, 1);
        // Note: Hit/miss tracking not enabled by default in moka for performance
    }

    #[tokio::test]
    async fn test_custom_capacity() {
        let cache = StorageCache::with_capacity(100, 200);
        let stats = cache.stats().await;

        // Cache should be empty initially
        assert_eq!(stats.node_cache_size, 0);
    }

    #[tokio::test]
    async fn test_concurrent_cache_access() {
        let cache = StorageCache::new();
        let cache_clone1 = cache.clone();
        let cache_clone2 = cache.clone();

        let session_id = SessionId::new();

        let handle1 = tokio::spawn(async move {
            for i in 0..50 {
                let prompt = PromptNode::new(session_id, format!("Prompt {}", i));
                let node = Node::Prompt(prompt.clone());
                cache_clone1.insert_node(prompt.id, node).await;
            }
        });

        let handle2 = tokio::spawn(async move {
            for i in 50..100 {
                let prompt = PromptNode::new(session_id, format!("Prompt {}", i));
                let node = Node::Prompt(prompt.clone());
                cache_clone2.insert_node(prompt.id, node).await;
            }
        });

        handle1.await.unwrap();
        handle2.await.unwrap();

        // stats() will sync pending tasks before returning counts
        let stats = cache.stats().await;
        assert_eq!(stats.node_cache_size, 100);
    }
}