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)]
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}
57
58/// Pluggable cache backend.
59///
60/// Implementations MUST be `Send + Sync + 'static` and propagate all backend
61/// failures as `Err(CamelError)` (Contract C1).
62#[async_trait::async_trait]
63pub trait CacheRepository: Send + Sync + std::fmt::Debug + 'static {
64 /// A human-readable name for this cache backend.
65 fn name(&self) -> &str;
66
67 /// Retrieve a value by key.
68 ///
69 /// Returns `Ok(None)` if the key is absent or expired. All backend failures
70 /// MUST be returned as `Err(CamelError)`.
71 async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError>;
72
73 /// Store a value with an optional TTL.
74 ///
75 /// The implementation computes `expires_at` from `ttl` and stores it in the
76 /// `CacheEntry`. If `ttl` is `None`, the entry has no expiry.
77 async fn set(
78 &self,
79 key: &str,
80 value: CacheEntry,
81 ttl: Option<Duration>,
82 ) -> Result<(), CamelError>;
83
84 /// Peek at a stale (expired but not yet evicted) entry.
85 ///
86 /// Returns `Ok(None)` if the key is absent or not yet expired.
87 async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError>;
88
89 /// Remove a specific key from the cache.
90 async fn invalidate(&self, key: &str) -> Result<(), CamelError>;
91
92 /// Remove all entries from the cache.
93 async fn clear(&self) -> Result<(), CamelError>;
94
95 /// Return current cache statistics.
96 ///
97 /// Default implementation returns zeroed stats.
98 fn stats(&self) -> CacheStats {
99 CacheStats::default()
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn cache_entry_construction() {
109 let entry = CacheEntry {
110 bytes: vec![b'x'],
111 content_type: ContentType::Bytes,
112 expires_at: None,
113 };
114 assert_eq!(entry.bytes.len(), 1);
115 assert_eq!(entry.content_type, ContentType::Bytes);
116 }
117
118 #[test]
119 fn cache_stats_default() {
120 assert_eq!(
121 CacheStats::default(),
122 CacheStats {
123 hits: 0,
124 misses: 0,
125 evictions: 0,
126 entries: 0,
127 }
128 );
129 }
130}