Skip to main content

nntp_proxy/router/
backend_info.rs

1//! Backend connection information and load tracking types
2//!
3//! Contains `BackendInfo` (the per-backend metadata) and its supporting
4//! atomic counter types for load balancing.
5
6use derive_more::{AsRef, Deref, Display, From};
7use std::cmp::Ordering as CmpOrdering;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11use crate::pool::DeadpoolConnectionProvider;
12use crate::types::{BackendId, ServerName};
13
14/// Load ratio (pending requests / max connections)
15///
16/// Lower ratios indicate less loaded backends. Range: 0.0 (empty) to `f64::MAX` (no capacity).
17#[derive(Debug, Clone, Copy, PartialEq, Display, From, AsRef, Deref)]
18pub struct LoadRatio(f64);
19
20impl LoadRatio {
21    /// Maximum load ratio when no capacity available
22    pub const MAX: Self = Self(f64::MAX);
23
24    /// Minimum load ratio for empty backend
25    pub const MIN: Self = Self(0.0);
26
27    /// Create a new load ratio
28    #[inline]
29    #[must_use]
30    pub const fn new(ratio: f64) -> Self {
31        Self(ratio)
32    }
33
34    /// Get the inner f64 value
35    #[inline]
36    #[must_use]
37    pub const fn get(&self) -> f64 {
38        self.0
39    }
40}
41
42impl PartialOrd for LoadRatio {
43    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
44        self.0.partial_cmp(&other.0)
45    }
46}
47
48/// Atomic counter for pending requests on a backend
49#[derive(Debug, Clone, Display, From, AsRef, Deref)]
50#[display("PendingCount({})", "_0.load(Ordering::Relaxed)")]
51pub struct PendingCount(Arc<AtomicUsize>);
52
53// Manual PartialEq because Arc<AtomicUsize> doesn't auto-derive
54impl PartialEq for PendingCount {
55    fn eq(&self, other: &Self) -> bool {
56        self.get() == other.get()
57    }
58}
59
60impl PartialEq<usize> for PendingCount {
61    fn eq(&self, other: &usize) -> bool {
62        self.get() == *other
63    }
64}
65
66impl Eq for PendingCount {}
67
68impl PendingCount {
69    /// Create a new pending count initialized to zero
70    #[inline]
71    #[must_use]
72    pub fn new() -> Self {
73        Self(Arc::new(AtomicUsize::new(0)))
74    }
75
76    /// Increment the pending count
77    #[inline]
78    pub fn increment(&self) {
79        self.0.fetch_add(1, Ordering::Relaxed);
80    }
81
82    /// Increment the pending count only if it still matches the observed value.
83    #[inline]
84    #[must_use]
85    pub fn try_increment_from(&self, observed: usize) -> bool {
86        self.0
87            .compare_exchange(observed, observed + 1, Ordering::AcqRel, Ordering::Acquire)
88            .is_ok()
89    }
90
91    /// Decrement the pending count
92    #[inline]
93    pub fn decrement(&self) {
94        self.0.fetch_sub(1, Ordering::Relaxed);
95    }
96
97    /// Get the current pending count
98    #[inline]
99    #[must_use]
100    pub fn get(&self) -> usize {
101        self.0.load(Ordering::Relaxed)
102    }
103}
104
105impl Default for PendingCount {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// Atomic counter for stateful connections on a backend
112#[derive(Debug, Clone, Display, From, AsRef, Deref)]
113#[display("StatefulCount({})", "_0.load(Ordering::Relaxed)")]
114pub struct StatefulCount(Arc<AtomicUsize>);
115
116// Manual PartialEq because Arc<AtomicUsize> doesn't auto-derive
117impl PartialEq for StatefulCount {
118    fn eq(&self, other: &Self) -> bool {
119        self.get() == other.get()
120    }
121}
122
123impl PartialEq<usize> for StatefulCount {
124    fn eq(&self, other: &usize) -> bool {
125        self.get() == *other
126    }
127}
128
129impl Eq for StatefulCount {}
130
131impl StatefulCount {
132    /// Create a new stateful count initialized to zero
133    #[inline]
134    #[must_use]
135    pub fn new() -> Self {
136        Self(Arc::new(AtomicUsize::new(0)))
137    }
138
139    /// Get the current stateful count
140    #[inline]
141    #[must_use]
142    pub fn get(&self) -> usize {
143        self.0.load(Ordering::Relaxed)
144    }
145
146    /// Try to acquire a stateful slot (compare-exchange loop)
147    ///
148    /// Returns true if successfully incremented below `max_stateful` limit
149    #[must_use]
150    pub fn try_acquire(&self, max_stateful: usize) -> bool {
151        let mut current = self.0.load(Ordering::Acquire);
152        loop {
153            if current >= max_stateful {
154                return false;
155            }
156
157            match self.0.compare_exchange_weak(
158                current,
159                current + 1,
160                Ordering::AcqRel,
161                Ordering::Acquire,
162            ) {
163                Ok(_) => return true,
164                Err(actual) => current = actual,
165            }
166        }
167    }
168
169    /// Release a stateful slot (decrement if > 0)
170    ///
171    /// Returns `Ok(previous_value)` if successfully decremented, `Err(0)` if already zero
172    ///
173    /// # Errors
174    /// Returns `Err(0)` when the counter is already zero and cannot be decremented.
175    pub fn release(&self) -> Result<usize, usize> {
176        self.0
177            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
178                if current == 0 {
179                    None
180                } else {
181                    Some(current - 1)
182                }
183            })
184    }
185}
186
187impl Default for StatefulCount {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193/// Backend connection information
194#[derive(Debug, Clone)]
195pub(super) struct BackendInfo {
196    /// Backend identifier
197    pub(super) id: BackendId,
198    /// Server name for logging
199    pub(super) name: ServerName,
200    /// Connection provider for this backend
201    pub(super) provider: DeadpoolConnectionProvider,
202    /// Number of pending requests on this backend (for load balancing)
203    pub(super) pending_count: PendingCount,
204    /// Number of connections in stateful mode (for hybrid routing reservation)
205    pub(super) stateful_count: StatefulCount,
206    /// Server tier for prioritization (lower = higher priority)
207    pub(super) tier: u8,
208}
209
210impl BackendInfo {
211    /// Calculate load ratio (pending requests / max connections)
212    ///
213    /// Lower ratios indicate less loaded backends.
214    #[must_use]
215    pub(super) fn load_ratio(&self) -> LoadRatio {
216        // Load ratio is a relative routing score. Exact integer counts are kept
217        // separately; float precision is sufficient for comparing backend load.
218        #[allow(clippy::cast_precision_loss)]
219        // This relative load score is display/ranking data, not exact accounting.
220        // Ratio comparisons do not require exact integer preservation.
221        let status = self.provider.status_counts();
222        let max_conns = status.max_size as f64;
223        if max_conns > 0.0 {
224            let checked_out = status.size.saturating_sub(status.available);
225            #[allow(clippy::cast_precision_loss)]
226            // Pending counts only feed the relative load score.
227            let active = self.pending_count.get().max(checked_out) as f64;
228            LoadRatio::new(active / max_conns)
229        } else {
230            LoadRatio::MAX
231        }
232    }
233}