Skip to main content

nntp_proxy/cache/
availability.rs

1//! Article missing-state tracking across backends
2//!
3//! Uses bitsets to track which backends definitively do not have a specific article.
4//! This is a self-contained type used by both the cache layer (persistence) and
5//! the retry loop (transient tracking).
6//!
7//! # NNTP Response Semantics (CRITICAL)
8//!
9//! **430 "No Such Article" is AUTHORITATIVE** - once a backend returns 430 for
10//! a cache entry, that backend stays missing for the lifetime of that entry.
11//!
12//! **2xx success responses are UNRELIABLE** - servers CAN give false positives,
13//! so they are not represented in this structure.
14//!
15//! Availability uses `usize` bitmaps, so local retry checks remain compact while
16//! allowing the backend count to grow with the target word size.
17
18use crate::router::BackendCount;
19use crate::types::BackendId;
20
21/// Maximum number of backends supported by `ArticleAvailability` bitset.
22///
23/// This is deliberately a fixed `usize` bitmap. It used to be u8; it is wider
24/// now because I am tired of being harassed by shitty robots about 8 backends.
25pub const MAX_BACKENDS: usize = BackendId::MAX_COUNT;
26
27/// Status of a backend for a specific article
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BackendStatus {
30    /// Backend hasn't been checked yet
31    Unknown,
32    /// Backend was checked and returned 430 (doesn't have article)
33    Missing,
34}
35
36/// Track which backends are known not to have a specific article.
37///
38/// Uses a `usize` bitset to track which backends returned authoritative 430.
39///
40/// # Example with 2 backends
41/// - Initial state: `missing=00` (no backend is known missing)
42/// - After backend 0 returns 430: `missing=01` (backend 0 doesn't have it)
43/// - If both return 430: `missing=11` (all backends exhausted)
44///
45/// # Usage Pattern
46/// This type serves two critical purposes:
47///
48/// 1. **Cache persistence** - Track availability across requests (long-lived)
49///    - Store authoritative negative facts in cache entries
50///    - Avoid querying backends known to be missing
51///    - Updated only after 430 responses
52///
53/// 2. **430 retry loop** - Track which backends tried during single request (transient)
54///    - Create fresh instance for each ARTICLE request
55///    - Mark backends as missing when they return 430
56///    - Stop when all backends exhausted or one succeeds
57///
58/// # Concurrency
59/// Cache entries store this value directly. Memory-cache updates use atomic
60/// per-key merge operations, and hybrid-cache updates use per-key locks before
61/// replacing an entry. Request-local retry state owns a separate copy, then
62/// records each 430 fact directly to the cache.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct ArticleAvailability {
65    /// Bitset of backends that DON'T have this article (returned 430)
66    missing: usize,
67}
68
69impl ArticleAvailability {
70    /// Create empty availability - assume all backends have article until proven otherwise
71    #[inline]
72    #[must_use]
73    pub const fn new() -> Self {
74        Self { missing: 0 }
75    }
76
77    /// Record that a backend returned 430 (doesn't have the article).
78    ///
79    /// Once marked missing, the backend should not be retried for this cache entry.
80    /// Later positive observations do not clear the missing bit.
81    ///
82    #[inline]
83    pub fn record_missing(&mut self, backend_id: BackendId) -> &mut Self {
84        let mask = backend_id.availability_bit();
85        self.missing |= mask; // Mark as missing
86        self
87    }
88
89    /// Check if a backend is known to be missing (returned 430)
90    ///
91    #[inline]
92    #[must_use]
93    pub fn is_missing(&self, backend_id: BackendId) -> bool {
94        self.missing & backend_id.availability_bit() != 0
95    }
96
97    /// Check if we should attempt to fetch from this backend
98    ///
99    /// Returns `true` if backend might have the article (not yet marked missing).
100    ///
101    #[inline]
102    #[must_use]
103    pub(crate) fn should_try(&self, backend_id: BackendId) -> bool {
104        !self.is_missing(backend_id)
105    }
106
107    /// Get the raw missing bitset for debugging
108    #[inline]
109    #[must_use]
110    pub const fn missing_bits(&self) -> usize {
111        self.missing
112    }
113
114    /// Check if all backends in the pool have been tried and returned 430
115    ///
116    /// Check if all backends have been tried and returned 430
117    ///
118    #[inline]
119    #[must_use]
120    pub fn all_exhausted(&self, backend_count: BackendCount) -> bool {
121        let expected_missing = match backend_count.get() {
122            0 => 0,
123            MAX_BACKENDS => usize::MAX,
124            n => (1usize << n) - 1,
125        };
126        self.missing & expected_missing == expected_missing
127    }
128
129    /// Reconstruct from stored negative bits.
130    #[inline]
131    #[must_use]
132    pub(crate) const fn from_missing_bits(missing: usize) -> Self {
133        Self { missing }
134    }
135
136    /// Check if we have any authoritative backend-missing information.
137    ///
138    /// Returns true if at least one backend is known missing.
139    #[inline]
140    #[must_use]
141    pub const fn has_availability_info(&self) -> bool {
142        self.missing != 0
143    }
144
145    /// Query backend availability status
146    ///
147    #[inline]
148    #[must_use]
149    pub fn status(&self, backend_id: BackendId) -> BackendStatus {
150        let mask = backend_id.availability_bit();
151        if self.missing & mask != 0 {
152            BackendStatus::Missing
153        } else {
154            BackendStatus::Unknown
155        }
156    }
157}
158
159impl Default for ArticleAvailability {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::router::BackendCount;
169
170    fn backend_count(count: usize) -> BackendCount {
171        BackendCount::try_new(count).expect("test backend count fits availability bitmap")
172    }
173    use crate::types::BackendId;
174
175    #[test]
176    fn test_backend_availability_basic() {
177        let mut avail = ArticleAvailability::new();
178        let b0 = BackendId::from_index(0);
179        let b1 = BackendId::from_index(1);
180
181        // Default: assume all backends have it
182        assert!(avail.should_try(b0));
183        assert!(avail.should_try(b1));
184
185        // Record b0 as missing (returned 430)
186        avail.record_missing(b0);
187        assert!(!avail.should_try(b0)); // Should not try again
188        assert!(avail.should_try(b1)); // Still should try
189
190        // Record b1 as missing too
191        avail.record_missing(b1);
192        assert!(!avail.should_try(b1));
193    }
194
195    #[test]
196    fn missing_backend_is_not_eligible() {
197        let mut avail = ArticleAvailability::new();
198        let b0 = BackendId::from_index(0);
199
200        // First mark as missing
201        avail.record_missing(b0);
202        assert!(avail.is_missing(b0));
203
204        assert!(avail.is_missing(b0));
205    }
206
207    #[test]
208    fn success_observation_does_not_change_availability() {
209        let mut cache_state = ArticleAvailability::new();
210        let b0 = BackendId::from_index(0);
211        let b1 = BackendId::from_index(1);
212
213        cache_state.record_missing(b0);
214        cache_state.record_missing(b1);
215        assert!(cache_state.is_missing(b0));
216        assert!(cache_state.is_missing(b1));
217
218        let fresh = ArticleAvailability::new();
219        assert!(!fresh.is_missing(b0));
220
221        assert_eq!(cache_state.missing_bits(), 0b11);
222    }
223
224    #[test]
225    fn test_backend_availability_all_exhausted() {
226        let mut avail = ArticleAvailability::new();
227
228        // None missing yet
229        assert!(!avail.all_exhausted(backend_count(2)));
230        assert!(!avail.all_exhausted(backend_count(3)));
231
232        // Record backends 0 and 1 as missing
233        avail.record_missing(BackendId::from_index(0));
234        avail.record_missing(BackendId::from_index(1));
235
236        // All 2 backends exhausted
237        assert!(avail.all_exhausted(backend_count(2)));
238
239        // But not all 3 backends (backend 2 still untried)
240        assert!(!avail.all_exhausted(backend_count(3)));
241    }
242}