Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@ criterion = "0.8"
# SmallBox for inline trait objects
smallbox = "0.8"


17 changes: 9 additions & 8 deletions hitbox-configuration/tests/test_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use hitbox_configuration::{
},
types::MaybeUndefined,
};
use hitbox_core::EvalContext;
use hitbox_http::predicates::NeutralRequestPredicate;
use hitbox_http::{BufferedBody, CacheableHttpRequest};
use http::Request as HttpRequest;
Expand Down Expand Up @@ -57,7 +58,7 @@ async fn test_expression_into_predicates() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::NonCacheable(_)));
}

Expand Down Expand Up @@ -132,7 +133,7 @@ async fn test_or_with_matching_first_predicate() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::Cacheable(_)));
}

Expand All @@ -154,7 +155,7 @@ async fn test_or_with_matching_middle_predicate() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::Cacheable(_)));
}

Expand All @@ -176,7 +177,7 @@ async fn test_or_with_matching_last_predicate() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::Cacheable(_)));
}

Expand All @@ -198,7 +199,7 @@ async fn test_or_with_no_matching_predicates() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::NonCacheable(_)));
}

Expand All @@ -216,7 +217,7 @@ async fn test_or_with_single_predicate_matching() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::Cacheable(_)));
}

Expand All @@ -234,7 +235,7 @@ async fn test_or_with_single_predicate_not_matching() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::NonCacheable(_)));
}

Expand All @@ -256,7 +257,7 @@ async fn test_or_with_mixed_predicate_types() {
.body(BufferedBody::Passthrough(Empty::<Bytes>::new()))
.unwrap(),
);
let cacheable = predicate_or.check(request).await;
let cacheable = predicate_or.check(request, &mut EvalContext::new()).await;
assert!(matches!(cacheable, PredicateResult::Cacheable(_)));
}

Expand Down
5 changes: 4 additions & 1 deletion hitbox-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ 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))
- `EvalContext` type-map for sharing computed values across predicates and extractors

