use std::time::Duration;
use hitbox::CacheStatus;
use hitbox::policy::PolicyConfig;
use hitbox_derive::{CacheableResponse, cached};
use hitbox_fn::Cache;
use hitbox_moka::MokaBackend;
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub struct DbConnection {
_id: u64,
}
impl DbConnection {
pub fn new(id: u64) -> Self {
Self { _id: id }
}
}
#[cached]
pub async fn no_args_function() -> i64 {
42
}
#[cached(prefix = "compute", skip(_request_id))]
pub async fn compute_with_skip(_request_id: String, value: i64) -> i64 {
value * 2
}
#[cached(prefix = "multi", skip(_trace_id, _span_id))]
pub async fn multi_params(a: i64, _trace_id: String, b: i64, _span_id: String) -> i64 {
a + b
}
#[cached(prefix = "all_skip", skip(_id1, _id2))]
pub async fn all_params_skipped(_id1: String, _id2: String) -> i64 {
42
}
#[cached(prefix = "first_skip", skip(_skip))]
pub async fn first_param_skipped(_skip: i64, keep: i64) -> i64 {
keep
}
#[cached(prefix = "last_skip", skip(_skip))]
pub async fn last_param_skipped(keep: i64, _skip: i64) -> i64 {
keep
}
#[cached(prefix = "with_db", skip(_db))]
pub async fn with_db_connection(_db: DbConnection, user_id: i64) -> String {
format!("user_{}", user_id)
}
use hitbox_core::KeyPart;
use hitbox_fn::KeyExtract;
#[derive(Debug, Clone)]
pub struct TypedId {
id: i64,
label: &'static str,
}
impl TypedId {
pub fn new(id: i64, label: &'static str) -> Self {
Self { id, label }
}
}
impl KeyExtract for TypedId {
fn extract(&self) -> Vec<KeyPart> {
vec![
KeyPart::new("label", Some(self.label.to_string())),
KeyPart::new("id", Some(self.id.to_string())),
]
}
}
#[cached]
pub async fn generic_function<T: KeyExtract + Clone + std::fmt::Debug + Send + Sync + 'static>(
value: T,
) -> String {
format!("{:?}", value)
}
#[cached(skip(_ctx))]
pub async fn generic_with_skip<T: KeyExtract + Clone + std::fmt::Debug + Send + Sync + 'static>(
_ctx: String,
value: T,
) -> String {
format!("{:?}", value)
}
fn create_cache()
-> Cache<MokaBackend, hitbox::concurrency::NoopConcurrencyManager, hitbox_core::DisabledOffload> {
Cache::builder()
.backend(MokaBackend::builder().max_entries(100).build())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build()
}
#[tokio::test]
async fn test_skipped_param_not_in_cache_key() {
let cache = create_cache();
let (r1, c1) = compute_with_skip("req-1".into(), 10)
.cache(&cache)
.with_context()
.await;
let (r2, c2) = compute_with_skip("req-2".into(), 10)
.cache(&cache)
.with_context()
.await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_included_param_affects_cache_key() {
let cache = create_cache();
let (_, c1) = compute_with_skip("req-1".into(), 10)
.cache(&cache)
.with_context()
.await;
let (_, c2) = compute_with_skip("req-1".into(), 20)
.cache(&cache)
.with_context()
.await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Miss);
}
#[tokio::test]
async fn test_multiple_skipped_params() {
let cache = create_cache();
let (r1, c1) = multi_params(1, "trace-1".into(), 2, "span-1".into())
.cache(&cache)
.with_context()
.await;
let cache2 = cache.clone();
let (r2, c2) = multi_params(1, "trace-2".into(), 2, "span-2".into())
.cache(&cache2)
.with_context()
.await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_multiple_params_different_values() {
let cache = create_cache();
let (_, c1) = multi_params(1, "trace".into(), 2, "span".into())
.cache(&cache)
.with_context()
.await;
let (_, c2) = multi_params(100, "trace".into(), 2, "span".into())
.cache(&cache)
.with_context()
.await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Miss);
}
#[tokio::test]
async fn test_all_params_skipped_same_key() {
let cache = create_cache();
let (r1, c1) = all_params_skipped("a".into(), "b".into())
.cache(&cache)
.with_context()
.await;
let (r2, c2) = all_params_skipped("x".into(), "y".into())
.cache(&cache)
.with_context()
.await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_first_param_skipped() {
let cache = create_cache();
let (_, c1) = first_param_skipped(999, 42)
.cache(&cache)
.with_context()
.await;
let (_, c2) = first_param_skipped(111, 42)
.cache(&cache)
.with_context()
.await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_last_param_skipped() {
let cache = create_cache();
let (_, c1) = last_param_skipped(42, 999)
.cache(&cache)
.with_context()
.await;
let (_, c2) = last_param_skipped(42, 111)
.cache(&cache)
.with_context()
.await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_skipped_type_without_key_extract() {
let cache = create_cache();
let db1 = DbConnection::new(1);
let db2 = DbConnection::new(2);
let (r1, c1) = with_db_connection(db1, 42)
.cache(&cache)
.with_context()
.await;
let (r2, c2) = with_db_connection(db2, 42)
.cache(&cache)
.with_context()
.await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_generic_function_same_value() {
let cache = create_cache();
let id1 = TypedId::new(42, "user");
let id2 = TypedId::new(42, "user");
let (r1, c1) = generic_function(id1).cache(&cache).with_context().await;
let (r2, c2) = generic_function(id2).cache(&cache).with_context().await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_generic_function_different_value() {
let cache = create_cache();
let id1 = TypedId::new(1, "user");
let id2 = TypedId::new(2, "user");
let (_, c1) = generic_function(id1).cache(&cache).with_context().await;
let (_, c2) = generic_function(id2).cache(&cache).with_context().await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Miss);
}
#[tokio::test]
async fn test_generic_function_different_label() {
let cache = create_cache();
let id1 = TypedId::new(42, "user");
let id2 = TypedId::new(42, "product");
let (_, c1) = generic_function(id1).cache(&cache).with_context().await;
let (_, c2) = generic_function(id2).cache(&cache).with_context().await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Miss);
}
#[tokio::test]
async fn test_generic_with_skip() {
let cache = create_cache();
let id1 = TypedId::new(42, "user");
let id2 = TypedId::new(42, "user");
let (r1, c1) = generic_with_skip("ctx-1".to_string(), id1)
.cache(&cache)
.with_context()
.await;
let (r2, c2) = generic_with_skip("ctx-2".to_string(), id2)
.cache(&cache)
.with_context()
.await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, CacheableResponse)]
pub struct AuthResult {
pub user_id: u64,
pub permissions: Vec<String>,
#[cacheable_response(skip)]
pub access_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AuthError;
#[cached]
pub async fn authenticate(user_id: i64) -> Result<AuthResult, AuthError> {
Ok(AuthResult {
user_id: user_id as u64,
permissions: vec!["read".into(), "write".into()],
access_token: Some("secret-token".into()),
})
}
#[tokio::test]
async fn test_skipped_response_field_preserved_on_miss() {
let cache = create_cache();
let (result, ctx) = authenticate(1).cache(&cache).with_context().await;
assert_eq!(ctx.status, CacheStatus::Miss);
let auth = result.unwrap();
assert_eq!(auth.access_token, Some("secret-token".into()));
assert_eq!(auth.permissions, vec!["read", "write"]);
}
#[tokio::test]
async fn test_skipped_response_field_default_on_hit() {
let cache = create_cache();
let (r1, c1) = authenticate(2).cache(&cache).with_context().await;
let (r2, c2) = authenticate(2).cache(&cache).with_context().await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
assert_eq!(
r1.as_ref().unwrap().access_token,
Some("secret-token".into())
);
assert_eq!(r2.as_ref().unwrap().access_token, None);
assert_eq!(r1.as_ref().unwrap().user_id, r2.as_ref().unwrap().user_id);
assert_eq!(
r1.as_ref().unwrap().permissions,
r2.as_ref().unwrap().permissions
);
}
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv_format",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct NonCloneable {
pub value: String,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, CacheableResponse)]
pub struct ResponseWithNonCloneable {
pub id: u64,
#[cacheable_response(skip)]
pub ctx: NonCloneable,
}
#[cached(prefix = "non_clone")]
pub async fn get_with_non_cloneable(id: i64) -> Result<ResponseWithNonCloneable, AuthError> {
Ok(ResponseWithNonCloneable {
id: id as u64,
ctx: NonCloneable {
value: "original".into(),
},
})
}
#[tokio::test]
async fn test_skipped_field_no_clone_bound() {
let cache = create_cache();
let (r1, c1) = get_with_non_cloneable(1).cache(&cache).with_context().await;
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(r1.as_ref().unwrap().ctx.value, "original");
let (r2, c2) = get_with_non_cloneable(1).cache(&cache).with_context().await;
assert_eq!(c2.status, CacheStatus::Hit);
assert_eq!(r2.as_ref().unwrap().ctx.value, "");
assert_eq!(r1.as_ref().unwrap().id, r2.as_ref().unwrap().id);
}
#[tokio::test]
async fn test_zero_args_always_same_key() {
use std::sync::Arc;
let cache = Arc::new(create_cache());
let (r1, c1) = no_args_function().cache(&cache).with_context().await;
let (r2, c2) = no_args_function().cache(&cache).with_context().await;
assert_eq!(r1, r2);
assert_eq!(c1.status, CacheStatus::Miss);
assert_eq!(c2.status, CacheStatus::Hit);
}
#[tokio::test]
async fn test_zero_args_generated_key() {
use hitbox::Extractor;
use hitbox_fn::{Arg, Args, FnExtractor, Skipped};
let args = Args::new(());
assert_eq!(args.into_inner(), ());
let arg = Arg::new("x", 42i64);
assert_eq!(*arg.value(), 42);
let skipped = Skipped::new("ctx");
assert_eq!(*skipped.value(), "ctx");
let extractor = FnExtractor::<Args<()>>::new("no_args_function");
let (_, key) = extractor.get(Args(())).await.into_cache_key();
assert_eq!(key.to_string(), "fn=no_args_function");
}
#[tokio::test]
async fn test_passthrough_no_backend() {
let result = compute_with_skip("req-1".into(), 10).await;
assert_eq!(result, 20);
}
#[tokio::test]
async fn test_passthrough_zero_args() {
let result = no_args_function().await;
assert_eq!(result, 42);
}
#[tokio::test]
async fn test_passthrough_generic() {
let id = TypedId::new(42, "user");
let result = generic_function(id).await;
assert!(result.contains("42"));
}
#[tokio::test]
async fn test_inline_backend_policy() {
let backend = MokaBackend::builder().max_entries(100).build();
let policy = PolicyConfig::builder().ttl(Duration::from_secs(60)).build();
let result = compute_with_skip("req-1".into(), 10)
.backend(backend)
.policy(policy)
.await;
assert_eq!(result, 20);
}
#[tokio::test]
async fn test_inline_backend_policy_with_context() {
let backend = MokaBackend::builder().max_entries(100).build();
let policy = PolicyConfig::builder().ttl(Duration::from_secs(60)).build();
let (result, ctx) = compute_with_skip("req-1".into(), 10)
.backend(backend)
.policy(policy)
.with_context()
.await;
assert_eq!(result, 20);
assert_eq!(ctx.status, CacheStatus::Miss);
}
mod spy_backend {
use async_trait::async_trait;
use dashmap::DashMap;
use hitbox::backend::{Backend, BackendError, CacheBackend, DeleteStatus};
use hitbox_backend::format::RonFormat;
use hitbox_core::{CacheKey, CacheValue, Raw};
pub struct SpyBackend {
store: DashMap<String, CacheValue<Raw>>,
}
impl SpyBackend {
pub fn new() -> Self {
Self {
store: DashMap::new(),
}
}
pub fn keys(&self) -> Vec<String> {
self.store.iter().map(|e| e.key().clone()).collect()
}
pub fn value_as_ron(&self, key: &str) -> Option<String> {
self.store.get(key).map(|entry| {
let (_, raw) = entry.value().clone().into_parts();
String::from_utf8(raw.to_vec()).unwrap()
})
}
}
#[async_trait]
impl Backend for SpyBackend {
async fn read(&self, _key: &CacheKey) -> Result<Option<CacheValue<Raw>>, BackendError> {
Ok(None)
}
async fn write(&self, key: &CacheKey, value: CacheValue<Raw>) -> Result<(), BackendError> {
self.store.insert(key.to_string(), value);
Ok(())
}
async fn remove(&self, _key: &CacheKey) -> Result<DeleteStatus, BackendError> {
Ok(DeleteStatus::Missing)
}
fn value_format(&self) -> &dyn hitbox_backend::format::Format {
&RonFormat
}
}
impl CacheBackend for SpyBackend {}
}
use spy_backend::SpyBackend;
#[tokio::test]
async fn test_key_zero_args() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
no_args_function().cache(&cache).await;
let keys = cache.backend().keys();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], "fn=no_args_function");
let cache2 = Cache::builder()
.backend(SpyBackend::new())
.policy(cache.policy().as_ref().clone())
.concurrency_manager(cache.concurrency_manager().clone())
.offload(*cache.offload())
.build();
no_args_function().cache(&cache2).await;
let keys2 = cache2.backend().keys();
assert_eq!(keys2.len(), 1);
assert_eq!(keys2[0], "fn=no_args_function");
}
#[tokio::test]
async fn test_key_with_skip() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
compute_with_skip("req-1".into(), 10).cache(&cache).await;
let keys = cache.backend().keys();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], "fn=compute&value=10");
}
#[tokio::test]
async fn test_key_multiple_params_with_skip() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
multi_params(1, "trace-1".into(), 2, "span-1".into())
.cache(&cache)
.await;
let keys = cache.backend().keys();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], "fn=multi&a=1&b=2");
}
#[tokio::test]
async fn test_value_i64() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
no_args_function().cache(&cache).await;
let ron = cache.backend().value_as_ron("fn=no_args_function").unwrap();
assert_eq!(ron, "42");
}
#[tokio::test]
async fn test_value_computed() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
compute_with_skip("req-1".into(), 10).cache(&cache).await;
let ron = cache.backend().value_as_ron("fn=compute&value=10").unwrap();
assert_eq!(ron, "20");
}
#[tokio::test]
async fn test_value_string() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
with_db_connection(DbConnection::new(1), 42)
.cache(&cache)
.await;
let ron = cache
.backend()
.value_as_ron("fn=with_db&user_id=42")
.unwrap();
assert_eq!(ron, "\"user_42\"");
}
#[tokio::test]
async fn test_value_skipped_response_field() {
let cache = Cache::builder()
.backend(SpyBackend::new())
.policy(PolicyConfig::builder().ttl(Duration::from_secs(60)).build())
.build();
let _ = authenticate(1).cache(&cache).await;
let ron = cache
.backend()
.value_as_ron("fn=authenticate&user_id=1")
.unwrap();
assert!(ron.contains("user_id:1"));
assert!(ron.contains("permissions:[\"read\",\"write\"]"));
assert!(!ron.contains("access_token"));
}