Skip to main content

hitbox_backend/composition/
compose.rs

1//! Trait for composing backends into layered cache hierarchies.
2//!
3//! The `Compose` trait provides a fluent API for building `CompositionBackend` instances,
4//! making it easy to create multi-level cache hierarchies with custom policies.
5//!
6//! # Examples
7//!
8//! Basic composition with default policies:
9//! ```ignore
10//! use hitbox_backend::composition::Compose;
11//!
12//! let cache = mem_backend.compose(redis_backend, offload);
13//! ```
14//!
15//! Composition with custom policies:
16//! ```ignore
17//! use hitbox_backend::composition::{Compose, CompositionPolicy};
18//! use hitbox_backend::composition::policy::{RaceReadPolicy, SequentialWritePolicy};
19//!
20//! let policy = CompositionPolicy::new()
21//!     .read(RaceReadPolicy::new())
22//!     .write(SequentialWritePolicy::new());
23//!
24//! let cache = mem_backend.compose_with(redis_backend, offload, policy);
25//! ```
26
27use super::policy::{CompositionReadPolicy, CompositionWritePolicy};
28use super::{CompositionBackend, CompositionPolicy};
29use crate::Backend;
30use hitbox_core::Offload;
31
32/// Trait for composing backends into layered cache hierarchies.
33///
34/// This trait is automatically implemented for all types that implement `Backend`,
35/// providing a fluent API for creating `CompositionBackend` instances.
36///
37/// # Examples
38///
39/// ```ignore
40/// use hitbox_backend::composition::Compose;
41///
42/// // Simple composition with default policies
43/// let cache = l1_backend.compose(l2_backend, offload);
44///
45/// // Composition with custom policies
46/// let policy = CompositionPolicy::new()
47///     .read(RaceReadPolicy::new())
48///     .write(SequentialWritePolicy::new());
49///
50/// let cache = l1_backend.compose_with(l2_backend, offload, policy);
51/// ```
52pub trait Compose: Backend + Sized {
53    /// Compose this backend with another backend as L2, using default policies.
54    ///
55    /// This creates a `CompositionBackend` where:
56    /// - `self` becomes L1 (first layer, checked first on reads)
57    /// - `l2` becomes L2 (second layer, checked if L1 misses)
58    ///
59    /// Default policies:
60    /// - Read: `SequentialReadPolicy` (try L1 first, then L2)
61    /// - Write: `OptimisticParallelWritePolicy` (write to both, succeed if ≥1 succeeds)
62    /// - Refill: `AlwaysRefill` (always populate L1 after L2 hit)
63    ///
64    /// # Arguments
65    /// * `l2` - The second-layer backend
66    /// * `offload` - Offload manager for background tasks
67    ///
68    /// # Example
69    /// ```ignore
70    /// use hitbox_backend::composition::Compose;
71    /// use hitbox_moka::MokaBackend;
72    /// use hitbox_redis::{RedisBackend, ConnectionMode};
73    ///
74    /// let moka = MokaBackend::builder().max_entries(1000).build();
75    /// let redis = RedisBackend::builder()
76    ///     .connection(ConnectionMode::single("redis://localhost/"))
77    ///     .build()?;
78    ///
79    /// // Moka as L1, Redis as L2
80    /// let cache = moka.compose(redis, offload);
81    /// ```
82    fn compose<L2, O>(self, l2: L2, offload: O) -> CompositionBackend<Self, L2, O>
83    where
84        L2: Backend,
85        O: Offload<'static>,
86    {
87        CompositionBackend::new(self, l2, offload)
88    }
89
90    /// Compose this backend with another backend as L2, using custom policies.
91    ///
92    /// This provides full control over read, write, and refill policies.
93    ///
94    /// # Arguments
95    /// * `l2` - The second-layer backend
96    /// * `offload` - Offload manager for background tasks
97    /// * `policy` - Custom composition policies
98    ///
99    /// # Example
100    /// ```ignore
101    /// use hitbox_backend::composition::{Compose, CompositionPolicy};
102    /// use hitbox_backend::composition::policy::{RaceReadPolicy, SequentialWritePolicy};
103    ///
104    /// let policy = CompositionPolicy::new()
105    ///     .read(RaceReadPolicy::new())
106    ///     .write(SequentialWritePolicy::new());
107    ///
108    /// let cache = l1.compose_with(l2, offload, policy);
109    /// ```
110    fn compose_with<L2, O, R, W>(
111        self,
112        l2: L2,
113        offload: O,
114        policy: CompositionPolicy<R, W>,
115    ) -> CompositionBackend<Self, L2, O, R, W>
116    where
117        L2: Backend,
118        O: Offload<'static>,
119        R: CompositionReadPolicy,
120        W: CompositionWritePolicy,
121    {
122        CompositionBackend::new(self, l2, offload).with_policy(policy)
123    }
124}
125
126// Blanket implementation for all Backend types
127impl<T: Backend> Compose for T {}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::format::{Format, JsonFormat};
133    use crate::{
134        Backend, BackendResult, CacheBackend, CacheKeyFormat, Compressor, DeleteStatus,
135        PassthroughCompressor,
136    };
137    use async_trait::async_trait;
138    use chrono::Utc;
139    use hitbox_core::{
140        BoxContext, CacheContext, CacheKey, CachePolicy, CacheValue, CacheableResponse,
141        EntityPolicyConfig, Predicate, Raw,
142    };
143    use serde::{Deserialize, Serialize};
144    use smol_str::SmolStr;
145    use std::collections::HashMap;
146    use std::future::Future;
147    use std::sync::{Arc, Mutex};
148
149    #[cfg(feature = "rkyv_format")]
150    use rkyv::{Archive, Serialize as RkyvSerialize};
151
152    /// Test offload that spawns tasks with tokio::spawn
153    #[derive(Clone, Debug)]
154    struct TestOffload;
155
156    impl Offload<'static> for TestOffload {
157        fn spawn<F>(&self, _kind: impl Into<SmolStr>, future: F)
158        where
159            F: Future<Output = ()> + Send + 'static,
160        {
161            tokio::spawn(future);
162        }
163    }
164
165    #[derive(Clone, Debug)]
166    struct TestBackend {
167        store: Arc<Mutex<HashMap<CacheKey, CacheValue<Raw>>>>,
168    }
169
170    impl TestBackend {
171        fn new() -> Self {
172            Self {
173                store: Arc::new(Mutex::new(HashMap::new())),
174            }
175        }
176    }
177
178    #[async_trait]
179    impl Backend for TestBackend {
180        async fn read(&self, key: &CacheKey) -> BackendResult<Option<CacheValue<Raw>>> {
181            Ok(self.store.lock().unwrap().get(key).cloned())
182        }
183
184        async fn write(&self, key: &CacheKey, value: CacheValue<Raw>) -> BackendResult<()> {
185            self.store.lock().unwrap().insert(key.clone(), value);
186            Ok(())
187        }
188
189        async fn remove(&self, key: &CacheKey) -> BackendResult<DeleteStatus> {
190            match self.store.lock().unwrap().remove(key) {
191                Some(_) => Ok(DeleteStatus::Deleted(1)),
192                None => Ok(DeleteStatus::Missing),
193            }
194        }
195
196        fn value_format(&self) -> &dyn Format {
197            &JsonFormat
198        }
199
200        fn key_format(&self) -> &CacheKeyFormat {
201            &CacheKeyFormat::Bitcode
202        }
203
204        fn compressor(&self) -> &dyn Compressor {
205            &PassthroughCompressor
206        }
207    }
208
209    impl CacheBackend for TestBackend {}
210
211    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212    #[cfg_attr(
213        feature = "rkyv_format",
214        derive(Archive, RkyvSerialize, rkyv::Deserialize)
215    )]
216    struct CachedData {
217        value: String,
218    }
219
220    struct MockResponse;
221
222    impl CacheableResponse for MockResponse {
223        type Cached = CachedData;
224        type Subject = MockResponse;
225        type IntoCachedFuture = std::future::Ready<CachePolicy<Self::Cached, Self>>;
226        type FromCachedFuture = std::future::Ready<Self>;
227
228        async fn cache_policy<P: Predicate<Subject = Self::Subject> + Send + Sync>(
229            self,
230            _predicate: P,
231            _config: &EntityPolicyConfig,
232        ) -> CachePolicy<CacheValue<Self::Cached>, Self> {
233            unimplemented!()
234        }
235
236        fn into_cached(self) -> Self::IntoCachedFuture {
237            unimplemented!()
238        }
239
240        fn from_cached(_cached: Self::Cached) -> Self::FromCachedFuture {
241            unimplemented!()
242        }
243    }
244
245    #[tokio::test]
246    async fn test_compose_basic() {
247        let l1 = TestBackend::new();
248        let l2 = TestBackend::new();
249        let offload = TestOffload;
250
251        // Use compose trait
252        let cache = l1.clone().compose(l2.clone(), offload);
253
254        let key = CacheKey::from_str("test", "key1");
255        let value = CacheValue::new(
256            CachedData {
257                value: "test_value".to_string(),
258            },
259            Some(Utc::now() + chrono::Duration::seconds(60)),
260            None,
261        );
262
263        // Write and read
264        let mut ctx: BoxContext = CacheContext::default().boxed();
265        cache
266            .set::<MockResponse>(&key, &value, &mut ctx)
267            .await
268            .unwrap();
269
270        let mut ctx: BoxContext = CacheContext::default().boxed();
271        let result = cache.get::<MockResponse>(&key, &mut ctx).await.unwrap();
272        assert_eq!(result.unwrap().data().value, "test_value");
273
274        // Verify both layers have the data
275        let mut ctx: BoxContext = CacheContext::default().boxed();
276        assert!(
277            l1.get::<MockResponse>(&key, &mut ctx)
278                .await
279                .unwrap()
280                .is_some()
281        );
282        let mut ctx: BoxContext = CacheContext::default().boxed();
283        assert!(
284            l2.get::<MockResponse>(&key, &mut ctx)
285                .await
286                .unwrap()
287                .is_some()
288        );
289    }
290
291    #[tokio::test]
292    async fn test_compose_with_policy() {
293        use super::super::policy::{RaceReadPolicy, RefillPolicy};
294
295        let l1 = TestBackend::new();
296        let l2 = TestBackend::new();
297        let offload = TestOffload;
298
299        // Use compose_with to specify custom policies
300        let policy = CompositionPolicy::new()
301            .read(RaceReadPolicy::new())
302            .refill(RefillPolicy::Never);
303
304        let cache = l1.clone().compose_with(l2.clone(), offload, policy);
305
306        let key = CacheKey::from_str("test", "key1");
307        let value = CacheValue::new(
308            CachedData {
309                value: "from_l2".to_string(),
310            },
311            Some(Utc::now() + chrono::Duration::seconds(60)),
312            None,
313        );
314
315        // Populate only L2
316        let mut ctx: BoxContext = CacheContext::default().boxed();
317        l2.set::<MockResponse>(&key, &value, &mut ctx)
318            .await
319            .unwrap();
320
321        // Read through composition (should use RaceReadPolicy)
322        let mut ctx: BoxContext = CacheContext::default().boxed();
323        let result = cache.get::<MockResponse>(&key, &mut ctx).await.unwrap();
324        assert_eq!(result.unwrap().data().value, "from_l2");
325
326        // With NeverRefill, L1 should NOT be populated
327        let mut ctx: BoxContext = CacheContext::default().boxed();
328        assert!(
329            l1.get::<MockResponse>(&key, &mut ctx)
330                .await
331                .unwrap()
332                .is_none()
333        );
334    }
335
336    #[tokio::test]
337    async fn test_compose_nested() {
338        // Test that composed backends can be further composed
339        let l1 = TestBackend::new();
340        let l2 = TestBackend::new();
341        let l3 = TestBackend::new();
342        let offload = TestOffload;
343
344        // Create L2+L3 composition
345        let l2_l3 = l2.clone().compose(l3.clone(), offload.clone());
346
347        // Compose L1 with the (L2+L3) composition
348        let cache = l1.clone().compose(l2_l3, offload);
349
350        let key = CacheKey::from_str("test", "nested_key");
351        let value = CacheValue::new(
352            CachedData {
353                value: "nested_value".to_string(),
354            },
355            Some(Utc::now() + chrono::Duration::seconds(60)),
356            None,
357        );
358
359        // Write through nested composition
360        let mut ctx: BoxContext = CacheContext::default().boxed();
361        cache
362            .set::<MockResponse>(&key, &value, &mut ctx)
363            .await
364            .unwrap();
365
366        // All three levels should have the data
367        let mut ctx: BoxContext = CacheContext::default().boxed();
368        assert!(
369            l1.get::<MockResponse>(&key, &mut ctx)
370                .await
371                .unwrap()
372                .is_some()
373        );
374        let mut ctx: BoxContext = CacheContext::default().boxed();
375        assert!(
376            l2.get::<MockResponse>(&key, &mut ctx)
377                .await
378                .unwrap()
379                .is_some()
380        );
381        let mut ctx: BoxContext = CacheContext::default().boxed();
382        assert!(
383            l3.get::<MockResponse>(&key, &mut ctx)
384                .await
385                .unwrap()
386                .is_some()
387        );
388    }
389
390    #[tokio::test]
391    async fn test_compose_chaining() {
392        use super::super::policy::RaceReadPolicy;
393
394        let l1 = TestBackend::new();
395        let l2 = TestBackend::new();
396        let offload = TestOffload;
397
398        // Test method chaining: compose + builder methods
399        let cache = l1
400            .clone()
401            .compose(l2.clone(), offload)
402            .read(RaceReadPolicy::new());
403
404        let key = CacheKey::from_str("test", "chain_key");
405        let value = CacheValue::new(
406            CachedData {
407                value: "chain_value".to_string(),
408            },
409            Some(Utc::now() + chrono::Duration::seconds(60)),
410            None,
411        );
412
413        let mut ctx: BoxContext = CacheContext::default().boxed();
414        cache
415            .set::<MockResponse>(&key, &value, &mut ctx)
416            .await
417            .unwrap();
418
419        let mut ctx: BoxContext = CacheContext::default().boxed();
420        let result = cache.get::<MockResponse>(&key, &mut ctx).await.unwrap();
421        assert_eq!(result.unwrap().data().value, "chain_value");
422    }
423}