corteq 0.1.0

Enterprise-grade multi-tenant SaaS framework for Rust with security-first design
Documentation
//! Tenant caching layer for performance optimization
//!
//! Provides in-memory caching of tenant data to reduce database queries.
//! Supports optional Redis backend for distributed caching.

use crate::TenantContext;
use async_trait::async_trait;
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;

/// Cache entry with TTL
#[derive(Debug, Clone)]
struct CacheEntry {
    context: TenantContext,
    inserted_at: Instant,
}

/// Trait for tenant caching implementations
#[async_trait]
pub trait TenantCache: Send + Sync + std::fmt::Debug {
    /// Get a tenant from cache
    async fn get(&self, tenant_id: &Uuid) -> Option<TenantContext>;

    /// Set a tenant in cache
    async fn set(&self, tenant_id: Uuid, context: TenantContext);

    /// Invalidate a tenant from cache
    async fn invalidate(&self, tenant_id: &Uuid);

    /// Clear all cached tenants
    async fn clear(&self);
}

/// In-memory tenant cache with TTL
#[derive(Debug, Clone)]
pub struct MemoryTenantCache {
    cache: Arc<DashMap<Uuid, CacheEntry>>,
    ttl: Duration,
    max_size: usize,
}

impl MemoryTenantCache {
    /// Create a new in-memory cache
    ///
    /// # Arguments
    /// * `ttl` - Time-to-live for cache entries (default: 15 minutes)
    /// * `max_size` - Maximum number of entries (default: 10,000)
    pub fn new(ttl: Duration, max_size: usize) -> Self {
        Self {
            cache: Arc::new(DashMap::new()),
            ttl,
            max_size,
        }
    }

    /// Check if an entry is expired
    fn is_expired(&self, entry: &CacheEntry) -> bool {
        entry.inserted_at.elapsed() > self.ttl
    }

    /// Evict expired entries (simple cleanup)
    fn evict_expired(&self) {
        self.cache.retain(|_, entry| !self.is_expired(entry));
    }

    /// Evict oldest entry if cache is full (LRU-like)
    fn evict_if_full(&self) {
        if self.cache.len() >= self.max_size {
            // Find and remove the oldest entry
            if let Some(oldest) = self
                .cache
                .iter()
                .min_by_key(|entry| entry.value().inserted_at)
                .map(|entry| *entry.key())
            {
                self.cache.remove(&oldest);
            }
        }
    }
}

impl Default for MemoryTenantCache {
    /// Create with default settings (15 min TTL, 10k max entries)
    fn default() -> Self {
        Self::new(Duration::from_secs(15 * 60), 10_000)
    }
}

#[async_trait]
impl TenantCache for MemoryTenantCache {
    async fn get(&self, tenant_id: &Uuid) -> Option<TenantContext> {
        // Periodically clean up expired entries (every 100 lookups)
        if rand::random::<u8>() < 3 {
            // ~1% chance
            self.evict_expired();
        }

        self.cache.get(tenant_id).and_then(|entry| {
            if self.is_expired(&entry) {
                // Entry expired, remove it
                drop(entry);
                self.cache.remove(tenant_id);
                None
            } else {
                Some(entry.context.clone())
            }
        })
    }

    async fn set(&self, tenant_id: Uuid, context: TenantContext) {
        // Evict if cache is full
        self.evict_if_full();

        let entry = CacheEntry {
            context,
            inserted_at: Instant::now(),
        };

        self.cache.insert(tenant_id, entry);
    }

    async fn invalidate(&self, tenant_id: &Uuid) {
        self.cache.remove(tenant_id);
    }

    async fn clear(&self) {
        self.cache.clear();
    }
}

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

    #[tokio::test]
    async fn test_cache_set_and_get() {
        let cache = MemoryTenantCache::default();
        let tenant_id = Uuid::new_v4();
        let context = TenantContext::new(tenant_id, "test".to_string(), "key".to_string());

        cache.set(tenant_id, context.clone()).await;
        let retrieved = cache.get(&tenant_id).await;

        assert!(retrieved.is_some());
        assert_eq!(retrieved.unwrap().tenant_id, tenant_id);
    }

    #[tokio::test]
    async fn test_cache_ttl_expiration() {
        let cache = MemoryTenantCache::new(Duration::from_millis(100), 1000);
        let tenant_id = Uuid::new_v4();
        let context = TenantContext::new(tenant_id, "test".to_string(), "key".to_string());

        cache.set(tenant_id, context).await;

        // Should exist immediately
        assert!(cache.get(&tenant_id).await.is_some());

        // Wait for expiration
        tokio::time::sleep(Duration::from_millis(150)).await;

        // Should be expired
        assert!(cache.get(&tenant_id).await.is_none());
    }

    #[tokio::test]
    async fn test_cache_invalidation() {
        let cache = MemoryTenantCache::default();
        let tenant_id = Uuid::new_v4();
        let context = TenantContext::new(tenant_id, "test".to_string(), "key".to_string());

        cache.set(tenant_id, context).await;
        assert!(cache.get(&tenant_id).await.is_some());

        cache.invalidate(&tenant_id).await;
        assert!(cache.get(&tenant_id).await.is_none());
    }

    #[tokio::test]
    async fn test_cache_max_size() {
        let cache = MemoryTenantCache::new(Duration::from_secs(60), 3);

        // Fill cache to max
        for i in 0..5 {
            let tenant_id = Uuid::new_v4();
            let context = TenantContext::new(tenant_id, format!("tenant-{i}"), format!("key-{i}"));
            cache.set(tenant_id, context).await;
        }

        // Cache should not exceed max size
        assert!(cache.cache.len() <= 3);
    }
}