use super::hybrid_codec::DiskCachedArticle;
use super::{CacheIngestResponse, HybridCacheStats};
use crate::types::{BackendId, MessageId};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct MockHybridCache {
storage: Arc<Mutex<HashMap<String, DiskCachedArticle>>>,
hits: Arc<AtomicU64>,
misses: Arc<AtomicU64>,
disk_hits: Arc<AtomicU64>,
}
impl MockHybridCache {
#[must_use]
fn new(_memory_capacity: u64) -> Self {
Self {
storage: Arc::new(Mutex::new(HashMap::new())),
hits: Arc::new(AtomicU64::new(0)),
misses: Arc::new(AtomicU64::new(0)),
disk_hits: Arc::new(AtomicU64::new(0)),
}
}
fn get(&self, message_id: &MessageId<'_>) -> Option<DiskCachedArticle> {
let key = message_id.without_brackets().to_string();
let storage = self.storage.lock().unwrap();
storage.get(&key).map_or_else(
|| {
self.misses.fetch_add(1, Ordering::Relaxed);
None
},
|entry| {
self.hits.fetch_add(1, Ordering::Relaxed);
Some(entry.clone())
},
)
}
fn upsert_ingest(
&self,
message_id: &MessageId<'_>,
buffer: impl Into<CacheIngestResponse>,
backend_id: BackendId,
) {
let buffer = buffer.into();
let key = message_id.without_brackets().to_string();
let mut storage = self.storage.lock().unwrap();
let Some(mut entry) = DiskCachedArticle::from_ingest_response_with_tier(
buffer,
super::ttl::CacheTier::new(0),
) else {
return;
};
let entry_len = entry.payload_len();
if let Some(existing) = storage.get(&key)
&& existing.payload_len() > entry_len
{
let mut updated = existing.clone();
updated.record_backend_has(backend_id);
storage.insert(key, updated);
return;
}
entry.record_backend_has(backend_id);
storage.insert(key, entry);
}
fn record_missing(&self, message_id: &MessageId<'_>, backend_id: BackendId) {
let key = message_id.without_brackets().to_string();
let mut storage = self.storage.lock().unwrap();
let entry = storage.get(&key).map_or_else(
|| {
let mut entry = DiskCachedArticle::missing(super::ttl::CacheTier::new(0));
entry.record_backend_missing(backend_id);
entry
},
|existing| {
let mut updated = existing.clone();
updated.record_backend_missing(backend_id);
updated
},
);
storage.insert(key, entry);
}
#[must_use]
fn stats(&self) -> HybridCacheStats {
HybridCacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
disk_hits: self.disk_hits.load(Ordering::Relaxed),
memory_capacity: 0,
disk_capacity: 0,
disk_write_bytes: 0,
disk_read_bytes: 0,
disk_write_ios: 0,
disk_read_ios: 0,
}
}
fn close() {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::RequestKind;
use anyhow::Result;
use futures::executor::block_on;
fn msg_id() -> MessageId<'static> {
MessageId::from_borrowed("<mock-hybrid@example>").expect("valid message id")
}
fn msgid(value: &str) -> MessageId<'_> {
MessageId::from_borrowed(value).unwrap()
}
fn render_response(
entry: &DiskCachedArticle,
request_kind: RequestKind,
message_id: &MessageId<'_>,
) -> Option<Vec<u8>> {
let response = entry.cached_response_for(request_kind, message_id.as_str())?;
let mut out = Vec::with_capacity(response.wire_len().get());
block_on(response.write_to(&mut out)).ok()?;
Some(out)
}
fn assert_article(entry: &DiskCachedArticle, message_id: &MessageId<'_>, expected: &[u8]) {
assert_eq!(
render_response(entry, RequestKind::Article, message_id).unwrap(),
expected
);
}
fn assert_availability(entry: &DiskCachedArticle, cases: &[(usize, bool)]) {
for (backend_index, should_try) in cases {
assert_eq!(
entry.should_try_backend(BackendId::from_index(*backend_index)),
*should_try,
"backend {backend_index}"
);
}
}
#[tokio::test]
async fn upsert_keeps_existing_semantic_payload_over_longer_metadata_only_response() {
let cache = MockHybridCache::new(1024);
cache.upsert_ingest(
&msg_id(),
b"220 1 <mock-hybrid@example>\r\nH: V\r\n\r\nBody\r\n.\r\n".as_slice(),
BackendId::from_index(0),
);
cache.upsert_ingest(
&msg_id(),
b"220 1 <mock-hybrid@example> long status line without payload\r\n".as_slice(),
BackendId::from_index(1),
);
let entry = cache.get(&msg_id()).expect("entry remains cached");
assert!(
entry
.cached_response_for(RequestKind::Article, "<mock-hybrid@example>")
.is_some(),
"longer metadata-only responses must not replace semantic article payloads"
);
assert!(entry.should_try_backend(BackendId::from_index(1)));
}
#[tokio::test]
async fn basic_ops() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<test@example.com>");
let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n";
cache.upsert_ingest(&message_id, buffer.as_slice(), BackendId::from_index(0));
assert_article(
&cache.get(&message_id).expect("Entry should exist"),
&message_id,
buffer,
);
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 0);
Ok(())
}
#[tokio::test]
async fn upsert_accepts_borrowed_backend_bytes() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<borrowed@example.com>");
let buffer = b"220 0 <borrowed@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n";
cache.upsert_ingest(&message_id, buffer.as_slice(), BackendId::from_index(0));
assert_article(
&cache.get(&message_id).expect("cached entry"),
&message_id,
buffer,
);
Ok(())
}
#[tokio::test]
async fn cache_miss_updates_stats() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<nonexistent@example.com>");
let entry = cache.get(&message_id);
assert!(entry.is_none());
let stats = cache.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 1);
Ok(())
}
#[tokio::test]
async fn upsert_preserves_larger_buffer() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<test@example.com>");
let large_buffer =
b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nLarge body content here\r\n.\r\n";
cache.upsert_ingest(
&message_id,
large_buffer.as_slice(),
BackendId::from_index(0),
);
cache.upsert_ingest(
&message_id,
b"223 0 <test@example.com>\r\n".as_slice(),
BackendId::from_index(1),
);
let entry = cache.get(&message_id).unwrap();
assert_article(&entry, &message_id, large_buffer);
assert_availability(&entry, &[(0, true), (1, true)]);
Ok(())
}
#[tokio::test]
async fn record_missing_creates_availability_entry() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<missing@example.com>");
cache.record_missing(&message_id, BackendId::from_index(0));
let entry = cache.get(&message_id);
assert!(entry.is_some());
let entry = entry.unwrap();
assert_availability(&entry, &[(0, false), (1, true)]);
Ok(())
}
#[tokio::test]
async fn tracks_availability() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<avail@example.com>");
cache.upsert_ingest(
&message_id,
b"220 0 <avail@example.com>\r\nBody\r\n.\r\n".as_slice(),
BackendId::from_index(0),
);
cache.record_missing(&message_id, BackendId::from_index(1));
cache.record_missing(&message_id, BackendId::from_index(2));
let entry = cache.get(&message_id).unwrap();
assert_availability(&entry, &[(0, true), (1, false), (2, false), (3, true)]);
Ok(())
}
#[tokio::test]
async fn close_succeeds() -> Result<()> {
let cache = MockHybridCache::new(1024 * 1024);
let message_id = msgid("<test@example.com>");
cache.upsert_ingest(
&message_id,
b"220 0 <test@example.com>\r\n.\r\n".as_slice(),
BackendId::from_index(0),
);
MockHybridCache::close();
Ok(())
}
}