Skip to main content

nntp_proxy/cache/
article.rs

1//! Article caching implementation using LRU cache with TTL
2//!
3//! This module provides article caching with per-backend availability tracking.
4//! The availability tracking type itself lives in [`super::availability`].
5
6use 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/// Cache entry for an article.
332///
333/// Stores typed response metadata and semantic payload only. Status lines and
334/// wire response bytes are regenerated when serving cache hits.
335#[derive(Clone, Debug)]
336pub struct CachedArticle {
337    /// Backend availability bitset (2 bytes)
338    ///
339    /// No mutex needed: moka clones entries on `get()`, and updates go through
340    /// `cache.insert()` which replaces the whole entry atomically.
341    backend_availability: ArticleAvailability,
342
343    status_code: StatusCode,
344    payload: CachedPayload,
345
346    /// Tier of the backend that provided this article
347    /// Used for tier-aware TTL: higher tier = longer TTL
348    tier: ttl::CacheTier,
349
350    /// Unix timestamp when this entry was inserted (milliseconds since epoch)
351    /// Populated via `ttl::now_millis()` and used with `tier` for TTL expiration
352    inserted_at: ttl::CacheTimestampMillis,
353}
354
355impl CachedArticle {
356    /// Create an availability-only cache entry without payload bytes.
357    #[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    /// Parse a contiguous ingest response into typed cache metadata and payload.
408    #[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    /// Check if this entry has expired based on tier-aware TTL
445    ///
446    /// See [`super::ttl`] for the TTL formula.
447    #[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    /// Get the tier of the backend that provided this article
454    #[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    /// Check if we should try fetching from this backend
499    ///
500    /// Returns false if backend is known to not have this article (returned 430 before).
501    #[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    /// Record that a backend returned 430 (doesn't have this article)
508    pub fn record_backend_missing(&mut self, backend_id: BackendId) {
509        self.backend_availability.record_missing(backend_id);
510    }
511
512    /// Check if all backends have been tried and none have the article
513    #[must_use]
514    pub fn all_backends_exhausted(&self, total_backends: BackendCount) -> bool {
515        self.backend_availability.all_exhausted(total_backends)
516    }
517
518    /// Check if this cache entry has useful availability information
519    ///
520    /// Returns true if at least one backend has returned authoritative 430.
521    #[inline]
522    #[must_use]
523    pub const fn has_availability_info(&self) -> bool {
524        self.backend_availability.has_availability_info()
525    }
526
527    /// Return the typed backend availability metadata stored with this entry.
528    #[inline]
529    #[must_use]
530    pub const fn availability(&self) -> ArticleAvailability {
531        self.backend_availability
532    }
533
534    /// Check if this cache entry contains a complete article (220) or body (222)
535    ///
536    /// Returns true if:
537    /// 1. Status code is 220 (ARTICLE) or 222 (BODY)
538    /// 2. Buffer contains actual content (not just a status line like "220\r\n")
539    ///
540    /// This is used by the full article cache to determine if we can serve
541    /// directly from cache or need to fetch additional data.
542    #[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    /// Initialize availability tracker from this cached entry
557    ///
558    /// Creates a fresh `ArticleAvailability` with backends marked missing based on
559    /// cached knowledge (backends that previously returned 430).
560    pub(crate) fn to_availability(&self, total_backends: BackendCount) -> ArticleAvailability {
561        let mut availability = ArticleAvailability::new();
562
563        // Mark backends we know don't have this article
564        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
659/// Return semantic payload bytes for an already captured cache-ingest response.
660///
661/// ARTICLE/HEAD/BODY payloads arrive here only after the session framer has
662/// captured a complete response. Cache parsing delegates multiline payload body
663/// extraction back through the backend facade instead of performing response
664/// boundary checks locally.
665fn 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/// Article cache using LRU eviction with TTL
717///
718/// Uses `Arc<str>` (message ID content without brackets) as key for zero-allocation lookups.
719/// `Arc<str>` implements `Borrow<str>`, allowing `cache.get(&str)` without allocation.
720///
721/// Supports tier-aware TTL: entries from higher tier backends get longer TTLs.
722/// Formula: `effective_ttl = base_ttl * (2 ^ tier)`
723#[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    /// Base TTL in milliseconds (used for tier-aware expiration via `effective_ttl`)
730    ttl_millis: ttl::CacheTtlMillis,
731}
732
733impl ArticleCache {
734    /// Create a new article cache
735    ///
736    /// # Arguments
737    /// * `max_capacity` - Maximum cache size in bytes (uses weighted entries)
738    /// * `ttl` - Time-to-live for cached articles
739    #[must_use]
740    pub fn new(max_capacity: u64, ttl: Duration) -> Self {
741        // Build cache with byte-based capacity using weigher
742        // max_capacity is total bytes allowed
743        //
744        // We handle tier-aware expiration ourselves in get(). A giant Moka TTL
745        // only adds timer-wheel/read bookkeeping without expiring normal entries
746        // at the effective tier TTL. Keep Moka TTL only for the zero-TTL case so
747        // tests and pathological configs still expire immediately.
748        let builder = Cache::builder().max_capacity(max_capacity).weigher(
749            move |key: &Arc<str>, entry: &CachedArticle| -> u32 {
750                // Calculate actual memory footprint for accurate capacity tracking.
751                //
752                // Memory layout per cache entry:
753                //
754                // Key: Arc<str>
755                //   - Arc control block: 16 bytes (strong_count + weak_count)
756                //   - String data: key.len() bytes
757                //   - Allocator overhead: ~16 bytes (malloc metadata, alignment)
758                //
759                // Value: CachedArticle
760                //   - Struct inline metadata: availability, status, tier, timestamp, payload tag
761                //   - Shared semantic payload sections: headers/body slice metadata and bytes
762                //   - Allocator overhead for present payload sections
763                //
764                // Moka internal per-entry overhead is MUCH larger than the data itself:
765                //   - Key stored twice: Bucket.key AND ValueEntry.info.key_hash.key
766                //   - EntryInfo<K> struct: ~72 bytes (atomics, timestamps, counters)
767                //   - LRU deque nodes and frequency sketch entries
768                //   - Timer wheel entries for TTL tracking
769                //   - crossbeam-epoch deferred garbage (can retain 2x entries)
770                //   - HashMap segments with open addressing (~2x load factor)
771                //
772                // Empirical testing shows ~10x actual RSS vs weighted_size().
773                // Our observed ratio: 362MB RSS / 36MB weighted = 10x
774                //
775                const ARC_STR_OVERHEAD: usize = 16 + 16; // Arc control block + allocator
776                const ENTRY_STRUCT: usize = 64; // CachedArticle inline metadata and enum tag
777                const PAYLOAD_OVERHEAD: usize = 2 * (16 + 16); // Up to headers/body shared slices + allocators
778                // Moka internal structures - empirically measured to address memory reporting gap.
779                // See moka issue #473: https://github.com/moka-rs/moka/issues/473
780                // Observed ratio: 362MB RSS / 36MB weighted_size() = 10x
781                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                // Availability-only entries have higher relative overhead
788                // due to allocator fragmentation on small allocations.
789                // Complete articles are dominated by content size, so no multiplier needed.
790                let weighted_size = if entry.is_complete_article() {
791                    base_size
792                } else {
793                    // Small allocations have ~50% more overhead from allocator fragmentation.
794                    // Use a 1.5x multiplier, rounding up, to avoid underestimating small entries.
795                    (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    /// Get an article from the cache
817    ///
818    /// Accepts any lifetime `MessageId` and uses the string content (without brackets) as key.
819    ///
820    /// **Zero-allocation**: `without_brackets()` returns `&str`, which moka accepts directly
821    /// for `Arc<str>` keys via the `Borrow<str>` trait. This avoids allocating a new `Arc<str>`
822    /// for every cache lookup. See `test_arc_str_borrow_lookup` test for verification.
823    ///
824    /// **Tier-aware TTL**: Even if moka hasn't expired the entry yet, we check if the entry
825    /// is expired based on tier-aware TTL. Higher tier entries get longer TTLs.
826    pub async fn get(&self, message_id: &MessageId<'_>) -> Option<CachedArticle> {
827        // moka::Cache<Arc<str>, V> supports get(&str) via Borrow<str> trait
828        // This is zero-allocation: no Arc<str> is created for the lookup
829        self.get_by_cache_key(message_id.without_brackets()).await
830    }
831
832    /// Get an article by the cache key form of a message ID (without brackets).
833    ///
834    /// This is the request hot path: `RequestContext` has already validated the
835    /// message-id span, so callers can avoid rebuilding a `MessageId` wrapper.
836    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                // Entry exists but expired by tier-aware TTL - invalidate and treat as cache miss
846                // Invalidating immediately frees capacity rather than waiting for LRU eviction,
847                // preventing repeated cache misses on the same stale key
848                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    /// Upsert cache entry (insert or update) - ATOMIC OPERATION
939    ///
940    /// Uses moka's `entry().and_upsert_with()` for atomic get-modify-store.
941    /// This eliminates the race condition of separate `get()` + `insert()` calls
942    /// and provides key-level locking for concurrent operations.
943    ///
944    /// If entry exists: updates the entry while preserving authoritative missing facts
945    /// If entry doesn't exist: inserts new entry
946    ///
947    /// The tier is stored with the entry for tier-aware TTL calculation.
948    ///
949    /// CRITICAL: Always re-insert to refresh TTL while preserving negative availability.
950    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        // Use atomic upsert - this provides key-level locking and eliminates
963        // the race condition between get() and insert() calls
964        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    /// Record successful backend availability without storing response payload bytes.
978    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    /// Record that a backend returned 430 for this article - ATOMIC OPERATION
1005    ///
1006    /// Uses moka's `entry().and_upsert_with()` for atomic get-modify-store.
1007    /// This eliminates the race condition of separate `get()` + `insert()` calls
1008    /// and provides key-level locking for concurrent operations.
1009    ///
1010    /// If the article is already cached, updates the availability bitset.
1011    /// If not cached, creates a typed missing cache entry.
1012    /// This prevents repeated queries to backends that don't have the article.
1013    ///
1014    /// Note: We don't store the actual backend 430 response because:
1015    /// 1. We always send a standardized 430 to clients, never the backend's response
1016    /// 2. The only info we need is the availability bitset (which backends returned 430)
1017    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        // Use atomic upsert - this provides key-level locking
1023        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        // Track misses for new entries
1036        if entry.is_fresh() {
1037            misses.fetch_add(1, Ordering::Relaxed);
1038        }
1039    }
1040
1041    /// Get cache statistics
1042    #[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    /// Insert an article entry directly (for testing)
1051    ///
1052    /// This is a low-level method that bypasses the usual upsert logic.
1053    /// Only use this in tests where you need precise control over cache state.
1054    #[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    /// Get maximum cache capacity
1061    #[inline]
1062    #[must_use]
1063    pub const fn capacity(&self) -> u64 {
1064        self.capacity
1065    }
1066
1067    /// Get current number of cached entries (synchronous)
1068    #[inline]
1069    #[must_use]
1070    pub fn entry_count(&self) -> u64 {
1071        self.cache.entry_count()
1072    }
1073
1074    /// Get current weighted size in bytes (synchronous)
1075    #[inline]
1076    #[must_use]
1077    pub fn weighted_size(&self) -> u64 {
1078        self.cache.weighted_size()
1079    }
1080
1081    /// Get cache hit rate as percentage (0.0 to 100.0)
1082    #[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    /// Run pending background tasks (for testing)
1097    ///
1098    /// Moka performs maintenance tasks (eviction, expiration) asynchronously.
1099    /// This method ensures all pending tasks complete, useful for deterministic testing.
1100    pub async fn sync(&self) {
1101        self.cache.run_pending_tasks().await;
1102    }
1103}
1104
1105/// Cache statistics
1106#[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        // Default: should try all backends
1442        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        // Metadata-only responses should NOT be complete articles
1591        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        // Full article SHOULD be complete
1601        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        // Wrong status code (not 220) should NOT be complete article
1607        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        // Incomplete article payloads should NOT be complete
1612        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        // Initially should try both
1625        assert!(entry.should_try_backend(backend0));
1626        assert!(entry.should_try_backend(backend1));
1627
1628        // Record backend1 as missing (430 response)
1629        entry.record_backend_missing(backend1);
1630
1631        // Should still try backend0, but not backend1
1632        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        // Not all exhausted yet
1643        assert!(!entry.all_backends_exhausted(backend_count(2)));
1644
1645        // Record both as missing
1646        entry.record_backend_missing(backend0);
1647        entry.record_backend_missing(backend1);
1648
1649        // Now all 2 backends are exhausted
1650        assert!(entry.all_backends_exhausted(backend_count(2)));
1651    }
1652
1653    #[tokio::test]
1654    async fn test_arc_str_borrow_lookup() {
1655        // Create cache with Arc<str> keys
1656        let cache = ArticleCache::new(100, Duration::from_secs(300));
1657
1658        // Create a MessageId and insert an article
1659        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        // Verify we can retrieve using a different MessageId instance (borrowed)
1665        // This demonstrates that Arc<str> supports Borrow<str> lookups via &str
1666        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        // Default: should try all backends
1735        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        // Insert with backend 0
1747        cache
1748            .upsert_ingest(
1749                msgid.clone(),
1750                buffer.clone(),
1751                BackendId::from_index(0),
1752                0.into(),
1753            )
1754            .await;
1755
1756        // Update with backend 1 - does nothing (entry already exists)
1757        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        // Default: should try all backends
1768        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        // Record backend 1 as missing
1782        cache
1783            .record_backend_missing(msgid.clone(), BackendId::from_index(1))
1784            .await;
1785
1786        let retrieved = cache.get(&msgid).await.unwrap();
1787        // Backend 0 should still be tried, backend 1 should not
1788        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    /// CRITICAL BUG FIX TEST: `record_backend_missing` must create cache entries
1900    /// for articles that don't exist anywhere (all backends return 430).
1901    ///
1902    /// Bug: Previously, if an article wasn't cached, `record_backend_missing`
1903    /// would silently do nothing. This caused repeated queries to all backends
1904    /// for missing articles, resulting in:
1905    /// - Massive bandwidth waste
1906    /// - `SABnzbd` reporting "gigabytes of missing articles"
1907    /// - 4xx/5xx error counts not increasing (metrics bug)
1908    #[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        // Verify article is NOT in cache
1915        assert!(cache.get(&msgid).await.is_none());
1916
1917        // Record backend 0 returned 430
1918        cache
1919            .record_backend_missing(msgid.clone(), BackendId::from_index(0))
1920            .await;
1921
1922        // CRITICAL: Cache entry MUST now exist
1923        let entry = cache
1924            .get(&msgid)
1925            .await
1926            .expect("Cache entry must exist after record_backend_missing");
1927
1928        // Verify backend 0 is marked as missing
1929        assert!(
1930            !entry.should_try_backend(BackendId::from_index(0)),
1931            "Backend 0 should be marked missing"
1932        );
1933
1934        // Verify backend 1 is still available (not tried yet)
1935        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        // Record backend 1 also returned 430
1948        cache
1949            .record_backend_missing(msgid.clone(), BackendId::from_index(1))
1950            .await;
1951
1952        let entry = cache.get(&msgid).await.unwrap();
1953
1954        // Now both backends should be marked missing
1955        assert!(!entry.should_try_backend(BackendId::from_index(0)));
1956        assert!(!entry.should_try_backend(BackendId::from_index(1)));
1957
1958        // Verify all backends exhausted
1959        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)); // 1MB
1968
1969        // Initial stats
1970        let stats = cache.stats();
1971        assert_eq!(stats.entry_count, 0);
1972
1973        // Insert one article
1974        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        // Wait for background tasks
1979        cache.sync().await;
1980
1981        // Check stats again
1982        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)); // 1MB
1989
1990        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        // Should be cached immediately
1996        assert!(cache.get(&msgid).await.is_some());
1997
1998        // Wait for TTL expiration + sync
1999        tokio::time::sleep(Duration::from_millis(100)).await;
2000        cache.sync().await;
2001
2002        // Should be expired
2003        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)); // 500 bytes total
2026
2027        // Insert 3 articles (exceeds capacity)
2028        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; // Force eviction
2034        }
2035
2036        // Wait for eviction to complete
2037        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)); // 1MB
2061        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        // Should be accessible from cloned cache
2070        assert!(cache2.get(&msgid).await.is_some());
2071    }
2072
2073    #[tokio::test]
2074    async fn test_weigher_large_articles() {
2075        // Test that large article bodies use ACTUAL SIZE (no multiplier)
2076        // when the response contains a real article body >10KB
2077        let cache = ArticleCache::new(10 * 1024 * 1024, Duration::from_secs(300)); // 10MB capacity
2078
2079        // Create a 750KB article (typical size)
2080        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        // With actual size (no multiplier): 750KB per entry
2092        // 10MB capacity should fit ~13 articles
2093        // With old 1.8x multiplier: 750KB * 1.8 ≈ 1.35MB per entry, fits ~7 articles
2094        // With old 2.5x multiplier: 750KB * 2.5 ≈ 1.875MB per entry, fits ~5 articles
2095
2096        // Insert 12 more articles (13 total)
2097        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        // With actual size (no multiplier), should fit 11-13 large articles
2115        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        // Test that small status-only responses account for moka internal overhead correctly
2125        // With MOKA_OVERHEAD = 2000 bytes (based on empirical 10x memory ratio from moka issue #473)
2126        let cache = ArticleCache::new(1_000_000, Duration::from_secs(300)); // 1MB capacity
2127
2128        // Create small metadata-only response (53 bytes)
2129        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        // With MOKA_OVERHEAD = 2000: parsed metadata + availability + overhead
2137        // fit comfortably without pretending the whole response is retained
2138        // With 2.5x small response multiplier: ~5400 bytes per response
2139        // 1MB capacity should fit ~185 metadata-only responses
2140
2141        // Insert many small metadata-only responses
2142        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        // Should be able to fit ~150-185 small metadata-only responses in 1MB
2156        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)); // 1MB
2166
2167        // Use owned MessageId
2168        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        // Retrieve with borrowed MessageId
2174        let borrowed_msgid = MessageId::from_borrowed("<owned@example.com>").unwrap();
2175        assert!(cache.get(&borrowed_msgid).await.is_some());
2176    }
2177}