1use 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
66fn check_available_space(_path: &Path) -> Option<u64> {
68 #[cfg(unix)]
70 {
71 let path = _path;
72 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 }
86
87#[derive(Debug, Clone)]
89pub struct HybridCacheConfig {
90 pub memory_capacity: u64,
92 pub disk_capacity: u64,
94 pub disk_path: std::path::PathBuf,
96 pub ttl: Duration,
98 pub compression: CompressionCodec,
100 pub shards: usize,
102}
103
104impl Default for HybridCacheConfig {
105 fn default() -> Self {
106 Self {
107 memory_capacity: 256 * 1024 * 1024, disk_capacity: 10 * 1024 * 1024 * 1024, disk_path: std::path::PathBuf::from("/var/cache/nntp-proxy"),
110 ttl: crate::constants::duration_polyfill::from_hours(1), compression: CompressionCodec::Lz4,
112 shards: 16, }
114 }
115}
116
117pub struct HybridArticleCache {
128 cache: HybridCache<String, DiskCachedArticle>,
129 hits: AtomicU64,
130 misses: AtomicU64,
131 disk_hits: AtomicU64,
132 config: HybridCacheConfig,
133 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 pub async fn new(config: HybridCacheConfig) -> anyhow::Result<Self> {
154 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 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 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 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) .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 }
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 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 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 if cloned.is_expired(self.ttl_millis) {
339 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 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 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 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 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 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 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 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 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 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#[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 pub disk_write_bytes: u64,
522 pub disk_read_bytes: u64,
524 pub disk_write_ios: u64,
526 pub disk_read_ios: u64,
528}
529
530impl HybridCacheStats {
531 #[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 #[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 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 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, 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), mutation_locks: (0..16).map(|_| Mutex::new(())).collect(),
604 })
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 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 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 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 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 let msg_id = MessageId::from_borrowed("<missing@example.com>").unwrap();
659 cache.record_missing(msg_id, BackendId::from_index(0)).await;
660
661 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}