Skip to main content

camel_api/
cache.rs

1//! # Cache Repository
2//!
3//! Pluggable cache backend abstraction for the Caching EIP.
4//!
5//! ## Contract
6//!
7//! Per ADR-0023 Contract C1, backend failures (network errors, timeouts, storage
8//! unavailability) MUST surface as `Err(CamelError)`, never as a silent miss.
9//! A `get` that returns `Ok(None)` means the key is definitively absent or
10//! expired — not "I couldn't check."
11
12use std::time::{Duration, SystemTime};
13
14use crate::CamelError;
15
16/// A cached entry with its content type and optional expiry.
17#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct CacheEntry {
19    /// The raw cached bytes.
20    pub bytes: Vec<u8>,
21    /// Relative blob filename when the payload is offloaded to disk; `None` = bytes live inline.
22    #[serde(default)]
23    pub payload_path: Option<String>,
24    /// The content type of the cached data.
25    pub content_type: ContentType,
26    /// When this entry expires (if set). `None` means no expiry.
27    pub expires_at: Option<SystemTime>,
28}
29
30/// The content type classification for a cached entry.
31///
32/// exhaustive-by-contract: closed 4-variant set; out-of-crate CacheService matches all variants
33/// for content_type→Body reconstruction (ADR-0049 §Exceptions).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum ContentType {
36    /// Arbitrary binary data.
37    Bytes,
38    /// UTF-8 text.
39    Text,
40    /// JSON-encoded data.
41    Json,
42    /// XML-encoded data.
43    Xml,
44}
45
46/// Cache usage statistics.
47///
48/// Backends construct this with struct literals — NOT `#[non_exhaustive]`.
49#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
50pub struct CacheStats {
51    /// Number of cache hits.
52    pub hits: u64,
53    /// Number of cache misses.
54    pub misses: u64,
55    /// Number of entries evicted.
56    pub evictions: u64,
57    /// Current number of entries in the cache.
58    pub entries: u64,
59    /// Number of peek_stale serves (fresh or stale).
60    pub peek_stale_served: u64,
61    /// Number of successful invalidation operations.
62    pub invalidations: u64,
63    /// Total stored payload bytes when the backend can report it; None = cannot.
64    pub bytes: Option<u64>,
65}
66
67/// Pluggable cache backend.
68///
69/// Implementations MUST be `Send + Sync + 'static` and propagate all backend
70/// failures as `Err(CamelError)` (Contract C1).
71#[async_trait::async_trait]
72pub trait CacheRepository: Send + Sync + std::fmt::Debug + 'static {
73    /// A human-readable name for this cache backend.
74    fn name(&self) -> &str;
75
76    /// Retrieve a value by key.
77    ///
78    /// Returns `Ok(None)` if the key is absent or expired. All backend failures
79    /// MUST be returned as `Err(CamelError)`.
80    async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError>;
81
82    /// Store a value with an optional TTL.
83    ///
84    /// The implementation computes `expires_at` from `ttl` and stores it in the
85    /// `CacheEntry`. If `ttl` is `None`, the entry has no expiry.
86    async fn set(
87        &self,
88        key: &str,
89        value: CacheEntry,
90        ttl: Option<Duration>,
91    ) -> Result<(), CamelError>;
92
93    /// Peek at a stale (expired but not yet evicted) entry.
94    ///
95    /// Returns `Ok(None)` if the key is absent or not yet expired.
96    async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError>;
97
98    /// Remove a specific key from the cache.
99    async fn invalidate(&self, key: &str) -> Result<(), CamelError>;
100
101    /// Remove all entries from the cache.
102    async fn clear(&self) -> Result<(), CamelError>;
103
104    /// Remove every entry whose key starts with `prefix`, returning the removed count.
105    ///
106    /// Default implementation reports the limitation for backends without key
107    /// iteration — it does NOT return `Ok(0)` pretending an empty namespace.
108    /// Backends with ordered keys override this with range deletion.
109    async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
110        let _ = prefix;
111        Err(CamelError::Config(format!(
112            "cache backend '{}' does not support invalidate_prefix (no key iteration)",
113            self.name()
114        )))
115    }
116
117    /// Return current cache statistics. Asynchronous so backends can offload
118    /// I/O-bound byte accounting off the tokio worker (bd rc-22wj). Default
119    /// implementation returns zeroed stats.
120    async fn stats(&self) -> CacheStats {
121        CacheStats::default()
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn cache_entry_construction() {
131        let entry = CacheEntry {
132            bytes: vec![b'x'],
133            payload_path: None,
134            content_type: ContentType::Bytes,
135            expires_at: None,
136        };
137        assert_eq!(entry.bytes.len(), 1);
138        assert_eq!(entry.content_type, ContentType::Bytes);
139    }
140
141    #[test]
142    fn legacy_json_without_payload_path_deserializes_as_none() {
143        let json = r#"{"bytes":[1,2,3],"content_type":"Bytes","expires_at":null}"#;
144        let entry: CacheEntry = serde_json::from_str(json).unwrap();
145        assert_eq!(entry.payload_path, None);
146        assert_eq!(entry.bytes, vec![1, 2, 3]);
147    }
148
149    #[test]
150    fn payload_path_round_trips_through_serde() {
151        let entry = CacheEntry {
152            bytes: vec![1, 2, 3],
153            payload_path: Some("abc.blob".into()),
154            content_type: ContentType::Bytes,
155            expires_at: None,
156        };
157        let json = serde_json::to_string(&entry).unwrap();
158        assert!(json.contains("\"payload_path\":\"abc.blob\""));
159        let back: CacheEntry = serde_json::from_str(&json).unwrap();
160        assert_eq!(back.payload_path, Some("abc.blob".to_string()));
161    }
162
163    #[test]
164    fn cache_stats_default() {
165        assert_eq!(
166            CacheStats::default(),
167            CacheStats {
168                hits: 0,
169                misses: 0,
170                evictions: 0,
171                entries: 0,
172                peek_stale_served: 0,
173                invalidations: 0,
174                bytes: None,
175            }
176        );
177    }
178
179    #[test]
180    fn cache_stats_serialize_round_trip() {
181        let stats = CacheStats {
182            hits: 2,
183            misses: 1,
184            evictions: 0,
185            entries: 3,
186            peek_stale_served: 4,
187            invalidations: 1,
188            bytes: None,
189        };
190        let json = serde_json::to_string(&stats).unwrap();
191        assert!(json.contains("\"peek_stale_served\""));
192        assert!(json.contains("\"bytes\":null"));
193        let back: CacheStats = serde_json::from_str(&json).unwrap();
194        assert_eq!(stats, back);
195    }
196
197    /// A backend with no key-iteration support: exercises the default
198    /// `invalidate_prefix` (which must report the limitation, not fake `Ok(0)`).
199    struct NoIter;
200
201    impl std::fmt::Debug for NoIter {
202        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203            f.write_str("NoIter")
204        }
205    }
206
207    #[async_trait::async_trait]
208    impl CacheRepository for NoIter {
209        fn name(&self) -> &str {
210            "noiter"
211        }
212
213        async fn get(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
214            Ok(None)
215        }
216
217        async fn set(
218            &self,
219            _key: &str,
220            _value: CacheEntry,
221            _ttl: Option<Duration>,
222        ) -> Result<(), CamelError> {
223            Ok(())
224        }
225
226        async fn peek_stale(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
227            Ok(None)
228        }
229
230        async fn invalidate(&self, _key: &str) -> Result<(), CamelError> {
231            Ok(())
232        }
233
234        async fn clear(&self) -> Result<(), CamelError> {
235            Ok(())
236        }
237    }
238
239    #[tokio::test]
240    async fn default_invalidate_prefix_returns_err_naming_backend() {
241        let repo = NoIter;
242        let err = repo.invalidate_prefix("ns:").await.unwrap_err();
243        assert!(
244            format!("{err}").contains("noiter"),
245            "error must name the backend, got: {err}"
246        );
247    }
248
249    #[tokio::test]
250    async fn default_async_stats_returns_zeroed() {
251        let stats = NoIter.stats().await;
252        assert_eq!(stats.hits, 0);
253        assert_eq!(stats.misses, 0);
254        assert_eq!(stats.evictions, 0);
255        assert_eq!(stats.entries, 0);
256        assert_eq!(stats.peek_stale_served, 0);
257        assert_eq!(stats.invalidations, 0);
258        assert_eq!(stats.bytes, None);
259    }
260}