### Changed
- `PolicyConfig` and related policy types moved from `hitbox` crate ([#253](https://github.com/hit-box/hitbox/pull/253))
- **Breaking:** `CacheConfig::policy()` now returns `Arc<PolicyConfig>` instead of `&PolicyConfig` for consistency with other trait methods ([#253](https://github.com/hit-box/hitbox/pull/253))
- **Breaking:** `CacheConfig::policy()` now returns `Arc<PolicyConfig>` instead of `&PolicyConfig` ([#253](https://github.com/hit-box/hitbox/pull/253))
- **Breaking:** `Predicate::check()` now accepts `&mut EvalContext` parameter
- **Breaking:** `Extractor::get()` now accepts `&mut EvalContext` parameter

### Changed
- **Breaking:** `Upstream::call` now takes `self` by value instead of `&mut self` — the FSM calls upstream exactly once, so consuming is semantically correct and simplifies lifetime handling ([#206](https://github.com/hit-box/hitbox/pull/206))
Expand Down
1 change: 0 additions & 1 deletion hitbox-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ bytes = { workspace = true }
smol_str = { workspace = true }
smallbox = { workspace = true }
pin-project = { workspace = true }

# Optional rkyv support
rkyv = { workspace = true, optional = true }

Expand Down
172 changes: 172 additions & 0 deletions hitbox-core/src/eval_context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Evaluation context for predicates and extractors.
//!
//! [`EvalContext`] is a type-map that allows predicates and extractors to share
//! computed values during a single evaluation phase. This avoids redundant
//! expensive operations (e.g., collecting a chunked body and deserializing it
//! into JSON) when multiple predicates or extractors need the same data.
//!
//! ## Usage
//!
//! ```ignore
//! use hitbox_core::EvalContext;
//!
//! struct ParsedBody(serde_json::Value);
//!
//! let mut ctx = EvalContext::new();
//!
//! let body = ctx.get_or_insert_with(|| {
//! let collected = body_bytes;
//! ParsedBody(serde_json::from_slice(&collected).unwrap())
//! });
//! ```
//!
//! ## Lifecycle
//!
//! An `EvalContext` is created inside each `cache_policy` implementation:
//! one for the request phase (shared by request predicates and extractors)
//! and another for the response phase (used by response predicates).

use std::any::{Any, TypeId};
use std::collections::HashMap;

/// A type-map for sharing computed values across predicates and extractors.
///
/// Each value is keyed by its concrete type (`TypeId`), so only one value
/// of each type can be stored. Use newtype wrappers to store multiple
/// values of the same underlying type.
///
/// Predicates and extractors are evaluated sequentially, so no interior
/// mutability or synchronization is needed. Methods that read take `&self`,
/// methods that write take `&mut self`.
pub struct EvalContext {
map: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}

impl EvalContext {
/// Creates an empty evaluation context.
pub fn new() -> Self {
Self {
map: HashMap::new(),
}
}

/// Inserts a value into the context.
///
/// If a value of this type already exists, it is replaced.
pub fn insert<T: Send + Sync + 'static>(&mut self, val: T) {
self.map.insert(TypeId::of::<T>(), Box::new(val));
}

/// Returns a reference to a value of the given type, if present.
pub fn get<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.map
.get(&TypeId::of::<T>())
.and_then(|boxed| boxed.downcast_ref())
}

/// Returns a reference to a value of the given type, inserting a default
/// computed by `f` if not present.
pub fn get_or_insert_with<T: Send + Sync + 'static>(&mut self, f: impl FnOnce() -> T) -> &T {
self.map
.entry(TypeId::of::<T>())
.or_insert_with(|| Box::new(f()))
.downcast_ref()
.expect("type mismatch in EvalContext (bug)")
}

/// Returns `true` if the context contains a value of the given type.
pub fn contains<T: Send + Sync + 'static>(&self) -> bool {
self.map.contains_key(&TypeId::of::<T>())
}

/// Removes a value of the given type, returning it if present.
pub fn remove<T: Send + Sync + 'static>(&mut self) -> Option<T> {
self.map
.remove(&TypeId::of::<T>())
.and_then(|boxed| boxed.downcast().ok())
.map(|boxed| *boxed)
}
}

impl Default for EvalContext {
fn default() -> Self {
Self::new()
}
}

impl std::fmt::Debug for EvalContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EvalContext")
.field("entries", &self.map.len())
.finish()
}
}

#[cfg(test)]
mod tests {
use super::*;

struct StringValue(String);
struct Counter(u32);

#[test]
fn test_insert_and_get() {
let mut ctx = EvalContext::new();
ctx.insert(StringValue("hello".into()));

assert!(ctx.contains::<StringValue>());
assert_eq!(ctx.get::<StringValue>().unwrap().0, "hello");
}

#[test]
fn test_insert_replaces() {
let mut ctx = EvalContext::new();
ctx.insert(Counter(1));
ctx.insert(Counter(2));
assert_eq!(ctx.get::<Counter>().unwrap().0, 2);
}

#[test]
fn test_get_or_insert_with() {
let mut ctx = EvalContext::new();

// First call inserts
let val = ctx.get_or_insert_with(|| Counter(42));
assert_eq!(val.0, 42);

// Second call returns existing
let val = ctx.get_or_insert_with(|| Counter(99));
assert_eq!(val.0, 42);
}

#[test]
fn test_remove() {
let mut ctx = EvalContext::new();
ctx.insert(Counter(10));
let removed = ctx.remove::<Counter>();
assert_eq!(removed.unwrap().0, 10);
assert!(!ctx.contains::<Counter>());
}

#[test]
fn test_missing_type_returns_none() {
let ctx = EvalContext::new();
assert!(ctx.get::<Counter>().is_none());
}

#[test]
fn test_multiple_types() {
let mut ctx = EvalContext::new();
ctx.insert(StringValue("a".into()));
ctx.insert(Counter(1));

assert_eq!(ctx.get::<StringValue>().unwrap().0, "a");
assert_eq!(ctx.get::<Counter>().unwrap().0, 1);
}

#[test]
fn test_default() {
let ctx = EvalContext::default();
assert!(!ctx.contains::<Counter>());
}
}
15 changes: 8 additions & 7 deletions hitbox-core/src/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ use std::sync::Arc;

use async_trait::async_trait;

use crate::EvalContext;
use crate::KeyParts;

/// Trait for extracting cache key components from a subject.
Expand Down Expand Up @@ -73,7 +74,7 @@ pub trait Extractor {
/// Extract cache key components from the subject.
///
/// Returns a [`KeyParts`] containing the subject and accumulated key parts.
async fn get(&self, subject: Self::Subject) -> KeyParts<Self::Subject>;
async fn get(&self, subject: Self::Subject, ctx: &mut EvalContext) -> KeyParts<Self::Subject>;
}

#[async_trait]
Expand All @@ -84,8 +85,8 @@ where
{
type Subject = T::Subject;

async fn get(&self, subject: T::Subject) -> KeyParts<T::Subject> {
self.get(subject).await
async fn get(&self, subject: T::Subject, ctx: &mut EvalContext) -> KeyParts<T::Subject> {
(**self).get(subject, ctx).await
}
}

Expand All @@ -97,8 +98,8 @@ where
{
type Subject = T::Subject;

async fn get(&self, subject: T::Subject) -> KeyParts<T::Subject> {
self.as_ref().get(subject).await
async fn get(&self, subject: T::Subject, ctx: &mut EvalContext) -> KeyParts<T::Subject> {
self.as_ref().get(subject, ctx).await
}
}

Expand All @@ -110,7 +111,7 @@ where
{
type Subject = T::Subject;

async fn get(&self, subject: T::Subject) -> KeyParts<T::Subject> {
self.as_ref().get(subject).await
async fn get(&self, subject: T::Subject, ctx: &mut EvalContext) -> KeyParts<T::Subject> {
self.as_ref().get(subject, ctx).await
}
}
2 changes: 2 additions & 0 deletions hitbox-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
pub mod cacheable;
pub mod config;
pub mod context;
pub mod eval_context;
pub mod extractor;
pub mod key;
pub mod label;
Expand All @@ -21,6 +22,7 @@ pub use context::{
BoxContext, CacheContext, CacheStatus, CacheStatusExt, Context, ReadMode, ResponseSource,
finalize_context,
};
pub use eval_context::EvalContext;
pub use extractor::Extractor;
pub use key::{CacheKey, KeyPart, KeyParts};
pub use label::BackendLabel;
Expand Down
Loading
Loading