Skip to main content

nntp_proxy/cache/
mod.rs

1//! Cache module for NNTP article caching
2//!
3//! This module provides caching functionality for NNTP articles,
4//! allowing the proxy to cache article content and reduce backend load.
5//!
6//! The `ArticleAvailability` type serves dual purposes:
7//! 1. Cache persistence - track which backends have which articles across requests
8//! 2. Retry tracking - track which backends tried during 430 retry loops (transient)
9//!
10//! ## Cache Implementations
11//!
12//! - [`ArticleCache`] - In-memory body cache using moka when article bodies stay in memory
13//! - [`HybridArticleCache`] - Memory + disk cache using foyer (when `[cache.disk]` configured)
14//! - [`UnifiedCache`] - Enum that wraps availability-only, memory, or hybrid cache modes
15
16mod article;
17mod availability;
18mod availability_index;
19mod hybrid;
20mod hybrid_codec;
21pub mod ttl;
22
23#[cfg(test)]
24mod mock_hybrid;
25
26pub use article::{ArticleCache, CachedArticle};
27pub use availability::{ArticleAvailability, BackendStatus, MAX_BACKENDS};
28pub use availability_index::AvailabilityIndex;
29pub use hybrid::{HybridArticleCache, HybridCacheConfig, HybridCacheStats};
30
31use crate::protocol::StatusCode;
32use crate::types::{BackendId, MessageId};
33use smallvec::SmallVec;
34
35/// Owned response storage passed across the async cache ingest boundary.
36///
37/// Hot-path code should hand off one of these owned forms directly instead of
38/// flattening into a fresh `Vec<u8>` before spawning cache work.
39#[derive(Debug)]
40#[allow(clippy::large_enum_variant)]
41pub enum CacheIngestResponse {
42    Owned(Box<[u8]>),
43    Pooled(crate::pool::PooledBuffer),
44    Chunked(crate::pool::ChunkedResponse),
45    Inline(SmallVec<[u8; 128]>),
46}
47
48impl CacheIngestResponse {
49    #[must_use]
50    pub(crate) fn len(&self) -> usize {
51        match self {
52            Self::Owned(buf) => buf.len(),
53            Self::Pooled(buf) => buf.len(),
54            Self::Chunked(buf) => buf.len(),
55            Self::Inline(buf) => buf.len(),
56        }
57    }
58
59    #[cfg(test)]
60    #[must_use]
61    pub(crate) fn status_code(&self) -> Option<StatusCode> {
62        match self {
63            Self::Owned(buf) => StatusCode::parse(buf),
64            Self::Pooled(buf) => StatusCode::parse(buf.as_ref()),
65            Self::Chunked(buf) => {
66                let mut prefix = SmallVec::<[u8; 128]>::new();
67                buf.copy_prefix_into(3, &mut prefix);
68                StatusCode::parse(&prefix)
69            }
70            Self::Inline(buf) => StatusCode::parse(buf),
71        }
72    }
73}
74
75#[cfg(test)]
76impl PartialEq for CacheIngestResponse {
77    fn eq(&self, other: &Self) -> bool {
78        fn chunks<'a>(buf: &'a CacheIngestResponse) -> Box<dyn Iterator<Item = &'a [u8]> + 'a> {
79            match buf {
80                CacheIngestResponse::Owned(v) => Box::new(std::iter::once(v.as_ref())),
81                CacheIngestResponse::Pooled(v) => Box::new(std::iter::once(v.as_ref())),
82                CacheIngestResponse::Chunked(v) => Box::new(v.iter_chunks()),
83                CacheIngestResponse::Inline(v) => Box::new(std::iter::once(v.as_slice())),
84            }
85        }
86
87        if self.len() != other.len() {
88            return false;
89        }
90
91        let left = chunks(self).flat_map(|chunk| chunk.iter().copied());
92        let mut right = chunks(other).flat_map(|chunk| chunk.iter().copied());
93        left.eq(&mut right)
94    }
95}
96
97#[cfg(test)]
98impl Eq for CacheIngestResponse {}
99
100impl From<Vec<u8>> for CacheIngestResponse {
101    fn from(value: Vec<u8>) -> Self {
102        Self::Owned(value.into_boxed_slice())
103    }
104}
105
106impl From<crate::pool::PooledBuffer> for CacheIngestResponse {
107    fn from(value: crate::pool::PooledBuffer) -> Self {
108        Self::Pooled(value)
109    }
110}
111
112impl From<crate::pool::ChunkedResponse> for CacheIngestResponse {
113    fn from(value: crate::pool::ChunkedResponse) -> Self {
114        Self::Chunked(value)
115    }
116}
117
118impl From<SmallVec<[u8; 128]>> for CacheIngestResponse {
119    fn from(value: SmallVec<[u8; 128]>) -> Self {
120        Self::Inline(value)
121    }
122}
123
124impl From<&[u8]> for CacheIngestResponse {
125    fn from(value: &[u8]) -> Self {
126        if value.len() <= 128 {
127            Self::Inline(SmallVec::<[u8; 128]>::from_slice(value))
128        } else {
129            Self::Owned(value.into())
130        }
131    }
132}
133
134impl<const N: usize> From<&[u8; N]> for CacheIngestResponse {
135    fn from(value: &[u8; N]) -> Self {
136        Self::from(value.as_slice())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[tokio::test]
145    async fn cache_ingest_response_equality_spans_storage_forms() {
146        let bytes = b"220 0 <test@example.com>\r\nBody\r\n.\r\n";
147        let pool =
148            crate::pool::BufferPool::new(crate::types::BufferSize::try_new(1024).unwrap(), 1)
149                .with_capture_pool(8, 4);
150
151        let mut pooled = pool.acquire();
152        pooled.copy_from_slice(bytes);
153
154        let mut chunked = crate::pool::ChunkedResponse::default();
155        chunked.extend_from_slice(&pool, bytes);
156
157        let small = SmallVec::<[u8; 128]>::from_slice(bytes);
158
159        assert_eq!(
160            CacheIngestResponse::Owned(bytes.to_vec().into_boxed_slice()),
161            CacheIngestResponse::Pooled(pooled)
162        );
163        assert_eq!(
164            CacheIngestResponse::Chunked(chunked),
165            CacheIngestResponse::Inline(small)
166        );
167    }
168
169    #[tokio::test]
170    async fn cache_ingest_response_status_code_spans_storage_forms() {
171        let bytes = b"220 0 <test@example.com>\r\nBody\r\n.\r\n";
172        let pool =
173            crate::pool::BufferPool::new(crate::types::BufferSize::try_new(1024).unwrap(), 1)
174                .with_capture_pool(8, 4);
175
176        let mut pooled = pool.acquire();
177        pooled.copy_from_slice(bytes);
178
179        let mut chunked = crate::pool::ChunkedResponse::default();
180        chunked.extend_from_slice(&pool, bytes);
181
182        let small = SmallVec::<[u8; 128]>::from_slice(bytes);
183
184        assert_eq!(
185            CacheIngestResponse::Owned(bytes.to_vec().into_boxed_slice()).status_code(),
186            Some(StatusCode::new(220))
187        );
188        assert_eq!(
189            CacheIngestResponse::Pooled(pooled).status_code(),
190            Some(StatusCode::new(220))
191        );
192        assert_eq!(
193            CacheIngestResponse::Chunked(chunked).status_code(),
194            Some(StatusCode::new(220))
195        );
196        assert_eq!(
197            CacheIngestResponse::Inline(small).status_code(),
198            Some(StatusCode::new(220))
199        );
200    }
201
202    #[test]
203    fn cache_ingest_response_from_short_slice_uses_inline_storage() {
204        let buffer = CacheIngestResponse::from(b"223\r\n".as_slice());
205
206        assert!(matches!(buffer, CacheIngestResponse::Inline(_)));
207        assert_eq!(buffer.status_code(), Some(StatusCode::new(223)));
208    }
209
210    #[test]
211    fn cache_ingest_response_from_large_slice_stores_owned_slice() {
212        let bytes = [b'x'; 129];
213
214        let buffer = CacheIngestResponse::from(bytes.as_slice());
215
216        assert!(matches!(buffer, CacheIngestResponse::Owned(_)));
217    }
218
219    #[test]
220    fn cache_ingest_response_from_vec_stores_tight_owned_slice() {
221        let buffer = CacheIngestResponse::from(Vec::from(&b"220 1 <tight@example>\r\n.\r\n"[..]));
222
223        assert!(matches!(buffer, CacheIngestResponse::Owned(_)));
224        assert_eq!(buffer.status_code(), Some(StatusCode::new(220)));
225    }
226
227    #[tokio::test]
228    async fn unified_cache_records_typed_availability_without_payload() {
229        let cache = UnifiedCache::memory(1000, std::time::Duration::from_secs(60));
230        let msg_id = MessageId::new("<typed-availability@example>".to_string()).unwrap();
231        let backend_id = BackendId::from_index(1);
232        let backend = backend_id;
233
234        cache
235            .record_backend_has_status(
236                msg_id.clone(),
237                StatusCode::new(220),
238                backend,
239                ttl::CacheTier::new(2),
240            )
241            .await;
242
243        let entry = cache.get(&msg_id).await.expect("entry is recorded");
244        assert_eq!(entry.status_code(), StatusCode::new(220));
245        assert_eq!(
246            entry
247                .request_cache_metadata(&entry.availability())
248                .payload_kind(),
249            crate::protocol::RequestCachePayloadKind::AvailabilityOnly
250        );
251        assert_eq!(entry.payload_len().get(), 0);
252        assert!(!entry.has_availability_info());
253        assert_eq!(entry.availability().missing_bits(), 0);
254        assert!(entry.should_try_backend(backend_id));
255    }
256}
257
258/// Statistics for cache display in TUI
259#[derive(Debug, Clone, Default)]
260pub struct CacheDisplayStats {
261    /// Number of cached entries
262    pub entry_count: u64,
263    /// Total size in bytes (memory tier for hybrid)
264    pub size_bytes: u64,
265    /// Cache hit rate as percentage (0.0 to 100.0)
266    pub hit_rate: f64,
267    /// Disk cache statistics (only for hybrid cache)
268    pub disk: Option<DiskDisplayStats>,
269}
270
271/// Disk-tier statistics for hybrid cache
272#[derive(Debug, Clone, Default)]
273pub struct DiskDisplayStats {
274    /// Hits served from disk tier
275    pub disk_hits: u64,
276    /// Percentage of total hits served from disk
277    pub disk_hit_rate: f64,
278    /// Configured disk capacity in bytes
279    pub capacity: u64,
280    /// Bytes actually written to disk
281    pub bytes_written: u64,
282    /// Bytes read from disk
283    pub bytes_read: u64,
284    /// Number of write I/O operations
285    pub write_ios: u64,
286    /// Number of read I/O operations
287    pub read_ios: u64,
288}
289
290/// Trait for getting cache statistics for TUI display
291pub trait CacheStatsProvider: Send + Sync {
292    /// Get statistics for TUI display
293    fn display_stats(&self) -> CacheDisplayStats;
294}
295
296impl CacheStatsProvider for ArticleCache {
297    fn display_stats(&self) -> CacheDisplayStats {
298        CacheDisplayStats {
299            entry_count: self.entry_count(),
300            size_bytes: self.weighted_size(),
301            hit_rate: self.hit_rate(),
302            disk: None,
303        }
304    }
305}
306
307impl CacheStatsProvider for AvailabilityIndex {
308    fn display_stats(&self) -> CacheDisplayStats {
309        CacheDisplayStats {
310            entry_count: self.entry_count(),
311            size_bytes: self.used_bytes(),
312            hit_rate: self.hit_rate(),
313            disk: None,
314        }
315    }
316}
317
318impl CacheStatsProvider for HybridArticleCache {
319    fn display_stats(&self) -> CacheDisplayStats {
320        let stats = self.stats();
321        CacheDisplayStats {
322            entry_count: 0,                    // foyer doesn't expose entry count easily
323            size_bytes: stats.memory_capacity, // Use configured capacity
324            hit_rate: stats.hit_rate(),
325            disk: Some(DiskDisplayStats {
326                disk_hits: stats.disk_hits,
327                disk_hit_rate: stats.disk_hit_rate(),
328                capacity: stats.disk_capacity,
329                bytes_written: stats.disk_write_bytes,
330                bytes_read: stats.disk_read_bytes,
331                write_ios: stats.disk_write_ios,
332                read_ios: stats.disk_read_ios,
333            }),
334        }
335    }
336}
337
338/// Unified cache that can be availability-only, memory-only (moka), or hybrid (foyer)
339///
340/// This enum provides a common interface for all cache implementations,
341/// allowing the proxy to switch between availability-only, memory, and disk-backed
342/// caching based on configuration.
343#[derive(Debug)]
344pub enum UnifiedCache {
345    /// Availability-only negative index with exact key matches.
346    Availability(AvailabilityIndex),
347    /// Memory-only cache using moka
348    Memory(ArticleCache),
349    /// Hybrid memory+disk cache using foyer
350    Hybrid(HybridArticleCache),
351}
352
353impl UnifiedCache {
354    /// Create an availability-only negative index.
355    #[must_use]
356    pub fn availability(ttl: std::time::Duration) -> Self {
357        Self::Availability(AvailabilityIndex::with_ttl(ttl))
358    }
359
360    /// Create a memory-only cache
361    #[must_use]
362    pub fn memory(capacity: u64, ttl: std::time::Duration) -> Self {
363        Self::Memory(ArticleCache::new(capacity, ttl))
364    }
365
366    /// Create a hybrid cache (async because foyer needs async initialization)
367    pub async fn hybrid(config: HybridCacheConfig) -> anyhow::Result<Self> {
368        Ok(Self::Hybrid(HybridArticleCache::new(config).await?))
369    }
370
371    /// Returns true when successful backend responses update positive
372    /// availability metadata.
373    #[must_use]
374    pub const fn records_backend_has_status(&self) -> bool {
375        !matches!(self, Self::Availability(_))
376    }
377
378    /// Returns true when this cache stores response payloads.
379    #[must_use]
380    pub const fn stores_payload_responses(&self) -> bool {
381        !matches!(self, Self::Availability(_))
382    }
383
384    /// Get an article from the cache
385    pub async fn get(&self, message_id: &MessageId<'_>) -> Option<CachedArticle> {
386        match self {
387            Self::Availability(index) => index.get(message_id),
388            Self::Memory(cache) => cache.get(message_id).await,
389            Self::Hybrid(cache) => cache
390                .get(message_id)
391                .await
392                .map(hybrid_codec::DiskCachedArticle::into_cached_article),
393        }
394    }
395
396    /// Get an article from a validated request message-id string (`<...>`).
397    ///
398    /// Memory and hybrid cache hits avoid rebuilding a `MessageId` by looking up
399    /// the stripped cache key directly.
400    pub async fn get_request_message_id(&self, message_id: &str) -> Option<CachedArticle> {
401        match self {
402            Self::Availability(index) => index.get_request_message_id(message_id),
403            Self::Memory(cache) => {
404                let key = message_id.strip_prefix('<')?.strip_suffix('>')?;
405                cache.get_by_cache_key(key).await
406            }
407            Self::Hybrid(cache) => {
408                let key = message_id.strip_prefix('<')?.strip_suffix('>')?;
409                cache
410                    .get_by_cache_key(key)
411                    .await
412                    .map(hybrid_codec::DiskCachedArticle::into_cached_article)
413            }
414        }
415    }
416
417    /// Store a successful article response for an eligible backend.
418    pub async fn upsert_ingest(
419        &self,
420        message_id: MessageId<'_>,
421        buffer: impl Into<CacheIngestResponse>,
422        backend: BackendId,
423        tier: ttl::CacheTier,
424    ) {
425        let buffer = buffer.into();
426        match self {
427            Self::Availability(_) => {}
428            Self::Memory(cache) => {
429                cache.upsert_ingest(message_id, buffer, backend, tier).await;
430            }
431            Self::Hybrid(cache) => {
432                cache.upsert_ingest(message_id, buffer, backend, tier).await;
433            }
434        }
435    }
436
437    /// Record that a backend returned 430 for this article
438    pub async fn record_backend_missing(&self, message_id: MessageId<'_>, backend_id: BackendId) {
439        match self {
440            Self::Availability(index) => index.record_backend_missing(&message_id, backend_id),
441            Self::Memory(cache) => cache.record_backend_missing(message_id, backend_id).await,
442            Self::Hybrid(cache) => cache.record_missing(message_id, backend_id).await,
443        }
444    }
445
446    /// Record that a backend has an article with a known status, without storing payload bytes.
447    pub async fn record_backend_has_status(
448        &self,
449        message_id: MessageId<'_>,
450        status_code: StatusCode,
451        backend: BackendId,
452        tier: ttl::CacheTier,
453    ) {
454        match self {
455            Self::Availability(_) => {}
456            Self::Memory(cache) => {
457                cache
458                    .record_backend_has_status(message_id, status_code, backend, tier)
459                    .await;
460            }
461            Self::Hybrid(cache) => {
462                cache
463                    .record_has_status(message_id, status_code, backend, tier)
464                    .await;
465            }
466        }
467    }
468
469    /// Load persisted availability state if this is an availability-only cache.
470    pub fn load_from_disk(&self, path: &std::path::Path) -> anyhow::Result<bool> {
471        match self {
472            Self::Availability(index) => index.load_from_path(path),
473            Self::Memory(_) | Self::Hybrid(_) => Ok(false),
474        }
475    }
476
477    /// Save persisted availability state if this is an availability-only cache.
478    pub fn save_to_disk(&self, path: &std::path::Path) -> anyhow::Result<bool> {
479        match self {
480            Self::Availability(index) => {
481                index.save_to_path(path)?;
482                Ok(true)
483            }
484            Self::Memory(_) | Self::Hybrid(_) => Ok(false),
485        }
486    }
487
488    /// Get cache capacity
489    #[must_use]
490    pub fn capacity(&self) -> u64 {
491        match self {
492            Self::Availability(index) => index.capacity_bytes(),
493            Self::Memory(cache) => cache.capacity(),
494            Self::Hybrid(cache) => cache.stats().memory_capacity,
495        }
496    }
497
498    /// Get number of cached entries
499    #[must_use]
500    pub fn entry_count(&self) -> u64 {
501        match self {
502            Self::Availability(index) => index.entry_count(),
503            Self::Memory(cache) => cache.entry_count(),
504            Self::Hybrid(_cache) => 0, // foyer doesn't expose this easily
505        }
506    }
507
508    /// Get weighted size in bytes
509    #[must_use]
510    pub fn weighted_size(&self) -> u64 {
511        match self {
512            Self::Availability(index) => index.used_bytes(),
513            Self::Memory(cache) => cache.weighted_size(),
514            Self::Hybrid(cache) => cache.stats().memory_capacity,
515        }
516    }
517
518    /// Get cache hit rate
519    #[must_use]
520    pub fn hit_rate(&self) -> f64 {
521        match self {
522            Self::Availability(index) => index.hit_rate(),
523            Self::Memory(cache) => cache.hit_rate(),
524            Self::Hybrid(cache) => cache.stats().hit_rate(),
525        }
526    }
527
528    /// Check if this is a hybrid cache (has disk tier)
529    #[must_use]
530    pub const fn is_hybrid(&self) -> bool {
531        matches!(self, Self::Hybrid(_))
532    }
533
534    /// Check if this cache is the dedicated availability-only index.
535    #[must_use]
536    pub const fn is_availability_only(&self) -> bool {
537        matches!(self, Self::Availability(_))
538    }
539
540    /// Run pending background tasks (for testing)
541    ///
542    /// Ensures all async maintenance tasks complete for deterministic testing.
543    pub async fn sync(&self) {
544        match self {
545            Self::Availability(_) | Self::Hybrid(_) => {}
546            Self::Memory(cache) => cache.sync().await,
547        }
548    }
549
550    /// Close the cache and flush all pending writes
551    ///
552    /// For hybrid cache, this ensures all enqueued disk writes complete before returning.
553    /// For memory cache, this is a no-op (no persistent state).
554    pub async fn close(&self) -> anyhow::Result<()> {
555        match self {
556            Self::Availability(_) => Ok(()),
557            Self::Memory(_) => Ok(()), // No persistent state
558            Self::Hybrid(cache) => cache.close().await,
559        }
560    }
561}
562
563impl CacheStatsProvider for UnifiedCache {
564    fn display_stats(&self) -> CacheDisplayStats {
565        match self {
566            Self::Availability(index) => index.display_stats(),
567            Self::Memory(cache) => cache.display_stats(),
568            Self::Hybrid(cache) => cache.display_stats(),
569        }
570    }
571}