nntp-proxy 0.5.1

NNTP proxy server with per-command backend multiplexing, caching, metrics, and TUI dashboard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Backend selection strategies
//!
//! This module contains different algorithms for selecting backend servers:
//! - Weighted round-robin: Distributes based on connection pool size
//! - Least-loaded: Selects backend with fewest pending requests

use std::sync::atomic::{AtomicUsize, Ordering};

/// Strategy for selecting backends based on weighted round-robin
///
/// Uses the pool size (`max_connections`) as the weight to ensure backends
/// with larger pools receive proportionally more requests.
///
/// Algorithm: Map counter to weighted position, then find which backend owns that slot.
#[derive(Debug)]
pub struct WeightedRoundRobin {
    /// Current counter for round-robin selection
    counter: AtomicUsize,
    /// Total weight across all backends (sum of all `max_connections`)
    total_weight: usize,
}

impl WeightedRoundRobin {
    /// Create a new weighted round-robin strategy
    #[must_use]
    pub const fn new(total_weight: usize) -> Self {
        Self {
            counter: AtomicUsize::new(0),
            total_weight,
        }
    }

    /// Update total weight when backends are added
    pub const fn set_total_weight(&mut self, total_weight: usize) {
        self.total_weight = total_weight;
    }

    /// Select backend index using a specific weight
    ///
    /// This method allows tier-aware selection by using a tier's total weight
    /// instead of the global total weight. This avoids modulo bias when selecting
    /// within a tier that has a different total weight than the global weight.
    ///
    /// The atomic counter is still shared across all calls, ensuring fair
    /// distribution even when tier weights change.
    #[must_use]
    pub fn select_with_weight(&self, weight: usize) -> Option<usize> {
        if weight == 0 {
            return None;
        }

        let counter = self.counter.fetch_add(1, Ordering::Relaxed);
        Some(counter % weight)
    }

    /// Get total weight
    #[must_use]
    pub const fn total_weight(&self) -> usize {
        self.total_weight
    }
}

/// Strategy for selecting backends based on current load
///
/// Routes requests to the backend with the fewest pending requests,
/// accounting for backend capacity (`max_connections`).
///
/// Algorithm: Calculate `load_ratio` = pending / `max_connections` for each backend,
/// select the one with lowest ratio. Breaks equal-load ties with a cheap
/// pseudo-random replacement decision.
#[derive(Debug)]
pub struct LeastLoaded {
    tie_breaker: AtomicUsize,
}

impl LeastLoaded {
    /// Create a new least-loaded strategy
    #[must_use]
    pub const fn new() -> Self {
        Self {
            tie_breaker: AtomicUsize::new(0),
        }
    }

    /// Return true when an equal-load candidate should replace the current winner.
    ///
    /// This is only called after a real tie is found. It keeps the common no-tie
    /// path free of random work and avoids allocating a candidate list.
    #[must_use]
    pub fn should_replace_tie(&self, tie_count: usize) -> bool {
        debug_assert!(tie_count > 1);
        if tie_count <= 1 {
            return false;
        }

        let counter = self
            .tie_breaker
            .fetch_add(0x9e37_79b9_7f4a_7c15usize, Ordering::Relaxed);
        Self::mix(counter).is_multiple_of(tie_count)
    }

    #[inline]
    fn mix(mut value: usize) -> usize {
        value ^= value >> 16;
        value = value.wrapping_mul(0x7feb_352d);
        value ^= value >> 15;
        value = value.wrapping_mul(0x846c_a68b);
        value ^ (value >> 16)
    }
}

impl Default for LeastLoaded {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;
    use std::thread;

    #[test]
    fn test_weighted_round_robin_basic() {
        let strategy = WeightedRoundRobin::new(10);

        // Should cycle through 0-9
        for i in 0..20 {
            assert_eq!(
                strategy.select_with_weight(strategy.total_weight()),
                Some(i % 10)
            );
        }
    }

    #[test]
    fn test_weighted_round_robin_zero_weight() {
        let strategy = WeightedRoundRobin::new(0);
        assert_eq!(strategy.select_with_weight(strategy.total_weight()), None);
    }

    #[test]
    fn test_weighted_round_robin_one_weight() {
        let strategy = WeightedRoundRobin::new(1);

        // All selections should return 0
        for _ in 0..100 {
            assert_eq!(
                strategy.select_with_weight(strategy.total_weight()),
                Some(0)
            );
        }
    }

    #[test]
    fn test_set_total_weight() {
        let mut strategy = WeightedRoundRobin::new(10);
        assert_eq!(strategy.total_weight(), 10);

        strategy.set_total_weight(20);
        assert_eq!(strategy.total_weight(), 20);
    }

    #[test]
    fn test_set_total_weight_to_zero() {
        let mut strategy = WeightedRoundRobin::new(10);

        // First should work
        assert!(
            strategy
                .select_with_weight(strategy.total_weight())
                .is_some()
        );

        // Change to zero
        strategy.set_total_weight(0);
        assert_eq!(strategy.total_weight(), 0);
        assert_eq!(strategy.select_with_weight(strategy.total_weight()), None);
    }

    #[test]
    fn test_weighted_round_robin_large_weight() {
        let strategy = WeightedRoundRobin::new(1000);

        // Should cycle through 0-999
        for i in 0..2000 {
            assert_eq!(
                strategy.select_with_weight(strategy.total_weight()),
                Some(i % 1000)
            );
        }
    }

