nntp_proxy/cache/
article.rs

1//! Article caching implementation using LRU cache with TTL
2
3use crate::types::MessageId;
4use moka::future::Cache;
5use std::sync::Arc;
6use std::time::Duration;
7
8/// Cached article data
9#[derive(Clone, Debug)]
10pub struct CachedArticle {
11    /// The complete response including status line and article data
12    /// Wrapped in Arc for cheap cloning when retrieving from cache
13    pub response: Arc<Vec<u8>>,
14}
15
16/// Article cache using LRU eviction with TTL
17///
18/// Uses Arc<str> (message ID content without brackets) as key for zero-allocation lookups.
19/// Arc<str> implements Borrow<str>, allowing cache.get(&str) without allocation.
20#[derive(Clone)]
21pub struct ArticleCache {
22    cache: Arc<Cache<Arc<str>, CachedArticle>>,
23}
24
25impl ArticleCache {
26    /// Create a new article cache
27    ///
28    /// # Arguments
29    /// * `max_capacity` - Maximum number of articles to cache
30    /// * `ttl` - Time-to-live for cached articles
31    pub fn new(max_capacity: u64, ttl: Duration) -> Self {
32        let cache = Cache::builder()
33            .max_capacity(max_capacity)
34            .time_to_live(ttl)
35            .build();
36
37        Self {
38            cache: Arc::new(cache),
39        }
40    }
41
42    /// Get an article from the cache
43    ///
44    /// Accepts any lifetime MessageId and uses the string content (without brackets) as key.
45    ///
46    /// **Zero-allocation**: `without_brackets()` returns `&str`, which moka accepts directly
47    /// for `Arc<str>` keys via the `Borrow<str>` trait. This avoids allocating a new `Arc<str>`
48    /// for every cache lookup. See `test_arc_str_borrow_lookup` test for verification.
49    pub async fn get<'a>(&self, message_id: &MessageId<'a>) -> Option<CachedArticle> {
50        // moka::Cache<Arc<str>, V> supports get(&str) via Borrow<str> trait
51        // This is zero-allocation: no Arc<str> is created for the lookup
52        self.cache.get(message_id.without_brackets()).await
53    }
54
55    /// Store an article in the cache
56    ///
57    /// Accepts any lifetime MessageId and stores using the ID content (without brackets) as key.
58    pub async fn insert<'a>(&self, message_id: MessageId<'a>, article: CachedArticle) {
59        // Store using the message ID content without brackets as Arc<str>
60        let key: Arc<str> = message_id.without_brackets().into();
61        self.cache.insert(key, article).await;
62    }
63
64    /// Get cache statistics
65    pub async fn stats(&self) -> CacheStats {
66        CacheStats {
67            entry_count: self.cache.entry_count(),
68            weighted_size: self.cache.weighted_size(),
69        }
70    }
71}
72
73/// Cache statistics
74#[derive(Debug, Clone)]
75pub struct CacheStats {
76    pub entry_count: u64,
77    pub weighted_size: u64,
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::types::MessageId;
84    use std::time::Duration;
85
86    #[tokio::test]
87    async fn test_arc_str_borrow_lookup() {
88        // Create cache with Arc<str> keys
89        let cache = ArticleCache::new(100, Duration::from_secs(300));
90
91        // Create a MessageId and insert an article
92        let msgid = MessageId::from_borrowed("<test123@example.com>").unwrap();
93        let article = CachedArticle {
94            response: Arc::new(b"220 0 0 <test123@example.com>\r\ntest body\r\n.\r\n".to_vec()),
95        };
96
97        cache.insert(msgid.clone(), article.clone()).await;
98
99        // Verify we can retrieve using a different MessageId instance (borrowed)
100        // This demonstrates that Arc<str> supports Borrow<str> lookups via &str
101        let msgid2 = MessageId::from_borrowed("<test123@example.com>").unwrap();
102        let retrieved = cache.get(&msgid2).await;
103
104        assert!(
105            retrieved.is_some(),
106            "Arc<str> cache should support Borrow<str> lookups"
107        );
108        assert_eq!(
109            retrieved.unwrap().response.as_ref(),
110            article.response.as_ref(),
111            "Retrieved article should match inserted article"
112        );
113    }
114
115    #[tokio::test]
116    async fn test_cache_miss() {
117        let cache = ArticleCache::new(100, Duration::from_secs(300));
118
119        let msgid = MessageId::from_borrowed("<nonexistent@example.com>").unwrap();
120        let result = cache.get(&msgid).await;
121
122        assert!(
123            result.is_none(),
124            "Cache lookup for non-existent key should return None"
125        );
126    }
127}