engram-cache 0.1.0

An asynchronous, multi-tier cache backed by Moka and SQLite
Documentation
use std::{
    sync::{
        atomic::{AtomicUsize, Ordering},
        Arc,
    },
    time::Duration,
};

use engram::{CacheKey, Compression, Engram};
use tempfile::TempDir;

async fn cache(directory: &TempDir) -> Engram {
    Engram::builder()
        .storage(directory.path().join("engram.db"))
        .compression(Compression::Zstd { level: 3 })
        .build()
        .await
        .unwrap()
}

#[tokio::test]
async fn misses_and_expired_values_are_not_returned() {
    let directory = TempDir::new().unwrap();
    let cache = cache(&directory).await;
    assert_eq!(cache.get::<String>("missing").await.unwrap(), None);
    cache
        .set("short", &"value".to_owned(), Some(Duration::from_millis(1)))
        .await
        .unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;
    assert_eq!(cache.get::<String>("short").await.unwrap(), None);
}

#[tokio::test]
async fn persisted_values_survive_restart_and_preserve_bytes() {
    let directory = TempDir::new().unwrap();
    let path = directory.path().join("engram.db");
    let payload = "a long, compressible payload ".repeat(256);
    let first = Engram::builder()
        .storage(&path)
        .compression(Compression::Zstd { level: 3 })
        .build()
        .await
        .unwrap();
    first.set("payload", &payload, None).await.unwrap();
    drop(first);
    let reopened = Engram::builder()
        .storage(path)
        .compression(Compression::Disabled)
        .build()
        .await
        .unwrap();
    assert_eq!(
        reopened.get::<String>("payload").await.unwrap(),
        Some(payload)
    );
}

#[tokio::test]
async fn removal_clear_and_type_checks_cover_both_tiers() {
    let directory = TempDir::new().unwrap();
    let cache = cache(&directory).await;
    cache.set("remove", &1_u64, None).await.unwrap();
    cache.remove("remove").await.unwrap();
    assert_eq!(cache.get::<u64>("remove").await.unwrap(), None);
    cache.set("typed", &1_u64, None).await.unwrap();
    assert!(matches!(
        cache.get::<String>("typed").await,
        Err(engram::Error::TypeMismatch { .. })
    ));
    cache.set("one", &1_u64, None).await.unwrap();
    cache.set("two", &2_u64, None).await.unwrap();
    cache.clear().await.unwrap();
    assert_eq!(cache.get::<u64>("one").await.unwrap(), None);
    assert_eq!(cache.get::<u64>("two").await.unwrap(), None);
}

#[tokio::test]
async fn concurrent_initialization_is_coalesced() {
    let directory = TempDir::new().unwrap();
    let cache = Arc::new(cache(&directory).await);
    let calls = Arc::new(AtomicUsize::new(0));
    let mut tasks = Vec::new();
    for _ in 0..16 {
        let cache = cache.clone();
        let calls = calls.clone();
        tasks.push(tokio::spawn(async move {
            cache
                .get_or_try_init("one", None, move || async move {
                    calls.fetch_add(1, Ordering::SeqCst);
                    tokio::time::sleep(Duration::from_millis(10)).await;
                    Ok("initialized".to_owned())
                })
                .await
                .unwrap()
        }));
    }
    for task in tasks {
        assert_eq!(task.await.unwrap(), "initialized");
    }
    assert_eq!(calls.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn concurrent_writes_remain_readable() {
    let directory = TempDir::new().unwrap();
    let cache = Arc::new(cache(&directory).await);
    let mut tasks = Vec::new();
    for value in 0_u64..32 {
        let cache = cache.clone();
        tasks.push(tokio::spawn(async move {
            cache
                .set(format!("key:{value}"), &value, None)
                .await
                .unwrap();
        }));
    }
    for task in tasks {
        task.await.unwrap();
    }
    for value in 0_u64..32 {
        assert_eq!(
            cache.get::<u64>(format!("key:{value}")).await.unwrap(),
            Some(value)
        );
    }
}

#[tokio::test]
async fn metadata_exposes_expiry_and_permanent_entries() {
    let directory = TempDir::new().unwrap();
    let cache = cache(&directory).await;
    cache.set("permanent", &1_u64, None).await.unwrap();
    let permanent = cache.metadata("permanent").await.unwrap().unwrap();
    assert_eq!(permanent.tier, engram::CacheTier::Memory);
    assert_eq!(permanent.expires_at, None);
    assert_eq!(permanent.remaining_ttl(), None);

    cache
        .set("temporary", &2_u64, Some(Duration::from_secs(5)))
        .await
        .unwrap();
    let temporary = cache
        .get_with_metadata::<u64>("temporary")
        .await
        .unwrap()
        .unwrap();
    assert!(temporary.metadata.expires_at.is_some());
    assert!(temporary.metadata.remaining_ttl().is_some());
}

#[tokio::test]
async fn arc_and_batch_apis_avoid_extra_value_copies_and_persist() {
    let directory = TempDir::new().unwrap();
    let cache = cache(&directory).await;
    let source = Arc::new(vec![7_u8; 8 * 1024]);
    cache.set_arc("shared", source.clone(), None).await.unwrap();
    let retrieved = cache.get_arc::<Vec<u8>>("shared").await.unwrap().unwrap();
    assert!(Arc::ptr_eq(&source, &retrieved));

    cache
        .set_many([("one", 1_u64), ("two", 2_u64)], None)
        .await
        .unwrap();
    assert_eq!(cache.get::<u64>("one").await.unwrap(), Some(1));
    assert_eq!(cache.get::<u64>("two").await.unwrap(), Some(2));
    cache
        .set_with_weight("weighted", &3_u64, None, 1)
        .await
        .unwrap();
    assert_eq!(cache.get::<u64>("weighted").await.unwrap(), Some(3));
}

#[tokio::test]
async fn invalid_database_path_surfaces_sqlite_error() {
    let directory = TempDir::new().unwrap();
    let result = Engram::builder()
        .storage(directory.path().join("absent").join("cache.db"))
        .build()
        .await;
    assert!(matches!(result, Err(engram::Error::Sqlite(_))));
}

#[tokio::test]
async fn caller_controlled_schema_tags_survive_type_name_changes() {
    let directory = TempDir::new().unwrap();
    let cache = cache(&directory).await;
    let v1 = CacheKey::<u64>::new("project:1", "project-record@1");
    cache.set_key(&v1, &42, None).await.unwrap();

    assert_eq!(cache.get_key(&v1).await.unwrap(), Some(42));
    let incompatible = CacheKey::<u64>::new("project:1", "project-record@2");
    assert!(matches!(
        cache.get_key(&incompatible).await,
        Err(engram::Error::TypeMismatch { .. })
    ));
}