use crate::config::CompressionCodec;
use crate::protocol::StatusCode;
use crate::types::{BackendId, MessageId};
use foyer::{
BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCache, HybridCacheBuilder,
HybridCachePolicy, LruConfig, PsyncIoEngineConfig, RecoverMode, Source, Spawner,
};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tracing::{debug, info, warn};
use super::availability::ArticleAvailability;
use super::hybrid_codec::{CacheableStatusCode, DiskCachedArticle};
use super::ttl;
const HYBRID_CACHE_NAME: &str = "nntp-article-cache-v3";
fn check_available_space(_path: &Path) -> Option<u64> {
#[cfg(unix)]
{
let path = _path;
if let Ok(temp_file) = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path.join(".space_check_tmp"))
{
drop(temp_file);
let _ = std::fs::remove_file(path.join(".space_check_tmp"));
}
}
None }
#[derive(Debug, Clone)]
pub struct HybridCacheConfig {
pub memory_capacity: u64,
pub disk_capacity: u64,
pub disk_path: std::path::PathBuf,
pub ttl: Duration,
pub compression: CompressionCodec,
pub shards: usize,
}
impl Default for HybridCacheConfig {
fn default() -> Self {
Self {
memory_capacity: 256 * 1024 * 1024, disk_capacity: 10 * 1024 * 1024 * 1024, disk_path: std::path::PathBuf::from("/var/cache/nntp-proxy"),
ttl: crate::constants::duration_polyfill::from_hours(1), compression: CompressionCodec::Lz4,
shards: 16, }
}
}
pub struct HybridArticleCache {
cache: HybridCache<String, DiskCachedArticle>,
hits: AtomicU64,
misses: AtomicU64,
disk_hits: AtomicU64,
config: HybridCacheConfig,
ttl_millis: ttl::CacheTtlMillis,
}
impl std::fmt::Debug for HybridArticleCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HybridArticleCache")
.field("hits", &self.hits.load(Ordering::Relaxed))
.field("misses", &self.misses.load(Ordering::Relaxed))
.field("disk_hits", &self.disk_hits.load(Ordering::Relaxed))
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl HybridArticleCache {
pub async fn new(config: HybridCacheConfig) -> anyhow::Result<Self> {
std::fs::create_dir_all(&config.disk_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::Other && e.to_string().contains("No space left") {
anyhow::anyhow!(
"Failed to create cache directory '{}': DISK FULL - No space left on device. \
Free up disk space or choose a different disk_path in config.",
config.disk_path.display()
)
} else {
anyhow::anyhow!(
"Failed to create cache directory '{}': {}",
config.disk_path.display(),
e
)
}
})?;
if let Ok(_metadata) = std::fs::metadata(&config.disk_path)
&& let Some(available_bytes) = check_available_space(&config.disk_path)
{
let required_bytes = config.disk_capacity;
if available_bytes < required_bytes {
anyhow::bail!(
"Insufficient disk space for cache:\n\
Path: {}\n\
Required: {} GB\n\
Available: {} GB\n\
Solution: Free up {} GB or reduce 'disk_capacity' in config.",
config.disk_path.display(),
required_bytes / (1024 * 1024 * 1024),
available_bytes / (1024 * 1024 * 1024),
(required_bytes - available_bytes) / (1024 * 1024 * 1024)
);
}
}
let disk_capacity_usize: usize = config.disk_capacity.try_into().map_err(|_| {
anyhow::anyhow!(
"Disk capacity {} bytes too large for platform (max {} bytes)",
config.disk_capacity,
usize::MAX
)
})?;
let device = FsDeviceBuilder::new(&config.disk_path)
.with_capacity(disk_capacity_usize)
.build()
.map_err(|e| {
if e.to_string().contains("No space left") || e.to_string().contains("ENOSPC") {
anyhow::anyhow!(
"Failed to initialize disk cache at '{}': DISK FULL - No space left on device.\n\
Required: {} GB\n\
Solution: Free up disk space or reduce 'disk_capacity' in config.",
config.disk_path.display(),
config.disk_capacity / (1024 * 1024 * 1024)
)
} else {
anyhow::anyhow!("Failed to initialize disk cache: {e}")
}
})?;
let memory_capacity_usize: usize = config
.memory_capacity
.try_into()
.map_err(|_| anyhow::anyhow!("Memory capacity too large for platform"))?;
let foyer_runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(8)
.max_blocking_threads(16)
.thread_name("foyer-disk-io")
.enable_all()
.build()
.map_err(|e| anyhow::anyhow!("Failed to create foyer runtime: {e}"))?;
let mut builder = HybridCacheBuilder::new()
.with_name(HYBRID_CACHE_NAME)
.with_policy(HybridCachePolicy::WriteOnInsertion)
.memory(memory_capacity_usize)
.with_shards(config.shards)
.with_eviction_config(LruConfig {
high_priority_pool_ratio: 0.1,
})
.with_weighter(|_key: &String, value: &DiskCachedArticle| value.payload_len().get())
.storage()
.with_io_engine_config(PsyncIoEngineConfig::new())
.with_engine_config(
BlockEngineConfig::new(device)
.with_block_size(64 * 1024 * 1024) .with_indexer_shards(16)
.with_flushers(4)
.with_reclaimers(2),
)
.with_recover_mode(RecoverMode::Quiet)
.with_spawner(Spawner::from(foyer_runtime));
match config.compression {
CompressionCodec::None => {
}
CompressionCodec::Lz4 => {
builder = builder.with_compression(foyer::Compression::Lz4);
}
CompressionCodec::Zstd => {
builder = builder.with_compression(foyer::Compression::Zstd);
}
}
let cache = builder.build().await.map_err(|e| {
if e.to_string().contains("No space left") || e.to_string().contains("ENOSPC") {
anyhow::anyhow!(
"Failed to build disk cache: DISK FULL - No space left on device.\n\
Cache path: {}\n\
Memory size: {} MB\n\
Disk size: {} GB\n\
Solution: Free up disk space or reduce cache sizes in config.",
config.disk_path.display(),
config.memory_capacity / (1024 * 1024),
config.disk_capacity / (1024 * 1024 * 1024)
)
} else {
anyhow::anyhow!("Failed to build hybrid cache: {e}")
}
})?;
info!(
memory_mb = config.memory_capacity / (1024 * 1024),
disk_gb = config.disk_capacity / (1024 * 1024 * 1024),
path = %config.disk_path.display(),
compression = %config.compression,
"Hybrid article cache initialized"
);
let ttl_millis = ttl::CacheTtlMillis::from_duration(config.ttl);
Ok(Self {
cache,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
disk_hits: AtomicU64::new(0),
config,
ttl_millis,
})
}
pub(crate) async fn get(&self, message_id: &MessageId<'_>) -> Option<DiskCachedArticle> {
self.get_by_cache_key(message_id.without_brackets()).await
}
pub(crate) async fn get_by_cache_key(&self, key: &str) -> Option<DiskCachedArticle> {
let result = self.cache.get(key).await;
match result {
Ok(Some(entry)) => {
let cloned = entry.value().clone();
if cloned.is_expired(self.ttl_millis) {
self.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
self.hits.fetch_add(1, Ordering::Relaxed);
let source = entry.source();
if source == Source::Disk {
self.disk_hits.fetch_add(1, Ordering::Relaxed);
}
Some(cloned)
}
Ok(None) => {
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
Err(e) => {
warn!(error = %e, "Error reading from hybrid cache");
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
}
pub async fn upsert_ingest(
&self,
message_id: MessageId<'_>,
buffer: impl Into<super::CacheIngestResponse>,
backend_id: BackendId,
tier: super::ttl::CacheTier,
) {
let buffer = buffer.into();
let key = message_id.without_brackets().to_string();
let buffer_len = buffer.len();
let Some(mut entry) = DiskCachedArticle::from_ingest_response_with_tier(buffer, tier)
else {
warn!(msg_id = %key, buffer_len, "Cannot cache: invalid status code");
return;
};
let entry_len = entry.payload_len();
if let Ok(Some(existing)) = self.cache.get(&key).await {
let existing_len = existing.value().payload_len();
if existing_len > entry_len {
let mut updated = existing.value().clone();
updated.record_backend_has(backend_id);
updated.timestamp = ttl::CacheTimestampMillis::now();
self.cache.insert(key.clone(), updated);
debug!(
msg_id = %key,
existing_bytes = existing_len.get(),
new_bytes = entry_len.get(),
"Hybrid cache upsert: preserved larger existing entry, updated availability"
);
return;
}
}
entry.record_backend_has(backend_id);
self.cache.insert(key.clone(), entry);
debug!(msg_id = %key, stored_bytes = entry_len.get(), tier = tier.get(), "Hybrid cache upsert");
}
pub async fn record_missing(&self, message_id: MessageId<'_>, backend_id: BackendId) {
let key = message_id.without_brackets().to_string();
let entry = if let Ok(Some(existing)) = self.cache.get(&key).await {
let mut updated = existing.value().clone();
updated.record_backend_missing(backend_id);
updated
} else {
let mut entry = DiskCachedArticle::missing(super::ttl::CacheTier::new(0));
entry.record_backend_missing(backend_id);
entry
};
self.cache.insert(key, entry);
}
pub async fn record_has_status(
&self,
message_id: MessageId<'_>,
status_code: StatusCode,
backend_id: BackendId,
tier: super::ttl::CacheTier,
) {
let Ok(cacheable_status) = CacheableStatusCode::try_from(status_code.as_u16()) else {
warn!(
msg_id = %message_id,
status_code = status_code.as_u16(),
"Cannot record availability: invalid cache status code"
);
return;
};
let key = message_id.without_brackets().to_string();
let entry = if let Ok(Some(existing)) = self.cache.get(&key).await {
let mut updated = existing.value().clone();
updated.record_backend_has_status(cacheable_status, backend_id, tier);
updated
} else {
let mut entry = DiskCachedArticle::availability_only(cacheable_status, tier);
entry.record_backend_has(backend_id);
entry
};
self.cache.insert(key, entry);
}
pub async fn sync_availability(
&self,
message_id: MessageId<'_>,
availability: &ArticleAvailability,
) {
if availability.checked_bits() == 0 {
return;
}
let key = message_id.without_brackets().to_string();
let updated_entry = match self.cache.get(&key).await {
Ok(Some(existing)) => {
let mut entry = existing.value().clone();
entry.availability.merge_from(availability);
Some(entry)
}
_ => {
if availability.any_backend_has_article() {
None
} else {
let mut entry = DiskCachedArticle::missing(super::ttl::CacheTier::new(0));
entry.availability = *availability;
self.misses.fetch_add(1, Ordering::Relaxed);
Some(entry)
}
}
};
if let Some(entry) = updated_entry {
self.cache.insert(key, entry);
}
}
pub fn stats(&self) -> HybridCacheStats {
let foyer_stats = self.cache.statistics();
HybridCacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
disk_hits: self.disk_hits.load(Ordering::Relaxed),
memory_capacity: self.config.memory_capacity,
disk_capacity: self.config.disk_capacity,
disk_write_bytes: foyer_stats.disk_write_bytes() as u64,
disk_read_bytes: foyer_stats.disk_read_bytes() as u64,
disk_write_ios: foyer_stats.disk_write_ios() as u64,
disk_read_ios: foyer_stats.disk_read_ios() as u64,
}
}
pub async fn close(&self) -> anyhow::Result<()> {
self.cache
.close()
.await
.map_err(|e| anyhow::anyhow!("Failed to close cache: {e}"))
}
}
#[derive(Debug, Clone)]
pub struct HybridCacheStats {
pub hits: u64,
pub misses: u64,
pub disk_hits: u64,
pub memory_capacity: u64,
pub disk_capacity: u64,
pub disk_write_bytes: u64,
pub disk_read_bytes: u64,
pub disk_write_ios: u64,
pub disk_read_ios: u64,
}
impl HybridCacheStats {
#[must_use]
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
(self.hits as f64 / total as f64) * 100.0
}
}
#[must_use]
pub fn disk_hit_rate(&self) -> f64 {
if self.hits == 0 {
0.0
} else {
(self.disk_hits as f64 / self.hits as f64) * 100.0
}
}
}
#[cfg(test)]
impl HybridArticleCache {
pub async fn new_memory_only(memory_capacity: u64) -> anyhow::Result<Self> {
use foyer::NoopIoEngineConfig;
let memory_capacity_usize: usize = memory_capacity
.try_into()
.map_err(|_| anyhow::anyhow!("Memory capacity too large for platform"))?;
let builder = HybridCacheBuilder::new()
.with_name("nntp-article-cache-test")
.with_policy(HybridCachePolicy::WriteOnEviction)
.memory(memory_capacity_usize)
.with_shards(1)
.with_eviction_config(LruConfig {
high_priority_pool_ratio: 0.1,
})
.with_weighter(|_key: &String, value: &DiskCachedArticle| value.payload_len().get())
.storage()
.with_io_engine_config(Box::new(NoopIoEngineConfig) as Box<dyn foyer::IoEngineConfig>);
let cache = builder.build().await?;
let config = HybridCacheConfig {
memory_capacity,
disk_capacity: 0, disk_path: std::path::PathBuf::new(),
ttl: Duration::from_secs(3600),
compression: CompressionCodec::None,
shards: 1,
};
Ok(Self {
cache,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
disk_hits: AtomicU64::new(0),
config,
ttl_millis: ttl::CacheTtlMillis::new(3600 * 1000), })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hybrid_cache_name_cold_invalidates_old_disk_formats() {
assert_eq!(HYBRID_CACHE_NAME, "nntp-article-cache-v3");
}
#[tokio::test]
async fn test_hybrid_cache_basic() {
let cache = HybridArticleCache::new_memory_only(1024 * 1024)
.await
.unwrap();
let msg_id = MessageId::from_borrowed("<test123@example.com>").unwrap();
let buffer = b"220 0 <test123@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
cache
.upsert_ingest(msg_id, buffer.clone(), BackendId::from_index(0), 0.into())
.await;
let msg_id = MessageId::from_borrowed("<test123@example.com>").unwrap();
let entry = cache.get(&msg_id).await.unwrap();
let response = entry
.cached_response_for(crate::protocol::RequestKind::Article, msg_id.as_str())
.unwrap();
let mut rendered = Vec::with_capacity(response.wire_len().get());
response.write_to(&mut rendered).await.unwrap();
assert_eq!(rendered, buffer);
assert!(entry.should_try_backend(BackendId::from_index(0)));
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 0);
cache.close().await.unwrap();
}
#[tokio::test]
async fn test_hybrid_cache_availability_tracking() {
let cache = HybridArticleCache::new_memory_only(1024 * 1024)
.await
.unwrap();
let msg_id = MessageId::from_borrowed("<missing@example.com>").unwrap();
cache.record_missing(msg_id, BackendId::from_index(0)).await;
let msg_id = MessageId::from_borrowed("<missing@example.com>").unwrap();
let entry = cache.get(&msg_id).await.unwrap();
assert_eq!(
entry.payload_len().get(),
0,
"missing hybrid cache entries must not retain response payload bytes"
);
assert!(!entry.should_try_backend(BackendId::from_index(0)));
assert!(entry.should_try_backend(BackendId::from_index(1)));
cache.close().await.unwrap();
}
}