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