hydracache 0.10.0

User-facing HydraCache runtime crate.
Documentation
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;

use hydracache_core::CacheOptions;
use tokio::sync::oneshot;

use crate::tests::common::{user, LoaderError, User};
use crate::HydraCache;

#[tokio::test]
async fn invalidating_tag_during_load_discards_stale_store() {
    let cache = HydraCache::local().build();
    let (started_tx, started_rx) = oneshot::channel();
    let (release_tx, release_rx) = oneshot::channel();
    let load_cache = cache.clone();

    let task = tokio::spawn(async move {
        load_cache
            .get_or_load(
                "user:stale",
                CacheOptions::new().tag("users"),
                move || async move {
                    started_tx.send(()).unwrap();
                    release_rx.await.unwrap();
                    Ok::<_, LoaderError>(user(1))
                },
            )
            .await
            .unwrap()
    });

    started_rx.await.unwrap();
    assert_eq!(cache.invalidate_tag("users").await.unwrap(), 0);
    release_tx.send(()).unwrap();

    assert_eq!(task.await.unwrap(), user(1));
    let cached: Option<User> = cache.get("user:stale").await.unwrap();
    assert_eq!(cached, None);
    assert_eq!(cache.stats().stale_load_discards, 1);
}

#[tokio::test]
async fn post_invalidation_caller_starts_fresh_load() {
    let cache = HydraCache::local().build();
    let calls = Arc::new(AtomicUsize::new(0));
    let (started_tx, started_rx) = oneshot::channel();
    let (release_tx, release_rx) = oneshot::channel();
    let stale_cache = cache.clone();
    let stale_calls = calls.clone();

    let stale_task = tokio::spawn(async move {
        stale_cache
            .get_or_load(
                "user:race",
                CacheOptions::new().tag("users"),
                move || async move {
                    stale_calls.fetch_add(1, Ordering::SeqCst);
                    started_tx.send(()).unwrap();
                    release_rx.await.unwrap();
                    Ok::<_, LoaderError>(user(1))
                },
            )
            .await
            .unwrap()
    });

    started_rx.await.unwrap();
    assert_eq!(cache.invalidate_tag("users").await.unwrap(), 0);

    let fresh_calls = calls.clone();
    let fresh = cache
        .get_or_load(
            "user:race",
            CacheOptions::new().tag("users"),
            move || async move {
                fresh_calls.fetch_add(1, Ordering::SeqCst);
                Ok::<_, LoaderError>(user(2))
            },
        )
        .await
        .unwrap();

    release_tx.send(()).unwrap();

    assert_eq!(fresh, user(2));
    assert_eq!(stale_task.await.unwrap(), user(1));
    assert_eq!(calls.load(Ordering::SeqCst), 2);
    let cached: Option<User> = cache.get("user:race").await.unwrap();
    assert_eq!(cached, Some(user(2)));
}

#[tokio::test]
async fn flush_and_multi_tag_invalidation_discard_stale_loads() {
    let cache = HydraCache::local().build();
    let (started_tx, started_rx) = oneshot::channel();
    let (release_tx, release_rx) = oneshot::channel();
    let load_cache = cache.clone();

    let task = tokio::spawn(async move {
        load_cache
            .get_or_load(
                "user:multi-tag",
                CacheOptions::new().tags(["users", "tenant:1"]),
                move || async move {
                    started_tx.send(()).unwrap();
                    release_rx.await.unwrap();
                    Ok::<_, LoaderError>(user(1))
                },
            )
            .await
            .unwrap()
    });

    started_rx.await.unwrap();
    assert_eq!(cache.invalidate_tag("tenant:1").await.unwrap(), 0);
    release_tx.send(()).unwrap();
    assert_eq!(task.await.unwrap(), user(1));

    let cached: Option<User> = cache.get("user:multi-tag").await.unwrap();
    assert_eq!(cached, None);

    let (started_tx, started_rx) = oneshot::channel();
    let (release_tx, release_rx) = oneshot::channel();
    let flush_cache = cache.clone();
    let flush_task = tokio::spawn(async move {
        flush_cache
            .get_or_load(
                "user:flush",
                CacheOptions::new().tag("users"),
                move || async move {
                    started_tx.send(()).unwrap();
                    release_rx.await.unwrap();
                    Ok::<_, LoaderError>(user(2))
                },
            )
            .await
            .unwrap()
    });

    started_rx.await.unwrap();
    cache.flush().await.unwrap();
    release_tx.send(()).unwrap();
    assert_eq!(flush_task.await.unwrap(), user(2));
    let cached: Option<User> = cache.get("user:flush").await.unwrap();
    assert_eq!(cached, None);
}

