hydracache 0.45.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 crate::tests::common::{user, LoaderError, User};
use crate::HydraCache;

#[tokio::test]
async fn concurrent_loader_errors_are_shared_and_retry_is_possible() {
    let cache = HydraCache::local().build();
    let calls = Arc::new(AtomicUsize::new(0));
    let mut tasks = Vec::new();

    for _ in 0..6 {
        let cache = cache.clone();
        let calls = calls.clone();
        tasks.push(tokio::spawn(async move {
            cache
                .get_or_load("user:error", CacheOptions::new(), move || {
                    let calls = calls.clone();
                    async move {
                        calls.fetch_add(1, Ordering::SeqCst);
                        tokio::time::sleep(Duration::from_millis(10)).await;
                        Err::<User, _>(LoaderError)
                    }
                })
                .await
        }));
    }

    for task in tasks {
        assert!(task.await.unwrap().is_err());
    }

    assert_eq!(calls.load(Ordering::SeqCst), 1);
    assert_eq!(cache.stats().single_flight_joins, 5);

    let retry_calls = calls.clone();
    let retry = cache
        .get_or_load("user:error", CacheOptions::new(), move || async move {
            retry_calls.fetch_add(1, Ordering::SeqCst);
            Ok::<_, LoaderError>(user(9))
        })
        .await
        .unwrap();

    assert_eq!(retry, user(9));
    assert_eq!(calls.load(Ordering::SeqCst), 2);
}

#[tokio::test]
async fn stress_concurrent_single_flight_same_key() {
    let cache = HydraCache::local().build();
    let calls = Arc::new(AtomicUsize::new(0));
    let mut tasks = Vec::new();

    for _ in 0..64 {
        let cache = cache.clone();
        let calls = calls.clone();
        tasks.push(tokio::spawn(async move {
            for _ in 0..8 {
                let value = cache
                    .get_or_load("user:stress", CacheOptions::new().tag("users"), {
                        let calls = calls.clone();
                        move || async move {
                            calls.fetch_add(1, Ordering::SeqCst);
                            tokio::time::sleep(Duration::from_millis(1)).await;
                            Ok::<_, LoaderError>(user(42))
                        }
                    })
                    .await
                    .unwrap();
                assert_eq!(value, user(42));
            }
        }));
    }

    for task in tasks {
        task.await.unwrap();
    }

    assert_eq!(calls.load(Ordering::SeqCst), 1);
    assert!(cache.stats().single_flight_joins > 0);
}

#[tokio::test]
async fn stress_concurrent_loads_and_invalidations_stay_usable() {
    let cache = HydraCache::local().build();
    let loads = Arc::new(AtomicUsize::new(0));
    let mut tasks = Vec::new();

    for worker in 0..24 {
        let cache = cache.clone();
        let loads = loads.clone();
        tasks.push(tokio::spawn(async move {
            for step in 0..20 {
                let key = format!("user:{}", step % 4);
                let expected = user((worker * 100 + step) as u64);

                let loaded = cache
                    .get_or_load(
                        &key,
                        CacheOptions::new().tags(["users", "tenant:stress"]),
                        {
                            let loads = loads.clone();
                            let expected = expected.clone();
                            move || async move {
                                loads.fetch_add(1, Ordering::SeqCst);
                                tokio::task::yield_now().await;
                                Ok::<_, LoaderError>(expected)
                            }
                        },
                    )
                    .await
                    .unwrap();

                assert!(loaded.name.starts_with("user-"));

                if step % 5 == 0 {
                    cache.invalidate_tag("users").await.unwrap();
                }

                if step % 11 == 0 {
                    cache.invalidate_tag("tenant:stress").await.unwrap();
                }
            }
        }));
    }

    for task in tasks {
        task.await.unwrap();
    }

    assert!(loads.load(Ordering::SeqCst) > 0);
    assert!(cache.stats().invalidations > 0);
}

#[tokio::test]
async fn stress_concurrent_put_remove_flush_and_load_stays_usable() {
    let cache = HydraCache::local()
        .default_ttl(Duration::from_millis(250))
        .build();
    let mut tasks = Vec::new();

    for worker in 0..16 {
        let cache = cache.clone();
        tasks.push(tokio::spawn(async move {
            for step in 0..40 {
                let key = format!("mixed:{}", step % 8);
                match (worker + step) % 5 {
                    0 => {
                        cache
                            .put(
                                &key,
                                user((worker * 1000 + step) as u64),
                                CacheOptions::new().tag("mixed"),
                            )
                            .await
                            .unwrap();
                    }
                    1 => {
                        let _: Option<User> = cache.get(&key).await.unwrap();
                    }
                    2 => {
                        cache.remove(&key).await.unwrap();
                    }
                    3 => {
                        let value = user((worker * 1000 + step) as u64);
                        let loaded = cache
                            .get_or_load(&key, CacheOptions::new().tag("mixed"), move || {
                                let value = value.clone();
                                async move {
                                    tokio::task::yield_now().await;
                                    Ok::<_, LoaderError>(value)
                                }
                            })
                            .await
                            .unwrap();
                        assert!(loaded.name.starts_with("user-"));
                    }
                    _ => {
                        if step % 2 == 0 {
                            cache.invalidate_tag("mixed").await.unwrap();
                        } else {
                            cache.flush().await.unwrap();
                        }
                    }
                }
            }
        }));
    }

    for task in tasks {
        task.await.unwrap();
    }

    cache
        .put("mixed:final", user(999), CacheOptions::new().tag("mixed"))
        .await
        .unwrap();
    let cached: Option<User> = cache.get("mixed:final").await.unwrap();
    assert_eq!(cached, Some(user(999)));
}