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    /// Maintenance-only row peek: return the stored row for `key`
99    /// regardless of expiry WITHOUT mutating any observable statistic
100    /// (`hits`, `misses`, `peek_stale_served`). For internal
101    /// bookkeeping such as the disk-offload decorator's predecessor
102    /// reclaim; application reads must use `get` or `peek_stale`.
103    ///
104    /// Default `Ok(None)`: backends without silent row access report
105    /// "no row", and callers fall back to their asynchronous backstop
106    /// (the sweeper) — never to a counted path.
107    async fn peek_row_silent(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
108        Ok(None)
109    }
110
111    /// Remove a specific key from the cache.
112    async fn invalidate(&self, key: &str) -> Result<(), CamelError>;
113
114    /// Remove all entries from the cache.
115    async fn clear(&self) -> Result<(), CamelError>;
116
117    /// Remove every entry whose key starts with `prefix`, returning the removed count.
118    ///
119    /// Default implementation reports the limitation for backends without key
120    /// iteration — it does NOT return `Ok(0)` pretending an empty namespace.
121    /// Backends with ordered keys override this with range deletion.
122    async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
123        let _ = prefix;
124        Err(CamelError::Config(format!(
125            "cache backend '{}' does not support invalidate_prefix (no key iteration)",
126            self.name()
127        )))
128    }
129
130    /// Return current cache statistics. Asynchronous so backends can offload
131    /// I/O-bound byte accounting off the tokio worker (bd rc-22wj). Default
132    /// implementation returns zeroed stats.
133    async fn stats(&self) -> CacheStats {
134        CacheStats::default()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn cache_entry_construction() {
144        let entry = CacheEntry {
145            bytes: vec![b'x'],
146            payload_path: None,
147            content_type: ContentType::Bytes,
148            expires_at: None,
149        };
150        assert_eq!(entry.bytes.len(), 1);
151        assert_eq!(entry.content_type, ContentType::Bytes);
152    }
153
154    #[test]
155    fn legacy_json_without_payload_path_deserializes_as_none() {
156        let json = r#"{"bytes":[1,2,3],"content_type":"Bytes","expires_at":null}"#;
157        let entry: CacheEntry = serde_json::from_str(json).unwrap();
158        assert_eq!(entry.payload_path, None);
159        assert_eq!(entry.bytes, vec![1, 2, 3]);
160    }
161
162    #[test]
163    fn payload_path_round_trips_through_serde() {
164        let entry = CacheEntry {
165            bytes: vec![1, 2, 3],
166            payload_path: Some("abc.blob".into()),
167            content_type: ContentType::Bytes,
168            expires_at: None,
169        };
170        let json = serde_json::to_string(&entry).unwrap();
171        assert!(json.contains("\"payload_path\":\"abc.blob\""));
172        let back: CacheEntry = serde_json::from_str(&json).unwrap();
173        assert_eq!(back.payload_path, Some("abc.blob".to_string()));
174    }
175
176    #[test]
177    fn cache_stats_default() {
178        assert_eq!(
179            CacheStats::default(),
180            CacheStats {
181                hits: 0,
182                misses: 0,
183                evictions: 0,
184                entries: 0,
185                peek_stale_served: 0,
186                invalidations: 0,
187                bytes: None,
188            }
189        );
190    }
191
192    #[test]
193    fn cache_stats_serialize_round_trip() {
194        let stats = CacheStats {
195            hits: 2,
196            misses: 1,
197            evictions: 0,
198            entries: 3,
199            peek_stale_served: 4,
200            invalidations: 1,
201            bytes: None,
202        };
203        let json = serde_json::to_string(&stats).unwrap();
204        assert!(json.contains("\"peek_stale_served\""));
205        assert!(json.contains("\"bytes\":null"));
206        let back: CacheStats = serde_json::from_str(&json).unwrap();
207        assert_eq!(stats, back);
208    }
209
210    /// A backend with no key-iteration support: exercises the default
211    /// `invalidate_prefix` (which must report the limitation, not fake `Ok(0)`).
212    struct NoIter;
213
214    impl std::fmt::Debug for NoIter {
215        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216            f.write_str("NoIter")
217        }
218    }
219
220    #[async_trait::async_trait]
221    impl CacheRepository for NoIter {
222        fn name(&self) -> &str {
223            "noiter"
224        }
225
226        async fn get(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
227            Ok(None)
228        }
229
230        async fn set(
231            &self,
232            _key: &str,
233            _value: CacheEntry,
234            _ttl: Option<Duration>,
235        ) -> Result<(), CamelError> {
236            Ok(())
237        }
238
239        async fn peek_stale(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
240            Ok(None)
241        }
242
243        async fn invalidate(&self, _key: &str) -> Result<(), CamelError> {
244            Ok(())
245        }
246
247        async fn clear(&self) -> Result<(), CamelError> {
248            Ok(())
249        }
250    }
251
252    #[tokio::test]
253    async fn default_invalidate_prefix_returns_err_naming_backend() {
254        let repo = NoIter;
255        let err = repo.invalidate_prefix("ns:").await.unwrap_err();
256        assert!(
257            format!("{err}").contains("noiter"),
258            "error must name the backend, got: {err}"
259        );
260    }
261
262    #[tokio::test]
263    async fn default_async_stats_returns_zeroed() {
264        let stats = NoIter.stats().await;
265        assert_eq!(stats.hits, 0);
266        assert_eq!(stats.misses, 0);
267        assert_eq!(stats.evictions, 0);
268        assert_eq!(stats.entries, 0);
269        assert_eq!(stats.peek_stale_served, 0);
270        assert_eq!(stats.invalidations, 0);
271        assert_eq!(stats.bytes, None);
272    }
273}