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}