mubit-sdk 0.13.1

Umbrella Rust SDK for Mubit core/control planes
Documentation
//! MuBit Learn Lesson Cache.
//!
//! Async-safe TTL cache for `get_context()` results keyed by (session_id, query_hash).

use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;

pub struct LessonCache {
    store: RwLock<HashMap<String, (Instant, String, Vec<String>)>>,
    ttl: Duration,
    max_entries: usize,
}

impl LessonCache {
    pub fn new(ttl: Duration, max_entries: usize) -> Self {
        Self {
            store: RwLock::new(HashMap::new()),
            ttl,
            max_entries,
        }
    }

    /// Returns `(context_block, recalled_entry_ids)` if fresh. The IDs are
    /// cached alongside the block so a cache hit still yields the IDs needed to
    /// attribute a later outcome.
    pub async fn get(&self, session_id: &str, query: &str) -> Option<(String, Vec<String>)> {
        let key = Self::key(session_id, query);
        let store = self.store.read().await;
        if let Some((ts, value, ids)) = store.get(&key) {
            if ts.elapsed() < self.ttl {
                return Some((value.clone(), ids.clone()));
            }
        }
        None
    }

    pub async fn set(&self, session_id: &str, query: &str, context_block: String, ids: Vec<String>) {
        let key = Self::key(session_id, query);
        let mut store = self.store.write().await;
        if store.len() >= self.max_entries {
            // Evict oldest
            if let Some(oldest_key) = store
                .iter()
                .min_by_key(|(_, (ts, _, _))| *ts)
                .map(|(k, _)| k.clone())
            {
                store.remove(&oldest_key);
            }
        }
        store.insert(key, (Instant::now(), context_block, ids));
    }

    #[allow(dead_code)]
    pub async fn clear(&self) {
        self.store.write().await.clear();
    }

    fn key(session_id: &str, query: &str) -> String {
        use std::hash::{Hash, Hasher};
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        query.hash(&mut hasher);
        let qh = format!("{:x}", hasher.finish());
        format!("{}:{}", session_id, &qh[..12.min(qh.len())])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn caches_block_with_ids() {
        let c = LessonCache::new(Duration::from_secs(30), 100);
        c.set("s", "q", "BLOCK".into(), vec!["e1".into(), "e2".into()]).await;
        assert_eq!(c.get("s", "q").await, Some(("BLOCK".to_string(), vec!["e1".to_string(), "e2".to_string()])));
        // miss
        assert_eq!(c.get("s", "other").await, None);
    }

    #[tokio::test]
    async fn ids_default_empty_when_omitted() {
        let c = LessonCache::new(Duration::from_secs(30), 100);
        c.set("s", "q", "BLOCK".into(), Vec::new()).await;
        let (block, ids) = c.get("s", "q").await.unwrap();
        assert_eq!(block, "BLOCK");
        assert!(ids.is_empty());
    }
}