diff --git a/examples/examples/memoization_derive.rs b/examples/examples/memoization_derive.rs index 5e3f060f..83a0f2d5 100644 --- a/examples/examples/memoization_derive.rs +++ b/examples/examples/memoization_derive.rs @@ -10,7 +10,7 @@ use std::time::Duration; -use hitbox::CacheStatus; +use hitbox::{CacheStatus, ForwardReason}; use hitbox_fn::Cache; use hitbox_fn::prelude::*; use hitbox_moka::MokaBackend; @@ -124,7 +124,7 @@ async fn main() { let (r1, c1) = get_user(UserId(1)).cache(&cache).with_context().await; let (r2, c2) = get_user(UserId(1)).cache(&cache).with_context().await; assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); // 2. Multiple args @@ -140,14 +140,14 @@ async fn main() { .cache(&cache) .with_context() .await; - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); - assert_eq!(c3.status, CacheStatus::Miss); // Different org = different key + assert_eq!(c3.status, CacheStatus::Forward(ForwardReason::Miss)); // Different org = different key // 3. Skip in CacheableResponse (tokens not cached but returned on miss) let (r1, c1) = authenticate(UserId(1)).cache(&cache).with_context().await; let (r2, c2) = authenticate(UserId(1)).cache(&cache).with_context().await; - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); assert!(r1.as_ref().unwrap().access_token.is_some()); // Present on miss assert!(r2.as_ref().unwrap().access_token.is_none()); // Skipped on hit (not in cache) @@ -165,12 +165,12 @@ async fn main() { }; let (_, c1) = search(q1).cache(&cache).with_context().await; let (_, c2) = search(q2).cache(&cache).with_context().await; // Same key despite different request_id - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); // 5. Zero-argument function let (_, c1) = get_config().cache(&cache).with_context().await; let (_, c2) = get_config().cache(&cache).with_context().await; - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } diff --git a/hitbox-backend/CHANGELOG.md b/hitbox-backend/CHANGELOG.md index 1d3910ff..f9a89a03 100644 --- a/hitbox-backend/CHANGELOG.md +++ b/hitbox-backend/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Propagate `created_at` through `CacheBackend::set()`/`get()` ([#269](https://github.com/hit-box/hitbox/pull/269)) ## [0.2.1] - 2026-02-05 diff --git a/hitbox-backend/src/backend.rs b/hitbox-backend/src/backend.rs index 8c7c37e1..ed7e5537 100644 --- a/hitbox-backend/src/backend.rs +++ b/hitbox-backend/src/backend.rs @@ -297,7 +297,8 @@ pub trait CacheBackend: Backend { ))) })?; - let cached_value = CacheValue::new(deserialized, meta.expire, meta.stale); + let cached_value = + CacheValue::new(deserialized, meta.expire, meta.stale, meta.created_at); // Refill L1 if read mode is Refill (data came from L2). // CompositionFormat will create L1-only envelope, so only L1 gets populated. @@ -357,7 +358,12 @@ pub trait CacheBackend: Backend { let result = self .write( key, - CacheValue::new(Bytes::from(compressed_value), value.expire(), value.stale()), + CacheValue::new( + Bytes::from(compressed_value), + value.expire(), + value.stale(), + value.created_at(), + ), ) .await; crate::metrics::record_write(backend_label.as_str(), write_timer.elapsed()); diff --git a/hitbox-backend/src/composition/compose.rs b/hitbox-backend/src/composition/compose.rs index dbb5b5f3..239af3ae 100644 --- a/hitbox-backend/src/composition/compose.rs +++ b/hitbox-backend/src/composition/compose.rs @@ -259,6 +259,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write and read @@ -311,6 +312,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Populate only L2 @@ -355,6 +357,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write through nested composition @@ -409,6 +412,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); diff --git a/hitbox-backend/src/composition/context.rs b/hitbox-backend/src/composition/context.rs index 05657e8c..c32c6ed2 100644 --- a/hitbox-backend/src/composition/context.rs +++ b/hitbox-backend/src/composition/context.rs @@ -5,7 +5,9 @@ use std::any::Any; -use hitbox_core::{BoxContext, CacheContext, CacheStatus, Context, ReadMode, ResponseSource}; +use hitbox_core::{ + BoxContext, CacheContext, CacheStatus, CacheTiming, Context, ReadMode, ResponseSource, +}; use smallbox::smallbox; use super::CompositionFormat; @@ -90,6 +92,22 @@ impl Context for CompositionContext { self.inner.set_read_mode(mode); } + fn timing(&self) -> Option<&CacheTiming> { + self.inner.timing() + } + + fn set_timing(&mut self, timing: Option) { + self.inner.set_timing(timing); + } + + fn stored(&self) -> bool { + self.inner.stored() + } + + fn set_stored(&mut self, stored: bool) { + self.inner.set_stored(stored); + } + fn as_any(&self) -> &dyn Any { self } diff --git a/hitbox-backend/src/composition/envelope.rs b/hitbox-backend/src/composition/envelope.rs index 609dddeb..24c2a81c 100644 --- a/hitbox-backend/src/composition/envelope.rs +++ b/hitbox-backend/src/composition/envelope.rs @@ -268,6 +268,7 @@ impl CompositionEnvelope { l1_data, header.decode_expire(), header.decode_stale(), + None, ))) } 1 => { @@ -289,6 +290,7 @@ impl CompositionEnvelope { l2_data, header.decode_expire(), header.decode_stale(), + None, ))) } 2 => { @@ -313,8 +315,18 @@ impl CompositionEnvelope { let l2_data = Bytes::copy_from_slice(&data[l1_end..l2_end]); Ok(CompositionEnvelope::Both { - l1: CacheValue::new(l1_data, header.decode_expire(), header.decode_stale()), - l2: CacheValue::new(l2_data, header.decode_expire(), header.decode_stale()), + l1: CacheValue::new( + l1_data, + header.decode_expire(), + header.decode_stale(), + None, + ), + l2: CacheValue::new( + l2_data, + header.decode_expire(), + header.decode_stale(), + None, + ), }) } _ => Err(BackendError::InternalError(Box::new(io::Error::new( @@ -343,7 +355,7 @@ mod tests { let expire = Some(Utc::now() + Duration::hours(1)); let stale = None; - let envelope = CompositionEnvelope::L1(CacheValue::new(data.clone(), expire, stale)); + let envelope = CompositionEnvelope::L1(CacheValue::new(data.clone(), expire, stale, None)); let serialized = envelope.serialize().unwrap(); let deserialized = CompositionEnvelope::deserialize(&serialized).unwrap(); @@ -366,8 +378,8 @@ mod tests { let stale = Some(Utc::now() + Duration::minutes(30)); let envelope = CompositionEnvelope::Both { - l1: CacheValue::new(l1_data.clone(), expire, stale), - l2: CacheValue::new(l2_data.clone(), expire, stale), + l1: CacheValue::new(l1_data.clone(), expire, stale, None), + l2: CacheValue::new(l2_data.clone(), expire, stale, None), }; let serialized = envelope.serialize().unwrap(); @@ -390,8 +402,8 @@ mod tests { let l2_data = Bytes::from(vec![1u8; 100_000]); let envelope = CompositionEnvelope::Both { - l1: CacheValue::new(l1_data.clone(), None, None), - l2: CacheValue::new(l2_data.clone(), None, None), + l1: CacheValue::new(l1_data.clone(), None, None, None), + l2: CacheValue::new(l2_data.clone(), None, None, None), }; let serialized = envelope.serialize().unwrap(); diff --git a/hitbox-backend/src/composition/format.rs b/hitbox-backend/src/composition/format.rs index 6818e3c3..42a83d67 100644 --- a/hitbox-backend/src/composition/format.rs +++ b/hitbox-backend/src/composition/format.rs @@ -218,8 +218,12 @@ impl Format for CompositionFormat { .map_err(|e| FormatError::Serialize(Box::new(e)))?; crate::metrics::record_compress(&self.l1_label, compress_timer.elapsed()); - let composition = - CompositionEnvelope::L1(CacheValue::new(Bytes::from(l1_compressed), None, None)); + let composition = CompositionEnvelope::L1(CacheValue::new( + Bytes::from(l1_compressed), + None, + None, + None, + )); return composition .serialize() @@ -259,8 +263,8 @@ impl Format for CompositionFormat { // Pack both compressed values into CompositionEnvelope let composition = CompositionEnvelope::Both { - l1: CacheValue::new(Bytes::from(l1_compressed), None, None), - l2: CacheValue::new(Bytes::from(l2_compressed), None, None), + l1: CacheValue::new(Bytes::from(l1_compressed), None, None, None), + l2: CacheValue::new(Bytes::from(l2_compressed), None, None, None), }; // Serialize the CompositionEnvelope using zero-copy repr(C) format diff --git a/hitbox-backend/src/composition/mod.rs b/hitbox-backend/src/composition/mod.rs index b9be6cf6..6d78b62e 100644 --- a/hitbox-backend/src/composition/mod.rs +++ b/hitbox-backend/src/composition/mod.rs @@ -429,7 +429,7 @@ where let (expire, stale) = (l1_value.expire(), l1_value.stale()); let envelope = CompositionEnvelope::L1(l1_value); match envelope.serialize() { - Ok(packed) => Ok(Some(CacheValue::new(packed, expire, stale))), + Ok(packed) => Ok(Some(CacheValue::new(packed, expire, stale, None))), Err(e) => Err(e), } } @@ -454,7 +454,7 @@ where let (expire, stale) = (l2_value.expire(), l2_value.stale()); let envelope = CompositionEnvelope::L2(l2_value); match envelope.serialize() { - Ok(packed) => Ok(Some(CacheValue::new(packed, expire, stale))), + Ok(packed) => Ok(Some(CacheValue::new(packed, expire, stale, None))), Err(e) => Err(e), } } @@ -681,7 +681,12 @@ where }; internal_ctx.set_source(ResponseSource::Backend(source)); - Ok(Some(CacheValue::new(deserialized, meta.expire, meta.stale))) + Ok(Some(CacheValue::new( + deserialized, + meta.expire, + meta.stale, + None, + ))) } None => Err(BackendError::InternalError(Box::new( std::io::Error::other("deserialization produced no result"), @@ -730,7 +735,7 @@ where Ok(()) => match deserialized_opt { Some(deserialized) => { let cache_value = - CacheValue::new(deserialized, meta.expire, meta.stale); + CacheValue::new(deserialized, meta.expire, meta.stale, None); // Set cache status and source for L2 hit internal_ctx.set_status(CacheStatus::Hit); @@ -824,7 +829,7 @@ where .map_err(|e| BackendError::InternalError(Box::new(e)))?; let l1_len = l1_bytes.len(); - let l1_value = CacheValue::new(l1_bytes, value.expire(), value.stale()); + let l1_value = CacheValue::new(l1_bytes, value.expire(), value.stale(), None); // Write to L1 with metrics let timer = Timer::new(); @@ -869,7 +874,7 @@ where .map_err(|e| BackendError::InternalError(Box::new(e)))?; let l1_len = l1_bytes.len(); - let l1_value = CacheValue::new(l1_bytes, value.expire(), value.stale()); + let l1_value = CacheValue::new(l1_bytes, value.expire(), value.stale(), None); // Write to L1 with metrics let timer = Timer::new(); @@ -912,8 +917,8 @@ where let l2_len = l2_bytes.len(); // Create raw values for Backend::write - let l1_value = CacheValue::new(l1_bytes, value.expire(), value.stale()); - let l2_value = CacheValue::new(l2_bytes, value.expire(), value.stale()); + let l1_value = CacheValue::new(l1_bytes, value.expire(), value.stale(), None); + let l2_value = CacheValue::new(l2_bytes, value.expire(), value.stale(), None); // Clone backends for 'static closures let l1 = self.l1.clone(); @@ -1139,6 +1144,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write to populate both layers @@ -1172,6 +1178,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Backend with RefillPolicy::Always @@ -1235,6 +1242,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let backend = CompositionBackend::new(l1.clone(), l2.clone(), TestOffload); @@ -1267,6 +1275,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let backend = CompositionBackend::new(l1.clone(), l2.clone(), TestOffload); @@ -1308,6 +1317,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write via original @@ -1345,6 +1355,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write only to innermost L1 (moka) @@ -1387,6 +1398,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write only to inner L2 (redis) - not to moka @@ -1429,6 +1441,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write only to outer L2 (disk) - not to inner composition @@ -1460,6 +1473,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write directly to L1 backend to set up the test @@ -1492,6 +1506,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let backend = CompositionBackend::new(l1.clone(), l2.clone(), TestOffload) @@ -1548,6 +1563,7 @@ mod tests { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write to innermost L1 (moka) diff --git a/hitbox-backend/tests/composition/builder.rs b/hitbox-backend/tests/composition/builder.rs index 2f2c28fe..2c1ffd9e 100644 --- a/hitbox-backend/tests/composition/builder.rs +++ b/hitbox-backend/tests/composition/builder.rs @@ -192,6 +192,7 @@ async fn test_backend_with_policy_functional() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); diff --git a/hitbox-backend/tests/composition/compose_api.rs b/hitbox-backend/tests/composition/compose_api.rs index f6c2c0f3..8f8787ae 100644 --- a/hitbox-backend/tests/composition/compose_api.rs +++ b/hitbox-backend/tests/composition/compose_api.rs @@ -29,6 +29,7 @@ async fn test_compose_trait_basic_usage() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write through composition @@ -75,6 +76,7 @@ async fn test_compose_with_custom_policy() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Populate only L2 @@ -116,6 +118,7 @@ async fn test_compose_nested_3_levels() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write cascades to all 3 levels @@ -160,6 +163,7 @@ async fn test_compose_with_builder_chaining() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Populate only L2 diff --git a/hitbox-backend/tests/composition/context_refill.rs b/hitbox-backend/tests/composition/context_refill.rs index 667e5ab1..b2819777 100644 --- a/hitbox-backend/tests/composition/context_refill.rs +++ b/hitbox-backend/tests/composition/context_refill.rs @@ -69,6 +69,7 @@ async fn test_direct_write_through_trait_object() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Create composition as trait object diff --git a/hitbox-backend/tests/composition/error_handling.rs b/hitbox-backend/tests/composition/error_handling.rs index 458bcae4..99bef93d 100644 --- a/hitbox-backend/tests/composition/error_handling.rs +++ b/hitbox-backend/tests/composition/error_handling.rs @@ -110,6 +110,7 @@ async fn test_both_layers_fail_set() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -198,6 +199,7 @@ async fn test_both_layers_fail_backend_write() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); diff --git a/hitbox-backend/tests/composition/nested.rs b/hitbox-backend/tests/composition/nested.rs index 9e52499a..afe47ce0 100644 --- a/hitbox-backend/tests/composition/nested.rs +++ b/hitbox-backend/tests/composition/nested.rs @@ -80,6 +80,7 @@ async fn test_nested_composition_static_dispatch() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write through nested composition - should populate all 3 levels @@ -125,6 +126,7 @@ async fn run_refill_3_levels_test( }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Step 1: Write through composition (populates all 3 levels in correct format) @@ -185,6 +187,7 @@ async fn run_refill_4_levels_test( }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write through composition @@ -235,6 +238,7 @@ async fn run_no_refill_never_policy_test( }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write through composition @@ -282,6 +286,7 @@ async fn run_never_policy_skips_refill_test( }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write through composition @@ -504,6 +509,7 @@ async fn test_nested_composition_dynamic_dispatch() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Write and read through dynamic dispatch @@ -539,6 +545,7 @@ async fn test_nested_composition_dynamic_as_trait_object() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Create nested composition @@ -599,6 +606,7 @@ async fn run_ttl_preserved_test( }, Some(expire_time), None, + None, ); // Write through composition @@ -653,6 +661,7 @@ async fn run_stale_preserved_test( }, Some(expire_time), Some(stale_time), + None, ); // Write through composition @@ -706,6 +715,7 @@ async fn run_no_ttl_no_stale_test(cache: B, l1: & }, None, None, + None, ); // Write through composition @@ -809,6 +819,7 @@ async fn test_nested_composition_delete_cascades() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Populate all levels diff --git a/hitbox-backend/tests/composition/policy/read.rs b/hitbox-backend/tests/composition/policy/read.rs index 4ec898c7..0b1d918a 100644 --- a/hitbox-backend/tests/composition/policy/read.rs +++ b/hitbox-backend/tests/composition/policy/read.rs @@ -26,7 +26,7 @@ async fn test_sequential_l1_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l1"), None, None); + let value = CacheValue::new(Bytes::from("from_l1"), None, None, None); l1.write(&key, value.clone()).await.unwrap(); @@ -52,7 +52,7 @@ async fn test_sequential_l2_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l2"), None, None); + let value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l2.write(&key, value.clone()).await.unwrap(); @@ -100,7 +100,7 @@ async fn test_sequential_l1_error_l2_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l2"), None, None); + let value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l2.write(&key, value.clone()).await.unwrap(); @@ -129,7 +129,7 @@ async fn test_race_l1_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l1"), None, None); + let value = CacheValue::new(Bytes::from("from_l1"), None, None, None); l1.write(&key, value.clone()).await.unwrap(); @@ -155,7 +155,7 @@ async fn test_race_l2_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l2"), None, None); + let value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l2.write(&key, value.clone()).await.unwrap(); @@ -203,7 +203,7 @@ async fn test_race_l1_error_l2_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l2"), None, None); + let value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l2.write(&key, value.clone()).await.unwrap(); @@ -233,12 +233,18 @@ async fn test_parallel_both_hit_prefer_l1() { let key = CacheKey::from_str("test", "key1"); - l1.write(&key, CacheValue::new(Bytes::from("from_l1"), None, None)) - .await - .unwrap(); - l2.write(&key, CacheValue::new(Bytes::from("from_l2"), None, None)) - .await - .unwrap(); + l1.write( + &key, + CacheValue::new(Bytes::from("from_l1"), None, None, None), + ) + .await + .unwrap(); + l2.write( + &key, + CacheValue::new(Bytes::from("from_l2"), None, None, None), + ) + .await + .unwrap(); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -263,7 +269,7 @@ async fn test_parallel_l1_miss_l2_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l2"), None, None); + let value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l2.write(&key, value.clone()).await.unwrap(); @@ -311,7 +317,7 @@ async fn test_parallel_l1_error_l2_hit() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("from_l2"), None, None); + let value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l2.write(&key, value.clone()).await.unwrap(); @@ -349,6 +355,7 @@ async fn test_parallel_both_hit_l2_fresher_ttl() { Bytes::from("from_l1"), Some(now + chrono::Duration::seconds(10)), None, + None, ); // L2 has longer TTL (expires in 60 seconds) @@ -356,6 +363,7 @@ async fn test_parallel_both_hit_l2_fresher_ttl() { Bytes::from("from_l2"), Some(now + chrono::Duration::seconds(60)), None, + None, ); l1.write(&key, l1_value).await.unwrap(); @@ -393,6 +401,7 @@ async fn test_parallel_both_hit_l1_fresher_ttl() { Bytes::from("from_l1"), Some(now + chrono::Duration::seconds(60)), None, + None, ); // L2 has shorter TTL (expires in 10 seconds) @@ -400,6 +409,7 @@ async fn test_parallel_both_hit_l1_fresher_ttl() { Bytes::from("from_l2"), Some(now + chrono::Duration::seconds(10)), None, + None, ); l1.write(&key, l1_value).await.unwrap(); @@ -434,9 +444,9 @@ async fn test_parallel_both_hit_equal_ttl() { let expiry = now + chrono::Duration::seconds(30); // Both have same TTL - let l1_value = CacheValue::new(Bytes::from("from_l1"), Some(expiry), None); + let l1_value = CacheValue::new(Bytes::from("from_l1"), Some(expiry), None, None); - let l2_value = CacheValue::new(Bytes::from("from_l2"), Some(expiry), None); + let l2_value = CacheValue::new(Bytes::from("from_l2"), Some(expiry), None, None); l1.write(&key, l1_value).await.unwrap(); l2.write(&key, l2_value).await.unwrap(); @@ -473,10 +483,11 @@ async fn test_parallel_both_hit_l2_no_expiry() { Bytes::from("from_l1"), Some(now + chrono::Duration::seconds(60)), None, + None, ); // L2 has no expiry (infinite TTL) - let l2_value = CacheValue::new(Bytes::from("from_l2"), None, None); + let l2_value = CacheValue::new(Bytes::from("from_l2"), None, None, None); l1.write(&key, l1_value).await.unwrap(); l2.write(&key, l2_value).await.unwrap(); @@ -509,13 +520,14 @@ async fn test_parallel_both_hit_l1_no_expiry() { let now = Utc::now(); // L1 has no expiry (infinite TTL) - let l1_value = CacheValue::new(Bytes::from("from_l1"), None, None); + let l1_value = CacheValue::new(Bytes::from("from_l1"), None, None, None); // L2 has expiry let l2_value = CacheValue::new( Bytes::from("from_l2"), Some(now + chrono::Duration::seconds(60)), None, + None, ); l1.write(&key, l1_value).await.unwrap(); diff --git a/hitbox-backend/tests/composition/policy/write.rs b/hitbox-backend/tests/composition/policy/write.rs index bb4497cb..b4e9d7c8 100644 --- a/hitbox-backend/tests/composition/policy/write.rs +++ b/hitbox-backend/tests/composition/policy/write.rs @@ -21,7 +21,7 @@ async fn test_sequential_both_succeed() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -48,7 +48,7 @@ async fn test_sequential_l1_fails() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -75,7 +75,7 @@ async fn test_sequential_l2_fails() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -103,7 +103,7 @@ async fn test_sequential_both_fail() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -131,7 +131,7 @@ async fn test_optimistic_parallel_both_succeed() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -158,7 +158,7 @@ async fn test_optimistic_parallel_l1_fails_l2_succeeds() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -185,7 +185,7 @@ async fn test_optimistic_parallel_l1_succeeds_l2_fails() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); @@ -212,7 +212,7 @@ async fn test_optimistic_parallel_both_fail() { let offload = TestOffloadManager; let key = CacheKey::from_str("test", "key1"); - let value = CacheValue::new(Bytes::from("test_value"), None, None); + let value = CacheValue::new(Bytes::from("test_value"), None, None, None); let l1_clone = l1.clone(); let l2_clone = l2.clone(); diff --git a/hitbox-backend/tests/composition/trait_objects.rs b/hitbox-backend/tests/composition/trait_objects.rs index 2547a8a2..7277d2b7 100644 --- a/hitbox-backend/tests/composition/trait_objects.rs +++ b/hitbox-backend/tests/composition/trait_objects.rs @@ -115,6 +115,7 @@ async fn test_boxed_composition_backend() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Should work through Box @@ -145,6 +146,7 @@ async fn test_arc_composition_backend() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Should work through Arc @@ -174,6 +176,7 @@ async fn test_ref_composition_backend() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Should work through reference @@ -204,6 +207,7 @@ async fn test_composition_as_dyn_backend() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Should work through trait object @@ -234,6 +238,7 @@ async fn test_arc_composition_as_dyn_backend() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Should work through Arc trait object @@ -264,6 +269,7 @@ async fn test_arc_sync_composition_as_dyn_backend() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // Should work through Arc'd trait object diff --git a/hitbox-backend/tests/erased/mod.rs b/hitbox-backend/tests/erased/mod.rs index b663c6dc..913267ef 100644 --- a/hitbox-backend/tests/erased/mod.rs +++ b/hitbox-backend/tests/erased/mod.rs @@ -42,7 +42,7 @@ impl Backend for MemBackend { let lock = self.storage.read().await; let key_str = String::from_utf8(CacheKeyFormat::UrlEncoded.serialize(key)?).unwrap(); let value = lock.get(&key_str).cloned(); - Ok(value.map(|value| CacheValue::new(value, Some(Utc::now()), Some(Utc::now())))) + Ok(value.map(|value| CacheValue::new(value, Some(Utc::now()), Some(Utc::now()), None))) } async fn write(&self, key: &CacheKey, value: CacheValue) -> BackendResult<()> { @@ -115,6 +115,7 @@ where }, Some(Utc::now()), Some(Utc::now()), + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); self.backend @@ -150,6 +151,7 @@ async fn dyn_backend() { }, Some(Utc::now()), Some(Utc::now()), + None, ); backend.set::(&key2, &value, &mut ctx).await.unwrap(); let value = backend.get::(&key2, &mut ctx).await.unwrap(); @@ -180,6 +182,7 @@ async fn test_composition_with_cloneable_backends() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); composition @@ -220,6 +223,7 @@ async fn test_composition_with_arc_dyn_backends() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); composition @@ -250,6 +254,7 @@ async fn test_composition_l1_l2_different_keys() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); l1_mem.set::(&key1, &value1, &mut ctx).await.unwrap(); @@ -262,6 +267,7 @@ async fn test_composition_l1_l2_different_keys() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); l2_mem.set::(&key2, &value2, &mut ctx).await.unwrap(); @@ -301,6 +307,7 @@ async fn test_composition_backend_as_trait_object() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); l1_mem .set::(&key_l1, &value_l1, &mut ctx) @@ -316,6 +323,7 @@ async fn test_composition_backend_as_trait_object() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); l2_mem .set::(&key_l2, &value_l2, &mut ctx) @@ -349,6 +357,7 @@ async fn test_composition_backend_as_trait_object() { }, Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); let mut ctx: BoxContext = CacheContext::default().boxed(); backend diff --git a/hitbox-backend/tests/metrics/mod.rs b/hitbox-backend/tests/metrics/mod.rs index 24bab9e4..0638455b 100644 --- a/hitbox-backend/tests/metrics/mod.rs +++ b/hitbox-backend/tests/metrics/mod.rs @@ -337,8 +337,12 @@ fn test_basic_backend_write_metrics() { id: 42, name: "test".to_string(), }; - let value = - CacheValue::new(data, Some(Utc::now() + chrono::Duration::seconds(60)), None); + let value = CacheValue::new( + data, + Some(Utc::now() + chrono::Duration::seconds(60)), + None, + None, + ); // Perform a cache write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -411,6 +415,7 @@ fn test_basic_backend_read_metrics() { data.clone(), Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // First write data to have something to read @@ -495,8 +500,12 @@ fn test_composition_backend_write_metrics() { id: 42, name: "test".to_string(), }; - let value = - CacheValue::new(data, Some(Utc::now() + chrono::Duration::seconds(60)), None); + let value = CacheValue::new( + data, + Some(Utc::now() + chrono::Duration::seconds(60)), + None, + None, + ); // Perform a cache write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -587,6 +596,7 @@ fn test_composition_backend_read_metrics() { data.clone(), Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // First write data @@ -684,8 +694,12 @@ fn test_dyn_composition_backend_write_metrics() { id: 42, name: "test".to_string(), }; - let value = - CacheValue::new(data, Some(Utc::now() + chrono::Duration::seconds(60)), None); + let value = CacheValue::new( + data, + Some(Utc::now() + chrono::Duration::seconds(60)), + None, + None, + ); // Perform a cache write via trait object let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -775,6 +789,7 @@ fn test_dyn_composition_backend_read_metrics() { data.clone(), Some(Utc::now() + chrono::Duration::seconds(60)), None, + None, ); // First write data diff --git a/hitbox-core/CHANGELOG.md b/hitbox-core/CHANGELOG.md index 2da735db..7071dcba 100644 --- a/hitbox-core/CHANGELOG.md +++ b/hitbox-core/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - `CacheConfig` and `CacheConfigs` traits for cache configuration abstraction ([#253](https://github.com/hit-box/hitbox/pull/253)) +- `ForwardReason`, `CacheTiming`, and protocol extensions in `CacheContext` for richer cache operation metadata ([#269](https://github.com/hit-box/hitbox/pull/269)) + +### Changed +- **Breaking:** `CacheStatus` extended with `Collapsed` and `Forward(ForwardReason)` variants ([#269](https://github.com/hit-box/hitbox/pull/269)) +- **Breaking:** `CacheStatusExt::cache_status()` takes `&CacheContext` instead of `CacheStatus` ([#269](https://github.com/hit-box/hitbox/pull/269)) +- **Breaking:** `CacheValue::new()` and `CacheMeta::new()` take additional `created_at` argument for tracking entry creation time ([#269](https://github.com/hit-box/hitbox/pull/269)) ### Changed - `PolicyConfig` and related policy types moved from `hitbox` crate ([#253](https://github.com/hit-box/hitbox/pull/253)) diff --git a/hitbox-core/src/context.rs b/hitbox-core/src/context.rs index bb9aa8d5..23ad3135 100644 --- a/hitbox-core/src/context.rs +++ b/hitbox-core/src/context.rs @@ -2,20 +2,59 @@ use std::any::Any; +use chrono::{DateTime, Utc}; use smallbox::{SmallBox, smallbox, space::S4}; use crate::label::BackendLabel; -/// Whether the request resulted in a cache hit, miss, or stale data. +/// Why a request was forwarded to upstream instead of served from cache. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CacheStatus { - /// Cache hit - valid cached data was found and returned. - Hit, - /// Cache miss - no cached data was found. +pub enum ForwardReason { + /// No matching cache entry found. #[default] Miss, - /// Stale data - cached data was found but has exceeded its freshness window. + /// Cache entry was expired/stale and policy required a fresh response. + Expired, + /// Cache was intentionally bypassed (predicate rejected the request). + /// Covers all predicate rejections including uncacheable methods. + Bypass, +} + +impl ForwardReason { + /// Returns the reason as a string slice. + #[inline] + pub const fn as_str(&self) -> &'static str { + match self { + ForwardReason::Miss => "miss", + ForwardReason::Expired => "expired", + ForwardReason::Bypass => "bypass", + } + } +} + +/// What the cache did with this request. +/// +/// Variants split into two groups matching RFC 9211 semantics: +/// - Served from cache: `Hit`, `Stale`, `Collapsed` → RFC 9211 `; hit` +/// - Forwarded upstream: `Forward(reason)` → RFC 9211 `; fwd=` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheStatus { + /// Cache hit — fresh cached data was found and returned. + Hit, + /// Stale hit — cached data was served despite being past freshness window + /// (stale-while-revalidate). Background refresh may be in progress. Stale, + /// Collapsed hit — request was coalesced with another in-flight request + /// (dog-pile prevention). Served from the other request's result. + Collapsed, + /// Forwarded to upstream with a specific reason. + Forward(ForwardReason), +} + +impl Default for CacheStatus { + fn default() -> Self { + CacheStatus::Forward(ForwardReason::default()) + } } impl CacheStatus { @@ -24,10 +63,33 @@ impl CacheStatus { pub const fn as_str(&self) -> &'static str { match self { CacheStatus::Hit => "hit", - CacheStatus::Miss => "miss", CacheStatus::Stale => "stale", + CacheStatus::Collapsed => "collapsed", + CacheStatus::Forward(reason) => reason.as_str(), } } + + /// Returns true if the response was served from cache (hit, stale, or collapsed). + #[inline] + pub const fn is_served_from_cache(&self) -> bool { + matches!( + self, + CacheStatus::Hit | CacheStatus::Stale | CacheStatus::Collapsed + ) + } +} + +/// Timing information from a cache operation. +/// +/// Used to compute `Age` and `ttl` for cache status headers. +/// Present when response was served from cache (Hit, Stale, Collapsed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CacheTiming { + /// When the cache entry was originally created/stored. + pub created_at: DateTime, + /// When the entry's freshness expires (for ttl computation). + /// Can be in the past for stale responses → negative ttl. + pub expire: Option>, } /// Source of the response - either from upstream or from a cache backend. @@ -106,6 +168,30 @@ pub trait Context: Send + Sync { // Default implementation does nothing - simple contexts ignore read mode } + // Cache timing + + /// Returns the cache timing information, if available. + fn timing(&self) -> Option<&CacheTiming> { + None + } + + /// Sets the cache timing information. + fn set_timing(&mut self, _timing: Option) { + // Default implementation does nothing + } + + // Stored flag + + /// Returns whether the response was stored in cache during this operation. + fn stored(&self) -> bool { + false + } + + /// Sets whether the response was stored in cache. + fn set_stored(&mut self, _stored: bool) { + // Default implementation does nothing + } + // Type identity and conversion /// Returns a reference to self as `Any` for downcasting. @@ -143,6 +229,16 @@ pub trait Context: Send + Sync { // No backend hit, keep as upstream } } + + // Merge timing - take from inner if it has timing (cache hit) + if let Some(timing) = other.timing() { + self.set_timing(Some(*timing)); + } + + // Merge stored flag + if other.stored() { + self.set_stored(true); + } } } @@ -167,14 +263,33 @@ pub fn finalize_context(ctx: BoxContext) -> CacheContext { } /// Context information about a cache operation. +/// +/// This is the single source of truth for all cache operation metadata. +/// The `Ext` type parameter carries protocol-specific data (e.g., HTTP status codes). +/// +/// - Inside the FSM: `CacheContext` (= `CacheContext<()>`) — protocol-agnostic +/// - At the integration boundary: `CacheContext>` — protocol-specific +/// +/// Use [`with_extensions`](Self::with_extensions) to transform between extension types. #[derive(Debug, Clone, Default)] -pub struct CacheContext { - /// Whether the request resulted in a cache hit, miss, or stale data. +pub struct CacheContext { + /// What the cache did with this request (hit, stale, collapsed, or forwarded). pub status: CacheStatus, /// Read mode for this operation. pub read_mode: ReadMode, /// Source of the response. pub source: ResponseSource, + /// Timing data for computing Age and ttl headers. + /// Present when response was served from cache (Hit, Stale, Collapsed). + pub timing: Option, + /// Whether the response was stored in cache during this operation. + pub stored: bool, + /// Protocol-specific extension data. + /// + /// Defaults to `()` inside the FSM. Protocol crates use their own type: + /// - HTTP: `Option` + /// - gRPC: `Option` + pub extensions: Ext, } impl CacheContext { @@ -187,6 +302,31 @@ impl CacheContext { } } +impl CacheContext { + /// Transform this context's extension type. + /// + /// Copies all protocol-agnostic fields and replaces extensions with the new value. + /// Used at the integration boundary to add protocol-specific data. + /// + /// # Example + /// + /// ```ignore + /// let ctx: CacheContext = finalize_context(box_ctx); // CacheContext<()> + /// let http_ctx = ctx.with_extensions(Some(HttpCacheData { upstream_status: 200 })); + /// // http_ctx: CacheContext> + /// ``` + pub fn with_extensions(self, extensions: NewExt) -> CacheContext { + CacheContext { + status: self.status, + read_mode: self.read_mode, + source: self.source, + timing: self.timing, + stored: self.stored, + extensions, + } + } +} + impl Context for CacheContext { fn status(&self) -> CacheStatus { self.status @@ -212,6 +352,22 @@ impl Context for CacheContext { self.read_mode = mode; } + fn timing(&self) -> Option<&CacheTiming> { + self.timing.as_ref() + } + + fn set_timing(&mut self, timing: Option) { + self.timing = timing; + } + + fn stored(&self) -> bool { + self.stored + } + + fn set_stored(&mut self, stored: bool) { + self.stored = stored; + } + fn as_any(&self) -> &dyn Any { self } @@ -229,22 +385,26 @@ impl Context for CacheContext { /// /// This trait provides a protocol-agnostic way to attach cache status /// metadata to responses. Each protocol (HTTP, gRPC, etc.) implements -/// this trait with its own configuration type. +/// this trait with its own configuration and extension types. /// /// # Example /// /// ```ignore -/// use hitbox_core::{CacheStatus, CacheStatusExt}; +/// use hitbox_core::{CacheContext, CacheStatusExt}; /// /// // For HTTP responses (implemented in hitbox-http) -/// response.cache_status(CacheStatus::Hit, &header_name); +/// let http_ctx = cache_context.with_extensions(Some(http_data)); +/// response.cache_status(&http_ctx, &config); /// ``` pub trait CacheStatusExt { /// Configuration type for applying cache status (e.g., header name for HTTP). type Config; + /// Protocol-specific extension type carried by [`CacheContext`]. + type Extensions; + /// Applies cache status information to the response. - fn cache_status(&mut self, status: CacheStatus, config: &Self::Config); + fn cache_status(&mut self, context: &CacheContext, config: &Self::Config); } #[cfg(test)] @@ -260,16 +420,51 @@ mod tests { println!("CacheContext size: {} bytes", cache_ctx_size); println!(" - CacheStatus: {} bytes", size_of::()); + println!(" - ForwardReason: {} bytes", size_of::()); + println!(" - CacheTiming: {} bytes", size_of::()); println!(" - ResponseSource: {} bytes", size_of::()); println!("BoxContext size: {} bytes", box_ctx_size); println!("S4 inline space: {} bytes", s4_space); - // CacheContext should fit in S4 inline storage (32 bytes on 64-bit) - assert!( - cache_ctx_size <= s4_space, - "CacheContext ({} bytes) should fit in S4 ({} bytes)", - cache_ctx_size, - s4_space + // CacheContext now exceeds S4 due to timing + stored fields. + // SmallBox automatically falls back to heap allocation, which is fine + // since context is created once per request. + println!( + "CacheContext {} S4 inline storage (heap fallback is OK)", + if cache_ctx_size <= s4_space { + "fits in" + } else { + "exceeds" + } ); } + + #[test] + fn test_cache_status_default() { + let status = CacheStatus::default(); + assert_eq!(status, CacheStatus::Forward(ForwardReason::Miss)); + } + + #[test] + fn test_cache_status_is_served_from_cache() { + assert!(CacheStatus::Hit.is_served_from_cache()); + assert!(CacheStatus::Stale.is_served_from_cache()); + assert!(CacheStatus::Collapsed.is_served_from_cache()); + assert!(!CacheStatus::Forward(ForwardReason::Miss).is_served_from_cache()); + assert!(!CacheStatus::Forward(ForwardReason::Expired).is_served_from_cache()); + assert!(!CacheStatus::Forward(ForwardReason::Bypass).is_served_from_cache()); + } + + #[test] + fn test_with_extensions() { + let ctx: CacheContext = CacheContext { + status: CacheStatus::Hit, + stored: true, + ..Default::default() + }; + let ext_ctx = ctx.with_extensions(Some(42u16)); + assert_eq!(ext_ctx.status, CacheStatus::Hit); + assert!(ext_ctx.stored); + assert_eq!(ext_ctx.extensions, Some(42u16)); + } } diff --git a/hitbox-core/src/lib.rs b/hitbox-core/src/lib.rs index 90465fc0..f2f2892e 100644 --- a/hitbox-core/src/lib.rs +++ b/hitbox-core/src/lib.rs @@ -18,8 +18,8 @@ pub mod value; pub use cacheable::Cacheable; pub use config::{CacheConfig, CacheConfigs}; pub use context::{ - BoxContext, CacheContext, CacheStatus, CacheStatusExt, Context, ReadMode, ResponseSource, - finalize_context, + BoxContext, CacheContext, CacheStatus, CacheStatusExt, CacheTiming, Context, ForwardReason, + ReadMode, ResponseSource, finalize_context, }; pub use extractor::Extractor; pub use key::{CacheKey, KeyPart, KeyParts}; diff --git a/hitbox-core/src/response.rs b/hitbox-core/src/response.rs index 104e9f58..c320ae09 100644 --- a/hitbox-core/src/response.rs +++ b/hitbox-core/src/response.rs @@ -123,6 +123,7 @@ pub enum CacheState { /// cached, /// config.ttl.map(|d| Utc::now() + d), /// config.stale_ttl.map(|d| Utc::now() + d), +/// Some(Utc::now()), /// )) /// } /// PredicateResult::NonCacheable(data) => CachePolicy::NonCacheable(data), @@ -215,6 +216,7 @@ macro_rules! impl_cacheable_response_for_scalar { cached, config.ttl.map(|d| Utc::now() + d), config.stale_ttl.map(|d| Utc::now() + d), + Some(Utc::now()), )) } PredicateResult::NonCacheable(data) => CachePolicy::NonCacheable(data), @@ -272,6 +274,7 @@ where cached, config.ttl.map(|d| Utc::now() + d), config.stale_ttl.map(|d| Utc::now() + d), + Some(Utc::now()), )) } PredicateResult::NonCacheable(data) => CachePolicy::NonCacheable(data), @@ -374,6 +377,7 @@ where res, config.ttl.map(|duration| Utc::now() + duration), config.stale_ttl.map(|duration| Utc::now() + duration), + Some(Utc::now()), )), CachePolicy::NonCacheable(res) => CachePolicy::NonCacheable(Ok(res)), }, diff --git a/hitbox-core/src/value.rs b/hitbox-core/src/value.rs index ced24d47..0d4850e3 100644 --- a/hitbox-core/src/value.rs +++ b/hitbox-core/src/value.rs @@ -32,6 +32,7 @@ //! "cached data", //! Some(Utc::now() + chrono::Duration::hours(1)), // expires in 1 hour //! Some(Utc::now() + chrono::Duration::minutes(5)), // stale in 5 minutes +//! Some(Utc::now()), // created now //! ); //! //! match value.cache_state() { @@ -67,7 +68,7 @@ use crate::response::CacheState; /// /// // Create a cache value that expires in 1 hour /// let expire_time = Utc::now() + chrono::Duration::hours(1); -/// let value = CacheValue::new("user_data", Some(expire_time), None); +/// let value = CacheValue::new("user_data", Some(expire_time), None, Some(Utc::now())); /// /// // Access data via getter /// assert_eq!(value.data(), &"user_data"); @@ -85,6 +86,7 @@ pub struct CacheValue { data: T, expire: Option>, stale: Option>, + created_at: Option>, } impl CacheValue { @@ -95,23 +97,33 @@ impl CacheValue { /// * `data` - The data to cache /// * `expire` - When the data expires (becomes invalid) /// * `stale` - When the data becomes stale (should refresh in background) - pub fn new(data: T, expire: Option>, stale: Option>) -> Self { + /// * `created_at` - When the cache entry was originally created (for Age header). + /// Pass `None` for legacy entries or when the creation time is unknown. + pub fn new( + data: T, + expire: Option>, + stale: Option>, + created_at: Option>, + ) -> Self { CacheValue { data, expire, stale, + created_at, } } /// Creates a new cache value using timestamps derived from an [`EntityPolicyConfig`]. /// /// Converts the config's TTL and stale TTL durations into absolute timestamps - /// relative to the current time. + /// relative to the current time. Sets `created_at` to now. pub fn from_config(data: T, config: &EntityPolicyConfig) -> Self { + let now = Utc::now(); Self::new( data, - config.ttl.map(|d| Utc::now() + d), - config.stale_ttl.map(|d| Utc::now() + d), + config.ttl.map(|d| now + d), + config.stale_ttl.map(|d| now + d), + Some(now), ) } @@ -133,6 +145,12 @@ impl CacheValue { self.stale } + /// Returns when the cache entry was originally created. + #[inline] + pub fn created_at(&self) -> Option> { + self.created_at + } + /// Consumes the cache value and returns the inner data. /// /// Discards the expiration metadata. @@ -144,7 +162,10 @@ impl CacheValue { /// /// Useful when you need to inspect or modify the metadata independently. pub fn into_parts(self) -> (CacheMeta, T) { - (CacheMeta::new(self.expire, self.stale), self.data) + ( + CacheMeta::new(self.expire, self.stale, self.created_at), + self.data, + ) } /// Calculate TTL (time-to-live) from the expire time. @@ -188,24 +209,29 @@ impl CacheValue { /// Cache expiration metadata without the data. /// -/// Contains just the staleness and expiration timestamps. Useful for +/// Contains just the staleness, expiration, and creation timestamps. Useful for /// passing metadata around without copying the cached data. -/// -/// # Fields -/// -/// * `expire` - When the data expires (becomes invalid) -/// * `stale` - When the data becomes stale (should refresh in background) pub struct CacheMeta { /// When the cached data expires and becomes invalid. pub expire: Option>, /// When the cached data becomes stale and should be refreshed. pub stale: Option>, + /// When the cache entry was originally created. + pub created_at: Option>, } impl CacheMeta { /// Creates new cache metadata with the given timestamps. - pub fn new(expire: Option>, stale: Option>) -> CacheMeta { - CacheMeta { expire, stale } + pub fn new( + expire: Option>, + stale: Option>, + created_at: Option>, + ) -> CacheMeta { + CacheMeta { + expire, + stale, + created_at, + } } } diff --git a/hitbox-core/tests/response.rs b/hitbox-core/tests/response.rs index fd33586a..e6697d03 100644 --- a/hitbox-core/tests/response.rs +++ b/hitbox-core/tests/response.rs @@ -37,9 +37,12 @@ impl CacheableResponse for TestResponse { { match predicates.check(self).await { PredicateResult::Cacheable(cacheable) => match cacheable.into_cached().await { - CachePolicy::Cacheable(res) => { - CachePolicy::Cacheable(CacheValue::new(res, Some(Utc::now()), Some(Utc::now()))) - } + CachePolicy::Cacheable(res) => CachePolicy::Cacheable(CacheValue::new( + res, + Some(Utc::now()), + Some(Utc::now()), + None, + )), CachePolicy::NonCacheable(res) => CachePolicy::NonCacheable(res), }, PredicateResult::NonCacheable(res) => CachePolicy::NonCacheable(res), diff --git a/hitbox-feoxdb/CHANGELOG.md b/hitbox-feoxdb/CHANGELOG.md index 3f725b71..53d8c49e 100644 --- a/hitbox-feoxdb/CHANGELOG.md +++ b/hitbox-feoxdb/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Persist `created_at` in `SerializableCacheValue` ([#269](https://github.com/hit-box/hitbox/pull/269)) ## [0.2.1] - 2026-02-09 diff --git a/hitbox-feoxdb/src/backend.rs b/hitbox-feoxdb/src/backend.rs index bc31e3a0..8c9e1605 100644 --- a/hitbox-feoxdb/src/backend.rs +++ b/hitbox-feoxdb/src/backend.rs @@ -27,6 +27,10 @@ struct SerializableCacheValue { data: Vec, stale: Option>, expire: Option>, + /// When the cache entry was originally created (for Age header). + /// Optional for backward compatibility with entries serialized before this field existed. + #[serde(default)] + created_at: Option>, } impl From> for SerializableCacheValue { @@ -35,13 +39,19 @@ impl From> for SerializableCacheValue { data: value.data().to_vec(), stale: value.stale(), expire: value.expire(), + created_at: value.created_at(), } } } impl From for CacheValue { fn from(value: SerializableCacheValue) -> Self { - CacheValue::new(Bytes::from(value.data), value.expire, value.stale) + CacheValue::new( + Bytes::from(value.data), + value.expire, + value.stale, + value.created_at, + ) } } @@ -411,6 +421,7 @@ mod tests { Bytes::from(&b"test-value"[..]), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); // Write with 1 hour TTL @@ -435,6 +446,7 @@ mod tests { Bytes::from(&b"test-value"[..]), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); // Write @@ -484,6 +496,7 @@ mod tests { Bytes::from(&b"memory-value"[..]), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); // Write @@ -509,6 +522,7 @@ mod tests { Bytes::from(&b"shared-value"[..]), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); // Write with backend1 @@ -534,12 +548,12 @@ mod tests { // Key 1 with 1 hour TTL let key1 = CacheKey::from_str("key1", "1"); - let value1 = CacheValue::new(Bytes::from(&b"value1"[..]), Some(expire_1h), None); + let value1 = CacheValue::new(Bytes::from(&b"value1"[..]), Some(expire_1h), None, None); backend.write(&key1, value1).await.unwrap(); // Key 2 with 24 hour TTL let key2 = CacheKey::from_str("key2", "1"); - let value2 = CacheValue::new(Bytes::from(&b"value2"[..]), Some(expire_24h), None); + let value2 = CacheValue::new(Bytes::from(&b"value2"[..]), Some(expire_24h), None, None); backend.write(&key2, value2).await.unwrap(); // Read and verify TTLs are preserved @@ -573,7 +587,7 @@ mod tests { // Write entry that's already expired let key = CacheKey::from_str("expired-key", "1"); let expired_time = Utc::now() - chrono::Duration::seconds(10); - let value = CacheValue::new(Bytes::from(&b"expired"[..]), Some(expired_time), None); + let value = CacheValue::new(Bytes::from(&b"expired"[..]), Some(expired_time), None, None); backend.write(&key, value).await.unwrap(); // Should not be returned (filtered by expire check) @@ -596,6 +610,7 @@ mod tests { Bytes::from(large_data), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); let result = backend.write(&key, value).await; @@ -632,6 +647,7 @@ mod tests { Bytes::from(&b"format-value"[..]), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); backend.write(&key, value).await.unwrap(); @@ -657,6 +673,7 @@ mod tests { Bytes::from(&b"persist-value"[..]), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); backend.write(&key, value).await.unwrap(); backend.flush(); @@ -698,6 +715,7 @@ mod tests { Bytes::from(chunk.clone()), Some(Utc::now() + chrono::Duration::hours(1)), None, + None, ); let _ = backend.write(&key, value).await; // Periodic flush to persist data incrementally diff --git a/hitbox-fn/tests/cached_macro.rs b/hitbox-fn/tests/cached_macro.rs index 2300859f..bb5afba1 100644 --- a/hitbox-fn/tests/cached_macro.rs +++ b/hitbox-fn/tests/cached_macro.rs @@ -2,8 +2,8 @@ use std::time::Duration; -use hitbox::CacheStatus; use hitbox::policy::PolicyConfig; +use hitbox::{CacheStatus, ForwardReason}; use hitbox_derive::{CacheableResponse, cached}; use hitbox_fn::Cache; use hitbox_moka::MokaBackend; @@ -152,7 +152,7 @@ async fn test_skipped_param_not_in_cache_key() { // Both should return same result assert_eq!(r1, r2); // First should be miss, second should be hit (same cache key) - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -171,8 +171,8 @@ async fn test_included_param_affects_cache_key() { .await; // Both should be misses (different cache keys due to different value) - assert_eq!(c1.status, CacheStatus::Miss); - assert_eq!(c2.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); + assert_eq!(c2.status, CacheStatus::Forward(ForwardReason::Miss)); } #[tokio::test] @@ -193,7 +193,7 @@ async fn test_multiple_skipped_params() { .await; assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -212,8 +212,8 @@ async fn test_multiple_params_different_values() { .await; // Different cache keys - assert_eq!(c1.status, CacheStatus::Miss); - assert_eq!(c2.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); + assert_eq!(c2.status, CacheStatus::Forward(ForwardReason::Miss)); } #[tokio::test] @@ -231,7 +231,7 @@ async fn test_all_params_skipped_same_key() { .await; assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -249,7 +249,7 @@ async fn test_first_param_skipped() { .await; // Different first param (skipped) - should hit - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -267,7 +267,7 @@ async fn test_last_param_skipped() { .await; // Different last param (skipped) - should hit - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -290,7 +290,7 @@ async fn test_skipped_type_without_key_extract() { // Same user_id = cache hit, despite different DbConnection assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -310,7 +310,7 @@ async fn test_generic_function_same_value() { // Same value = cache hit assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -325,8 +325,8 @@ async fn test_generic_function_different_value() { let (_, c2) = generic_function(id2).cache(&cache).with_context().await; // Different value = cache miss - assert_eq!(c1.status, CacheStatus::Miss); - assert_eq!(c2.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); + assert_eq!(c2.status, CacheStatus::Forward(ForwardReason::Miss)); } #[tokio::test] @@ -341,8 +341,8 @@ async fn test_generic_function_different_label() { let (_, c2) = generic_function(id2).cache(&cache).with_context().await; // Different label = cache miss - assert_eq!(c1.status, CacheStatus::Miss); - assert_eq!(c2.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); + assert_eq!(c2.status, CacheStatus::Forward(ForwardReason::Miss)); } #[tokio::test] @@ -364,7 +364,7 @@ async fn test_generic_with_skip() { // Same value, different ctx (skipped) = cache hit assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -400,7 +400,7 @@ async fn test_reference_param() { assert_eq!(r1, "HELLO"); assert_eq!(r2, "HELLO"); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -412,8 +412,8 @@ async fn test_reference_different_values() { let (_, c2) = with_reference("world").cache(&cache).with_context().await; // Different values = different cache keys - assert_eq!(c1.status, CacheStatus::Miss); - assert_eq!(c2.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); + assert_eq!(c2.status, CacheStatus::Forward(ForwardReason::Miss)); } #[tokio::test] @@ -432,7 +432,7 @@ async fn test_mixed_ref_and_owned() { assert_eq!(r1, "user_42"); assert_eq!(r2, "user_42"); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -452,7 +452,7 @@ async fn test_skipped_reference() { assert_eq!(r1, 42); assert_eq!(r2, 42); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -503,7 +503,7 @@ async fn test_two_lifetimes_cached() { assert_eq!(r1, "key-val"); assert_eq!(r2, "key-val"); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -522,8 +522,8 @@ async fn test_two_lifetimes_different_values() { assert_eq!(r1, "a-b"); assert_eq!(r2, "a-c"); - assert_eq!(c1.status, CacheStatus::Miss); - assert_eq!(c2.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); + assert_eq!(c2.status, CacheStatus::Forward(ForwardReason::Miss)); } // ============================================================================= @@ -556,7 +556,7 @@ async fn test_skipped_response_field_preserved_on_miss() { let (result, ctx) = authenticate(1).cache(&cache).with_context().await; - assert_eq!(ctx.status, CacheStatus::Miss); + assert_eq!(ctx.status, CacheStatus::Forward(ForwardReason::Miss)); let auth = result.unwrap(); assert_eq!(auth.access_token, Some("secret-token".into())); assert_eq!(auth.permissions, vec!["read", "write"]); @@ -571,7 +571,7 @@ async fn test_skipped_response_field_default_on_hit() { // Second call — hit, from cache let (r2, c2) = authenticate(2).cache(&cache).with_context().await; - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); // On miss: skipped field preserved @@ -628,7 +628,7 @@ async fn test_skipped_field_no_clone_bound() { // Miss: NonCloneable field preserved despite not implementing Clone let (r1, c1) = get_with_non_cloneable(1).cache(&cache).with_context().await; - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(r1.as_ref().unwrap().ctx.value, "original"); // Hit: NonCloneable field is Default @@ -655,7 +655,7 @@ async fn test_zero_args_always_same_key() { let (r2, c2) = no_args_function().cache(&cache).with_context().await; assert_eq!(r1, r2); - assert_eq!(c1.status, CacheStatus::Miss); + assert_eq!(c1.status, CacheStatus::Forward(ForwardReason::Miss)); assert_eq!(c2.status, CacheStatus::Hit); } @@ -735,7 +735,7 @@ async fn test_inline_backend_policy_with_context() { .await; assert_eq!(result, 20); - assert_eq!(ctx.status, CacheStatus::Miss); + assert_eq!(ctx.status, CacheStatus::Forward(ForwardReason::Miss)); } // ============================================================================= diff --git a/hitbox-http/CHANGELOG.md b/hitbox-http/CHANGELOG.md index 01ab58ec..e72c7cec 100644 --- a/hitbox-http/CHANGELOG.md +++ b/hitbox-http/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Preserve HTTP/2 trailers through body buffering and cache serialization roundtrip ([#261](https://github.com/hit-box/hitbox/pull/261)) +- RFC 9211 `Cache-Status` and RFC 9111 `Age` header generation with `HttpCacheStatusConfig` ([#269](https://github.com/hit-box/hitbox/pull/269)) ### Changed - **Breaking:** Unified Config-based extractor/predicate API with `Into` shorthands, `Transform::Truncate`, `Transforms::builder()` with typestate, and full 64-char SHA256 for `Hash` ([#202](https://github.com/hit-box/hitbox/pull/202)) diff --git a/hitbox-http/src/cache_status.rs b/hitbox-http/src/cache_status.rs index 4b772c36..9916c011 100644 --- a/hitbox-http/src/cache_status.rs +++ b/hitbox-http/src/cache_status.rs @@ -2,31 +2,362 @@ //! //! This module provides the [`CacheStatusExt`] implementation for HTTP responses, //! allowing cache status information to be attached as headers. +//! +//! Two headers are generated: +//! +//! - **`Cache-Status`** (RFC 9211) — structured field describing what the cache did +//! - **`Age`** (RFC 9111 §5.1) — how long the response has been in cache +//! - **`x-cache-status`** (legacy) — simple HIT/MISS/STALE string (kept for backward compatibility) + +use std::fmt::Write; -use hitbox::{CacheStatus, CacheStatusExt}; -use http::{HeaderValue, header::HeaderName}; +use chrono::Utc; +use hitbox::{CacheContext, CacheStatus, CacheStatusExt, ForwardReason}; +use http::header::HeaderName; +use http::{HeaderValue, header}; use hyper::body::Body as HttpBody; use crate::CacheableHttpResponse; -/// Default header name for cache status (HIT/MISS/STALE). +/// Default header name for the legacy cache status header (HIT/MISS/STALE). /// /// The value is `x-cache-status`. Use builder methods on cache middleware /// to customize the header name. pub const DEFAULT_CACHE_STATUS_HEADER: HeaderName = HeaderName::from_static("x-cache-status"); +/// Default cache name used in the RFC 9211 `Cache-Status` header. +pub const DEFAULT_CACHE_NAME: &str = "hitbox"; + +/// Header name for the RFC 9211 Cache-Status structured header. +pub const CACHE_STATUS_HEADER: HeaderName = HeaderName::from_static("cache-status"); + +/// HTTP-specific cache data stored in [`CacheContext`] extensions. +/// +/// Protocol-specific data that doesn't belong in the protocol-agnostic +/// `CacheContext` but is needed for HTTP header generation. +#[derive(Debug, Clone, Copy)] +pub struct HttpCacheData { + /// Upstream HTTP response status code (for `fwd-status` parameter in RFC 9211). + pub upstream_status: u16, +} + +/// Configuration for HTTP cache status headers. +#[derive(Debug, Clone)] +pub struct HttpCacheStatusConfig { + /// Header name for the legacy `x-cache-status` header. + /// Set to `None` to disable the legacy header. + pub legacy_header: Option, + /// Cache name used in the RFC 9211 `Cache-Status` header (default: "hitbox"). + pub cache_name: String, +} + +impl Default for HttpCacheStatusConfig { + fn default() -> Self { + Self { + legacy_header: Some(DEFAULT_CACHE_STATUS_HEADER), + cache_name: DEFAULT_CACHE_NAME.to_string(), + } + } +} + +impl HttpCacheStatusConfig { + /// Creates a config with a custom cache name. + pub fn with_cache_name(mut self, name: impl Into) -> Self { + self.cache_name = name.into(); + self + } + + /// Creates a config with a custom legacy header name. + pub fn with_legacy_header(mut self, header: HeaderName) -> Self { + self.legacy_header = Some(header); + self + } + + /// Disables the legacy `x-cache-status` header. + pub fn without_legacy_header(mut self) -> Self { + self.legacy_header = None; + self + } +} + +/// Formats the RFC 9211 `Cache-Status` header value. +/// +/// See: +/// Type alias for the HTTP-specific cache context. +pub type HttpCacheContext = CacheContext>; + +fn format_cache_status(ctx: &HttpCacheContext, cache_name: &str) -> String { + let mut buf = String::with_capacity(64); + buf.push_str(cache_name); + + match ctx.status { + CacheStatus::Hit | CacheStatus::Stale | CacheStatus::Collapsed => { + buf.push_str("; hit"); + + // Add ttl (can be negative for stale responses) + if let Some(timing) = &ctx.timing + && let Some(expire) = timing.expire + { + let ttl = (expire - Utc::now()).num_seconds(); + let _ = write!(buf, "; ttl={ttl}"); + } + + // Collapsed requests get the collapsed parameter + if ctx.status == CacheStatus::Collapsed { + buf.push_str("; collapsed"); + } + } + CacheStatus::Forward(reason) => { + let fwd_value = match reason { + // RFC 9211: fwd=stale means "had stale data but forwarded anyway" + ForwardReason::Expired => "stale", + ForwardReason::Miss => "miss", + ForwardReason::Bypass => "bypass", + }; + let _ = write!(buf, "; fwd={fwd_value}"); + + // Add fwd-status from protocol extensions + if let Some(http_data) = &ctx.extensions { + let _ = write!(buf, "; fwd-status={}", http_data.upstream_status); + } + } + } + + // Stored flag + if ctx.stored { + buf.push_str("; stored"); + } + + buf +} + +/// Computes the `Age` header value in seconds. +/// +/// Returns `None` if the response wasn't served from cache. +/// Per RFC 9111 §5.1, caches MUST generate an Age header in responses served from cache. +fn compute_age(ctx: &HttpCacheContext) -> Option { + if !ctx.status.is_served_from_cache() { + return None; + } + + ctx.timing.map(|timing| { + let age = (Utc::now() - timing.created_at).num_seconds().max(0); + age as u64 + }) +} + impl CacheStatusExt for CacheableHttpResponse where ResBody: HttpBody, { - type Config = HeaderName; + type Config = HttpCacheStatusConfig; + type Extensions = Option; + + fn cache_status(&mut self, context: &HttpCacheContext, config: &Self::Config) { + // RFC 9211: Cache-Status structured header + let cache_status_value = format_cache_status(context, &config.cache_name); + if let Ok(value) = HeaderValue::from_str(&cache_status_value) { + self.parts + .headers + .insert(CACHE_STATUS_HEADER.clone(), value); + } + + // RFC 9111 §5.1: Age header (only for responses served from cache) + if let Some(age) = compute_age(context) + && let Ok(value) = HeaderValue::from_str(&age.to_string()) + { + self.parts.headers.insert(header::AGE, value); + } + + // Legacy x-cache-status header (backward compatibility) + if let Some(ref legacy_header) = config.legacy_header { + let value = match context.status { + CacheStatus::Hit => HeaderValue::from_static("HIT"), + CacheStatus::Collapsed => HeaderValue::from_static("HIT"), + CacheStatus::Stale => HeaderValue::from_static("STALE"), + CacheStatus::Forward(_) => HeaderValue::from_static("MISS"), + }; + self.parts.headers.insert(legacy_header.clone(), value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use hitbox::CacheTiming; + + #[test] + fn test_format_cache_hit() { + let created = Utc::now() - Duration::seconds(900); + let expire = Utc::now() + Duration::seconds(2700); + + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Hit, + timing: Some(CacheTiming { + created_at: created, + expire: Some(expire), + }), + ..Default::default() + }; + + let result = format_cache_status(&ctx, "hitbox"); + assert!(result.starts_with("hitbox; hit; ttl=")); + // ttl should be approximately 2700 (±1 for timing) + let ttl_str = result.strip_prefix("hitbox; hit; ttl=").unwrap(); + let ttl: i64 = ttl_str.parse().unwrap(); + assert!((2699..=2701).contains(&ttl)); + } + + #[test] + fn test_format_cache_miss() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Forward(ForwardReason::Miss), + stored: true, + ..Default::default() + }; + + let result = format_cache_status(&ctx, "hitbox"); + assert_eq!(result, "hitbox; fwd=miss; stored"); + } + + #[test] + fn test_format_cache_miss_with_fwd_status() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Forward(ForwardReason::Miss), + stored: true, + extensions: Some(HttpCacheData { + upstream_status: 200, + }), + ..Default::default() + }; - fn cache_status(&mut self, status: CacheStatus, config: &Self::Config) { - let value = match status { - CacheStatus::Hit => HeaderValue::from_static("HIT"), - CacheStatus::Miss => HeaderValue::from_static("MISS"), - CacheStatus::Stale => HeaderValue::from_static("STALE"), + let result = format_cache_status(&ctx, "hitbox"); + assert_eq!(result, "hitbox; fwd=miss; fwd-status=200; stored"); + } + + #[test] + fn test_format_cache_bypass() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Forward(ForwardReason::Bypass), + ..Default::default() }; - self.parts.headers.insert(config.clone(), value); + + let result = format_cache_status(&ctx, "hitbox"); + assert_eq!(result, "hitbox; fwd=bypass"); + } + + #[test] + fn test_format_stale_swr() { + let created = Utc::now() - Duration::seconds(3720); + let expire = Utc::now() - Duration::seconds(120); + + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Stale, + timing: Some(CacheTiming { + created_at: created, + expire: Some(expire), + }), + ..Default::default() + }; + + let result = format_cache_status(&ctx, "hitbox"); + assert!(result.starts_with("hitbox; hit; ttl=-")); + let ttl_str = result.strip_prefix("hitbox; hit; ttl=").unwrap(); + let ttl: i64 = ttl_str.parse().unwrap(); + assert!(((-121)..=(-119)).contains(&ttl)); + } + + #[test] + fn test_format_collapsed() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Collapsed, + timing: Some(CacheTiming { + created_at: Utc::now(), + expire: None, + }), + ..Default::default() + }; + + let result = format_cache_status(&ctx, "hitbox"); + assert_eq!(result, "hitbox; hit; collapsed"); + } + + #[test] + fn test_format_expired_forward() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Forward(ForwardReason::Expired), + stored: true, + extensions: Some(HttpCacheData { + upstream_status: 200, + }), + ..Default::default() + }; + + let result = format_cache_status(&ctx, "hitbox"); + assert_eq!(result, "hitbox; fwd=stale; fwd-status=200; stored"); + } + + #[test] + fn test_format_custom_cache_name() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Hit, + ..Default::default() + }; + + let result = format_cache_status(&ctx, "my-proxy"); + assert_eq!(result, "my-proxy; hit"); + } + + #[test] + fn test_compute_age_cache_hit() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Hit, + timing: Some(CacheTiming { + created_at: Utc::now() - Duration::seconds(900), + expire: Some(Utc::now() + Duration::seconds(2700)), + }), + ..Default::default() + }; + + let age = compute_age(&ctx).unwrap(); + assert!((899..=901).contains(&age)); + } + + #[test] + fn test_compute_age_cache_miss_returns_none() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Forward(ForwardReason::Miss), + ..Default::default() + }; + + assert!(compute_age(&ctx).is_none()); + } + + #[test] + fn test_compute_age_no_timing_returns_none() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Hit, + timing: None, + ..Default::default() + }; + + assert!(compute_age(&ctx).is_none()); + } + + #[test] + fn test_format_upstream_error_not_stored() { + let ctx: HttpCacheContext = CacheContext { + status: CacheStatus::Forward(ForwardReason::Miss), + stored: false, + extensions: Some(HttpCacheData { + upstream_status: 500, + }), + ..Default::default() + }; + + let result = format_cache_status(&ctx, "hitbox"); + assert_eq!(result, "hitbox; fwd=miss; fwd-status=500"); } } diff --git a/hitbox-http/src/lib.rs b/hitbox-http/src/lib.rs index db7c40a6..3b7bdec0 100644 --- a/hitbox-http/src/lib.rs +++ b/hitbox-http/src/lib.rs @@ -24,7 +24,10 @@ pub mod request; pub mod response; pub use body::{BufferedBody, CollectExactResult, CollectedBody, PartialBufferedBody, Remaining}; -pub use cache_status::DEFAULT_CACHE_STATUS_HEADER; +pub use cache_status::{ + CACHE_STATUS_HEADER, DEFAULT_CACHE_NAME, DEFAULT_CACHE_STATUS_HEADER, HttpCacheData, + HttpCacheStatusConfig, +}; pub use cacheable::CacheableSubject; pub use request::CacheableHttpRequest; pub use response::{CacheableHttpResponse, SerializableHttpResponse}; diff --git a/hitbox-http/src/response/mod.rs b/hitbox-http/src/response/mod.rs index 735b4943..052d32f8 100644 --- a/hitbox-http/src/response/mod.rs +++ b/hitbox-http/src/response/mod.rs @@ -427,6 +427,7 @@ where res, config.ttl.map(|duration| Utc::now() + duration), config.stale_ttl.map(|duration| Utc::now() + duration), + Some(Utc::now()), )), CachePolicy::NonCacheable(res) => CachePolicy::NonCacheable(res), }, diff --git a/hitbox-moka/tests/memory_eviction.rs b/hitbox-moka/tests/memory_eviction.rs index c865e878..ba680ce2 100644 --- a/hitbox-moka/tests/memory_eviction.rs +++ b/hitbox-moka/tests/memory_eviction.rs @@ -15,7 +15,7 @@ fn make_key(id: u32) -> CacheKey { fn make_value(size: usize) -> CacheValue { let data = Bytes::from(vec![0u8; size]); let expire = Some(Utc::now() + chrono::Duration::hours(1)); - CacheValue::new(data, expire, None) + CacheValue::new(data, expire, None, None) } /// Calculate total entry size (key + value). diff --git a/hitbox-redis/CHANGELOG.md b/hitbox-redis/CHANGELOG.md index 0c0d0b33..ecfe383c 100644 --- a/hitbox-redis/CHANGELOG.md +++ b/hitbox-redis/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Persist `created_at` as hash field `"c"` ([#269](https://github.com/hit-box/hitbox/pull/269)) ## [0.2.1] - 2026-02-09 diff --git a/hitbox-redis/src/backend.rs b/hitbox-redis/src/backend.rs index b4886f40..495c95b5 100644 --- a/hitbox-redis/src/backend.rs +++ b/hitbox-redis/src/backend.rs @@ -808,14 +808,18 @@ where let mut con = self.get_connection().await?.clone(); let cache_key = self.key_format.serialize(key)?; - // Pipeline: HMGET (data, stale) + PTTL with typed decoding - let ((data, stale_ms), pttl): ((Option>, Option), i64) = con + // Pipeline: HMGET (data, stale, created_at) + PTTL with typed decoding + let ((data, stale_ms, created_at_ms), pttl): ( + (Option>, Option, Option), + i64, + ) = con .query_pipeline( redis::pipe() .cmd("HMGET") .arg(&cache_key) .arg("d") .arg("s") + .arg("c") .cmd("PTTL") .arg(&cache_key), ) @@ -831,23 +835,29 @@ where // Convert stale millis to DateTime let stale = stale_ms.and_then(DateTime::from_timestamp_millis); + // Convert created_at millis to DateTime + let created_at = created_at_ms.and_then(DateTime::from_timestamp_millis); + // Calculate expire from PTTL (milliseconds remaining) // PTTL returns: -2 if key doesn't exist, -1 if no TTL, else milliseconds let expire = (pttl > 0).then(|| Utc::now() + chrono::Duration::milliseconds(pttl)); - Ok(Some(CacheValue::new(data, expire, stale))) + Ok(Some(CacheValue::new(data, expire, stale, created_at))) } async fn write(&self, key: &CacheKey, value: CacheValue) -> BackendResult<()> { let mut con = self.get_connection().await?.clone(); let cache_key = self.key_format.serialize(key)?; - // Build HSET command with data field, optionally add stale field + // Build HSET command with data field, optionally add stale and created_at fields let mut cmd = redis::cmd("HSET"); cmd.arg(&cache_key).arg("d").arg(value.data().as_ref()); if let Some(stale) = value.stale() { cmd.arg("s").arg(stale.timestamp_millis()); } + if let Some(created_at) = value.created_at() { + cmd.arg("c").arg(created_at.timestamp_millis()); + } // Pipeline: HSET + optional PEXPIRE (computed from value.ttl()) let mut pipe = redis::pipe(); diff --git a/hitbox-reqwest/CHANGELOG.md b/hitbox-reqwest/CHANGELOG.md index 55e4c23d..afe5ac31 100644 --- a/hitbox-reqwest/CHANGELOG.md +++ b/hitbox-reqwest/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `CacheMiddleware` now requires `C: CacheConfigs` instead of `C: CacheConfig`, routing all requests through `SelectiveCacheFuture` ([#253](https://github.com/hit-box/hitbox/pull/253)) +- **Breaking:** `CacheMiddleware` uses `HttpCacheStatusConfig` instead of `HeaderName` ([#269](https://github.com/hit-box/hitbox/pull/269)) ### Changed - Adapted to `Upstream::call(self)` API change ([#206](https://github.com/hit-box/hitbox/pull/206)) diff --git a/hitbox-reqwest/src/middleware.rs b/hitbox-reqwest/src/middleware.rs index e97f5610..c62b7bd2 100644 --- a/hitbox-reqwest/src/middleware.rs +++ b/hitbox-reqwest/src/middleware.rs @@ -10,13 +10,13 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use hitbox::CacheStatusExt; use hitbox::backend::CacheBackend; use hitbox::concurrency::{ConcurrencyManager, NoopConcurrencyManager}; use hitbox::fsm::SelectiveCacheFuture; +use hitbox::{CacheStatus, CacheStatusExt}; use hitbox_core::{CacheConfigs, DisabledOffload}; use hitbox_http::{ - BufferedBody, CacheableHttpRequest, CacheableHttpResponse, DEFAULT_CACHE_STATUS_HEADER, + BufferedBody, CacheableHttpRequest, CacheableHttpResponse, HttpCacheData, HttpCacheStatusConfig, }; use http::Extensions; use http::header::HeaderName; @@ -39,8 +39,8 @@ pub struct CacheMiddleware { backend: Arc, configuration: C, concurrency_manager: CM, - /// Header name for cache status (HIT/MISS/STALE). - cache_status_header: HeaderName, + /// Configuration for cache status headers (RFC 9211 + legacy). + cache_status_config: HttpCacheStatusConfig, } impl CacheMiddleware { @@ -52,13 +52,13 @@ impl CacheMiddleware { backend: Arc, configuration: C, concurrency_manager: CM, - cache_status_header: HeaderName, + cache_status_config: HttpCacheStatusConfig, ) -> Self { Self { backend, configuration, concurrency_manager, - cache_status_header, + cache_status_config, } } } @@ -86,7 +86,7 @@ where backend: self.backend.clone(), configuration: self.configuration.clone(), concurrency_manager: self.concurrency_manager.clone(), - cache_status_header: self.cache_status_header.clone(), + cache_status_config: self.cache_status_config.clone(), } } } @@ -145,8 +145,18 @@ where // Convert CacheableHttpResponse back to reqwest::Response let mut cacheable_response = response?; - // Add cache status header based on cache context - cacheable_response.cache_status(cache_context.status, &self.cache_status_header); + // Set HTTP-specific extension data (upstream status code) + let http_ext = if matches!(cache_context.status, CacheStatus::Forward(_)) { + Some(HttpCacheData { + upstream_status: cacheable_response.parts.status.as_u16(), + }) + } else { + None + }; + let http_ctx = cache_context.with_extensions(http_ext); + + // Add cache status headers (RFC 9211 Cache-Status, Age, legacy x-cache-status) + cacheable_response.cache_status(&http_ctx, &self.cache_status_config); let http_response = cacheable_response.into_response(); let (parts, buffered_body) = http_response.into_parts(); @@ -171,7 +181,7 @@ pub struct CacheMiddlewareBuilder { backend: B, configuration: C, concurrency_manager: CM, - cache_status_header: Option, + cache_status_config: Option, } impl CacheMiddlewareBuilder { @@ -184,7 +194,7 @@ impl CacheMiddlewareBuilder { backend: Arc::new(backend), configuration: self.configuration, concurrency_manager: self.concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } @@ -196,7 +206,7 @@ impl CacheMiddlewareBuilder { backend: self.backend, configuration, concurrency_manager: self.concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } @@ -211,19 +221,18 @@ impl CacheMiddlewareBuilder { backend: self.backend, configuration: self.configuration, concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } - /// Sets the header name for cache status. - /// - /// The cache status header indicates whether a response was served from cache. - /// Possible values are `HIT`, `MISS`, or `STALE`. + /// Sets the legacy header name for cache status. /// /// Defaults to `x-cache-status` if not set. pub fn cache_status_header(self, header_name: HeaderName) -> Self { + let mut config = self.cache_status_config.unwrap_or_default(); + config.legacy_header = Some(header_name); CacheMiddlewareBuilder { - cache_status_header: Some(header_name), + cache_status_config: Some(config), ..self } } @@ -242,9 +251,7 @@ where backend: self.backend, configuration: self.configuration, concurrency_manager: self.concurrency_manager, - cache_status_header: self - .cache_status_header - .unwrap_or(DEFAULT_CACHE_STATUS_HEADER), + cache_status_config: self.cache_status_config.unwrap_or_default(), } } } @@ -256,7 +263,7 @@ impl CacheMiddlewareBuilder { backend: NotSet, configuration: NotSet, concurrency_manager: NoopConcurrencyManager, - cache_status_header: None, + cache_status_config: None, } } } diff --git a/hitbox-test/benches/backend_comparison.rs b/hitbox-test/benches/backend_comparison.rs index 31ff810c..624944f1 100644 --- a/hitbox-test/benches/backend_comparison.rs +++ b/hitbox-test/benches/backend_comparison.rs @@ -50,7 +50,7 @@ async fn bench_write_single( B: Backend + CacheBackend, { let key = CacheKey::from_str("bench", &format!("key-{}", key_num)); - let value = CacheValue::new(serialized.clone(), None, None); + let value = CacheValue::new(serialized.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -78,7 +78,7 @@ async fn bench_mixed_single( B: Backend + CacheBackend, { let key = CacheKey::from_str("bench", &format!("key-{}", key_num)); - let value = CacheValue::new(serialized.clone(), None, None); + let value = CacheValue::new(serialized.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); // Write @@ -142,7 +142,7 @@ fn moka_backend_benchmarks(c: &mut Criterion) { runtime.block_on(async { for i in 0..1000 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -256,7 +256,7 @@ fn feoxdb_backend_benchmarks(c: &mut Criterion) { runtime.block_on(async { for i in 0..1000 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -390,7 +390,7 @@ fn redis_backend_benchmarks(c: &mut Criterion) { runtime.block_on(async { for i in 0..1000 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) diff --git a/hitbox-test/benches/backend_concurrency.rs b/hitbox-test/benches/backend_concurrency.rs index fe574c8e..4a18fe08 100644 --- a/hitbox-test/benches/backend_concurrency.rs +++ b/hitbox-test/benches/backend_concurrency.rs @@ -65,7 +65,7 @@ where while Instant::now() < deadline { let key_num = (task_id * 1000 + ops) % 1000; let key = CacheKey::from_str("bench", &format!("key-{}", key_num)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); if backend @@ -166,7 +166,7 @@ where while Instant::now() < deadline { let key_num = (task_id * 1000 + ops) % 1000; let key = CacheKey::from_str("bench", &format!("key-{}", key_num)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); // Write @@ -296,7 +296,7 @@ async fn main() { // Pre-populate for read test for i in 0..1000 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -360,7 +360,7 @@ async fn main() { // Pre-populate for read test for i in 0..1000 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); let _ = backend.set::(&key, &value, &mut ctx).await; } @@ -442,7 +442,7 @@ async fn main() { // Pre-populate for read test for i in 0..1000 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); let _ = backend.set::(&key, &value, &mut ctx).await; } diff --git a/hitbox-test/benches/backend_format.rs b/hitbox-test/benches/backend_format.rs index 0d431afd..86c292ac 100644 --- a/hitbox-test/benches/backend_format.rs +++ b/hitbox-test/benches/backend_format.rs @@ -48,7 +48,7 @@ async fn bench_write_single( B: Backend + CacheBackend, { let key = CacheKey::from_str("bench", &format!("key-{}", key_num)); - let value = CacheValue::new(serialized.clone(), None, None); + let value = CacheValue::new(serialized.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -183,7 +183,7 @@ fn format_read_benchmark(c: &mut Criterion) { runtime.block_on(async { for i in 0..100 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -211,7 +211,7 @@ fn format_read_benchmark(c: &mut Criterion) { runtime.block_on(async { for i in 0..100 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -243,7 +243,7 @@ fn format_read_benchmark(c: &mut Criterion) { runtime.block_on(async { for i in 0..100 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) @@ -273,7 +273,7 @@ fn format_read_benchmark(c: &mut Criterion) { runtime.block_on(async { for i in 0..100 { let key = CacheKey::from_str("bench", &format!("key-{}", i)); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx = CacheContext::default().boxed(); backend .set::(&key, &value, &mut ctx) diff --git a/hitbox-test/benches/cache_future_reference.rs b/hitbox-test/benches/cache_future_reference.rs index 258703b0..8cc80909 100644 --- a/hitbox-test/benches/cache_future_reference.rs +++ b/hitbox-test/benches/cache_future_reference.rs @@ -282,7 +282,7 @@ fn bench_compare_cache_write(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); // ===== Dynamic backend setup ===== let config = load_config(); @@ -349,7 +349,7 @@ fn bench_compare_cache_read(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); // Pre-populate static cache rt.block_on(async { @@ -449,7 +449,7 @@ fn bench_compare_composition_read(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); // Pre-populate static composition cache rt.block_on(async { @@ -566,7 +566,7 @@ fn bench_compare_composition_write(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); // ===== Dynamic CompositionBackend setup (all levels are dyn Backend) ===== let dyn_l1: Arc = Arc::new( @@ -794,7 +794,7 @@ fn bench_compare_cache_future_hit(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); rt.block_on(async { let mut ctx = CacheContext::default().boxed(); @@ -828,7 +828,7 @@ fn bench_compare_cache_future_hit(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); rt.block_on(async { let mut ctx = CacheContext::default().boxed(); @@ -1177,7 +1177,7 @@ fn bench_compare_body_cache_future_hit(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); rt.block_on(async { let mut ctx = CacheContext::default().boxed(); @@ -1211,7 +1211,7 @@ fn bench_compare_body_cache_future_hit(c: &mut Criterion) { hitbox::CachePolicy::NonCacheable(_) => panic!("Response should be cacheable"), } }); - let cache_value = CacheValue::new(cached_response, None, None); + let cache_value = CacheValue::new(cached_response, None, None, None); rt.block_on(async { let mut ctx = CacheContext::default().boxed(); diff --git a/hitbox-test/benches/composition_overhead.rs b/hitbox-test/benches/composition_overhead.rs index 01eff706..98bd2725 100644 --- a/hitbox-test/benches/composition_overhead.rs +++ b/hitbox-test/benches/composition_overhead.rs @@ -54,7 +54,7 @@ fn bench_direct_moka(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -122,7 +122,7 @@ fn bench_composition_concrete(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -191,7 +191,7 @@ fn bench_composition_outer_dyn(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -269,7 +269,7 @@ fn bench_composition_inner_dyn(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -349,7 +349,7 @@ fn bench_composition_both_dyn(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -434,7 +434,7 @@ fn bench_nested_2_concrete(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -524,7 +524,7 @@ fn bench_nested_2_dyn(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -616,7 +616,7 @@ fn bench_nested_3_concrete(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime @@ -719,7 +719,7 @@ fn bench_nested_3_dyn(c: &mut Criterion) { let response = runtime.block_on(generate_response(*size_bytes)); let key = CacheKey::from_str("bench", "key1"); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Pre-populate for read benchmark runtime diff --git a/hitbox-test/src/backend/mod.rs b/hitbox-test/src/backend/mod.rs index e80cac92..a4d43b2d 100644 --- a/hitbox-test/src/backend/mod.rs +++ b/hitbox-test/src/backend/mod.rs @@ -46,7 +46,7 @@ impl CacheableResponse for TestResponse { P: hitbox_core::Predicate + Send + Sync, { // Always cacheable for testing - CachePolicy::Cacheable(CacheValue::new(self.clone(), None, None)) + CachePolicy::Cacheable(CacheValue::new(self.clone(), None, None, None)) } fn into_cached(self) -> Self::IntoCachedFuture { @@ -86,7 +86,7 @@ pub async fn run_backend_tests(backend: &B) { async fn test_write_and_read(backend: &B) { let key = CacheKey::from_str("test", "write-read"); let response = TestResponse::new(1, "test-response", vec![1, 2, 3, 4, 5]); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -113,7 +113,7 @@ async fn test_write_and_read_with_metadata(backend: &B) { let expire = Some(Utc::now() + chrono::Duration::hours(1)); let stale = Some(Utc::now() + chrono::Duration::minutes(30)); - let value = CacheValue::new(response.clone(), expire, stale); + let value = CacheValue::new(response.clone(), expire, stale, None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -139,7 +139,7 @@ async fn test_write_and_read_with_metadata(backend: &B) { async fn test_delete_existing(backend: &B) { let key = CacheKey::from_str("test", "delete-existing"); let response = TestResponse::new(3, "delete-test", vec![]); - let value = CacheValue::new(response, None, None); + let value = CacheValue::new(response, None, None, None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -192,7 +192,7 @@ async fn test_overwrite(backend: &B) { // Write first value let response1 = TestResponse::new(4, "original", vec![1, 2, 3]); - let value1 = CacheValue::new(response1, None, None); + let value1 = CacheValue::new(response1, None, None, None); let mut ctx: BoxContext = CacheContext::default().boxed(); backend .set::(&key, &value1, &mut ctx) @@ -201,7 +201,7 @@ async fn test_overwrite(backend: &B) { // Overwrite with second value let response2 = TestResponse::new(5, "updated", vec![4, 5, 6, 7]); - let value2 = CacheValue::new(response2.clone(), None, None); + let value2 = CacheValue::new(response2.clone(), None, None, None); let mut ctx: BoxContext = CacheContext::default().boxed(); backend .set::(&key, &value2, &mut ctx) @@ -240,7 +240,7 @@ async fn test_multiple_keys(backend: &B) { // Write all for (key, response) in &keys_and_values { - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx: BoxContext = CacheContext::default().boxed(); backend .set::(key, &value, &mut ctx) @@ -270,7 +270,7 @@ async fn test_binary_data(backend: &B) { // Create response with various binary data let binary_data: Vec = (0..=255).collect(); let response = TestResponse::new(99, "binary-test", binary_data.clone()); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -328,7 +328,7 @@ async fn test_expire_metadata_exact_match(backend: &B) { // Use a specific expire time let expire_time = Utc::now() + chrono::Duration::seconds(3600); - let value = CacheValue::new(response.clone(), Some(expire_time), None); + let value = CacheValue::new(response.clone(), Some(expire_time), None, None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -364,7 +364,7 @@ async fn test_stale_metadata_exact_match(backend: &B) { // Use specific expire and stale times let expire_time = Utc::now() + chrono::Duration::seconds(3600); let stale_time = Utc::now() + chrono::Duration::seconds(1800); - let value = CacheValue::new(response.clone(), Some(expire_time), Some(stale_time)); + let value = CacheValue::new(response.clone(), Some(expire_time), Some(stale_time), None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -404,7 +404,7 @@ async fn test_expire_and_stale_combined(backend: &B) { // Set expire far in the future, stale closer let expire_time = Utc::now() + chrono::Duration::hours(24); let stale_time = Utc::now() + chrono::Duration::hours(1); - let value = CacheValue::new(response.clone(), Some(expire_time), Some(stale_time)); + let value = CacheValue::new(response.clone(), Some(expire_time), Some(stale_time), None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -448,7 +448,7 @@ async fn test_no_metadata(backend: &B) { let response = TestResponse::new(203, "no-metadata-test", vec![10, 11, 12]); // No expire, no stale - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Write let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -482,7 +482,7 @@ pub async fn test_url_encoded_key_json_value(backend: let key = CacheKey::from_str("format-test", "url-json"); let response = TestResponse::new(100, "url-json-test", vec![1, 2, 3]); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Write and read let mut ctx: BoxContext = CacheContext::default().boxed(); @@ -532,7 +532,7 @@ pub async fn test_url_encoded_key_bincode_value(backe let key = CacheKey::from_str("format-test", "url-bincode"); let response = TestResponse::new(101, "url-bincode-test", vec![4, 5, 6]); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx: BoxContext = CacheContext::default().boxed(); backend @@ -580,7 +580,7 @@ pub async fn test_bitcode_key_json_value(backend: &B) let key = CacheKey::from_str("format-test", "bitcode-json"); let response = TestResponse::new(102, "bitcode-json-test", vec![7, 8, 9]); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx: BoxContext = CacheContext::default().boxed(); backend @@ -628,7 +628,7 @@ pub async fn test_bitcode_key_bincode_value(backend: let key = CacheKey::from_str("format-test", "bitcode-bincode"); let response = TestResponse::new(103, "bitcode-bincode-test", vec![10, 11, 12]); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); let mut ctx: BoxContext = CacheContext::default().boxed(); backend @@ -680,7 +680,7 @@ where let large_repeated_data = vec![42u8; 10000]; // 10KB of the same byte let key = CacheKey::from_str("compression-test", "verify-compression"); let response = TestResponse::new(999, "compression-test-data", large_repeated_data); - let value = CacheValue::new(response.clone(), None, None); + let value = CacheValue::new(response.clone(), None, None, None); // Serialize the value to get the raw uncompressed serialized bytes let ctx = CacheContext::default(); diff --git a/hitbox-test/src/fsm/world.rs b/hitbox-test/src/fsm/world.rs index ebc41d5b..cf4b43da 100644 --- a/hitbox-test/src/fsm/world.rs +++ b/hitbox-test/src/fsm/world.rs @@ -81,7 +81,7 @@ impl CacheableResponse for SimpleResponse { { match predicates.check(self).await { PredicateResult::Cacheable(response) => { - CachePolicy::Cacheable(CacheValue::new(response.0, None, None)) + CachePolicy::Cacheable(CacheValue::new(response.0, None, None, None)) } PredicateResult::NonCacheable(response) => CachePolicy::NonCacheable(response), } @@ -555,7 +555,7 @@ impl FsmWorld { CacheState::Fresh(value) => { // Fresh: expires in the future let expire = Some(Utc::now() + chrono::Duration::hours(1)); - let cache_value = CacheValue::new(*value, expire, None); + let cache_value = CacheValue::new(*value, expire, None, None); let _ = backend .set::(&cache_key, &cache_value, &mut ctx) .await; @@ -565,7 +565,7 @@ impl FsmWorld { // This means: not expired yet, but past the "fresh" period let expire = Some(Utc::now() + chrono::Duration::hours(1)); let stale = Some(Utc::now() - chrono::Duration::seconds(1)); - let cache_value = CacheValue::new(*value, expire, stale); + let cache_value = CacheValue::new(*value, expire, stale, None); let _ = backend .set::(&cache_key, &cache_value, &mut ctx) .await; @@ -577,7 +577,7 @@ impl FsmWorld { // functionally this tests the same behavior. let expire = Some(Utc::now() - chrono::Duration::hours(1)); let stale = Some(Utc::now() - chrono::Duration::minutes(30)); - let cache_value = CacheValue::new(*value, expire, stale); + let cache_value = CacheValue::new(*value, expire, stale, None); let _ = backend .set::(&cache_key, &cache_value, &mut ctx) .await; @@ -610,7 +610,7 @@ impl FsmWorld { CacheState::Empty => {} CacheState::Fresh(value) => { let expire = Some(Utc::now() + chrono::Duration::hours(1)); - let cache_value = CacheValue::new(*value, expire, None); + let cache_value = CacheValue::new(*value, expire, None, None); let _ = backend .set::(cache_key, &cache_value, &mut ctx) .await; @@ -618,7 +618,7 @@ impl FsmWorld { CacheState::Stale(value) => { let expire = Some(Utc::now() + chrono::Duration::hours(1)); let stale = Some(Utc::now() - chrono::Duration::seconds(1)); - let cache_value = CacheValue::new(*value, expire, stale); + let cache_value = CacheValue::new(*value, expire, stale, None); let _ = backend .set::(cache_key, &cache_value, &mut ctx) .await; @@ -628,7 +628,7 @@ impl FsmWorld { // See comment in prepopulate_cache for details. let expire = Some(Utc::now() - chrono::Duration::hours(1)); let stale = Some(Utc::now() - chrono::Duration::minutes(30)); - let cache_value = CacheValue::new(*value, expire, stale); + let cache_value = CacheValue::new(*value, expire, stale, None); let _ = backend .set::(cache_key, &cache_value, &mut ctx) .await; diff --git a/hitbox-test/src/steps/then.rs b/hitbox-test/src/steps/then.rs index 3591a1cd..a5344e09 100644 --- a/hitbox-test/src/steps/then.rs +++ b/hitbox-test/src/steps/then.rs @@ -170,6 +170,39 @@ fn response_header_is_correct( Ok(()) } +#[then(expr = "response header {string} starts with {string}")] +fn response_header_starts_with( + world: &mut HitboxWorld, + header_name: String, + expected_prefix: String, +) -> Result<(), Error> { + let response = world + .state + .response + .as_ref() + .ok_or_else(|| anyhow!("No response available"))?; + + let header_value = response + .headers() + .get(&header_name) + .ok_or_else(|| anyhow!("Header '{}' not found", header_name))?; + + let actual_value = header_value + .to_str() + .map_err(|_| anyhow!("Header '{}' contains invalid UTF-8", header_name))?; + + if !actual_value.starts_with(&expected_prefix) { + return Err(anyhow!( + "Expected header '{}' to start with '{}', but found '{}'", + header_name, + expected_prefix, + actual_value + )); + } + + Ok(()) +} + #[then(expr = "backend read was called {int} times with all miss")] fn backend_read_all_miss(world: &mut HitboxWorld, expected: usize) -> Result<(), Error> { let read_count = world.backend.read_count(); @@ -286,6 +319,54 @@ fn response_headers_table(world: &mut HitboxWorld, step: &Step) -> Result<(), Er Ok(()) } +#[then(expr = "response headers start with")] +fn response_headers_start_with_table(world: &mut HitboxWorld, step: &Step) -> Result<(), Error> { + let table = step + .table + .as_ref() + .ok_or_else(|| anyhow!("Expected a table"))?; + + if table.rows.len() != world.state.responses.len() { + return Err(anyhow!( + "Expected {} rows but got {}", + world.state.responses.len(), + table.rows.len() + )); + } + + for (i, (row, response)) in table + .rows + .iter() + .zip(world.state.responses.iter()) + .enumerate() + { + let header_name = row + .first() + .ok_or_else(|| anyhow!("Row {} missing header name", i))?; + let expected_prefix = row + .get(1) + .ok_or_else(|| anyhow!("Row {} missing value", i))?; + + let actual_value = response + .headers() + .get(header_name) + .map(|v| v.to_str().unwrap_or("")) + .unwrap_or(""); + + if !actual_value.starts_with(expected_prefix.as_str()) { + return Err(anyhow!( + "Response {}: header '{}' expected to start with '{}', got '{}'", + i, + header_name, + expected_prefix, + actual_value + )); + } + } + + Ok(()) +} + #[then(expr = "{word} should be called 1 time")] fn upstream_called_once(world: &mut HitboxWorld, handler: String) -> Result<(), Error> { let handler_name: HandlerName = handler diff --git a/hitbox-test/tests/features/concurrency/concurrency.feature b/hitbox-test/tests/features/concurrency/concurrency.feature index f9ddb4a8..ccc01c04 100644 --- a/hitbox-test/tests/features/concurrency/concurrency.feature +++ b/hitbox-test/tests/features/concurrency/concurrency.feature @@ -35,8 +35,8 @@ Feature: Concurrency Control (Dogpile Prevention) And backend write was called 1 times And response headers are | X-Cache-Status | MISS | - | X-Cache-Status | MISS | - | X-Cache-Status | MISS | + | X-Cache-Status | HIT | + | X-Cache-Status | HIT | Scenario: Concurrent requests with concurrency limit of 3 - all requests go to upstream Given hitbox with policy @@ -76,7 +76,7 @@ Feature: Concurrency Control (Dogpile Prevention) And backend write was called 1 times And response headers are | X-Cache-Status | MISS | - | X-Cache-Status | MISS | + | X-Cache-Status | HIT | | X-Cache-Status | HIT | Scenario: Non-cacheable response (status 201) - no coalescing, all go to upstream diff --git a/hitbox-test/tests/features/policy/cache_status_headers.feature b/hitbox-test/tests/features/policy/cache_status_headers.feature new file mode 100644 index 00000000..93f6f2bb --- /dev/null +++ b/hitbox-test/tests/features/policy/cache_status_headers.feature @@ -0,0 +1,403 @@ +@serial +Feature: Cache-Status and Age Headers (RFC 9211 / RFC 9111) + + Verifies that the Cache-Status structured header (RFC 9211) and Age header + (RFC 9111 §5.1) are correctly generated for all cache scenarios. + + Background: + Given hitbox with policy + ```yaml + Enabled: + ttl: 10s + ``` + + # =========================================================================== + # Group 1: Forward scenarios (fwd=miss) + # =========================================================================== + + @cache-status @forward @miss + Scenario: Cache miss - first request produces fwd=miss with stored + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200; stored" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @not-stored + Scenario: Non-cacheable response (404) - fwd=miss without stored + Given response predicates + ```yaml + - Status: 200 + ``` + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/nonexistent-book + ``` + Then response status is 404 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=404" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @not-stored @500 + Scenario: Upstream error (500) - fwd=miss without stored + Given response predicates + ```yaml + - Status: 200 + ``` + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/invalid-book-id + ``` + Then response status is 500 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=500" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @not-stored @empty-body + Scenario: Empty list response rejected by body predicate - fwd=miss without stored + Given response predicates + ```yaml + - Body: + jq: 'length > 0' + ``` + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books?page=999 + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @bypass + Scenario: Request predicate bypass - fwd=bypass + Given request predicates + ```yaml + - Header: + name: X-Custom + eq: "cache-me" + ``` + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" starts with "hitbox; fwd=bypass" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @expired + Scenario: Expired cache entry - fwd=stale (RFC 9211 uses "stale" for expired forwards) + Given hitbox with policy + ```yaml + Enabled: + ttl: 200ms + ``` + # First request - miss, stored + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200; stored" + + # Wait past TTL - entry expired + When sleep 250ms + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=stale; fwd-status=200; stored" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @disabled + Scenario: Cache disabled - fwd=bypass without stored + Given hitbox with policy + ```yaml + Disabled + ``` + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" starts with "hitbox; fwd=bypass" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + @cache-status @forward @revalidate + Scenario: Stale entry with Revalidate policy - synchronous forward + Given hitbox with policy + ```yaml + Enabled: + ttl: 300ms + stale: 100ms + policy: + stale: Revalidate + ``` + # First request - miss + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200; stored" + + # Wait past stale mark - Revalidate policy forwards synchronously + When sleep 150ms + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=stale; fwd-status=200; stored" + And response header "X-Cache-Status" is "MISS" + + # =========================================================================== + # Group 2: Hit scenarios + # =========================================================================== + + @cache-status @hit + Scenario: Cache hit - second request produces hit with ttl + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" starts with "hitbox; hit; ttl=" + And response header "X-Cache-Status" is "HIT" + And response headers contain "Age" header + + @cache-status @hit @no-ttl + Scenario: Cache hit without expire - hit without ttl parameter + Given hitbox with policy + ```yaml + Enabled: {} + ``` + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "X-Cache-Status" is "MISS" + + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; hit" + And response header "X-Cache-Status" is "HIT" + And response headers contain "Age" header + + @cache-status @hit @lifecycle + Scenario: Full miss-to-hit lifecycle in a single scenario + # First request - miss, stored + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200; stored" + And response header "X-Cache-Status" is "MISS" + And response headers have no "Age" header + And cache has 1 records + + # Second request - hit + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" starts with "hitbox; hit; ttl=" + And response header "X-Cache-Status" is "HIT" + And response headers contain "Age" header + + # =========================================================================== + # Group 3: Age header + # =========================================================================== + + @cache-status @age + Scenario: Age is 0 on immediate cache hit + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response headers have no "Age" header + + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Age" is "0" + + @cache-status @age @increases + Scenario: Age increases over time + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Age" is "0" + + When sleep 1000ms + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Age" is "1" + + # =========================================================================== + # Group 4: Stale scenarios + # =========================================================================== + + @cache-status @stale @swr + Scenario: Stale-while-revalidate (OffloadRevalidate) - hit with stale status + Given offload revalidation is enabled + Given hitbox with policy + ```yaml + Enabled: + ttl: 300ms + stale: 100ms + policy: + stale: OffloadRevalidate + ``` + # Miss + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200; stored" + + # Past stale mark (100ms) but within TTL (300ms) - serves stale + When sleep 150ms + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" starts with "hitbox; hit; ttl=" + And response header "X-Cache-Status" is "STALE" + And response headers contain "Age" header + + @cache-status @stale @return + Scenario: Stale-while-revalidate (Return policy) - serves stale without background refresh + Given hitbox with policy + ```yaml + Enabled: + ttl: 300ms + stale: 100ms + policy: + stale: Return + ``` + # Miss + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" is "hitbox; fwd=miss; fwd-status=200; stored" + + # Past stale mark - serves stale + When sleep 150ms + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response header "Cache-Status" starts with "hitbox; hit; ttl=" + And response header "X-Cache-Status" is "STALE" + And response headers contain "Age" header + + # =========================================================================== + # Group 5: Collapsed (dog-pile prevention) + # =========================================================================== + + @cache-status @collapsed + Scenario: Collapsed requests - first goes upstream, others wait + Given request predicates + ```yaml + - Method: GET + - Path: /v1/authors/{author_id}/books/{book_id} + ``` + And response predicates + ```yaml + - Status: 200 + ``` + And key extractors + ```yaml + - Method: + - Path: "/v1/authors/{author_id}/books/{book_id}" + ``` + And hitbox with policy + ```yaml + Enabled: + ttl: 300s + concurrency: 1 + ``` + And upstream delay for GetBook is 35ms + When 3 concurrent requests are made with delay 10ms + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then all responses should have status 200 + And GetBook should be called 1 time + And response headers start with + | Cache-Status | hitbox; fwd=miss | + | Cache-Status | hitbox; hit; collapsed | + | Cache-Status | hitbox; hit; collapsed | + + # =========================================================================== + # Group 6: Legacy header coexistence + # =========================================================================== + + @cache-status @legacy + Scenario: Both Cache-Status and X-Cache-Status headers are present on miss + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response headers contain "Cache-Status" header + And response headers contain "X-Cache-Status" header + And response header "X-Cache-Status" is "MISS" + + @cache-status @legacy + Scenario: Both Cache-Status and X-Cache-Status headers are present on hit + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + + When execute request + ```hurl + GET http://localhost/v1/authors/robert-sheckley/books/victim-prime + ``` + Then response status is 200 + And response headers contain "Cache-Status" header + And response headers contain "X-Cache-Status" header + And response header "X-Cache-Status" is "HIT" diff --git a/hitbox-tower/CHANGELOG.md b/hitbox-tower/CHANGELOG.md index d2183298..153cfb91 100644 --- a/hitbox-tower/CHANGELOG.md +++ b/hitbox-tower/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `CacheService` now requires `C: CacheConfigs` instead of `C: CacheConfig`, routing all requests through `SelectiveCacheFuture` ([#253](https://github.com/hit-box/hitbox/pull/253)) +- **Breaking:** `CacheService` and builder use `HttpCacheStatusConfig` instead of `HeaderName`; added `.cache_name()` builder method ([#269](https://github.com/hit-box/hitbox/pull/269)) ### Changed - Adapted to `Upstream::call(self)` API change ([#206](https://github.com/hit-box/hitbox/pull/206)) diff --git a/hitbox-tower/src/future.rs b/hitbox-tower/src/future.rs index 35febde9..b47c1732 100644 --- a/hitbox-tower/src/future.rs +++ b/hitbox-tower/src/future.rs @@ -13,10 +13,9 @@ use std::task::{Context, Poll}; use futures::Future; use futures::ready; -use hitbox::{CacheContext, CacheStatusExt}; -use hitbox_http::{BufferedBody, CacheableHttpResponse}; +use hitbox::{CacheContext, CacheStatus, CacheStatusExt}; +use hitbox_http::{BufferedBody, CacheableHttpResponse, HttpCacheData, HttpCacheStatusConfig}; use http::Response; -use http::header::HeaderName; use pin_project::pin_project; /// Future returned by [`CacheService::call`](crate::service::CacheService). @@ -45,7 +44,7 @@ where { #[pin] inner: F, - cache_status_header: HeaderName, + cache_status_config: HttpCacheStatusConfig, } impl CacheServiceFuture @@ -54,10 +53,10 @@ where ResBody: hyper::body::Body, { /// Creates a new future that will add cache status headers to the response. - pub fn new(inner: F, cache_status_header: HeaderName) -> Self { + pub fn new(inner: F, cache_status_config: HttpCacheStatusConfig) -> Self { Self { inner, - cache_status_header, + cache_status_config, } } } @@ -77,8 +76,18 @@ where // Transform the response and add cache headers let response = result.map(|mut cacheable_response| { - // Add cache status header based on cache context - cacheable_response.cache_status(cache_context.status, this.cache_status_header); + // Set HTTP-specific extension data (upstream status code) + let http_ext = if matches!(cache_context.status, CacheStatus::Forward(_)) { + Some(HttpCacheData { + upstream_status: cacheable_response.parts.status.as_u16(), + }) + } else { + None + }; + let http_ctx = cache_context.with_extensions(http_ext); + + // Add cache status headers (RFC 9211 Cache-Status, Age, legacy x-cache-status) + cacheable_response.cache_status(&http_ctx, this.cache_status_config); cacheable_response.into_response() }); diff --git a/hitbox-tower/src/layer.rs b/hitbox-tower/src/layer.rs index 9fc2a7a3..fabbea4d 100644 --- a/hitbox-tower/src/layer.rs +++ b/hitbox-tower/src/layer.rs @@ -37,7 +37,7 @@ use std::sync::Arc; use hitbox::backend::CacheBackend; use hitbox::concurrency::NoopConcurrencyManager; use hitbox_core::DisabledOffload; -use hitbox_http::DEFAULT_CACHE_STATUS_HEADER; +use hitbox_http::HttpCacheStatusConfig; use http::header::HeaderName; use tower::Layer; @@ -106,8 +106,8 @@ pub struct Cache { pub offload: O, /// Concurrency manager for dogpile prevention. pub concurrency_manager: CM, - /// Header name for cache status (HIT/MISS/STALE). - pub cache_status_header: HeaderName, + /// Configuration for cache status headers (RFC 9211 + legacy). + pub cache_status_config: HttpCacheStatusConfig, } impl Layer for Cache @@ -125,7 +125,7 @@ where self.configuration.clone(), self.offload.clone(), self.concurrency_manager.clone(), - self.cache_status_header.clone(), + self.cache_status_config.clone(), ) } } @@ -211,7 +211,7 @@ pub struct CacheBuilder { configuration: C, offload: O, concurrency_manager: CM, - cache_status_header: Option, + cache_status_config: Option, } impl CacheBuilder { @@ -224,7 +224,7 @@ impl CacheBuilder { configuration: NotSet, offload: DisabledOffload, concurrency_manager: NoopConcurrencyManager, - cache_status_header: None, + cache_status_config: None, } } } @@ -258,7 +258,7 @@ impl CacheBuilder { configuration: self.configuration, offload: self.offload, concurrency_manager: self.concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } @@ -302,7 +302,7 @@ impl CacheBuilder { configuration, offload: self.offload, concurrency_manager: self.concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } @@ -324,7 +324,7 @@ impl CacheBuilder { configuration: self.configuration, offload: self.offload, concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } @@ -342,16 +342,16 @@ impl CacheBuilder { configuration: self.configuration, offload, concurrency_manager: self.concurrency_manager, - cache_status_header: self.cache_status_header, + cache_status_config: self.cache_status_config, } } - /// Sets the header name for cache status. + /// Sets the legacy header name for cache status. /// - /// The cache status header indicates whether a response was served from cache. - /// Possible values are `HIT`, `MISS`, or `STALE`. + /// Controls the legacy `x-cache-status` header name. The RFC 9211 + /// `Cache-Status` header is always included. /// - /// Defaults to [`DEFAULT_CACHE_STATUS_HEADER`] (`x-cache-status`). + /// Defaults to [`hitbox_http::DEFAULT_CACHE_STATUS_HEADER`] (`x-cache-status`). /// /// # Examples /// @@ -365,8 +365,22 @@ impl CacheBuilder { /// .cache_status_header(HeaderName::from_static("x-custom-cache")); /// ``` pub fn cache_status_header(self, header_name: HeaderName) -> Self { + let mut config = self.cache_status_config.unwrap_or_default(); + config.legacy_header = Some(header_name); CacheBuilder { - cache_status_header: Some(header_name), + cache_status_config: Some(config), + ..self + } + } + + /// Sets the cache name used in the RFC 9211 `Cache-Status` header. + /// + /// Defaults to `"hitbox"`. + pub fn cache_name(self, name: impl Into) -> Self { + let mut config = self.cache_status_config.unwrap_or_default(); + config.cache_name = name.into(); + CacheBuilder { + cache_status_config: Some(config), ..self } } @@ -386,9 +400,7 @@ where configuration: self.configuration, offload: self.offload, concurrency_manager: self.concurrency_manager, - cache_status_header: self - .cache_status_header - .unwrap_or(DEFAULT_CACHE_STATUS_HEADER), + cache_status_config: self.cache_status_config.unwrap_or_default(), } } } diff --git a/hitbox-tower/src/service.rs b/hitbox-tower/src/service.rs index bc12bfa4..4a6dea90 100644 --- a/hitbox-tower/src/service.rs +++ b/hitbox-tower/src/service.rs @@ -9,8 +9,9 @@ use hitbox_core::{CacheConfigs, DisabledOffload, Offload}; use std::sync::Arc; use hitbox::{backend::CacheBackend, fsm::SelectiveCacheFuture}; -use hitbox_http::{BufferedBody, CacheableHttpRequest, CacheableHttpResponse}; -use http::header::HeaderName; +use hitbox_http::{ + BufferedBody, CacheableHttpRequest, CacheableHttpResponse, HttpCacheStatusConfig, +}; use http::{Request, Response}; use hyper::body::Body as HttpBody; use tower::Service; @@ -46,7 +47,7 @@ pub struct CacheService { configuration: C, offload: O, concurrency_manager: CM, - cache_status_header: HeaderName, + cache_status_config: HttpCacheStatusConfig, } impl CacheService { @@ -62,7 +63,7 @@ impl CacheService { configuration: C, offload: O, concurrency_manager: CM, - cache_status_header: HeaderName, + cache_status_config: HttpCacheStatusConfig, ) -> Self { CacheService { upstream, @@ -70,7 +71,7 @@ impl CacheService { configuration, offload, concurrency_manager, - cache_status_header, + cache_status_config, } } } @@ -90,7 +91,7 @@ where configuration: self.configuration.clone(), offload: self.offload.clone(), concurrency_manager: self.concurrency_manager.clone(), - cache_status_header: self.cache_status_header.clone(), + cache_status_config: self.cache_status_config.clone(), } } } @@ -157,6 +158,6 @@ where ); // Wrap in CacheServiceFuture to add cache headers - CacheServiceFuture::new(cache_future, self.cache_status_header.clone()) + CacheServiceFuture::new(cache_future, self.cache_status_config.clone()) } } diff --git a/hitbox/CHANGELOG.md b/hitbox/CHANGELOG.md index f80b2ddd..015655fc 100644 --- a/hitbox/CHANGELOG.md +++ b/hitbox/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - `SelectiveCacheFuture` for multi-config routing with first-match-wins strategy ([#253](https://github.com/hit-box/hitbox/pull/253)) +- FSM wiring for `CacheTiming`, `stored`, `Collapsed`, and `Forward(Bypass)` statuses ([#269](https://github.com/hit-box/hitbox/pull/269)) - `SelectiveConfig` container for multiple `CacheConfig` instances ([#253](https://github.com/hit-box/hitbox/pull/253)) ### Changed diff --git a/hitbox/src/context.rs b/hitbox/src/context.rs index 403a476e..017bdd8a 100644 --- a/hitbox/src/context.rs +++ b/hitbox/src/context.rs @@ -3,5 +3,6 @@ //! Re-exported from `hitbox-core`. pub use hitbox_core::{ - BoxContext, CacheContext, CacheStatus, CacheStatusExt, Context, ResponseSource, + BoxContext, CacheContext, CacheStatus, CacheStatusExt, CacheTiming, Context, ForwardReason, + ResponseSource, }; diff --git a/hitbox/src/fsm/future.rs b/hitbox/src/fsm/future.rs index b518e6db..924d8d13 100644 --- a/hitbox/src/fsm/future.rs +++ b/hitbox/src/fsm/future.rs @@ -437,7 +437,7 @@ where let mut state = response_state.take().expect(POLL_AFTER_READY_ERROR); // For cache miss, set source to Upstream. // For hit/stale, the backend has already set the correct source. - if state.ctx.status() == CacheStatus::Miss { + if matches!(state.ctx.status(), CacheStatus::Forward(_)) { state.ctx.set_source(ResponseSource::Upstream); } let ctx = hitbox_core::finalize_context(state.ctx); diff --git a/hitbox/src/fsm/selective/mod.rs b/hitbox/src/fsm/selective/mod.rs index ad889dd3..3df4db70 100644 --- a/hitbox/src/fsm/selective/mod.rs +++ b/hitbox/src/fsm/selective/mod.rs @@ -21,7 +21,7 @@ use crate::backend::CacheBackend; use crate::concurrency::ConcurrencyManager; use crate::fsm::CacheFuture; use crate::policy::PolicyConfig; -use crate::{CacheConfig, CacheContext, CacheableRequest, CacheableResponse}; +use crate::{CacheConfig, CacheContext, CacheStatus, CacheableRequest, CacheableResponse}; use states::{CheckPredicate, Passthrough, SelectiveState, SelectiveStateProj}; @@ -249,8 +249,11 @@ where let passthrough = state.as_ref().expect(POLL_AFTER_READY); trace!(parent: &passthrough.span, "FSM state: Passthrough"); let response = ready!(upstream_future.poll(cx)); - let ctx = CacheContext::default().boxed(); - let ctx = hitbox_core::finalize_context(ctx); + let ctx = CacheContext { + status: CacheStatus::Forward(hitbox_core::ForwardReason::Bypass), + ..Default::default() + }; + let ctx = hitbox_core::finalize_context(ctx.boxed()); return Poll::Ready((response, ctx)); } }; diff --git a/hitbox/src/fsm/states.rs b/hitbox/src/fsm/states.rs index ad65c8f1..f4ab6776 100644 --- a/hitbox/src/fsm/states.rs +++ b/hitbox/src/fsm/states.rs @@ -31,7 +31,11 @@ use crate::fsm::transitions::{ PollUpstreamTransition, UpdateCacheTransition, }; use crate::policy::{EnabledCacheConfig, PolicyConfig, StalePolicy}; -use crate::{CacheKey, CacheState, CacheStatus, CacheableRequest, CacheableResponse, Extractor}; +use crate::{ + CacheKey, CacheState, CacheStatus, CacheTiming, CacheableRequest, CacheableResponse, Extractor, + ForwardReason, +}; +use chrono::Utc; // ============================================================================= // Helper Types @@ -240,7 +244,7 @@ where /// Based on policy configuration: /// - Enabled: create CheckRequestCachePolicy future /// - Disabled: call upstream directly - pub fn transition<'req>(self, policy: &PolicyConfig) -> InitialTransition<'req, Req, U> + pub fn transition<'req>(mut self, policy: &PolicyConfig) -> InitialTransition<'req, Req, U> where Req: 'req, ReqP: 'req, @@ -259,6 +263,8 @@ where } } PolicyConfig::Disabled => { + self.ctx + .set_status(CacheStatus::Forward(ForwardReason::Bypass)); let upstream_future = self.upstream.call(self.request); InitialTransition::PollUpstream { upstream_future, @@ -485,6 +491,7 @@ impl CheckResponseCachePolicy { } let cache_key = self.cache_key; let mut ctx = self.ctx; + ctx.set_stored(true); let update_cache_future = Box::pin(async move { let update_cache_result = backend.set::(&cache_key, &cache_value, &mut ctx).await; @@ -586,10 +593,12 @@ impl CheckRequestCachePolicy { } } CachePolicy::NonCacheable(request) => { + let mut ctx = self.ctx; + ctx.set_status(CacheStatus::Forward(ForwardReason::Bypass)); let upstream_future = self.upstream.call(request); CheckRequestCachePolicyTransition::PollUpstream { upstream_future, - ctx: self.ctx, + ctx, } } } @@ -660,6 +669,11 @@ impl PollCache { match cache_state { CacheState::Actual(value) => { + let timing = CacheTiming { + created_at: value.created_at().unwrap_or_else(Utc::now), + expire: value.expire(), + }; + ctx.set_timing(Some(timing)); if ctx.read_mode() == ReadMode::Refill { let cache_key = self.cache_key; let update_cache_future = Box::pin(async move { @@ -683,6 +697,11 @@ impl PollCache { } } CacheState::Stale(value) => { + let timing = CacheTiming { + created_at: value.created_at().unwrap_or_else(Utc::now), + expire: value.expire(), + }; + ctx.set_timing(Some(timing)); let cache_key = self.cache_key; let request = self.request; let upstream = self.upstream; @@ -696,7 +715,7 @@ impl PollCache { } } CacheState::Expired(_value) => { - ctx.set_status(CacheStatus::Miss); + ctx.set_status(CacheStatus::Forward(ForwardReason::Expired)); self.transition_to_upstream(ctx, policy, concurrency_manager) } } @@ -899,7 +918,7 @@ impl HandleStale { } StalePolicy::Revalidate => { self.span.record("stale.policy", "revalidate"); - ctx.set_status(CacheStatus::Miss); + ctx.set_status(CacheStatus::Forward(ForwardReason::Expired)); let upstream_future = self.upstream.call(self.request); HandleStaleResult { transition: HandleStaleTransition::Revalidate { @@ -987,14 +1006,17 @@ impl AwaitResponse { U: Upstream, C: ConcurrencyManager, { - let ctx = self.ctx; + let mut ctx = self.ctx; match result { - Ok(response) => AwaitResponseTransition::Response(Response { - response, - ctx, - span: Span::none(), - }), + Ok(response) => { + ctx.set_status(CacheStatus::Collapsed); + AwaitResponseTransition::Response(Response { + response, + ctx, + span: Span::none(), + }) + } Err(ref concurrency_error) => { match concurrency_error { ConcurrencyError::Lagged(n) => { diff --git a/hitbox/src/lib.rs b/hitbox/src/lib.rs index e5155de8..001facd1 100644 --- a/hitbox/src/lib.rs +++ b/hitbox/src/lib.rs @@ -74,7 +74,10 @@ pub mod context; pub mod offload; pub use config::{Config, ConfigBuilder, NotSet, SelectiveConfig}; -pub use context::{BoxContext, CacheContext, CacheStatus, CacheStatusExt, Context, ResponseSource}; +pub use context::{ + BoxContext, CacheContext, CacheStatus, CacheStatusExt, CacheTiming, Context, ForwardReason, + ResponseSource, +}; pub use hitbox_core::{CacheConfig, CacheConfigs}; /// Policy configuration for cache behavior. diff --git a/hitbox/src/metrics.rs b/hitbox/src/metrics.rs index abdcc65c..b9473ac3 100644 --- a/hitbox/src/metrics.rs +++ b/hitbox/src/metrics.rs @@ -173,8 +173,9 @@ pub fn record_context_metrics(ctx: &CacheContext, duration: Duration, revalidate // Record cache status counter let counter = match ctx.status { crate::context::CacheStatus::Hit => *CACHE_HIT_COUNTER, - crate::context::CacheStatus::Miss => *CACHE_MISS_COUNTER, + crate::context::CacheStatus::Forward(_) => *CACHE_MISS_COUNTER, crate::context::CacheStatus::Stale => *CACHE_STALE_COUNTER, + crate::context::CacheStatus::Collapsed => *CACHE_HIT_COUNTER, }; metrics::counter!(counter, "backend" => backend.to_string()).increment(1);