#[tokio::test]
async fn untagged_load_is_not_guarded_by_tag_generation() {
    let cache = HydraCache::local().build();
    let (started_tx, started_rx) = oneshot::channel();
    let (release_tx, release_rx) = oneshot::channel();
    let load_cache = cache.clone();

    let task = tokio::spawn(async move {
        load_cache
            .get_or_load("user:untagged", CacheOptions::new(), move || async move {
                started_tx.send(()).unwrap();
                release_rx.await.unwrap();
                Ok::<_, LoaderError>(user(1))
            })
            .await
            .unwrap()
    });

    started_rx.await.unwrap();
    assert_eq!(cache.invalidate_tag("users").await.unwrap(), 0);
    release_tx.send(()).unwrap();

    assert_eq!(task.await.unwrap(), user(1));
    let cached: Option<User> = cache.get("user:untagged").await.unwrap();
    assert_eq!(cached, Some(user(1)));
}

#[tokio::test]
async fn stale_load_does_not_overwrite_fresh_value() {
    let cache = HydraCache::local().build();
    let (started_tx, started_rx) = oneshot::channel();
    let (release_tx, release_rx) = oneshot::channel();
    let stale_cache = cache.clone();

    let stale_task = tokio::spawn(async move {
        stale_cache
            .get_or_load(
                "user:overwrite",
                CacheOptions::new().tag("users"),
                move || async move {
                    started_tx.send(()).unwrap();
                    release_rx.await.unwrap();
                    Ok::<_, LoaderError>(user(1))
                },
            )
            .await
            .unwrap()
    });

    started_rx.await.unwrap();
    assert_eq!(cache.invalidate_tag("users").await.unwrap(), 0);

    let fresh = cache
        .get_or_load(
            "user:overwrite",
            CacheOptions::new().tag("users"),
            || async { Ok::<_, LoaderError>(user(2)) },
        )
        .await
        .unwrap();

    release_tx.send(()).unwrap();
    assert_eq!(fresh, user(2));
    assert_eq!(stale_task.await.unwrap(), user(1));

    let cached: Option<User> = cache.get("user:overwrite").await.unwrap();
    assert_eq!(cached, Some(user(2)));
}

#[tokio::test]
async fn invalidation_without_in_flight_loader_does_not_increment_stale_discards() {
    let cache = HydraCache::local().build();

    cache
        .put("user:stats", user(1), CacheOptions::new().tag("users"))
        .await
        .unwrap();

    assert_eq!(cache.invalidate_tag("users").await.unwrap(), 1);

    let stats = cache.stats();
    assert_eq!(stats.invalidations, 1);
    assert_eq!(stats.stale_load_discards, 0);
}

#[tokio::test]
async fn per_entry_ttl_overrides_default_ttl() {
    let cache = HydraCache::local()
        .default_ttl(Duration::from_millis(20))
        .build();

    cache
        .put(
            "user:1",
            user(1),
            CacheOptions::new().ttl(Duration::from_millis(120)),
        )
        .await
        .unwrap();

    tokio::time::sleep(Duration::from_millis(50)).await;
    assert!(cache.contains_key("user:1").await);
}