1use crate::protocol::{
7 RequestCacheArticleNumber, RequestCacheAvailability, RequestCacheEntryMetadata,
8 RequestCachePayloadKind, RequestCacheTier, RequestCacheTimestampMillis, RequestKind,
9 StatusCode,
10};
11use crate::router::BackendCount;
12use crate::types::{BackendId, MessageId};
13use moka::Entry;
14use moka::future::Cache;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::Duration;
18
19use super::availability::ArticleAvailability;
20use super::ttl;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub(crate) struct CachedArticleNumber(u64);
24
25impl CachedArticleNumber {
26 #[must_use]
27 pub(crate) const fn new(value: u64) -> Self {
28 Self(value)
29 }
30
31 #[must_use]
32 pub(crate) const fn get(self) -> u64 {
33 self.0
34 }
35}
36
37impl From<u64> for CachedArticleNumber {
38 fn from(value: u64) -> Self {
39 Self::new(value)
40 }
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub(crate) struct CachedPayloadLen(usize);
45
46impl CachedPayloadLen {
47 #[must_use]
48 pub const fn new(value: usize) -> Self {
49 Self(value)
50 }
51
52 #[must_use]
53 pub const fn get(self) -> usize {
54 self.0
55 }
56}
57
58impl From<usize> for CachedPayloadLen {
59 fn from(value: usize) -> Self {
60 Self::new(value)
61 }
62}
63
64impl std::fmt::Display for CachedPayloadLen {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 self.get().fmt(f)
67 }
68}
69
70impl PartialEq<usize> for CachedPayloadLen {
71 fn eq(&self, other: &usize) -> bool {
72 self.get() == *other
73 }
74}
75
76impl PartialOrd<usize> for CachedPayloadLen {
77 fn partial_cmp(&self, other: &usize) -> Option<std::cmp::Ordering> {
78 self.get().partial_cmp(other)
79 }
80}
81
82#[derive(Debug, PartialEq, Eq)]
83pub(crate) enum CachedPayload {
84 Missing,
85 AvailabilityOnly,
86 Article {
87 article_number: Option<CachedArticleNumber>,
88 headers: Arc<[u8]>,
89 body: Arc<[u8]>,
90 },
91 Head {
92 article_number: Option<CachedArticleNumber>,
93 headers: Arc<[u8]>,
94 },
95 Body {
96 article_number: Option<CachedArticleNumber>,
97 body: Arc<[u8]>,
98 },
99 Stat {
100 article_number: Option<CachedArticleNumber>,
101 },
102}
103
104impl Clone for CachedPayload {
105 fn clone(&self) -> Self {
106 match self {
107 Self::Missing => Self::Missing,
108 Self::AvailabilityOnly => Self::AvailabilityOnly,
109 Self::Article {
110 article_number,
111 headers,
112 body,
113 } => Self::Article {
114 article_number: *article_number,
115 headers: Arc::clone(headers),
116 body: Arc::clone(body),
117 },
118 Self::Head {
119 article_number,
120 headers,
121 } => Self::Head {
122 article_number: *article_number,
123 headers: Arc::clone(headers),
124 },
125 Self::Body {
126 article_number,
127 body,
128 } => Self::Body {
129 article_number: *article_number,
130 body: Arc::clone(body),
131 },
132 Self::Stat { article_number } => Self::Stat {
133 article_number: *article_number,
134 },
135 }
136 }
137}
138
139#[derive(Debug, Clone, Copy)]
140pub struct CachedResponseWire<'a> {
141 status: StatusCode,
142 status_line: StackStatusLine,
143 payload: CachedResponseWirePayload<'a>,
144}
145
146#[derive(Debug, Clone, Copy)]
147enum CachedResponseWirePayload<'a> {
148 None,
149 Article { headers: &'a [u8], body: &'a [u8] },
150 Head { headers: &'a [u8] },
151 Body { body: &'a [u8] },
152}
153
154impl CachedResponseWire<'_> {
155 fn response_completion() -> std::io::IoSlice<'static> {
156 crate::session::backend::cached_response_completion()
157 }
158
159 fn response_completion_len() -> usize {
160 Self::response_completion().len()
161 }
162
163 fn wire_len_usize(&self) -> usize {
164 self.status_line.len() + self.payload_len()
165 }
166
167 #[must_use]
168 pub fn wire_len(&self) -> crate::protocol::ResponseWireLen {
169 self.wire_len_usize().into()
170 }
171
172 fn status_line(&self) -> &[u8] {
173 self.status_line.as_slice()
174 }
175
176 #[must_use]
177 pub const fn status(&self) -> StatusCode {
178 self.status
179 }
180
181 pub async fn write_to<W>(&self, writer: &mut W) -> std::io::Result<()>
182 where
183 W: tokio::io::AsyncWrite + Unpin,
184 {
185 use std::io::IoSlice;
186 use tokio::io::AsyncWriteExt as _;
187
188 match self.payload {
189 CachedResponseWirePayload::None => writer.write_all(self.status_line()).await?,
190 CachedResponseWirePayload::Article { headers, body } => {
191 let mut slices = [
192 IoSlice::new(self.status_line()),
193 IoSlice::new(headers),
194 IoSlice::new(b"\r\n\r\n"),
195 IoSlice::new(body),
196 Self::response_completion(),
197 ];
198 crate::io_util::write_all_vectored(writer, &mut slices).await?;
199 }
200 CachedResponseWirePayload::Head { headers } => {
201 let mut slices = [
202 IoSlice::new(self.status_line()),
203 IoSlice::new(headers),
204 Self::response_completion(),
205 ];
206 crate::io_util::write_all_vectored(writer, &mut slices).await?;
207 }
208 CachedResponseWirePayload::Body { body } => {
209 let mut slices = [
210 IoSlice::new(self.status_line()),
211 IoSlice::new(body),
212 Self::response_completion(),
213 ];
214 crate::io_util::write_all_vectored(writer, &mut slices).await?;
215 }
216 }
217 Ok(())
218 }
219
220 fn payload_len(&self) -> usize {
221 match self.payload {
222 CachedResponseWirePayload::None => 0,
223 CachedResponseWirePayload::Article { headers, body } => {
224 headers.len() + 4 + body.len() + Self::response_completion_len()
225 }
226 CachedResponseWirePayload::Head { headers } => {
227 headers.len() + Self::response_completion_len()
228 }
229 CachedResponseWirePayload::Body { body } => {
230 body.len() + Self::response_completion_len()
231 }
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy)]
237struct StackStatusLine {
238 bytes: [u8; 1024],
239 len: usize,
240}
241
242impl StackStatusLine {
243 fn new(code: u16, article_number: u64, message_id: &str) -> Option<Self> {
244 let mut line = Self {
245 bytes: [0; 1024],
246 len: 0,
247 };
248 line.push_u64(u64::from(code))?;
249 line.push_slice(b" ")?;
250 line.push_u64(article_number)?;
251 line.push_slice(b" ")?;
252 line.push_slice(message_id.as_bytes())?;
253 line.push_slice(b"\r\n")?;
254 Some(line)
255 }
256
257 fn push_slice(&mut self, part: &[u8]) -> Option<()> {
258 let end = self.len.checked_add(part.len())?;
259 let dst = self.bytes.get_mut(self.len..end)?;
260 dst.copy_from_slice(part);
261 self.len = end;
262 Some(())
263 }
264
265 fn push_u64(&mut self, value: u64) -> Option<()> {
266 let mut digits = [0_u8; 20];
267 let mut cursor = digits.len();
268 let mut n = value;
269 loop {
270 cursor -= 1;
271 digits[cursor] = b'0' + (n % 10) as u8;
272 n /= 10;
273 if n == 0 {
274 break;
275 }
276 }
277 self.push_slice(&digits[cursor..])
278 }
279
280 fn as_slice(&self) -> &[u8] {
281 &self.bytes[..self.len]
282 }
283
284 fn len(&self) -> usize {
285 self.len
286 }
287}
288
289impl CachedPayload {
290 #[must_use]
291 pub(crate) fn len(&self) -> CachedPayloadLen {
292 let len = match self {
293 Self::Missing | Self::AvailabilityOnly | Self::Stat { .. } => 0,
294 Self::Article { headers, body, .. } => headers.len() + body.len(),
295 Self::Head { headers, .. } => headers.len(),
296 Self::Body { body, .. } => body.len(),
297 };
298 CachedPayloadLen::new(len)
299 }
300
301 #[must_use]
302 pub(crate) const fn article_number(&self) -> Option<CachedArticleNumber> {
303 match self {
304 Self::Article { article_number, .. }
305 | Self::Head { article_number, .. }
306 | Self::Body { article_number, .. }
307 | Self::Stat { article_number } => *article_number,
308 Self::Missing | Self::AvailabilityOnly => None,
309 }
310 }
311
312 const fn request_payload_kind(&self) -> RequestCachePayloadKind {
313 match self {
314 Self::Missing => RequestCachePayloadKind::Missing,
315 Self::AvailabilityOnly => RequestCachePayloadKind::AvailabilityOnly,
316 Self::Article { .. } => RequestCachePayloadKind::Article,
317 Self::Head { .. } => RequestCachePayloadKind::Head,
318 Self::Body { .. } => RequestCachePayloadKind::Body,
319 Self::Stat { .. } => RequestCachePayloadKind::Stat,
320 }
321 }
322
323 const fn request_article_number(&self) -> Option<RequestCacheArticleNumber> {
324 match self.article_number() {
325 Some(number) => Some(RequestCacheArticleNumber::new(number.get())),
326 None => None,
327 }
328 }
329}
330
331#[derive(Clone, Debug)]
336pub struct CachedArticle {
337 backend_availability: ArticleAvailability,
342
343 status_code: StatusCode,
344 payload: CachedPayload,
345
346 tier: ttl::CacheTier,
349
350 inserted_at: ttl::CacheTimestampMillis,
353}
354
355impl CachedArticle {
356 #[must_use]
358 pub(crate) fn availability_only(status_code: StatusCode, tier: ttl::CacheTier) -> Self {
359 Self {
360 backend_availability: ArticleAvailability::new(),
361 status_code,
362 payload: CachedPayload::AvailabilityOnly,
363 tier,
364 inserted_at: ttl::CacheTimestampMillis::now(),
365 }
366 }
367
368 #[must_use]
369 pub(crate) fn missing(tier: ttl::CacheTier) -> Self {
370 Self {
371 backend_availability: ArticleAvailability::new(),
372 status_code: StatusCode::new(430),
373 payload: CachedPayload::Missing,
374 tier,
375 inserted_at: ttl::CacheTimestampMillis::now(),
376 }
377 }
378
379 #[must_use]
380 pub(crate) fn negative_only(missing_bits: usize) -> Self {
381 Self {
382 backend_availability: ArticleAvailability::from_missing_bits(missing_bits),
383 status_code: StatusCode::new(430),
384 payload: CachedPayload::Missing,
385 tier: ttl::CacheTier::new(0),
386 inserted_at: ttl::CacheTimestampMillis::now(),
387 }
388 }
389
390 #[must_use]
391 pub(crate) const fn from_parts(
392 status_code: StatusCode,
393 payload: CachedPayload,
394 backend_availability: ArticleAvailability,
395 tier: ttl::CacheTier,
396 inserted_at: u64,
397 ) -> Self {
398 Self {
399 backend_availability,
400 status_code,
401 payload,
402 tier,
403 inserted_at: ttl::CacheTimestampMillis::new(inserted_at),
404 }
405 }
406
407 #[must_use]
409 fn from_contiguous_ingest_with_tier(response: impl AsRef<[u8]>, tier: ttl::CacheTier) -> Self {
410 let response = response.as_ref();
411 let status_code = StatusCode::parse(response).unwrap_or_else(|| StatusCode::new(430));
412 let payload = parse_payload(status_code, response);
413 Self {
414 backend_availability: ArticleAvailability::new(),
415 status_code,
416 payload,
417 tier,
418 inserted_at: ttl::CacheTimestampMillis::now(),
419 }
420 }
421
422 #[must_use]
423 pub(crate) fn from_ingest_response_with_tier(
424 buffer: impl Into<super::CacheIngestResponse>,
425 tier: ttl::CacheTier,
426 ) -> Self {
427 let buffer = buffer.into();
428 match buffer {
429 super::CacheIngestResponse::Owned(buffer) => {
430 Self::from_contiguous_ingest_with_tier(buffer, tier)
431 }
432 super::CacheIngestResponse::Pooled(buffer) => {
433 Self::from_contiguous_ingest_with_tier(buffer.as_ref(), tier)
434 }
435 super::CacheIngestResponse::Chunked(buffer) => {
436 Self::from_contiguous_ingest_with_tier(buffer.to_vec(), tier)
437 }
438 super::CacheIngestResponse::Inline(buffer) => {
439 Self::from_contiguous_ingest_with_tier(buffer, tier)
440 }
441 }
442 }
443
444 #[inline]
448 #[must_use]
449 pub(crate) fn is_expired(&self, base_ttl: ttl::CacheTtlMillis) -> bool {
450 ttl::is_expired(self.inserted_at, base_ttl, self.tier)
451 }
452
453 #[inline]
455 #[must_use]
456 pub const fn tier(&self) -> ttl::CacheTier {
457 self.tier
458 }
459
460 #[inline]
461 #[must_use]
462 pub const fn inserted_at(&self) -> ttl::CacheTimestampMillis {
463 self.inserted_at
464 }
465
466 #[inline]
467 #[must_use]
468 #[cfg(test)]
469 pub(crate) const fn article_number(&self) -> Option<CachedArticleNumber> {
470 self.payload.article_number()
471 }
472
473 #[inline]
474 #[must_use]
475 pub(crate) const fn request_cache_metadata(
476 &self,
477 availability: &ArticleAvailability,
478 ) -> RequestCacheEntryMetadata {
479 RequestCacheEntryMetadata::new(
480 self.status_code,
481 RequestCacheAvailability::from_bits(
482 availability.missing_bits(),
483 availability.missing_bits(),
484 ),
485 RequestCacheTier::new(self.tier.get()),
486 RequestCacheTimestampMillis::new(self.inserted_at.get()),
487 self.payload.request_payload_kind(),
488 self.payload.request_article_number(),
489 )
490 }
491
492 #[inline]
493 #[must_use]
494 pub const fn status_code(&self) -> StatusCode {
495 self.status_code
496 }
497
498 #[inline]
502 #[must_use]
503 pub fn should_try_backend(&self, backend_id: BackendId) -> bool {
504 self.backend_availability.should_try(backend_id)
505 }
506
507 pub fn record_backend_missing(&mut self, backend_id: BackendId) {
509 self.backend_availability.record_missing(backend_id);
510 }
511
512 #[must_use]
514 pub fn all_backends_exhausted(&self, total_backends: BackendCount) -> bool {
515 self.backend_availability.all_exhausted(total_backends)
516 }
517
518 #[inline]
522 #[must_use]
523 pub const fn has_availability_info(&self) -> bool {
524 self.backend_availability.has_availability_info()
525 }
526
527 #[inline]
529 #[must_use]
530 pub const fn availability(&self) -> ArticleAvailability {
531 self.backend_availability
532 }
533
534 #[inline]
543 #[must_use]
544 pub fn is_complete_article(&self) -> bool {
545 let code = self.status_code();
546 matches!(
547 (&self.payload, code.as_u16()),
548 (CachedPayload::Article { headers, body, .. }, 220)
549 if !headers.is_empty() || !body.is_empty()
550 ) || matches!(
551 (&self.payload, code.as_u16()),
552 (CachedPayload::Body { body, .. }, 222) if !body.is_empty()
553 )
554 }
555
556 pub(crate) fn to_availability(&self, total_backends: BackendCount) -> ArticleAvailability {
561 let mut availability = ArticleAvailability::new();
562
563 for backend_id in total_backends.backend_ids() {
565 if !self.should_try_backend(backend_id) {
566 availability.record_missing(backend_id);
567 }
568 }
569
570 availability
571 }
572
573 #[must_use]
574 pub(crate) fn payload_len(&self) -> CachedPayloadLen {
575 self.payload.len()
576 }
577
578 #[must_use]
579 pub fn cached_response_for(
580 &self,
581 request_kind: RequestKind,
582 message_id: &str,
583 ) -> Option<CachedResponseWire<'_>> {
584 cached_response_for_payload(&self.payload, request_kind, message_id)
585 }
586}
587
588pub(crate) fn cached_response_for_payload<'a>(
589 payload: &'a CachedPayload,
590 request_kind: RequestKind,
591 message_id: &str,
592) -> Option<CachedResponseWire<'a>> {
593 let article_number = match payload {
594 CachedPayload::Article { article_number, .. }
595 | CachedPayload::Head { article_number, .. }
596 | CachedPayload::Body { article_number, .. }
597 | CachedPayload::Stat { article_number } => *article_number,
598 CachedPayload::Missing | CachedPayload::AvailabilityOnly => None,
599 };
600 let number = article_number.map_or(0, CachedArticleNumber::get);
601
602 match (request_kind, payload) {
603 (
604 RequestKind::Stat,
605 CachedPayload::Article { .. }
606 | CachedPayload::Head { .. }
607 | CachedPayload::Body { .. }
608 | CachedPayload::Stat { .. },
609 ) => Some(CachedResponseWire {
610 status: StatusCode::new(223),
611 status_line: StackStatusLine::new(223, number, message_id)?,
612 payload: CachedResponseWirePayload::None,
613 }),
614 (RequestKind::Article, CachedPayload::Article { headers, body, .. }) => {
615 Some(CachedResponseWire {
616 status: StatusCode::new(220),
617 status_line: StackStatusLine::new(220, number, message_id)?,
618 payload: CachedResponseWirePayload::Article { headers, body },
619 })
620 }
621 (
622 RequestKind::Head,
623 CachedPayload::Article { headers, .. } | CachedPayload::Head { headers, .. },
624 ) => Some(CachedResponseWire {
625 status: StatusCode::new(221),
626 status_line: StackStatusLine::new(221, number, message_id)?,
627 payload: CachedResponseWirePayload::Head { headers },
628 }),
629 (
630 RequestKind::Body,
631 CachedPayload::Article { body, .. } | CachedPayload::Body { body, .. },
632 ) => Some(CachedResponseWire {
633 status: StatusCode::new(222),
634 status_line: StackStatusLine::new(222, number, message_id)?,
635 payload: CachedResponseWirePayload::Body { body },
636 }),
637 _ => None,
638 }
639}
640
641pub(crate) fn parse_payload(status_code: StatusCode, buffer: &[u8]) -> CachedPayload {
642 let code = status_code.as_u16();
643 if code == 430 {
644 return CachedPayload::Missing;
645 }
646 let Some(status_end) = memchr::memmem::find(buffer, b"\r\n").map(|pos| pos + 2) else {
647 return CachedPayload::AvailabilityOnly;
648 };
649 let article_number = parse_article_number(&buffer[..status_end]);
650 let Some(payload) = captured_payload_body_for_status(code, &buffer[status_end..]) else {
651 return match code {
652 223 => CachedPayload::Stat { article_number },
653 _ => CachedPayload::AvailabilityOnly,
654 };
655 };
656 payload_for_status(code, article_number, payload)
657}
658
659fn captured_payload_body_for_status(code: u16, payload: &[u8]) -> Option<&[u8]> {
666 if !matches!(code, 220..=222) {
667 return Some(payload);
668 }
669 crate::session::backend::captured_multiline_payload_body(payload)
670}
671
672fn payload_for_status(
673 code: u16,
674 article_number: Option<CachedArticleNumber>,
675 payload: &[u8],
676) -> CachedPayload {
677 match code {
678 220 => {
679 if let Some(split) = memchr::memmem::find(payload, b"\r\n\r\n") {
680 CachedPayload::Article {
681 article_number,
682 headers: payload[..split].into(),
683 body: payload[split + 4..].into(),
684 }
685 } else {
686 CachedPayload::Article {
687 article_number,
688 headers: Arc::from([]),
689 body: payload.into(),
690 }
691 }
692 }
693 221 => CachedPayload::Head {
694 article_number,
695 headers: payload.into(),
696 },
697 222 => CachedPayload::Body {
698 article_number,
699 body: payload.into(),
700 },
701 223 => CachedPayload::Stat { article_number },
702 _ => CachedPayload::AvailabilityOnly,
703 }
704}
705
706fn parse_article_number(status_line: &[u8]) -> Option<CachedArticleNumber> {
707 let rest = status_line.get(4..)?;
708 let end = memchr::memchr(b' ', rest).unwrap_or(rest.len());
709 std::str::from_utf8(&rest[..end])
710 .ok()?
711 .parse::<u64>()
712 .ok()
713 .map(CachedArticleNumber::new)
714}
715
716#[derive(Clone, Debug)]
724pub struct ArticleCache {
725 cache: Arc<Cache<Arc<str>, CachedArticle>>,
726 hits: Arc<AtomicU64>,
727 misses: Arc<AtomicU64>,
728 capacity: u64,
729 ttl_millis: ttl::CacheTtlMillis,
731}
732
733impl ArticleCache {
734 #[must_use]
740 pub fn new(max_capacity: u64, ttl: Duration) -> Self {
741 let builder = Cache::builder().max_capacity(max_capacity).weigher(
749 move |key: &Arc<str>, entry: &CachedArticle| -> u32 {
750 const ARC_STR_OVERHEAD: usize = 16 + 16; const ENTRY_STRUCT: usize = 64; const PAYLOAD_OVERHEAD: usize = 2 * (16 + 16); const MOKA_OVERHEAD: usize = 2000;
782
783 let key_size = ARC_STR_OVERHEAD + key.len();
784 let buffer_size = PAYLOAD_OVERHEAD + entry.payload_len().get();
785 let base_size = key_size + buffer_size + ENTRY_STRUCT + MOKA_OVERHEAD;
786
787 let weighted_size = if entry.is_complete_article() {
791 base_size
792 } else {
793 (base_size * 3).div_ceil(2)
796 };
797
798 weighted_size.try_into().unwrap_or(u32::MAX)
799 },
800 );
801 let cache = if ttl.is_zero() {
802 builder.time_to_live(Duration::ZERO).build()
803 } else {
804 builder.build()
805 };
806
807 Self {
808 cache: Arc::new(cache),
809 hits: Arc::new(AtomicU64::new(0)),
810 misses: Arc::new(AtomicU64::new(0)),
811 capacity: max_capacity,
812 ttl_millis: ttl::CacheTtlMillis::from_duration(ttl),
813 }
814 }
815
816 pub async fn get(&self, message_id: &MessageId<'_>) -> Option<CachedArticle> {
827 self.get_by_cache_key(message_id.without_brackets()).await
830 }
831
832 pub(crate) async fn get_by_cache_key(&self, key: &str) -> Option<CachedArticle> {
837 let result = self.cache.get(key).await;
838
839 match result {
840 Some(entry) if !entry.is_expired(self.ttl_millis) => {
841 self.hits.fetch_add(1, Ordering::Relaxed);
842 Some(entry)
843 }
844 Some(_) => {
845 self.cache.invalidate(key).await;
849 self.misses.fetch_add(1, Ordering::Relaxed);
850 None
851 }
852 None => {
853 self.misses.fetch_add(1, Ordering::Relaxed);
854 None
855 }
856 }
857 }
858
859 fn merge_ingest_entry(
860 maybe_entry: Option<Entry<Arc<str>, CachedArticle>>,
861 new_entry_template: &CachedArticle,
862 backend: BackendId,
863 ttl_millis: ttl::CacheTtlMillis,
864 ) -> CachedArticle {
865 if let Some(mut entry) = Self::fresh_entry_for_mutation(maybe_entry, ttl_millis) {
866 if entry.backend_availability.is_missing(backend) {
867 return entry;
868 }
869
870 let existing_complete = entry.is_complete_article();
871 let new_complete = new_entry_template.is_complete_article();
872 let should_replace = match (existing_complete, new_complete) {
873 (false, true) => true,
874 (true, false) => false,
875 (true, true) | (false, false) => {
876 new_entry_template.payload_len() > entry.payload_len()
877 }
878 };
879
880 if should_replace {
881 entry.status_code = new_entry_template.status_code;
882 entry.payload = new_entry_template.payload.clone();
883 entry.tier = new_entry_template.tier;
884 }
885
886 entry.inserted_at = ttl::CacheTimestampMillis::now();
887 entry
888 } else {
889 new_entry_template.clone()
890 }
891 }
892
893 fn merge_backend_has_status_entry(
894 maybe_entry: Option<Entry<Arc<str>, CachedArticle>>,
895 new_entry_template: &CachedArticle,
896 status_code: StatusCode,
897 backend: BackendId,
898 tier: ttl::CacheTier,
899 ttl_millis: ttl::CacheTtlMillis,
900 ) -> CachedArticle {
901 let mut entry = Self::fresh_entry_for_mutation(maybe_entry, ttl_millis)
902 .unwrap_or_else(|| new_entry_template.clone());
903 if entry.backend_availability.is_missing(backend) {
904 return entry;
905 }
906 if !entry.is_complete_article() {
907 entry.status_code = status_code;
908 entry.tier = tier;
909 entry.payload = CachedPayload::AvailabilityOnly;
910 }
911 entry.inserted_at = ttl::CacheTimestampMillis::now();
912 entry
913 }
914
915 fn merge_backend_missing_entry(
916 maybe_entry: Option<Entry<Arc<str>, CachedArticle>>,
917 backend_id: BackendId,
918 ttl_millis: ttl::CacheTtlMillis,
919 ) -> CachedArticle {
920 if let Some(mut entry) = Self::fresh_entry_for_mutation(maybe_entry, ttl_millis) {
921 entry.record_backend_missing(backend_id);
922 entry
923 } else {
924 let mut entry = CachedArticle::missing(ttl::CacheTier::new(0));
925 entry.record_backend_missing(backend_id);
926 entry
927 }
928 }
929
930 fn fresh_entry_for_mutation(
931 maybe_entry: Option<Entry<Arc<str>, CachedArticle>>,
932 ttl_millis: ttl::CacheTtlMillis,
933 ) -> Option<CachedArticle> {
934 let entry = maybe_entry?.into_value();
935 (!entry.is_expired(ttl_millis)).then_some(entry)
936 }
937
938 pub async fn upsert_ingest(
951 &self,
952 message_id: MessageId<'_>,
953 buffer: impl Into<super::CacheIngestResponse>,
954 backend: BackendId,
955 tier: ttl::CacheTier,
956 ) {
957 let buffer = buffer.into();
958 let key: Arc<str> = message_id.without_brackets().into();
959 let new_entry_template = CachedArticle::from_ingest_response_with_tier(buffer, tier);
960 let ttl_millis = self.ttl_millis;
961
962 self.cache
965 .entry(key)
966 .and_upsert_with(move |maybe_entry| {
967 std::future::ready(Self::merge_ingest_entry(
968 maybe_entry,
969 &new_entry_template,
970 backend,
971 ttl_millis,
972 ))
973 })
974 .await;
975 }
976
977 pub async fn record_backend_has_status(
979 &self,
980 message_id: MessageId<'_>,
981 status_code: StatusCode,
982 backend: BackendId,
983 tier: ttl::CacheTier,
984 ) {
985 let key: Arc<str> = message_id.without_brackets().into();
986 let new_entry_template = CachedArticle::availability_only(status_code, tier);
987 let ttl_millis = self.ttl_millis;
988
989 self.cache
990 .entry(key)
991 .and_upsert_with(move |maybe_entry| {
992 std::future::ready(Self::merge_backend_has_status_entry(
993 maybe_entry,
994 &new_entry_template,
995 status_code,
996 backend,
997 tier,
998 ttl_millis,
999 ))
1000 })
1001 .await;
1002 }
1003
1004 pub async fn record_backend_missing(&self, message_id: MessageId<'_>, backend_id: BackendId) {
1018 let key: Arc<str> = message_id.without_brackets().into();
1019 let misses = &self.misses;
1020 let ttl_millis = self.ttl_millis;
1021
1022 let entry = self
1024 .cache
1025 .entry(key)
1026 .and_upsert_with(move |maybe_entry| {
1027 std::future::ready(Self::merge_backend_missing_entry(
1028 maybe_entry,
1029 backend_id,
1030 ttl_millis,
1031 ))
1032 })
1033 .await;
1034
1035 if entry.is_fresh() {
1037 misses.fetch_add(1, Ordering::Relaxed);
1038 }
1039 }
1040
1041 #[must_use]
1043 pub fn stats(&self) -> CacheStats {
1044 CacheStats {
1045 entry_count: self.cache.entry_count(),
1046 weighted_size: self.cache.weighted_size(),
1047 }
1048 }
1049
1050 #[cfg(test)]
1055 pub(crate) async fn insert(&self, message_id: MessageId<'_>, entry: CachedArticle) {
1056 let key: Arc<str> = message_id.without_brackets().into();
1057 self.cache.insert(key, entry).await;
1058 }
1059
1060 #[inline]
1062 #[must_use]
1063 pub const fn capacity(&self) -> u64 {
1064 self.capacity
1065 }
1066
1067 #[inline]
1069 #[must_use]
1070 pub fn entry_count(&self) -> u64 {
1071 self.cache.entry_count()
1072 }
1073
1074 #[inline]
1076 #[must_use]
1077 pub fn weighted_size(&self) -> u64 {
1078 self.cache.weighted_size()
1079 }
1080
1081 #[inline]
1083 #[must_use]
1084 pub fn hit_rate(&self) -> f64 {
1085 let hits = self.hits.load(Ordering::Relaxed);
1086 let misses = self.misses.load(Ordering::Relaxed);
1087 let total = hits + misses;
1088
1089 if total == 0 {
1090 0.0
1091 } else {
1092 (hits as f64 / total as f64) * 100.0
1093 }
1094 }
1095
1096 pub async fn sync(&self) {
1101 self.cache.run_pending_tasks().await;
1102 }
1103}
1104
1105#[derive(Debug, Clone)]
1107pub struct CacheStats {
1108 pub entry_count: u64,
1109 pub weighted_size: u64,
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 fn backend_count(count: usize) -> crate::router::BackendCount {
1117 crate::router::BackendCount::try_new(count)
1118 .expect("test backend count fits availability bitmap")
1119 }
1120 use crate::types::MessageId;
1121 use futures::executor::block_on;
1122 use std::io::IoSlice;
1123 use std::pin::Pin;
1124 use std::task::{Context, Poll};
1125 use std::time::Duration;
1126 use tokio::io::AsyncWrite;
1127
1128 #[derive(Default)]
1129 struct CountingWriter {
1130 bytes: Vec<u8>,
1131 writes: usize,
1132 vectored_writes: usize,
1133 }
1134
1135 impl AsyncWrite for CountingWriter {
1136 fn poll_write(
1137 mut self: Pin<&mut Self>,
1138 _cx: &mut Context<'_>,
1139 buf: &[u8],
1140 ) -> Poll<std::io::Result<usize>> {
1141 self.writes += 1;
1142 self.bytes.extend_from_slice(buf);
1143 Poll::Ready(Ok(buf.len()))
1144 }
1145
1146 fn poll_write_vectored(
1147 mut self: Pin<&mut Self>,
1148 _cx: &mut Context<'_>,
1149 bufs: &[IoSlice<'_>],
1150 ) -> Poll<std::io::Result<usize>> {
1151 self.vectored_writes += 1;
1152 let len = bufs.iter().map(|buf| buf.len()).sum();
1153 bufs.iter()
1154 .for_each(|buf| self.bytes.extend_from_slice(buf));
1155 Poll::Ready(Ok(len))
1156 }
1157
1158 fn is_write_vectored(&self) -> bool {
1159 true
1160 }
1161
1162 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1163 Poll::Ready(Ok(()))
1164 }
1165
1166 fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1167 Poll::Ready(Ok(()))
1168 }
1169 }
1170
1171 fn cached_article_from_ingest_bytes(buffer: impl AsRef<[u8]>) -> CachedArticle {
1172 CachedArticle::from_contiguous_ingest_with_tier(buffer, ttl::CacheTier::new(0))
1173 }
1174
1175 fn create_test_cached_article(msgid: &str) -> CachedArticle {
1176 let buffer = format!("220 0 {msgid}\r\nSubject: Test\r\n\r\nBody\r\n.\r\n").into_bytes();
1177 cached_article_from_ingest_bytes(buffer)
1178 }
1179
1180 fn rendered(entry: &CachedArticle, request_kind: RequestKind, msgid: &str) -> Vec<u8> {
1181 let response = entry.cached_response_for(request_kind, msgid).unwrap();
1182 let mut out = Vec::with_capacity(response.wire_len().get());
1183 block_on(response.write_to(&mut out)).unwrap();
1184 out
1185 }
1186
1187 fn serves(entry: &CachedArticle, request_kind: RequestKind, msgid: &str) -> bool {
1188 entry.cached_response_for(request_kind, msgid).is_some()
1189 }
1190
1191 fn assert_serves(entry: &CachedArticle, cases: &[(RequestKind, bool)]) {
1192 cases.iter().for_each(|(request_kind, expected)| {
1193 assert_eq!(
1194 serves(entry, *request_kind, "<test@example.com>"),
1195 *expected,
1196 "serve decision for {request_kind:?}"
1197 );
1198 });
1199 }
1200
1201 #[tokio::test]
1202 async fn cached_article_response_writes_wire_slices() {
1203 let entry = create_test_cached_article("<test@example.com>");
1204 let response = entry
1205 .cached_response_for(RequestKind::Article, "<test@example.com>")
1206 .unwrap();
1207 let mut out = Vec::new();
1208
1209 response.write_to(&mut out).await.unwrap();
1210
1211 assert_eq!(
1212 out,
1213 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n"
1214 );
1215 }
1216
1217 #[tokio::test]
1218 async fn cached_article_response_uses_vectored_write() {
1219 let entry = create_test_cached_article("<test@example.com>");
1220 let response = entry
1221 .cached_response_for(RequestKind::Article, "<test@example.com>")
1222 .unwrap();
1223 let mut out = CountingWriter::default();
1224
1225 response.write_to(&mut out).await.unwrap();
1226
1227 assert_eq!(
1228 out.bytes,
1229 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n"
1230 );
1231 assert_eq!(out.writes, 0);
1232 assert_eq!(out.vectored_writes, 1);
1233 }
1234
1235 #[test]
1236 fn cached_article_response_exposes_typed_wire_len() {
1237 let entry = create_test_cached_article("<test@example.com>");
1238 let response = entry
1239 .cached_response_for(RequestKind::Stat, "<test@example.com>")
1240 .unwrap();
1241
1242 assert_eq!(
1243 response.wire_len(),
1244 crate::protocol::ResponseWireLen::new(26)
1245 );
1246 }
1247
1248 #[tokio::test]
1249 async fn cached_article_response_writes_derived_wire_shapes() {
1250 let entry = create_test_cached_article("<test@example.com>");
1251 let cases = [
1252 (
1253 RequestKind::Head,
1254 b"221 0 <test@example.com>\r\nSubject: Test\r\n.\r\n".as_slice(),
1255 ),
1256 (
1257 RequestKind::Body,
1258 b"222 0 <test@example.com>\r\nBody\r\n.\r\n".as_slice(),
1259 ),
1260 (
1261 RequestKind::Stat,
1262 b"223 0 <test@example.com>\r\n".as_slice(),
1263 ),
1264 ];
1265
1266 for (request_kind, expected) in cases {
1267 let response = entry
1268 .cached_response_for(request_kind, "<test@example.com>")
1269 .unwrap();
1270 let mut out = Vec::new();
1271
1272 response.write_to(&mut out).await.unwrap();
1273
1274 assert_eq!(out, expected, "{request_kind:?}");
1275 }
1276
1277 let response = entry
1278 .cached_response_for(RequestKind::Body, "<test@example.com>")
1279 .unwrap();
1280 let mut out = Vec::new();
1281
1282 response.write_to(&mut out).await.unwrap();
1283
1284 assert_eq!(out, b"222 0 <test@example.com>\r\nBody\r\n.\r\n");
1285 }
1286
1287 #[test]
1288 fn body_payload_serves_body_and_stat_only() {
1289 let entry = cached_article_from_ingest_bytes(
1290 b"222 0 <test@example.com>\r\nBody content only\r\n.\r\n",
1291 );
1292
1293 assert_serves(
1294 &entry,
1295 &[
1296 (RequestKind::Article, false),
1297 (RequestKind::Body, true),
1298 (RequestKind::Head, false),
1299 (RequestKind::Stat, true),
1300 ],
1301 );
1302 }
1303
1304 #[test]
1305 fn article_payload_serves_article_head_body_and_stat() {
1306 let entry = cached_article_from_ingest_bytes(
1307 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1308 );
1309
1310 assert_serves(
1311 &entry,
1312 &[
1313 (RequestKind::Article, true),
1314 (RequestKind::Body, true),
1315 (RequestKind::Head, true),
1316 (RequestKind::Stat, true),
1317 ],
1318 );
1319 }
1320
1321 #[test]
1322 fn head_payload_serves_head_and_stat_only() {
1323 let entry =
1324 cached_article_from_ingest_bytes(b"221 0 <test@example.com>\r\nSubject: Test\r\n.\r\n");
1325
1326 assert_serves(
1327 &entry,
1328 &[
1329 (RequestKind::Article, false),
1330 (RequestKind::Body, false),
1331 (RequestKind::Head, true),
1332 (RequestKind::Stat, true),
1333 ],
1334 );
1335 }
1336
1337 #[test]
1338 fn body_payload_completeness_requires_semantic_body() {
1339 let complete =
1340 cached_article_from_ingest_bytes(b"222 0 <test@example.com>\r\nBody content\r\n.\r\n");
1341 let metadata_only =
1342 cached_article_from_ingest_bytes(b"222 0 <test@example.com>\r\n".as_slice());
1343
1344 assert!(complete.is_complete_article());
1345 assert!(!metadata_only.is_complete_article());
1346 }
1347
1348 #[tokio::test]
1349 async fn upsert_preserves_complete_body_over_metadata_only_response() {
1350 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300));
1351 let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();
1352 let backend_id = BackendId::from_index(0);
1353 let backend = backend_id;
1354 let complete = format!(
1355 "222 0 <test@example.com>\r\n{}\r\n.\r\n",
1356 "X".repeat(750_000)
1357 );
1358
1359 cache
1360 .upsert_ingest(
1361 msg_id.clone(),
1362 complete.as_bytes().to_vec(),
1363 backend,
1364 0.into(),
1365 )
1366 .await;
1367 let backend = backend_id;
1368 cache
1369 .upsert_ingest(
1370 msg_id.clone(),
1371 b"222 0 <test@example.com>\r\n".to_vec(),
1372 backend,
1373 0.into(),
1374 )
1375 .await;
1376
1377 let cached = cache.get(&msg_id).await.expect("cached body");
1378
1379 assert_eq!(
1380 rendered(&cached, RequestKind::Body, msg_id.as_str()),
1381 complete.as_bytes()
1382 );
1383 }
1384
1385 #[tokio::test]
1386 async fn upsert_replaces_metadata_only_body_with_complete_body() {
1387 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300));
1388 let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();
1389 let backend_id = BackendId::from_index(0);
1390 let backend = backend_id;
1391 let complete = format!(
1392 "222 0 <test@example.com>\r\n{}\r\n.\r\n",
1393 "X".repeat(750_000)
1394 );
1395
1396 cache
1397 .upsert_ingest(
1398 msg_id.clone(),
1399 b"222 0 <test@example.com>\r\n".to_vec(),
1400 backend,
1401 0.into(),
1402 )
1403 .await;
1404 assert!(
1405 cache
1406 .get(&msg_id)
1407 .await
1408 .expect("metadata entry")
1409 .cached_response_for(RequestKind::Body, msg_id.as_str())
1410 .is_none()
1411 );
1412
1413 let backend = backend_id;
1414 cache
1415 .upsert_ingest(
1416 msg_id.clone(),
1417 complete.as_bytes().to_vec(),
1418 backend,
1419 0.into(),
1420 )
1421 .await;
1422 let cached = cache.get(&msg_id).await.expect("cached body");
1423
1424 assert_eq!(
1425 rendered(&cached, RequestKind::Body, msg_id.as_str()),
1426 complete.as_bytes()
1427 );
1428 }
1429
1430 #[test]
1431 fn test_cached_article_basic() {
1432 let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
1433 let entry = cached_article_from_ingest_bytes(buffer.clone());
1434
1435 assert_eq!(entry.status_code(), StatusCode::new(220));
1436 assert_eq!(
1437 rendered(&entry, RequestKind::Article, "<test@example.com>"),
1438 buffer
1439 );
1440
1441 assert!(entry.should_try_backend(BackendId::from_index(0)));
1443 assert!(entry.should_try_backend(BackendId::from_index(1)));
1444 }
1445
1446 #[test]
1447 fn cached_article_ingests_contiguous_ingest_by_name() {
1448 let entry = cached_article_from_ingest_bytes(
1449 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1450 );
1451
1452 assert_eq!(entry.status_code(), StatusCode::new(220));
1453 assert!(matches!(entry.payload, CachedPayload::Article { .. }));
1454 }
1455
1456 #[test]
1457 fn cached_article_ingests_borrowed_ingest() {
1458 let entry = cached_article_from_ingest_bytes(
1459 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".as_slice(),
1460 );
1461
1462 assert_eq!(entry.status_code(), StatusCode::new(220));
1463 assert!(matches!(entry.payload, CachedPayload::Article { .. }));
1464 }
1465
1466 #[test]
1467 fn cached_article_defaults_to_tier_zero() {
1468 let entry = cached_article_from_ingest_bytes(b"220 0 <test@example.com>\r\n.\r\n");
1469
1470 assert_eq!(entry.tier(), ttl::CacheTier::new(0));
1471 }
1472
1473 #[test]
1474 fn cached_article_can_ingest_with_tier_internally() {
1475 let entry = CachedArticle::from_contiguous_ingest_with_tier(
1476 b"220 0 <test@example.com>\r\n.\r\n",
1477 ttl::CacheTier::new(5),
1478 );
1479
1480 assert_eq!(entry.tier(), ttl::CacheTier::new(5));
1481 }
1482
1483 #[test]
1484 fn cached_article_stores_payload_sections_as_shared_slices() {
1485 trait SharedSlice {}
1486 impl SharedSlice for Arc<[u8]> {}
1487 fn assert_shared_slice<T: SharedSlice>(_: &T) {}
1488
1489 let entry = cached_article_from_ingest_bytes(
1490 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1491 );
1492
1493 match &entry.payload {
1494 CachedPayload::Article { headers, body, .. } => {
1495 assert_shared_slice(headers);
1496 assert_shared_slice(body);
1497 }
1498 other => panic!("expected article payload, got {other:?}"),
1499 }
1500 }
1501
1502 #[test]
1503 fn cached_article_clone_shares_payload_sections() {
1504 let entry = cached_article_from_ingest_bytes(
1505 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1506 );
1507 let cloned = entry.clone();
1508
1509 match (&entry.payload, &cloned.payload) {
1510 (
1511 CachedPayload::Article { headers, body, .. },
1512 CachedPayload::Article {
1513 headers: cloned_headers,
1514 body: cloned_body,
1515 ..
1516 },
1517 ) => {
1518 assert!(std::ptr::eq(headers.as_ptr(), cloned_headers.as_ptr()));
1519 assert!(std::ptr::eq(body.as_ptr(), cloned_body.as_ptr()));
1520 }
1521 other => panic!("expected cloned article payload, got {other:?}"),
1522 }
1523 }
1524
1525 #[test]
1526 fn cached_article_ingests_cache_ingest_response_without_required_vec() {
1527 let entry = CachedArticle::from_ingest_response_with_tier(
1528 smallvec::SmallVec::<[u8; 128]>::from_slice(
1529 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1530 ),
1531 ttl::CacheTier::new(0),
1532 );
1533
1534 assert_eq!(entry.status_code(), StatusCode::new(220));
1535 assert!(matches!(entry.payload, CachedPayload::Article { .. }));
1536 }
1537
1538 #[test]
1539 fn cached_article_ingests_chunked_cache_ingest_response() {
1540 let pool = crate::pool::BufferPool::new(
1541 crate::types::BufferSize::try_new(1024).expect("valid buffer size"),
1542 1,
1543 )
1544 .with_capture_pool(8, 4);
1545 let mut response = crate::pool::ChunkedResponse::default();
1546 response.extend_from_slice(
1547 &pool,
1548 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1549 );
1550 assert!(
1551 response.iter_chunks().count() > 1,
1552 "test response must span chunks"
1553 );
1554
1555 let entry = CachedArticle::from_ingest_response_with_tier(response, ttl::CacheTier::new(0));
1556
1557 assert_eq!(entry.status_code(), StatusCode::new(220));
1558 match entry.payload {
1559 CachedPayload::Article { headers, body, .. } => {
1560 assert_eq!(headers.as_ref(), b"Subject: Test");
1561 assert_eq!(body.as_ref(), b"Body");
1562 }
1563 other => panic!("expected article payload, got {other:?}"),
1564 }
1565 }
1566
1567 #[test]
1568 fn chunked_cache_ingest_parses_article_number() {
1569 let pool = crate::pool::BufferPool::new(
1570 crate::types::BufferSize::try_new(1024).expect("valid buffer size"),
1571 1,
1572 )
1573 .with_capture_pool(8, 4);
1574 let mut response = crate::pool::ChunkedResponse::default();
1575 response.extend_from_slice(
1576 &pool,
1577 b"220 123456789 <very-long-message-id-that-spans-chunks@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1578 );
1579
1580 let entry = CachedArticle::from_ingest_response_with_tier(response, ttl::CacheTier::new(0));
1581
1582 assert_eq!(
1583 entry.article_number(),
1584 Some(CachedArticleNumber::new(123_456_789))
1585 );
1586 }
1587
1588 #[test]
1589 fn test_is_complete_article() {
1590 let metadata_only_430 = cached_article_from_ingest_bytes(b"430\r\n");
1592 assert!(!metadata_only_430.is_complete_article());
1593
1594 let metadata_only_220 = cached_article_from_ingest_bytes(b"220\r\n");
1595 assert!(!metadata_only_220.is_complete_article());
1596
1597 let metadata_only_223 = cached_article_from_ingest_bytes(b"223\r\n");
1598 assert!(!metadata_only_223.is_complete_article());
1599
1600 let full = cached_article_from_ingest_bytes(
1602 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
1603 );
1604 assert!(full.is_complete_article());
1605
1606 let head_response =
1608 cached_article_from_ingest_bytes(b"221 0 <test@example.com>\r\nSubject: Test\r\n.\r\n");
1609 assert!(!head_response.is_complete_article());
1610
1611 let incomplete_article = cached_article_from_ingest_bytes(
1613 b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n",
1614 );
1615 assert!(!incomplete_article.is_complete_article());
1616 }
1617
1618 #[test]
1619 fn test_cached_article_record_backend_missing() {
1620 let backend0 = BackendId::from_index(0);
1621 let backend1 = BackendId::from_index(1);
1622 let mut entry = create_test_cached_article("<test@example.com>");
1623
1624 assert!(entry.should_try_backend(backend0));
1626 assert!(entry.should_try_backend(backend1));
1627
1628 entry.record_backend_missing(backend1);
1630
1631 assert!(entry.should_try_backend(backend0));
1633 assert!(!entry.should_try_backend(backend1));
1634 }
1635
1636 #[test]
1637 fn test_cached_article_all_backends_exhausted() {
1638 let backend0 = BackendId::from_index(0);
1639 let backend1 = BackendId::from_index(1);
1640 let mut entry = create_test_cached_article("<test@example.com>");
1641
1642 assert!(!entry.all_backends_exhausted(backend_count(2)));
1644
1645 entry.record_backend_missing(backend0);
1647 entry.record_backend_missing(backend1);
1648
1649 assert!(entry.all_backends_exhausted(backend_count(2)));
1651 }
1652
1653 #[tokio::test]
1654 async fn test_arc_str_borrow_lookup() {
1655 let cache = ArticleCache::new(100, Duration::from_secs(300));
1657
1658 let msgid = MessageId::from_borrowed("<test123@example.com>").unwrap();
1660 let article = create_test_cached_article("<test123@example.com>");
1661
1662 cache.insert(msgid.clone(), article.clone()).await;
1663
1664 let msgid2 = MessageId::from_borrowed("<test123@example.com>").unwrap();
1667 let retrieved = cache.get(&msgid2).await;
1668
1669 assert!(
1670 retrieved.is_some(),
1671 "Arc<str> cache should support Borrow<str> lookups"
1672 );
1673 assert_eq!(
1674 rendered(
1675 &retrieved.unwrap(),
1676 RequestKind::Article,
1677 "<test123@example.com>"
1678 ),
1679 rendered(&article, RequestKind::Article, "<test123@example.com>"),
1680 "Retrieved article should match inserted article"
1681 );
1682 }
1683
1684 #[tokio::test]
1685 async fn test_cache_hit_miss() {
1686 let cache = ArticleCache::new(100, Duration::from_secs(300));
1687
1688 let msgid = MessageId::from_borrowed("<nonexistent@example.com>").unwrap();
1689 let result = cache.get(&msgid).await;
1690
1691 assert!(
1692 result.is_none(),
1693 "Cache lookup for non-existent key should return None"
1694 );
1695 }
1696
1697 #[tokio::test]
1698 async fn test_cache_insert_and_retrieve() {
1699 let cache = ArticleCache::new(10, Duration::from_secs(300));
1700
1701 let msgid = MessageId::from_borrowed("<article@example.com>").unwrap();
1702 let article = create_test_cached_article("<article@example.com>");
1703
1704 cache.insert(msgid.clone(), article.clone()).await;
1705
1706 let retrieved = cache.get(&msgid).await.unwrap();
1707 assert_eq!(
1708 rendered(&retrieved, RequestKind::Article, "<article@example.com>"),
1709 rendered(&article, RequestKind::Article, "<article@example.com>")
1710 );
1711 }
1712
1713 #[tokio::test]
1714 async fn test_cache_upsert_new_entry() {
1715 let cache = ArticleCache::new(100, Duration::from_secs(300));
1716
1717 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
1718 let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
1719
1720 cache
1721 .upsert_ingest(
1722 msgid.clone(),
1723 buffer.clone(),
1724 BackendId::from_index(0),
1725 0.into(),
1726 )
1727 .await;
1728
1729 let retrieved = cache.get(&msgid).await.unwrap();
1730 assert_eq!(
1731 rendered(&retrieved, RequestKind::Article, "<test@example.com>"),
1732 buffer
1733 );
1734 assert!(retrieved.should_try_backend(BackendId::from_index(0)));
1736 assert!(retrieved.should_try_backend(BackendId::from_index(1)));
1737 }
1738
1739 #[tokio::test]
1740 async fn test_cache_upsert_existing_entry() {
1741 let cache = ArticleCache::new(100, Duration::from_secs(300));
1742
1743 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
1744 let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
1745
1746 cache
1748 .upsert_ingest(
1749 msgid.clone(),
1750 buffer.clone(),
1751 BackendId::from_index(0),
1752 0.into(),
1753 )
1754 .await;
1755
1756 cache
1758 .upsert_ingest(
1759 msgid.clone(),
1760 buffer.clone(),
1761 BackendId::from_index(1),
1762 0.into(),
1763 )
1764 .await;
1765
1766 let retrieved = cache.get(&msgid).await.unwrap();
1767 assert!(retrieved.should_try_backend(BackendId::from_index(0)));
1769 assert!(retrieved.should_try_backend(BackendId::from_index(1)));
1770 }
1771
1772 #[tokio::test]
1773 async fn test_record_backend_missing() {
1774 let cache = ArticleCache::new(100, Duration::from_secs(300));
1775
1776 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
1777 let article = create_test_cached_article("<test@example.com>");
1778
1779 cache.insert(msgid.clone(), article).await;
1780
1781 cache
1783 .record_backend_missing(msgid.clone(), BackendId::from_index(1))
1784 .await;
1785
1786 let retrieved = cache.get(&msgid).await.unwrap();
1787 assert!(retrieved.should_try_backend(BackendId::from_index(0)));
1789 assert!(!retrieved.should_try_backend(BackendId::from_index(1)));
1790 }
1791
1792 #[tokio::test]
1793 async fn cached_430_prevents_same_backend_success_token() {
1794 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300));
1795 let msgid = MessageId::from_borrowed("<permanent-missing@example.com>").unwrap();
1796 let backend = BackendId::from_index(0);
1797 let buffer =
1798 b"220 0 <permanent-missing@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
1799
1800 cache.record_backend_missing(msgid.clone(), backend).await;
1801 let retrieved = cache.get(&msgid).await.unwrap();
1802 assert!(
1803 retrieved.availability().is_missing(backend),
1804 "cached 430 must not produce an eligible success token"
1805 );
1806
1807 cache
1808 .upsert_ingest(msgid.clone(), buffer, backend, 0.into())
1809 .await;
1810
1811 let retrieved = cache.get(&msgid).await.unwrap();
1812 assert_eq!(retrieved.status_code(), StatusCode::new(430));
1813 assert!(!retrieved.should_try_backend(backend));
1814 assert!(
1815 retrieved
1816 .cached_response_for(RequestKind::Article, msgid.as_str())
1817 .is_none()
1818 );
1819 }
1820
1821 #[tokio::test]
1822 async fn expired_missing_does_not_block_successful_upsert() {
1823 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300));
1824 let msgid = MessageId::from_borrowed("<expired-missing-upsert@example.com>").unwrap();
1825 let backend = BackendId::from_index(0);
1826 let mut expired = CachedArticle::missing(ttl::CacheTier::new(0));
1827 expired.record_backend_missing(backend);
1828 expired.inserted_at = ttl::CacheTimestampMillis::new(0);
1829 cache.insert(msgid.clone(), expired).await;
1830
1831 let buffer =
1832 b"220 0 <expired-missing-upsert@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n"
1833 .to_vec();
1834 cache
1835 .upsert_ingest(msgid.clone(), buffer.clone(), backend, 0.into())
1836 .await;
1837
1838 let retrieved = cache
1839 .get(&msgid)
1840 .await
1841 .expect("successful fetch should replace expired missing metadata");
1842 assert_eq!(retrieved.status_code(), StatusCode::new(220));
1843 assert!(retrieved.should_try_backend(backend));
1844 assert_eq!(
1845 rendered(&retrieved, RequestKind::Article, msgid.as_str()),
1846 buffer
1847 );
1848 }
1849
1850 #[tokio::test]
1851 async fn expired_missing_does_not_block_status_record() {
1852 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300));
1853 let msgid = MessageId::from_borrowed("<expired-missing-status@example.com>").unwrap();
1854 let backend = BackendId::from_index(0);
1855 let mut expired = CachedArticle::missing(ttl::CacheTier::new(0));
1856 expired.record_backend_missing(backend);
1857 expired.inserted_at = ttl::CacheTimestampMillis::new(0);
1858 cache.insert(msgid.clone(), expired).await;
1859
1860 cache
1861 .record_backend_has_status(
1862 msgid.clone(),
1863 StatusCode::new(223),
1864 backend,
1865 ttl::CacheTier::new(0),
1866 )
1867 .await;
1868
1869 let retrieved = cache
1870 .get(&msgid)
1871 .await
1872 .expect("successful status should replace expired missing metadata");
1873 assert_eq!(retrieved.status_code(), StatusCode::new(223));
1874 assert!(retrieved.should_try_backend(backend));
1875 assert_eq!(retrieved.payload_len().get(), 0);
1876 }
1877
1878 #[tokio::test]
1879 async fn record_missing_replaces_expired_entry() {
1880 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300));
1881 let msgid = MessageId::from_borrowed("<expired-record-missing@example.com>").unwrap();
1882 let backend = BackendId::from_index(0);
1883 let mut expired = create_test_cached_article(msgid.as_str());
1884 expired.inserted_at = ttl::CacheTimestampMillis::new(0);
1885 cache.insert(msgid.clone(), expired).await;
1886
1887 cache.record_backend_missing(msgid.clone(), backend).await;
1888
1889 let retrieved = cache
1890 .get(&msgid)
1891 .await
1892 .expect("fresh missing fact should replace expired payload");
1893 assert_eq!(retrieved.status_code(), StatusCode::new(430));
1894 assert!(!retrieved.should_try_backend(backend));
1895 assert!(matches!(retrieved.payload, CachedPayload::Missing));
1896 assert_eq!(retrieved.payload_len().get(), 0);
1897 }
1898
1899 #[tokio::test]
1909 async fn test_record_backend_missing_creates_new_entry() {
1910 let cache = ArticleCache::new(100, Duration::from_secs(300));
1911
1912 let msgid = MessageId::from_borrowed("<missing@example.com>").unwrap();
1913
1914 assert!(cache.get(&msgid).await.is_none());
1916
1917 cache
1919 .record_backend_missing(msgid.clone(), BackendId::from_index(0))
1920 .await;
1921
1922 let entry = cache
1924 .get(&msgid)
1925 .await
1926 .expect("Cache entry must exist after record_backend_missing");
1927
1928 assert!(
1930 !entry.should_try_backend(BackendId::from_index(0)),
1931 "Backend 0 should be marked missing"
1932 );
1933
1934 assert!(
1936 entry.should_try_backend(BackendId::from_index(1)),
1937 "Backend 1 should still be available"
1938 );
1939
1940 assert!(matches!(entry.payload, CachedPayload::Missing));
1941 assert_eq!(
1942 entry.payload_len().get(),
1943 0,
1944 "missing cache entries must not retain response payload bytes"
1945 );
1946
1947 cache
1949 .record_backend_missing(msgid.clone(), BackendId::from_index(1))
1950 .await;
1951
1952 let entry = cache.get(&msgid).await.unwrap();
1953
1954 assert!(!entry.should_try_backend(BackendId::from_index(0)));
1956 assert!(!entry.should_try_backend(BackendId::from_index(1)));
1957
1958 assert!(
1960 entry.all_backends_exhausted(backend_count(2)),
1961 "All backends should be exhausted"
1962 );
1963 }
1964
1965 #[tokio::test]
1966 async fn test_cache_stats() {
1967 let cache = ArticleCache::new(1024 * 1024, Duration::from_secs(300)); let stats = cache.stats();
1971 assert_eq!(stats.entry_count, 0);
1972
1973 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
1975 let article = create_test_cached_article("<test@example.com>");
1976 cache.insert(msgid, article).await;
1977
1978 cache.sync().await;
1980
1981 let stats = cache.stats();
1983 assert_eq!(stats.entry_count, 1);
1984 }
1985
1986 #[tokio::test]
1987 async fn test_cache_ttl_expiration() {
1988 let cache = ArticleCache::new(1024 * 1024, Duration::from_millis(50)); let msgid = MessageId::from_borrowed("<expire@example.com>").unwrap();
1991 let article = create_test_cached_article("<expire@example.com>");
1992
1993 cache.insert(msgid.clone(), article).await;
1994
1995 assert!(cache.get(&msgid).await.is_some());
1997
1998 tokio::time::sleep(Duration::from_millis(100)).await;
2000 cache.sync().await;
2001
2002 assert!(cache.get(&msgid).await.is_none());
2004 }
2005
2006 #[tokio::test]
2007 async fn test_insert_caches_full_article_payload() {
2008 let cache = ArticleCache::new(1024 * 1024, Duration::from_secs(300));
2009
2010 let msgid = MessageId::from_borrowed("<test2@example.com>").unwrap();
2011 let buffer = b"220 0 <test2@example.com>\r\nSubject: Test2\r\n\r\nBody2\r\n.\r\n".to_vec();
2012 let original_payload_size = b"Subject: Test2".len() + b"Body2".len();
2013
2014 cache
2015 .upsert_ingest(msgid.clone(), buffer, BackendId::from_index(0), 0.into())
2016 .await;
2017 cache.sync().await;
2018
2019 let retrieved = cache.get(&msgid).await.unwrap();
2020 assert_eq!(retrieved.payload_len(), original_payload_size);
2021 }
2022
2023 #[tokio::test]
2024 async fn test_cache_capacity_limit() {
2025 let cache = ArticleCache::new(500, Duration::from_secs(300)); for i in 1..=3 {
2029 let msgid_str = format!("<article{i}@example.com>");
2030 let msgid = MessageId::new(msgid_str).unwrap();
2031 let article = create_test_cached_article(msgid.as_ref());
2032 cache.insert(msgid, article).await;
2033 cache.sync().await; }
2035
2036 tokio::time::sleep(Duration::from_millis(10)).await;
2038 cache.sync().await;
2039
2040 let stats = cache.stats();
2041 assert!(
2042 stats.entry_count <= 3,
2043 "Cache should have at most 3 entries with 500 byte capacity"
2044 );
2045 }
2046
2047 #[tokio::test]
2048 async fn test_cached_article_clone() {
2049 let article = create_test_cached_article("<test@example.com>");
2050
2051 let cloned = article.clone();
2052 assert_eq!(
2053 rendered(&article, RequestKind::Article, "<test@example.com>"),
2054 rendered(&cloned, RequestKind::Article, "<test@example.com>")
2055 );
2056 }
2057
2058 #[tokio::test]
2059 async fn test_cache_clone() {
2060 let cache1 = ArticleCache::new(1024 * 1024, Duration::from_secs(300)); let cache2 = cache1.clone();
2062
2063 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
2064 let article = create_test_cached_article("<test@example.com>");
2065
2066 cache1.insert(msgid.clone(), article).await;
2067 cache1.sync().await;
2068
2069 assert!(cache2.get(&msgid).await.is_some());
2071 }
2072
2073 #[tokio::test]
2074 async fn test_weigher_large_articles() {
2075 let cache = ArticleCache::new(10 * 1024 * 1024, Duration::from_secs(300)); let body = vec![b'X'; 750_000];
2081 let response = format!(
2082 "222 0 <test@example.com>\r\n{}\r\n.\r\n",
2083 std::str::from_utf8(&body).unwrap()
2084 );
2085 let article = cached_article_from_ingest_bytes(response.as_bytes());
2086
2087 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
2088 cache.insert(msgid.clone(), article).await;
2089 cache.sync().await;
2090
2091 for i in 2..=13 {
2098 let msgid_str = format!("<article{i}@example.com>");
2099 let msgid = MessageId::new(msgid_str).unwrap();
2100 let response = format!(
2101 "222 0 {}\r\n{}\r\n.\r\n",
2102 msgid.as_str(),
2103 std::str::from_utf8(&body).unwrap()
2104 );
2105 let article = cached_article_from_ingest_bytes(response.as_bytes());
2106 cache.insert(msgid, article).await;
2107 cache.sync().await;
2108 }
2109
2110 tokio::time::sleep(Duration::from_millis(50)).await;
2111 cache.sync().await;
2112
2113 let stats = cache.stats();
2114 assert!(
2116 stats.entry_count >= 11,
2117 "Cache should fit at least 11 large articles with actual size (no multiplier) (got {})",
2118 stats.entry_count
2119 );
2120 }
2121
2122 #[tokio::test]
2123 async fn test_weigher_small_status_only_responses() {
2124 let cache = ArticleCache::new(1_000_000, Duration::from_secs(300)); let metadata_only = b"223 0 <test@example.com>\r\n".to_vec();
2130 let article = cached_article_from_ingest_bytes(metadata_only);
2131
2132 let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
2133 cache.insert(msgid, article).await;
2134 cache.sync().await;
2135
2136 for i in 2..=200 {
2143 let msgid_str = format!("<status_only{i}@example.com>");
2144 let msgid = MessageId::new(msgid_str).unwrap();
2145 let metadata_only = format!("223 0 {}\r\n", msgid.as_str());
2146 let article = cached_article_from_ingest_bytes(metadata_only.as_bytes());
2147 cache.insert(msgid, article).await;
2148 }
2149
2150 cache.sync().await;
2151 tokio::time::sleep(Duration::from_millis(50)).await;
2152 cache.sync().await;
2153
2154 let stats = cache.stats();
2155 assert!(
2157 stats.entry_count >= 100,
2158 "Cache should fit many small metadata-only responses (got {})",
2159 stats.entry_count
2160 );
2161 }
2162
2163 #[tokio::test]
2164 async fn test_cache_with_owned_message_id() {
2165 let cache = ArticleCache::new(1024 * 1024, Duration::from_secs(300)); let msgid = MessageId::new("<owned@example.com>".to_string()).unwrap();
2169 let article = create_test_cached_article("<owned@example.com>");
2170
2171 cache.insert(msgid.clone(), article).await;
2172
2173 let borrowed_msgid = MessageId::from_borrowed("<owned@example.com>").unwrap();
2175 assert!(cache.get(&borrowed_msgid).await.is_some());
2176 }
2177}