    #[test]
    fn test_weighted_round_robin_odd_weight() {
        let strategy = WeightedRoundRobin::new(7);

        // Should cycle through 0-6
        for i in 0..21 {
            assert_eq!(
                strategy.select_with_weight(strategy.total_weight()),
                Some(i % 7)
            );
        }
    }

    #[test]
    fn test_weighted_round_robin_prime_weight() {
        let strategy = WeightedRoundRobin::new(13);

        // Test with prime number to ensure no modulo bias
        for i in 0..26 {
            assert_eq!(
                strategy.select_with_weight(strategy.total_weight()),
                Some(i % 13)
            );
        }
    }

    #[test]
    fn test_weighted_round_robin_counter_wraparound() {
        let strategy = WeightedRoundRobin::new(10);

        // Set counter to near max value (simulate wraparound without looping)
        strategy.counter.store(usize::MAX - 5, Ordering::Relaxed);

        // Next few selections should still work correctly
        // Even when counter wraps around, modulo should still produce valid results
        for _ in 0..10 {
            let result = strategy.select_with_weight(strategy.total_weight());
            assert!(result.is_some());
            assert!(result.unwrap() < 10);
        }
    }

    #[test]
    fn test_weighted_round_robin_distribution() {
        let strategy = WeightedRoundRobin::new(10);
        let mut counts = [0usize; 10];

        // Make 1000 selections
        for _ in 0..1000 {
            let pos = strategy
                .select_with_weight(strategy.total_weight())
                .unwrap();
            counts[pos] += 1;
        }

        // Each position should get exactly 100 selections
        for &count in &counts {
            assert_eq!(count, 100);
        }
    }

    #[test]
    fn test_weighted_round_robin_concurrent() {
        let strategy = Arc::new(WeightedRoundRobin::new(100));
        let mut handles = vec![];

        // Spawn 10 threads, each making 100 selections
        for _ in 0..10 {
            let strategy_clone = Arc::clone(&strategy);
            handles.push(thread::spawn(move || {
                let mut results = vec![];
                for _ in 0..100 {
                    results.push(strategy_clone.select_with_weight(100).unwrap());
                }
                results
            }));
        }

        // Collect all results
        let mut all_results = vec![];
        for handle in handles {
            all_results.extend(handle.join().unwrap());
        }

        // Should have 1000 total selections
        assert_eq!(all_results.len(), 1000);

        // All should be valid (< 100)
        for result in all_results {
            assert!(result < 100);
        }
    }

    #[test]
    fn test_weighted_round_robin_concurrent_distribution() {
        let strategy = Arc::new(WeightedRoundRobin::new(50));
        let mut handles = vec![];

        // Spawn 5 threads, each making 1000 selections
        for _ in 0..5 {
            let strategy_clone = Arc::clone(&strategy);
            handles.push(thread::spawn(move || {
                let mut counts = [0usize; 50];
                for _ in 0..1000 {
                    let pos = strategy_clone.select_with_weight(50).unwrap();
                    counts[pos] += 1;
                }
                counts
            }));
        }

        // Aggregate counts from all threads
        let mut total_counts = [0usize; 50];
        for handle in handles {
            let thread_counts = handle.join().unwrap();
            for (i, &count) in thread_counts.iter().enumerate() {
                total_counts[i] += count;
            }
        }

        // Each position should get exactly 100 selections (5 threads × 1000 / 50)
        for &count in &total_counts {
            assert_eq!(count, 100);
        }
    }

    #[test]
    fn test_weighted_round_robin_new_default_state() {
        let strategy = WeightedRoundRobin::new(42);

        // First selection should be 0 (counter starts at 0)
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(0)
        );
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(1)
        );
    }

    #[test]
    fn test_weighted_round_robin_debug_format() {
        let strategy = WeightedRoundRobin::new(10);
        let debug_str = format!("{strategy:?}");

        assert!(debug_str.contains("WeightedRoundRobin"));
    }

    #[test]
    fn test_set_total_weight_multiple_times() {
        let mut strategy = WeightedRoundRobin::new(10);

        assert_eq!(strategy.total_weight(), 10);
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(0)
        );

        strategy.set_total_weight(5);
        assert_eq!(strategy.total_weight(), 5);
        // Counter continues from 1, so 1 % 5 = 1
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(1)
        );

        strategy.set_total_weight(20);
        assert_eq!(strategy.total_weight(), 20);
        // Counter at 2, so 2 % 20 = 2
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(2)
        );
    }

    #[test]
    fn test_weighted_round_robin_power_of_two() {
        let strategy = WeightedRoundRobin::new(64);

        // Powers of 2 should work efficiently with modulo
        for i in 0..128 {
            assert_eq!(
                strategy.select_with_weight(strategy.total_weight()),
                Some(i % 64)
            );
        }
    }

    #[test]
    fn test_weighted_round_robin_max_usize_weight() {
        // This is an edge case - extremely large weight
        let strategy = WeightedRoundRobin::new(usize::MAX);

        // First few selections should work normally
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(0)
        );
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(1)
        );
        assert_eq!(
            strategy.select_with_weight(strategy.total_weight()),
            Some(2)
        );
    }
}