Skip to main content

nntp_proxy/cache/
hybrid.rs

1//! Hybrid memory+disk article cache using foyer
2//!
3//! This module provides a two-tier cache that stores hot articles in memory
4//! and spills to disk when memory capacity is exceeded. The entry type and
5//! its foyer codec are in [`super::hybrid_codec`].
6//!
7//! # Architecture
8//!
9//! ```text
10//! Client Request → Memory Cache (fast, limited)
11//!                         ↓ miss
12//!                  Disk Cache (slower, larger)
13//!                         ↓ miss
14//!                  Backend Server
15//! ```
16//!
17//! # Usage
18//!
19//! Configure disk cache in your config.toml:
20//! ```toml
21//! [cache]
22//! article_cache_capacity = "256mb"
23//! store_article_bodies = true
24//!
25//! [cache.disk]
26//! path = "/var/cache/nntp-proxy"
27//! capacity = "10gb"
28//! ```
29//!
30//! # Compression Options
31//!
32//! The disk cache supports three compression codecs:
33//! - **LZ4** (default): Fast compression (~60% reduction), minimal CPU overhead, SIMD-accelerated
34//! - **Zstd**: Better compression ratio, moderate CPU overhead, SIMD-accelerated
35//! - **None**: No compression, fastest but largest disk usage
36//!
37//! The LZ4 and Zstd codecs use auto-detected SIMD (SSE2/AVX2/AVX512) for maximum performance;
38//! the `None` codec disables compression entirely and performs no codec-level SIMD work.
39//!
40//! # Performance Characteristics
41//!
42//! - Memory tier: ~1μs access latency, bounded by configured memory capacity
43//! - Disk tier: ~100μs-1ms access latency, bounded by disk capacity
44//! - Automatic promotion: Frequently accessed disk entries promoted to memory
45//! - Compression: Reduces disk usage (LZ4: ~60%, Zstd: ~65%+ for typical NNTP articles)
46
47use crate::config::CompressionCodec;
48use crate::protocol::StatusCode;
49use crate::types::{BackendId, MessageId};
50use foyer::{
51    BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCache, HybridCacheBuilder,
52    HybridCachePolicy, LruConfig, PsyncIoEngineConfig, RecoverMode, Source, Spawner,
53};
54use std::hash::{Hash, Hasher};
55use std::path::Path;
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::time::Duration;
58use tokio::sync::Mutex;
59use tracing::{debug, info, warn};
60
61use super::hybrid_codec::{CacheableStatusCode, DiskCachedArticle};
62use super::ttl;
63
64const HYBRID_CACHE_NAME: &str = "nntp-article-cache-v4";
65
66/// Check available disk space at the given path using df command
67fn check_available_space(_path: &Path) -> Option<u64> {
68    // Try to use statfs on Linux/Unix
69    #[cfg(unix)]
70    {
71        let path = _path;
72        // Get filesystem stats using a known working approach
73        // We'll create a temp file to trigger actual space check
74        if let Ok(temp_file) = std::fs::OpenOptions::new()
75            .write(true)
76            .create(true)
77            .truncate(true)
78            .open(path.join(".space_check_tmp"))
79        {
80            drop(temp_file);
81            let _ = std::fs::remove_file(path.join(".space_check_tmp"));
82        }
83    }
84    None // For now, skip the check - let foyer handle it
85}
86
87/// Configuration for hybrid cache
88#[derive(Debug, Clone)]
89pub struct HybridCacheConfig {
90    /// Memory cache capacity in bytes
91    pub memory_capacity: u64,
92    /// Disk cache capacity in bytes
93    pub disk_capacity: u64,
94    /// Path to disk cache directory
95    pub disk_path: std::path::PathBuf,
96    /// Time-to-live for cached articles (not directly used by foyer, but kept for API consistency)
97    pub ttl: Duration,
98    /// Compression codec for disk storage (lz4, zstd, or none)
99    pub compression: CompressionCodec,
100    /// Number of shards for concurrent access
101    pub shards: usize,
102}
103
104impl Default for HybridCacheConfig {
105    fn default() -> Self {
106        Self {
107            memory_capacity: 256 * 1024 * 1024,     // 256 MB memory
108            disk_capacity: 10 * 1024 * 1024 * 1024, // 10 GB disk
109            disk_path: std::path::PathBuf::from("/var/cache/nntp-proxy"),
110            ttl: crate::constants::duration_polyfill::from_hours(1), // 1 hour
111            compression: CompressionCodec::Lz4,
112            shards: 16, // Match indexer shards for consistent lock contention
113        }
114    }
115}
116
117/// Hybrid article cache with memory and disk tiers
118///
119/// Uses foyer's `HybridCache` for automatic memory→disk spillover.
120/// Hot articles stay in memory, cold articles spill to disk.
121///
122/// Supports tier-aware TTL: entries from higher tier backends get longer TTLs.
123/// Formula: `effective_ttl = base_ttl * (2 ^ tier)`
124///
125/// Note: Keys are stored as `String` (message ID without brackets) because
126/// foyer requires keys to implement the `Code` trait for disk serialization.
127pub struct HybridArticleCache {
128    cache: HybridCache<String, DiskCachedArticle>,
129    hits: AtomicU64,
130    misses: AtomicU64,
131    disk_hits: AtomicU64,
132    config: HybridCacheConfig,
133    /// Base TTL in milliseconds (used for tier-aware expiration via `effective_ttl`)
134    ttl_millis: ttl::CacheTtlMillis,
135    mutation_locks: Vec<Mutex<()>>,
136}
137
138impl std::fmt::Debug for HybridArticleCache {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("HybridArticleCache")
141            .field("hits", &self.hits.load(Ordering::Relaxed))
142            .field("misses", &self.misses.load(Ordering::Relaxed))
143            .field("disk_hits", &self.disk_hits.load(Ordering::Relaxed))
144            .field("config", &self.config)
145            .finish_non_exhaustive()
146    }
147}
148
149impl HybridArticleCache {
150    /// Create a new hybrid cache with the given configuration
151    ///
152    /// This will create the disk cache directory if it doesn't exist.
153    pub async fn new(config: HybridCacheConfig) -> anyhow::Result<Self> {
154        // Ensure disk cache directory exists
155        std::fs::create_dir_all(&config.disk_path).map_err(|e| {
156            if e.kind() == std::io::ErrorKind::Other && e.to_string().contains("No space left") {
157                anyhow::anyhow!(
158                    "Failed to create cache directory '{}': DISK FULL - No space left on device. \
159                     Free up disk space or choose a different disk_path in config.",
160                    config.disk_path.display()
161                )
162            } else {
163                anyhow::anyhow!(
164                    "Failed to create cache directory '{}': {}",
165                    config.disk_path.display(),
166                    e
167                )
168            }
169        })?;
170
171        // Check available disk space before initializing
172        if let Ok(_metadata) = std::fs::metadata(&config.disk_path)
173            && let Some(available_bytes) = check_available_space(&config.disk_path)
174        {
175            let required_bytes = config.disk_capacity;
176            if available_bytes < required_bytes {
177                anyhow::bail!(
178                    "Insufficient disk space for cache:\n\
179                     Path: {}\n\
180                     Required: {} GB\n\
181                     Available: {} GB\n\
182                     Solution: Free up {} GB or reduce 'disk_capacity' in config.",
183                    config.disk_path.display(),
184                    required_bytes / (1024 * 1024 * 1024),
185                    available_bytes / (1024 * 1024 * 1024),
186                    (required_bytes - available_bytes) / (1024 * 1024 * 1024)
187                );
188            }
189        }
190
191        // Use FsDevice - optimized for filesystem use (uses pread/pwrite properly)
192        // Block engine controls partition sizes via block_size
193        let disk_capacity_usize: usize = config.disk_capacity.try_into().map_err(|_| {
194            anyhow::anyhow!(
195                "Disk capacity {} bytes too large for platform (max {} bytes)",
196                config.disk_capacity,
197                usize::MAX
198            )
199        })?;
200
201        let device = FsDeviceBuilder::new(&config.disk_path)
202            .with_capacity(disk_capacity_usize)
203            .build()
204            .map_err(|e| {
205                if e.to_string().contains("No space left") || e.to_string().contains("ENOSPC") {
206                    anyhow::anyhow!(
207                        "Failed to initialize disk cache at '{}': DISK FULL - No space left on device.\n\
208                         Required: {} GB\n\
209                         Solution: Free up disk space or reduce 'disk_capacity' in config.",
210                        config.disk_path.display(),
211                        config.disk_capacity / (1024 * 1024 * 1024)
212                    )
213                } else {
214                    anyhow::anyhow!("Failed to initialize disk cache: {e}")
215                }
216            })?;
217
218        let memory_capacity_usize: usize = config
219            .memory_capacity
220            .try_into()
221            .map_err(|_| anyhow::anyhow!("Memory capacity too large for platform"))?;
222
223        // Block size controls the disk partition file size used by foyer's block engine.
224        // Smaller blocks = faster reclaim cycles but more FDs (~160 for 10GB at 64MB).
225
226        // Create a dedicated tokio runtime for foyer disk I/O.
227        // With WriteOnInsertion, every cache insert triggers a background disk write.
228        // A separate runtime prevents disk I/O from starving the main proxy event loop.
229        let foyer_runtime = tokio::runtime::Builder::new_multi_thread()
230            .worker_threads(8)
231            .max_blocking_threads(16)
232            .thread_name("foyer-disk-io")
233            .enable_all()
234            .build()
235            .map_err(|e| anyhow::anyhow!("Failed to create foyer runtime: {e}"))?;
236
237        let mut builder = HybridCacheBuilder::new()
238            .with_name(HYBRID_CACHE_NAME)
239            .with_policy(HybridCachePolicy::WriteOnInsertion)
240            .memory(memory_capacity_usize)
241            .with_shards(config.shards)
242            .with_eviction_config(LruConfig {
243                high_priority_pool_ratio: 0.1,
244            })
245            .with_weighter(|_key: &String, value: &DiskCachedArticle| value.payload_len().get())
246            .storage()
247            .with_io_engine_config(PsyncIoEngineConfig::new())
248            .with_engine_config(
249                BlockEngineConfig::new(device)
250                    .with_block_size(64 * 1024 * 1024) // 64MB blocks - faster reclaim, ~160 FDs for 10GB
251                    .with_indexer_shards(16)
252                    .with_flushers(4)
253                    .with_reclaimers(2),
254            )
255            .with_recover_mode(RecoverMode::Quiet)
256            .with_spawner(Spawner::from(foyer_runtime));
257
258        match config.compression {
259            CompressionCodec::None => {
260                // No compression - builder already has no compression by default
261            }
262            CompressionCodec::Lz4 => {
263                builder = builder.with_compression(foyer::Compression::Lz4);
264            }
265            CompressionCodec::Zstd => {
266                builder = builder.with_compression(foyer::Compression::Zstd);
267            }
268        }
269
270        let cache = builder.build().await.map_err(|e| {
271            if e.to_string().contains("No space left") || e.to_string().contains("ENOSPC") {
272                anyhow::anyhow!(
273                    "Failed to build disk cache: DISK FULL - No space left on device.\n\
274                     Cache path: {}\n\
275                     Memory size: {} MB\n\
276                     Disk size: {} GB\n\
277                     Solution: Free up disk space or reduce cache sizes in config.",
278                    config.disk_path.display(),
279                    config.memory_capacity / (1024 * 1024),
280                    config.disk_capacity / (1024 * 1024 * 1024)
281                )
282            } else {
283                anyhow::anyhow!("Failed to build hybrid cache: {e}")
284            }
285        })?;
286
287        info!(
288            memory_mb = config.memory_capacity / (1024 * 1024),
289            disk_gb = config.disk_capacity / (1024 * 1024 * 1024),
290            path = %config.disk_path.display(),
291            compression = %config.compression,
292            "Hybrid article cache initialized"
293        );
294
295        let ttl_millis = ttl::CacheTtlMillis::from_duration(config.ttl);
296        Ok(Self {
297            cache,
298            hits: AtomicU64::new(0),
299            misses: AtomicU64::new(0),
300            disk_hits: AtomicU64::new(0),
301            config,
302            ttl_millis,
303            mutation_locks: (0..16).map(|_| Mutex::new(())).collect(),
304        })
305    }
306
307    fn mutation_lock(&self, key: &str) -> &Mutex<()> {
308        let mut hasher = std::collections::hash_map::DefaultHasher::new();
309        key.hash(&mut hasher);
310        &self.mutation_locks[hasher.finish() as usize % self.mutation_locks.len()]
311    }
312
313    async fn get_fresh_entry_for_mutation(&self, key: &str) -> Option<DiskCachedArticle> {
314        let existing = self.cache.get(key).await.ok()??;
315        let entry = existing.value().clone();
316        (!entry.is_expired(self.ttl_millis)).then_some(entry)
317    }
318
319    /// Get an article from the cache
320    ///
321    /// Checks memory first, then disk. Returns None if not found in either tier.
322    /// Applies tier-aware TTL expiration - higher tier entries get longer TTLs.
323    pub(crate) async fn get(&self, message_id: &MessageId<'_>) -> Option<DiskCachedArticle> {
324        self.get_by_cache_key(message_id.without_brackets()).await
325    }
326
327    /// Get an article by the cache key form of a message ID (without brackets).
328    ///
329    /// The hybrid cache accepts borrowed lookup keys and only owns the key if it
330    /// has to populate from disk, so RAM-tier hits avoid allocating a `String`.
331    pub(crate) async fn get_by_cache_key(&self, key: &str) -> Option<DiskCachedArticle> {
332        let result = self.cache.get(key).await;
333        match result {
334            Ok(Some(entry)) => {
335                let cloned = entry.value().clone();
336
337                // Check tier-aware TTL expiration
338                if cloned.is_expired(self.ttl_millis) {
339                    // Expired by tier-aware TTL - treat as cache miss
340                    // We intentionally do NOT remove from foyer. Eviction decisions are delegated
341                    // to foyer's capacity-based and LRU policies. Expired entries may linger on
342                    // disk until capacity pressure forces eviction, avoiding explicit removal overhead.
343                    self.misses.fetch_add(1, Ordering::Relaxed);
344                    return None;
345                }
346
347                self.hits.fetch_add(1, Ordering::Relaxed);
348                let source = entry.source();
349                // Track disk hits for monitoring
350                if source == Source::Disk {
351                    self.disk_hits.fetch_add(1, Ordering::Relaxed);
352                }
353                Some(cloned)
354            }
355            Ok(None) => {
356                self.misses.fetch_add(1, Ordering::Relaxed);
357                None
358            }
359            Err(e) => {
360                warn!(error = %e, "Error reading from hybrid cache");
361                self.misses.fetch_add(1, Ordering::Relaxed);
362                None
363            }
364        }
365    }
366
367    /// Insert or update an article in the cache
368    ///
369    /// With `WriteOnInsertion` policy, articles are written to disk immediately
370    /// in a background task (non-blocking).
371    ///
372    /// **UPSERT SEMANTICS**: Never overwrites a larger semantic payload with a smaller one.
373    /// A cached full article (220/222 response) must not be replaced by STAT availability.
374    ///
375    /// The tier is stored with the entry for tier-aware TTL calculation.
376    pub async fn upsert_ingest(
377        &self,
378        message_id: MessageId<'_>,
379        buffer: impl Into<super::CacheIngestResponse>,
380        backend: super::BackendId,
381        tier: super::ttl::CacheTier,
382    ) {
383        let buffer = buffer.into();
384        let key = message_id.without_brackets().to_string();
385        let _mutation_guard = self.mutation_lock(&key).lock().await;
386        let buffer_len = buffer.len();
387        let Some(mut entry) = DiskCachedArticle::from_ingest_response_with_tier(buffer, tier)
388        else {
389            warn!(msg_id = %key, buffer_len, "Cannot cache: invalid status code");
390            return;
391        };
392        let entry_len = entry.payload_len();
393
394        let mut existing_availability = None;
395
396        // Check for existing entry - don't overwrite larger semantic payloads with smaller ones.
397        if let Some(existing) = self.get_fresh_entry_for_mutation(&key).await {
398            existing_availability = Some(existing.availability());
399            if existing.availability().is_missing(backend) {
400                return;
401            }
402            let existing_len = existing.payload_len();
403            let existing_complete = existing.is_complete_article();
404            let new_complete = entry.is_complete_article();
405            let keep_existing = match (existing_complete, new_complete) {
406                (true, false) => true,
407                (false, true) => false,
408                (true, true) | (false, false) => existing_len > entry_len,
409            };
410            if keep_existing {
411                // Existing entry is larger - refresh TTL without changing negative availability.
412                let mut updated = existing;
413                updated.timestamp = ttl::CacheTimestampMillis::now();
414                self.cache.insert(key.clone(), updated);
415                debug!(
416                    msg_id = %key,
417                    existing_bytes = existing_len.get(),
418                    new_bytes = entry_len.get(),
419                    "Hybrid cache upsert: preserved larger existing entry"
420                );
421                return;
422            }
423        }
424
425        if let Some(availability) = existing_availability {
426            entry.availability = availability;
427        }
428        self.cache.insert(key.clone(), entry);
429        debug!(msg_id = %key, stored_bytes = entry_len.get(), tier = tier.get(), "Hybrid cache upsert");
430    }
431
432    /// Record that a backend doesn't have an article (430 response)
433    pub async fn record_missing(&self, message_id: MessageId<'_>, backend_id: BackendId) {
434        let key = message_id.without_brackets().to_string();
435        let _mutation_guard = self.mutation_lock(&key).lock().await;
436
437        // Get existing entry or create a typed missing entry.
438        let entry = if let Some(existing) = self.get_fresh_entry_for_mutation(&key).await {
439            let mut updated = existing;
440            updated.record_backend_missing(backend_id);
441            updated
442        } else {
443            let mut entry = DiskCachedArticle::missing(super::ttl::CacheTier::new(0));
444            entry.record_backend_missing(backend_id);
445            entry
446        };
447
448        self.cache.insert(key, entry);
449    }
450
451    /// Record successful backend availability without storing response payload bytes.
452    pub async fn record_has_status(
453        &self,
454        message_id: MessageId<'_>,
455        status_code: StatusCode,
456        backend: super::BackendId,
457        tier: super::ttl::CacheTier,
458    ) {
459        let Ok(cacheable_status) = CacheableStatusCode::try_from(status_code.as_u16()) else {
460            warn!(
461                msg_id = %message_id,
462                status_code = status_code.as_u16(),
463                "Cannot record availability: invalid cache status code"
464            );
465            return;
466        };
467
468        let key = message_id.without_brackets().to_string();
469        let _mutation_guard = self.mutation_lock(&key).lock().await;
470        let entry = if let Some(existing) = self.get_fresh_entry_for_mutation(&key).await {
471            if existing.availability().is_missing(backend) {
472                return;
473            }
474            let mut updated = existing;
475            updated.record_backend_has_status(cacheable_status, tier);
476            updated
477        } else {
478            DiskCachedArticle::availability_only(cacheable_status, tier)
479        };
480
481        self.cache.insert(key, entry);
482    }
483
484    /// Get cache statistics
485    pub fn stats(&self) -> HybridCacheStats {
486        let foyer_stats = self.cache.statistics();
487        HybridCacheStats {
488            hits: self.hits.load(Ordering::Relaxed),
489            misses: self.misses.load(Ordering::Relaxed),
490            disk_hits: self.disk_hits.load(Ordering::Relaxed),
491            memory_capacity: self.config.memory_capacity,
492            disk_capacity: self.config.disk_capacity,
493            disk_write_bytes: foyer_stats.disk_write_bytes() as u64,
494            disk_read_bytes: foyer_stats.disk_read_bytes() as u64,
495            disk_write_ios: foyer_stats.disk_write_ios() as u64,
496            disk_read_ios: foyer_stats.disk_read_ios() as u64,
497        }
498    }
499
500    /// Close the cache gracefully
501    ///
502    /// Flushes pending writes to disk before returning.
503    /// This waits for all enqueued disk writes to complete.
504    pub async fn close(&self) -> anyhow::Result<()> {
505        self.cache
506            .close()
507            .await
508            .map_err(|e| anyhow::anyhow!("Failed to close cache: {e}"))
509    }
510}
511
512/// Statistics for hybrid cache
513#[derive(Debug, Clone)]
514pub struct HybridCacheStats {
515    pub hits: u64,
516    pub misses: u64,
517    pub disk_hits: u64,
518    pub memory_capacity: u64,
519    pub disk_capacity: u64,
520    /// Bytes written to disk (from foyer Statistics)
521    pub disk_write_bytes: u64,
522    /// Bytes read from disk (from foyer Statistics)
523    pub disk_read_bytes: u64,
524    /// Number of write I/O operations
525    pub disk_write_ios: u64,
526    /// Number of read I/O operations
527    pub disk_read_ios: u64,
528}
529
530impl HybridCacheStats {
531    /// Calculate hit rate as a percentage
532    #[must_use]
533    pub fn hit_rate(&self) -> f64 {
534        let total = self.hits + self.misses;
535        if total == 0 {
536            0.0
537        } else {
538            (self.hits as f64 / total as f64) * 100.0
539        }
540    }
541
542    /// Calculate disk hit rate (hits from disk vs total hits)
543    #[must_use]
544    pub fn disk_hit_rate(&self) -> f64 {
545        if self.hits == 0 {
546            0.0
547        } else {
548            (self.disk_hits as f64 / self.hits as f64) * 100.0
549        }
550    }
551}
552
553#[cfg(test)]
554impl HybridArticleCache {
555    /// Create a memory-only cache for testing.
556    ///
557    /// Uses `NoopIoEngineConfig` without a block engine config so foyer falls back
558    /// to `NoopEngineConfig` — a trivially synchronous engine that spawns zero
559    /// background tasks. Safe with any tokio runtime flavor.
560    pub async fn new_memory_only(memory_capacity: u64) -> anyhow::Result<Self> {
561        use foyer::NoopIoEngineConfig;
562        use std::sync::atomic::{AtomicU64, Ordering};
563
564        static TEST_CACHE_ID: AtomicU64 = AtomicU64::new(0);
565        let cache_id = TEST_CACHE_ID.fetch_add(1, Ordering::Relaxed);
566
567        let memory_capacity_usize: usize = memory_capacity
568            .try_into()
569            .map_err(|_| anyhow::anyhow!("Memory capacity too large for platform"))?;
570
571        // NoopIoEngineConfig + no with_engine_config() call → foyer uses NoopEngineConfig,
572        // which is completely synchronous and never spawns background flusher/reclaimer tasks.
573        let builder = HybridCacheBuilder::new()
574            .with_name(format!("nntp-article-cache-test-{cache_id}"))
575            .with_policy(HybridCachePolicy::WriteOnEviction)
576            .memory(memory_capacity_usize)
577            .with_shards(1)
578            .with_eviction_config(LruConfig {
579                high_priority_pool_ratio: 0.1,
580            })
581            .with_weighter(|_key: &String, value: &DiskCachedArticle| value.payload_len().get())
582            .storage()
583            .with_io_engine_config(Box::new(NoopIoEngineConfig) as Box<dyn foyer::IoEngineConfig>);
584
585        let cache = builder.build().await?;
586
587        let config = HybridCacheConfig {
588            memory_capacity,
589            disk_capacity: 0, // No disk in memory-only mode
590            disk_path: std::path::PathBuf::new(),
591            ttl: Duration::from_secs(3600),
592            compression: CompressionCodec::None,
593            shards: 1,
594        };
595
596        Ok(Self {
597            cache,
598            hits: AtomicU64::new(0),
599            misses: AtomicU64::new(0),
600            disk_hits: AtomicU64::new(0),
601            config,
602            ttl_millis: ttl::CacheTtlMillis::new(3600 * 1000), // 1 hour in milliseconds
603            mutation_locks: (0..16).map(|_| Mutex::new(())).collect(),
604        })
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    //! Cache-level integration tests for `HybridArticleCache`
611    //!
612    use super::*;
613
614    #[test]
615    fn hybrid_cache_name_cold_invalidates_old_disk_formats() {
616        assert_eq!(HYBRID_CACHE_NAME, "nntp-article-cache-v4");
617    }
618
619    #[tokio::test]
620    async fn test_hybrid_cache_basic() {
621        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
622            .await
623            .unwrap();
624
625        // Insert an article
626        let msg_id = MessageId::from_borrowed("<test123@example.com>").unwrap();
627        let buffer = b"220 0 <test123@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
628        cache
629            .upsert_ingest(msg_id, buffer.clone(), BackendId::from_index(0), 0.into())
630            .await;
631
632        // Retrieve it
633        let msg_id = MessageId::from_borrowed("<test123@example.com>").unwrap();
634        let entry = cache.get(&msg_id).await.unwrap();
635        let response = entry
636            .cached_response_for(crate::protocol::RequestKind::Article, msg_id.as_str())
637            .unwrap();
638        let mut rendered = Vec::with_capacity(response.wire_len().get());
639        response.write_to(&mut rendered).await.unwrap();
640        assert_eq!(rendered, buffer);
641        assert!(entry.should_try_backend(BackendId::from_index(0)));
642
643        // Check stats
644        let stats = cache.stats();
645        assert_eq!(stats.hits, 1);
646        assert_eq!(stats.misses, 0);
647
648        cache.close().await.unwrap();
649    }
650
651    #[tokio::test]
652    async fn test_hybrid_cache_availability_tracking() {
653        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
654            .await
655            .unwrap();
656
657        // Record a 430 response
658        let msg_id = MessageId::from_borrowed("<missing@example.com>").unwrap();
659        cache.record_missing(msg_id, BackendId::from_index(0)).await;
660
661        // Check availability
662        let msg_id = MessageId::from_borrowed("<missing@example.com>").unwrap();
663        let entry = cache.get(&msg_id).await.unwrap();
664        assert_eq!(
665            entry.payload_len().get(),
666            0,
667            "missing hybrid cache entries must not retain response payload bytes"
668        );
669        assert!(!entry.should_try_backend(BackendId::from_index(0)));
670        assert!(entry.should_try_backend(BackendId::from_index(1)));
671
672        cache.close().await.unwrap();
673    }
674
675    #[tokio::test]
676    async fn test_hybrid_cached_430_prevents_same_backend_success_token() {
677        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
678            .await
679            .unwrap();
680        let msg_id = MessageId::from_borrowed("<hybrid-permanent-missing@example.com>").unwrap();
681        let backend = BackendId::from_index(0);
682        let buffer =
683            b"220 0 <hybrid-permanent-missing@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n"
684                .to_vec();
685
686        cache.record_missing(msg_id, backend).await;
687
688        let msg_id = MessageId::from_borrowed("<hybrid-permanent-missing@example.com>").unwrap();
689        let entry = cache.get(&msg_id).await.unwrap();
690        assert!(
691            entry.availability().is_missing(backend),
692            "cached 430 must not produce an eligible success token"
693        );
694
695        let msg_id = MessageId::from_borrowed("<hybrid-permanent-missing@example.com>").unwrap();
696        cache.upsert_ingest(msg_id, buffer, backend, 0.into()).await;
697
698        let msg_id = MessageId::from_borrowed("<hybrid-permanent-missing@example.com>").unwrap();
699        let entry = cache.get(&msg_id).await.unwrap();
700        assert_eq!(entry.status_code(), StatusCode::new(430));
701        assert!(!entry.should_try_backend(backend));
702        assert_eq!(entry.payload_len().get(), 0);
703
704        cache.close().await.unwrap();
705    }
706
707    #[tokio::test]
708    async fn test_hybrid_expired_missing_does_not_block_successful_upsert() {
709        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
710            .await
711            .unwrap();
712        let msg_id = MessageId::from_borrowed("<expired-missing-upsert@example.com>").unwrap();
713        let key = msg_id.without_brackets().to_string();
714        let backend = BackendId::from_index(0);
715        let mut expired = DiskCachedArticle::missing(super::ttl::CacheTier::new(0));
716        expired.record_backend_missing(backend);
717        expired.timestamp = super::ttl::CacheTimestampMillis::new(0);
718        cache.cache.insert(key, expired);
719
720        let buffer =
721            b"220 0 <expired-missing-upsert@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n"
722                .to_vec();
723        cache
724            .upsert_ingest(msg_id, buffer.clone(), backend, 0.into())
725            .await;
726
727        let msg_id = MessageId::from_borrowed("<expired-missing-upsert@example.com>").unwrap();
728        let entry = cache
729            .get(&msg_id)
730            .await
731            .expect("successful fetch should replace expired missing metadata");
732        assert_eq!(entry.status_code(), StatusCode::new(220));
733        assert!(entry.should_try_backend(backend));
734        let response = entry
735            .cached_response_for(crate::protocol::RequestKind::Article, msg_id.as_str())
736            .expect("replacement should store a complete article response");
737        let mut rendered = Vec::with_capacity(response.wire_len().get());
738        response.write_to(&mut rendered).await.unwrap();
739        assert_eq!(rendered, buffer);
740
741        cache.close().await.unwrap();
742    }
743
744    #[tokio::test]
745    async fn test_hybrid_expired_missing_does_not_block_status_record() {
746        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
747            .await
748            .unwrap();
749        let msg_id = MessageId::from_borrowed("<expired-missing-status@example.com>").unwrap();
750        let key = msg_id.without_brackets().to_string();
751        let backend = BackendId::from_index(0);
752        let mut expired = DiskCachedArticle::missing(super::ttl::CacheTier::new(0));
753        expired.record_backend_missing(backend);
754        expired.timestamp = super::ttl::CacheTimestampMillis::new(0);
755        cache.cache.insert(key, expired);
756
757        cache
758            .record_has_status(
759                msg_id,
760                StatusCode::new(223),
761                backend,
762                super::ttl::CacheTier::new(0),
763            )
764            .await;
765
766        let msg_id = MessageId::from_borrowed("<expired-missing-status@example.com>").unwrap();
767        let entry = cache
768            .get(&msg_id)
769            .await
770            .expect("successful status should replace expired missing metadata");
771        assert_eq!(entry.status_code(), StatusCode::new(223));
772        assert!(entry.should_try_backend(backend));
773        assert_eq!(entry.payload_len().get(), 0);
774
775        cache.close().await.unwrap();
776    }
777
778    #[tokio::test]
779    async fn test_hybrid_record_missing_replaces_expired_entry() {
780        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
781            .await
782            .unwrap();
783        let msg_id = MessageId::from_borrowed("<expired-record-missing@example.com>").unwrap();
784        let key = msg_id.without_brackets().to_string();
785        let backend = BackendId::from_index(0);
786        let mut expired = DiskCachedArticle::from_ingest_response_with_tier(
787            b"220 0 <expired-record-missing@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n"
788                .as_slice()
789                .into(),
790            super::ttl::CacheTier::new(0),
791        )
792        .expect("valid cache entry");
793        expired.timestamp = super::ttl::CacheTimestampMillis::new(0);
794        cache.cache.insert(key, expired);
795
796        cache.record_missing(msg_id, backend).await;
797
798        let msg_id = MessageId::from_borrowed("<expired-record-missing@example.com>").unwrap();
799        let entry = cache
800            .get(&msg_id)
801            .await
802            .expect("fresh missing fact should replace expired payload");
803        assert_eq!(entry.status_code(), StatusCode::new(430));
804        assert!(!entry.should_try_backend(backend));
805        assert_eq!(entry.payload_len().get(), 0);
806
807        cache.close().await.unwrap();
808    }
809
810    #[tokio::test]
811    async fn test_hybrid_records_mixed_availability_facts() {
812        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
813            .await
814            .unwrap();
815        let msg_id = MessageId::from_borrowed("<mixed@example.com>").unwrap();
816        cache.record_missing(msg_id, BackendId::from_index(0)).await;
817        let msg_id = MessageId::from_borrowed("<mixed@example.com>").unwrap();
818        cache
819            .record_has_status(
820                msg_id,
821                StatusCode::new(223),
822                BackendId::from_index(1),
823                super::ttl::CacheTier::new(0),
824            )
825            .await;
826
827        let msg_id = MessageId::from_borrowed("<mixed@example.com>").unwrap();
828        let entry = cache
829            .get(&msg_id)
830            .await
831            .expect("mixed facts should create a status-only entry");
832        assert_eq!(entry.status_code(), StatusCode::new(223));
833        assert_eq!(entry.payload_len().get(), 0);
834        assert!(!entry.should_try_backend(BackendId::from_index(0)));
835        assert!(entry.should_try_backend(BackendId::from_index(1)));
836
837        cache.close().await.unwrap();
838    }
839
840    #[tokio::test]
841    async fn test_hybrid_missing_fact_preserves_existing_payload() {
842        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
843            .await
844            .unwrap();
845        let msg_id = MessageId::from_borrowed("<mixed-payload@example.com>").unwrap();
846        let buffer =
847            b"220 0 <mixed-payload@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
848        cache
849            .upsert_ingest(msg_id, buffer.clone(), BackendId::from_index(1), 0.into())
850            .await;
851
852        let msg_id = MessageId::from_borrowed("<mixed-payload@example.com>").unwrap();
853        cache.record_missing(msg_id, BackendId::from_index(0)).await;
854
855        let msg_id = MessageId::from_borrowed("<mixed-payload@example.com>").unwrap();
856        let entry = cache
857            .get(&msg_id)
858            .await
859            .expect("payload entry should remain cached");
860        let response = entry
861            .cached_response_for(crate::protocol::RequestKind::Article, msg_id.as_str())
862            .expect("missing fact must not replace payload with status-only entry");
863        let mut rendered = Vec::with_capacity(response.wire_len().get());
864        response.write_to(&mut rendered).await.unwrap();
865        assert_eq!(rendered, buffer);
866        assert!(!entry.should_try_backend(BackendId::from_index(0)));
867        assert!(entry.should_try_backend(BackendId::from_index(1)));
868
869        cache.close().await.unwrap();
870    }
871
872    #[tokio::test]
873    async fn test_hybrid_complete_payload_replaces_larger_metadata() {
874        let cache = HybridArticleCache::new_memory_only(1024 * 1024)
875            .await
876            .unwrap();
877        let msg_id = MessageId::from_borrowed("<complete-replaces-metadata@example.com>").unwrap();
878        let head = b"221 0 <complete-replaces-metadata@example.com>\r\nSubject: A very long header value that should not block a later body\r\nX-Test: metadata only\r\n.\r\n".to_vec();
879        let body = b"222 0 <complete-replaces-metadata@example.com>\r\nbody\r\n.\r\n".to_vec();
880
881        cache
882            .upsert_ingest(msg_id, head, BackendId::from_index(0), 0.into())
883            .await;
884
885        let msg_id = MessageId::from_borrowed("<complete-replaces-metadata@example.com>").unwrap();
886        cache
887            .upsert_ingest(msg_id, body.clone(), BackendId::from_index(1), 0.into())
888            .await;
889
890        let msg_id = MessageId::from_borrowed("<complete-replaces-metadata@example.com>").unwrap();
891        let entry = cache
892            .get(&msg_id)
893            .await
894            .expect("body entry should be cached");
895        assert!(entry.is_complete_article());
896        assert!(
897            entry
898                .cached_response_for(crate::protocol::RequestKind::Body, msg_id.as_str())
899                .is_some(),
900            "complete body response should replace larger metadata-only HEAD"
901        );
902        assert!(entry.should_try_backend(BackendId::from_index(0)));
903        assert!(entry.should_try_backend(BackendId::from_index(1)));
904
905        cache.close().await.unwrap();
906    }
907}