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        #[allow(deprecated)]
158        fn spawn<F>(&self, _kind: impl Into<SmolStr>, future: F)
159        where
160            F: Future<Output = ()> + Send + 'static,
161        {
162            tokio::spawn(future);
163        }
164    }
165
166    #[derive(Clone, Debug)]
167    struct TestBackend {
168        store: Arc<Mutex<HashMap<CacheKey, CacheValue<Raw>>>>,
169    }
170
171    impl TestBackend {
172        fn new() -> Self {
173            Self {
174                store: Arc::new(Mutex::new(HashMap::new())),
175            }
176        }
177    }
178
179    #[async_trait]
180    impl Backend for TestBackend {
181        async fn read(&self, key: &CacheKey) -> BackendResult<Option<CacheValue<Raw>>> {
182            Ok(self.store.lock().unwrap().get(key).cloned())
183        }
184
185        async fn write(&self, key: &CacheKey, value: CacheValue<Raw>) -> BackendResult<()> {
186            self.store.lock().unwrap().insert(key.clone(), value);
187            Ok(())
188        }
189
190        async fn remove(&self, key: &CacheKey) -> BackendResult<DeleteStatus> {
191            match self.store.lock().unwrap().remove(key) {
192                Some(_) => Ok(DeleteStatus::Deleted(1)),
193                None => Ok(DeleteStatus::Missing),
194            }
195        }
196
197        fn value_format(&self) -> &dyn Format {
198            &JsonFormat
199        }
200
201        fn key_format(&self) -> &CacheKeyFormat {
202            &CacheKeyFormat::Bitcode
203        }
204
205        fn compressor(&self) -> &dyn Compressor {
206            &PassthroughCompressor
207        }
208    }
209
210    impl CacheBackend for TestBackend {}
211
212    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213    #[cfg_attr(
214        feature = "rkyv_format",
215        derive(Archive, RkyvSerialize, rkyv::Deserialize)
216    )]
217    struct CachedData {
218        value: String,
219    }
220
221    struct MockResponse;
222
223    impl CacheableResponse for MockResponse {
224        type Cached = CachedData;
225        type Subject = MockResponse;
226        type IntoCachedFuture = std::future::Ready<CachePolicy<Self::Cached, Self>>;
227        type FromCachedFuture = std::future::Ready<Self>;
228
229        async fn cache_policy<P: Predicate<Subject = Self::Subject> + Send + Sync>(
230            self,
231            _predicate: P,
232            _config: &EntityPolicyConfig,
233        ) -> CachePolicy<CacheValue<Self::Cached>, Self> {
234            unimplemented!()
235        }
236
237        fn into_cached(self) -> Self::IntoCachedFuture {
238            unimplemented!()
239        }
240
241        fn from_cached(_cached: Self::Cached) -> Self::FromCachedFuture {
242            unimplemented!()
243        }
244    }
245
246    #[tokio::test]
247    async fn test_compose_basic() {
248        let l1 = TestBackend::new();
249        let l2 = TestBackend::new();
250        let offload = TestOffload;
251
252        // Use compose trait
253        let cache = l1.clone().compose(l2.clone(), offload);
254
255        let key = CacheKey::from_str("test", "key1");
256        let value = CacheValue::new(
257            CachedData {
258                value: "test_value".to_string(),
259            },
260            Some(Utc::now() + chrono::Duration::seconds(60)),
261            None,
262        );
263
264        // Write and read
265        let mut ctx: BoxContext = CacheContext::default().boxed();
266        cache
267            .set::<MockResponse>(&key, &value, &mut ctx)
268            .await
269            .unwrap();
270
271        let mut ctx: BoxContext = CacheContext::default().boxed();
272        let result = cache.get::<MockResponse>(&key, &mut ctx).await.unwrap();
273        assert_eq!(result.unwrap().data().value, "test_value");
274
275        // Verify both layers have the data
276        let mut ctx: BoxContext = CacheContext::default().boxed();
277        assert!(
278            l1.get::<MockResponse>(&key, &mut ctx)
279                .await
280                .unwrap()
281                .is_some()
282        );
283        let mut ctx: BoxContext = CacheContext::default().boxed();
284        assert!(
285            l2.get::<MockResponse>(&key, &mut ctx)
286                .await
287                .unwrap()
288                .is_some()
289        );
290    }
291
292    #[tokio::test]
293    async fn test_compose_with_policy() {
294        use super::super::policy::{RaceReadPolicy, RefillPolicy};
295
296        let l1 = TestBackend::new();
297        let l2 = TestBackend::new();
298        let offload = TestOffload;
299
300        // Use compose_with to specify custom policies
301        let policy = CompositionPolicy::new()
302            .read(RaceReadPolicy::new())
303            .refill(RefillPolicy::Never);
304
305        let cache = l1.clone().compose_with(l2.clone(), offload, policy);
306
307        let key = CacheKey::from_str("test", "key1");
308        let value = CacheValue::new(
309            CachedData {
310                value: "from_l2".to_string(),
311            },
312            Some(Utc::now() + chrono::Duration::seconds(60)),
313            None,
314        );
315
316        // Populate only L2
317        let mut ctx: BoxContext = CacheContext::default().boxed();
318        l2.set::<MockResponse>(&key, &value, &mut ctx)
319            .await
320            .unwrap();
321
322        // Read through composition (should use RaceReadPolicy)
323        let mut ctx: BoxContext = CacheContext::default().boxed();
324        let result = cache.get::<MockResponse>(&key, &mut ctx).await.unwrap();
325        assert_eq!(result.unwrap().data().value, "from_l2");
326
327        // With NeverRefill, L1 should NOT be populated
328        let mut ctx: BoxContext = CacheContext::default().boxed();
329        assert!(
330            l1.get::<MockResponse>(&key, &mut ctx)
331                .await
332                .unwrap()
333                .is_none()
334        );
335    }
336
337    #[tokio::test]
338    async fn test_compose_nested() {
339        // Test that composed backends can be further composed
340        let l1 = TestBackend::new();
341        let l2 = TestBackend::new();
342        let l3 = TestBackend::new();
343        let offload = TestOffload;
344
345        // Create L2+L3 composition
346        let l2_l3 = l2.clone().compose(l3.clone(), offload.clone());
347
348        // Compose L1 with the (L2+L3) composition
349        let cache = l1.clone().compose(l2_l3, offload);
350
351        let key = CacheKey::from_str("test", "nested_key");
352        let value = CacheValue::new(
353            CachedData {
354                value: "nested_value".to_string(),
355            },
356            Some(Utc::now() + chrono::Duration::seconds(60)),
357            None,
358        );
359
360        // Write through nested composition
361        let mut ctx: BoxContext = CacheContext::default().boxed();
362        cache
363            .set::<MockResponse>(&key, &value, &mut ctx)
364            .await
365            .unwrap();
366
367        // All three levels should have the data
368        let mut ctx: BoxContext = CacheContext::default().boxed();
369        assert!(
370            l1.get::<MockResponse>(&key, &mut ctx)
371                .await
372                .unwrap()
373                .is_some()
374        );
375        let mut ctx: BoxContext = CacheContext::default().boxed();
376        assert!(
377            l2.get::<MockResponse>(&key, &mut ctx)
378                .await
379                .unwrap()
380                .is_some()
381        );
382        let mut ctx: BoxContext = CacheContext::default().boxed();
383        assert!(
384            l3.get::<MockResponse>(&key, &mut ctx)
385                .await
386                .unwrap()
387                .is_some()
388        );
389    }
390
391    #[tokio::test]
392    async fn test_compose_chaining() {
393        use super::super::policy::RaceReadPolicy;
394
395        let l1 = TestBackend::new();
396        let l2 = TestBackend::new();
397        let offload = TestOffload;
398
399        // Test method chaining: compose + builder methods
400        let cache = l1
401            .clone()
402            .compose(l2.clone(), offload)
403            .read(RaceReadPolicy::new());
404
405        let key = CacheKey::from_str("test", "chain_key");
406        let value = CacheValue::new(
407            CachedData {
408                value: "chain_value".to_string(),
409            },
410            Some(Utc::now() + chrono::Duration::seconds(60)),
411            None,
412        );
413
414        let mut ctx: BoxContext = CacheContext::default().boxed();
415        cache
416            .set::<MockResponse>(&key, &value, &mut ctx)
417            .await
418            .unwrap();
419
420        let mut ctx: BoxContext = CacheContext::default().boxed();
421        let result = cache.get::<MockResponse>(&key, &mut ctx).await.unwrap();
422        assert_eq!(result.unwrap().data().value, "chain_value");
423    }
424}