1mod 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#[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#[derive(Debug, Clone, Default)]
260pub struct CacheDisplayStats {
261 pub entry_count: u64,
263 pub size_bytes: u64,
265 pub hit_rate: f64,
267 pub disk: Option<DiskDisplayStats>,
269}
270
271#[derive(Debug, Clone, Default)]
273pub struct DiskDisplayStats {
274 pub disk_hits: u64,
276 pub disk_hit_rate: f64,
278 pub capacity: u64,
280 pub bytes_written: u64,
282 pub bytes_read: u64,
284 pub write_ios: u64,
286 pub read_ios: u64,
288}
289
290pub trait CacheStatsProvider: Send + Sync {
292 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, size_bytes: stats.memory_capacity, 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#[derive(Debug)]
344pub enum UnifiedCache {
345 Availability(AvailabilityIndex),
347 Memory(ArticleCache),
349 Hybrid(HybridArticleCache),
351}
352
353impl UnifiedCache {
354 #[must_use]
356 pub fn availability(ttl: std::time::Duration) -> Self {
357 Self::Availability(AvailabilityIndex::with_ttl(ttl))
358 }
359
360 #[must_use]
362 pub fn memory(capacity: u64, ttl: std::time::Duration) -> Self {
363 Self::Memory(ArticleCache::new(capacity, ttl))
364 }
365
366 pub async fn hybrid(config: HybridCacheConfig) -> anyhow::Result<Self> {
368 Ok(Self::Hybrid(HybridArticleCache::new(config).await?))
369 }
370
371 #[must_use]
374 pub const fn records_backend_has_status(&self) -> bool {
375 !matches!(self, Self::Availability(_))
376 }
377
378 #[must_use]
380 pub const fn stores_payload_responses(&self) -> bool {
381 !matches!(self, Self::Availability(_))
382 }
383
384 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 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 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 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 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 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 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 #[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 #[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, }
506 }
507
508 #[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 #[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 #[must_use]
530 pub const fn is_hybrid(&self) -> bool {
531 matches!(self, Self::Hybrid(_))
532 }
533
534 #[must_use]
536 pub const fn is_availability_only(&self) -> bool {
537 matches!(self, Self::Availability(_))
538 }
539
540 pub async fn sync(&self) {
544 match self {
545 Self::Availability(_) | Self::Hybrid(_) => {}
546 Self::Memory(cache) => cache.sync().await,
547 }
548 }
549
550 pub async fn close(&self) -> anyhow::Result<()> {
555 match self {
556 Self::Availability(_) => Ok(()),
557 Self::Memory(_) => Ok(()), 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}