Skip to main content

nntp_proxy/router/
mod.rs

1//! Backend server selection and load balancing
2//!
3//! This module handles selecting backend servers using round-robin
4//! with simple load tracking for monitoring.
5//!
6//! # Overview
7//!
8//! The `BackendSelector` provides thread-safe backend selection for routing
9//! NNTP commands across multiple backend servers.
10//!
11//! # Usage
12//!
13//! ```no_run
14//! use nntp_proxy::router::BackendSelector;
15//! use nntp_proxy::types::{ClientId, ServerName};
16//! # use nntp_proxy::pool::DeadpoolConnectionProvider;
17//!
18//! let mut selector = BackendSelector::new();
19//! # let provider = DeadpoolConnectionProvider::new(
20//! #     "localhost".to_string(), 119, "test".to_string(), 10, None, None
21//! # );
22//! selector.add_backend(
23//!     ServerName::try_new("server1".to_string()).unwrap(),
24//!     provider,
25//!     0, // tier (lower = higher priority)
26//! );
27//!
28//! // Route a command
29//! let client_id = ClientId::new();
30//! let backend_id = selector
31//!     .route(nntp_proxy::router::RouteRequest::new(client_id))
32//!     .unwrap();
33//!
34//! // After command completes
35//! selector.complete_command(backend_id);
36//! ```
37
38mod backend_info;
39mod strategies;
40
41use anyhow::Result;
42use nutype::nutype;
43use std::cmp::Ordering as CmpOrdering;
44use std::sync::Arc;
45use std::sync::atomic::{AtomicUsize, Ordering};
46use tracing::{debug, info};
47
48use crate::cache::ArticleAvailability;
49use crate::config::BackendSelectionStrategy;
50use crate::pool::DeadpoolConnectionProvider;
51use crate::types::{BackendId, ClientId, ServerName};
52use strategies::{LeastLoaded, WeightedRoundRobin};
53
54use backend_info::BackendInfo;
55pub use backend_info::{LoadRatio, PendingCount, StatefulCount};
56
57mod route_mode {
58    pub trait Sealed {}
59}
60
61struct SelectedBackend<'a> {
62    backend: &'a BackendInfo,
63    pending_snapshot: Option<usize>,
64}
65
66/// Builder for selecting a backend.
67#[derive(Debug, Clone)]
68pub struct RouteRequest<'a, Mode = RawRoute> {
69    _client_id: ClientId,
70    suppressed_backends: SuppressedBackends,
71    mode: Mode,
72    _lifetime: std::marker::PhantomData<&'a ()>,
73}
74
75/// Transient backend suppressions for a single retry loop.
76///
77/// This is intentionally distinct from article availability. Suppression means
78/// "do not pick this backend again for this in-flight request because its pool
79/// or connection failed"; it does not mean the backend lacks the article.
80///
81/// Keep this as a fixed bitmap. Real deployments have a small number of Usenet
82/// backends, and a dynamic set here would only add overhead to a hot retry path.
83/// This is deliberately the same fixed bitmap width as `ArticleAvailability`.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub struct SuppressedBackends {
86    bits: usize,
87}
88
89impl SuppressedBackends {
90    #[must_use]
91    pub const fn empty() -> Self {
92        Self { bits: 0 }
93    }
94
95    pub fn suppress(&mut self, backend_id: BackendId) {
96        self.bits |= backend_id.availability_bit();
97    }
98
99    #[must_use]
100    pub fn contains(self, backend_id: BackendId) -> bool {
101        self.bits & backend_id.availability_bit() != 0
102    }
103
104    #[must_use]
105    pub const fn bits(self) -> usize {
106        self.bits
107    }
108}
109
110/// Routing without article availability state.
111#[derive(Debug, Clone, Copy)]
112pub struct RawRoute;
113
114/// Routing for article commands with availability state.
115#[derive(Debug, Clone, Copy)]
116pub struct ArticleRoute<'a> {
117    availability: &'a ArticleAvailability,
118}
119
120/// Backend selected through article availability routing.
121///
122/// Article execution accepts this type instead of raw `BackendId`, so a caller
123/// must route with current `ArticleAvailability` before it can issue an article
124/// request to a backend.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct ArticleBackend {
127    backend_id: BackendId,
128}
129
130impl ArticleBackend {
131    #[inline]
132    #[must_use]
133    pub(crate) fn from_availability(
134        backend_id: BackendId,
135        availability: &ArticleAvailability,
136    ) -> Option<Self> {
137        availability
138            .should_try(backend_id)
139            .then_some(Self { backend_id })
140    }
141
142    #[inline]
143    #[must_use]
144    pub const fn backend_id(self) -> BackendId {
145        self.backend_id
146    }
147
148    #[inline]
149    #[must_use]
150    pub fn as_index(self) -> usize {
151        self.backend_id.as_index()
152    }
153}
154
155impl RouteRequest<'_, RawRoute> {
156    #[must_use]
157    pub fn new(client_id: ClientId) -> Self {
158        Self {
159            _client_id: client_id,
160            suppressed_backends: SuppressedBackends::empty(),
161            mode: RawRoute,
162            _lifetime: std::marker::PhantomData,
163        }
164    }
165
166    #[must_use]
167    pub fn with_availability(
168        self,
169        availability: &ArticleAvailability,
170    ) -> RouteRequest<'_, ArticleRoute<'_>> {
171        RouteRequest {
172            _client_id: self._client_id,
173            suppressed_backends: self.suppressed_backends,
174            mode: ArticleRoute { availability },
175            _lifetime: std::marker::PhantomData,
176        }
177    }
178
179    #[must_use]
180    pub fn suppressing_backends(mut self, suppressed_backends: SuppressedBackends) -> Self {
181        self.suppressed_backends = suppressed_backends;
182        self
183    }
184}
185
186impl RouteRequest<'_, ArticleRoute<'_>> {
187    #[must_use]
188    pub fn suppressing_backends(mut self, suppressed_backends: SuppressedBackends) -> Self {
189        self.suppressed_backends = suppressed_backends;
190        self
191    }
192}
193
194#[doc(hidden)]
195pub trait RouteMode: route_mode::Sealed {
196    type Output;
197
198    fn availability<'r>(request: &'r RouteRequest<'_, Self>) -> Option<&'r ArticleAvailability>
199    where
200        Self: Sized;
201
202    fn output(request: &RouteRequest<'_, Self>, backend_id: BackendId) -> Result<Self::Output>
203    where
204        Self: Sized;
205}
206
207impl RouteMode for RawRoute {
208    type Output = BackendId;
209
210    fn availability<'r>(_request: &'r RouteRequest<'_, Self>) -> Option<&'r ArticleAvailability> {
211        None
212    }
213
214    fn output(_request: &RouteRequest<'_, Self>, backend_id: BackendId) -> Result<Self::Output> {
215        Ok(backend_id)
216    }
217}
218
219impl route_mode::Sealed for RawRoute {}
220
221impl RouteMode for ArticleRoute<'_> {
222    type Output = ArticleBackend;
223
224    fn availability<'r>(request: &'r RouteRequest<'_, Self>) -> Option<&'r ArticleAvailability> {
225        Some(request.mode.availability)
226    }
227
228    fn output(request: &RouteRequest<'_, Self>, backend_id: BackendId) -> Result<Self::Output> {
229        ArticleBackend::from_availability(backend_id, request.mode.availability).ok_or_else(|| {
230            anyhow::anyhow!(
231                "selected backend {} is no longer eligible for article routing",
232                backend_id.as_index()
233            )
234        })
235    }
236}
237
238impl route_mode::Sealed for ArticleRoute<'_> {}
239
240/// Selection strategy enum that holds either strategy type
241#[derive(Debug)]
242enum SelectionStrategy {
243    WeightedRoundRobin(WeightedRoundRobin),
244    LeastLoaded(LeastLoaded),
245}
246
247/// Number of backend servers in the router.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
249pub struct BackendCount(usize);
250
251impl PartialEq<usize> for BackendCount {
252    fn eq(&self, other: &usize) -> bool {
253        self.0 == *other
254    }
255}
256
257impl PartialOrd<usize> for BackendCount {
258    fn partial_cmp(&self, other: &usize) -> Option<CmpOrdering> {
259        self.0.partial_cmp(other)
260    }
261}
262
263impl std::fmt::Display for BackendCount {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        self.0.fmt(f)
266    }
267}
268
269impl BackendCount {
270    /// Maximum backend count that fits the article availability bitmap.
271    pub const MAX: usize = BackendId::MAX_COUNT;
272
273    /// Zero backends
274    #[must_use]
275    pub const fn zero() -> Self {
276        Self(0)
277    }
278
279    /// Construct a bounded backend count from a raw length.
280    #[must_use]
281    pub const fn try_new(count: usize) -> Option<Self> {
282        if count <= Self::MAX {
283            Some(Self(count))
284        } else {
285            None
286        }
287    }
288
289    /// Get the inner usize value
290    #[inline]
291    #[must_use]
292    pub const fn get(self) -> usize {
293        self.0
294    }
295
296    /// Iterate every valid backend ID in this bounded count.
297    pub fn backend_ids(self) -> impl ExactSizeIterator<Item = BackendId> {
298        (0..self.0).map(BackendId::from_index)
299    }
300
301    fn from_router_len(count: usize) -> Self {
302        Self::try_new(count).expect("router backend count exceeds availability bitmap")
303    }
304}
305
306/// Total weight across all backends (sum of `max_connections`)
307#[nutype(derive(
308    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Display, From, AsRef
309))]
310pub struct TotalWeight(usize);
311
312impl PartialEq<usize> for TotalWeight {
313    fn eq(&self, other: &usize) -> bool {
314        self.into_inner() == *other
315    }
316}
317
318impl PartialOrd<usize> for TotalWeight {
319    fn partial_cmp(&self, other: &usize) -> Option<CmpOrdering> {
320        self.into_inner().partial_cmp(other)
321    }
322}
323
324impl TotalWeight {
325    /// Zero weight
326    #[must_use]
327    pub fn zero() -> Self {
328        Self::new(0)
329    }
330
331    /// Get the inner usize value
332    #[inline]
333    #[must_use]
334    pub fn get(&self) -> usize {
335        self.into_inner()
336    }
337}
338
339/// Traffic share percentage for a backend
340#[nutype(derive(Debug, Clone, Copy, PartialEq, Display, From, AsRef))]
341pub struct TrafficShare(f64);
342
343impl TrafficShare {
344    /// Get the inner f64 value
345    #[inline]
346    #[must_use]
347    pub fn get(&self) -> f64 {
348        self.into_inner()
349    }
350
351    /// Calculate traffic share from `max_connections` and `total_weight`
352    #[inline]
353    #[must_use]
354    pub fn from_weight(max_connections: usize, total_weight: TotalWeight) -> Self {
355        if total_weight.get() > 0 {
356            // Traffic share is a display percentage; routing uses the original
357            // integer weights, so precision loss here cannot affect selection.
358            #[allow(clippy::cast_precision_loss)] // This is a display-only capacity percentage.
359            // Display-only percentage; backend weights stay in integer form.
360            Self::new((max_connections as f64 / total_weight.get() as f64) * 100.0)
361        } else {
362            Self::new(0.0)
363        }
364    }
365}
366
367/// RAII guard that decrements the backend's pending command count on drop.
368///
369/// Prevents TUI in-flight count drift when error paths forget to call `complete_command()`.
370/// On success paths, call [`CommandGuard::complete`] to explicitly finalize.
371/// On error/early-return paths, `Drop` handles cleanup automatically.
372pub struct CommandGuard {
373    router: Arc<BackendSelector>,
374    backend_id: BackendId,
375    completed: bool,
376}
377
378impl CommandGuard {
379    /// Create a new guard that will call `complete_command` on drop.
380    pub const fn new(router: Arc<BackendSelector>, backend_id: BackendId) -> Self {
381        Self {
382            router,
383            backend_id,
384            completed: false,
385        }
386    }
387
388    /// Explicitly complete (consumes the obligation, Drop becomes no-op).
389    pub fn complete(mut self) {
390        self.router.complete_command(self.backend_id);
391        self.completed = true;
392    }
393
394    /// Get the backend ID this guard is protecting.
395    #[must_use]
396    pub const fn backend_id(&self) -> BackendId {
397        self.backend_id
398    }
399}
400
401impl Drop for CommandGuard {
402    fn drop(&mut self) {
403        if !self.completed {
404            self.router.complete_command(self.backend_id);
405        }
406    }
407}
408
409/// Selects backend servers using weighted round-robin with load tracking
410///
411/// # Thread Safety
412///
413/// This struct is designed for concurrent access across multiple threads.
414/// The round-robin counter and pending counts use atomic operations for
415/// lock-free performance.
416///
417/// # Load Balancing
418///
419/// - **Strategy**: Weighted round-robin based on `max_connections`
420/// - **Tracking**: Atomic counters track pending commands per backend
421/// - **Monitoring**: Load statistics available via `backend_load()`
422/// - **Fairness**: Backends with larger pools receive proportionally more requests
423///
424/// # Examples
425///
426/// ```no_run
427/// # use nntp_proxy::router::{BackendSelector, RouteRequest};
428/// # use nntp_proxy::types::{ClientId, ServerName};
429/// # use nntp_proxy::pool::DeadpoolConnectionProvider;
430/// let mut selector = BackendSelector::new();
431///
432/// # let provider = DeadpoolConnectionProvider::new(
433/// #     "localhost".to_string(), 119, "test".to_string(), 10, None, None
434/// # );
435/// selector.add_backend(
436///     ServerName::try_new("backend-1".to_string()).unwrap(),
437///     provider,
438///     0, // tier (lower = higher priority)
439/// );
440///
441/// // Route commands without article availability filtering
442/// let backend = selector.route(RouteRequest::new(ClientId::new()))?;
443/// # Ok::<(), anyhow::Error>(())
444/// ```
445#[derive(Debug)]
446pub struct BackendSelector {
447    /// Backend connection providers
448    backends: Vec<BackendInfo>,
449    /// Selection strategy (weighted round-robin or least-loaded)
450    strategy: SelectionStrategy,
451    /// H4: Pre-computed sorted unique tiers (avoids Vec allocation in hot path)
452    sorted_tiers: smallvec::SmallVec<[u8; 4]>,
453    /// Capacity-fair probe counter for the first availability-aware article attempt.
454    initial_article_probe_counter: AtomicUsize,
455}
456
457impl Default for BackendSelector {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463impl BackendSelector {
464    /// Find backend by ID
465    ///
466    /// Common helper to avoid repeating find logic across methods.
467    #[inline]
468    fn find_backend(&self, backend_id: BackendId) -> Option<&BackendInfo> {
469        self.backends.iter().find(|b| b.id == backend_id)
470    }
471
472    /// Get the tier for a backend
473    ///
474    /// Returns the tier value for the specified backend, or None if the backend doesn't exist.
475    /// Used by cache to implement tier-aware TTL (higher tier = longer TTL).
476    #[inline]
477    #[must_use]
478    pub fn get_tier(&self, backend_id: BackendId) -> Option<u8> {
479        self.find_backend(backend_id).map(|b| b.tier)
480    }
481
482    /// Iterate backend tiers in priority order.
483    ///
484    /// Lower tier numbers have higher priority.
485    pub(crate) fn tiers(&self) -> impl Iterator<Item = u8> + '_ {
486        self.sorted_tiers.iter().copied()
487    }
488
489    /// Iterate backend IDs within a tier.
490    pub(crate) fn backend_ids_in_tier(&self, tier: u8) -> impl Iterator<Item = BackendId> + '_ {
491        self.backends
492            .iter()
493            .filter(move |backend| backend.tier == tier)
494            .map(|backend| backend.id)
495    }
496
497    /// Create a new backend selector with weighted round-robin strategy (default)
498    #[must_use]
499    pub fn new() -> Self {
500        Self::with_strategy(BackendSelectionStrategy::WeightedRoundRobin)
501    }
502
503    /// Create a new backend selector with specified strategy
504    #[must_use]
505    pub fn with_strategy(strategy: BackendSelectionStrategy) -> Self {
506        let selection_strategy = match strategy {
507            BackendSelectionStrategy::WeightedRoundRobin => {
508                SelectionStrategy::WeightedRoundRobin(WeightedRoundRobin::new(0))
509            }
510            BackendSelectionStrategy::LeastLoaded => {
511                SelectionStrategy::LeastLoaded(LeastLoaded::new())
512            }
513        };
514
515        Self {
516            // Pre-allocate for typical number of backend servers (most setups have 2-8)
517            backends: Vec::with_capacity(4),
518            strategy: selection_strategy,
519            sorted_tiers: smallvec::SmallVec::new(),
520            initial_article_probe_counter: AtomicUsize::new(0),
521        }
522    }
523
524    /// Add a backend server to the router
525    ///
526    /// # Arguments
527    /// * `name` - Human-readable name for logging
528    /// * `provider` - Connection pool provider
529    /// * `tier` - Server tier (lower = higher priority, 0 is highest)
530    ///
531    /// Returns the assigned `BackendId`. Each proxy port owns one router;
532    /// production setup adds servers in config order, so backend IDs stay contiguous.
533    pub fn add_backend(
534        &mut self,
535        name: ServerName,
536        provider: DeadpoolConnectionProvider,
537        tier: u8,
538    ) -> BackendId {
539        let backend_id = BackendId::from_index(self.backends.len());
540        let max_connections = provider.max_size();
541
542        // Update strategy-specific state
543        match &mut self.strategy {
544            SelectionStrategy::WeightedRoundRobin(wrr) => {
545                let old_weight = TotalWeight::new(wrr.total_weight());
546                let new_weight = TotalWeight::new(old_weight.get() + max_connections);
547                wrr.set_total_weight(new_weight.get());
548
549                // Calculate this backend's share of traffic
550                let traffic_share = TrafficShare::from_weight(max_connections, new_weight);
551
552                info!(
553                    "Added backend {:?} ({}) tier {} with {} connections - will receive {:.1}% of traffic (total weight: {} -> {}) [weighted round-robin]",
554                    backend_id,
555                    name,
556                    tier,
557                    max_connections,
558                    traffic_share.get(),
559                    old_weight,
560                    new_weight
561                );
562            }
563            SelectionStrategy::LeastLoaded(_) => {
564                info!(
565                    "Added backend {:?} ({}) tier {} with {} connections [least-loaded strategy]",
566                    backend_id, name, tier, max_connections
567                );
568            }
569        }
570
571        self.backends.push(BackendInfo {
572            id: backend_id,
573            name,
574            provider,
575            pending_count: PendingCount::new(),
576            stateful_count: StatefulCount::new(),
577            tier,
578        });
579
580        // H4: Maintain sorted unique tiers (avoids Vec allocation in select_backend hot path)
581        if !self.sorted_tiers.contains(&tier) {
582            self.sorted_tiers.push(tier);
583            self.sorted_tiers.sort_unstable();
584        }
585
586        backend_id
587    }
588
589    /// Select the next backend using the configured strategy with tier-aware prioritization
590    ///
591    /// Selection is tier-aware: backends with lower tier numbers are tried first.
592    /// Within each tier, the configured strategy applies:
593    /// - **Weighted round-robin**: Distributes proportionally to `max_connections`
594    /// - **Least-loaded**: Routes to backend with fewest pending requests
595    ///
596    /// # Arguments
597    /// * `availability` - Optional filter to restrict selection to available backends
598    fn select_backend(
599        &self,
600        availability: Option<&ArticleAvailability>,
601        suppressed_backends: &SuppressedBackends,
602    ) -> Option<SelectedBackend<'_>> {
603        if self.backends.is_empty() {
604            return None;
605        }
606
607        // Availability check closure
608        let is_available = |backend: &&BackendInfo| {
609            !suppressed_backends.contains(backend.id)
610                && availability.is_none_or(|avail| avail.should_try(backend.id))
611        };
612
613        // H4: Tier filtering enabled - try tiers in order 0, 1, 2, ...
614        // Use pre-computed sorted tiers (no allocation)
615        // Try each tier until we find an available backend
616        for &tier in &self.sorted_tiers {
617            // Only count available backends if debug logging is enabled (avoid O(n) scan)
618            if tracing::enabled!(tracing::Level::DEBUG) {
619                let available_in_tier = self
620                    .backends
621                    .iter()
622                    .filter(|b| b.tier == tier && is_available(b))
623                    .count();
624
625                tracing::debug!(
626                    tier = tier,
627                    available_in_tier = available_in_tier,
628                    "Checking tier for available backends"
629                );
630            }
631
632            // Try to select from this specific tier
633            let tier_filter = |b: &&BackendInfo| b.tier == tier && is_available(b);
634
635            let selected = if availability
636                .is_some_and(|avail| !self.availability_missing_in_tier(avail, tier))
637            {
638                self.select_capacity_weighted(tier_filter)
639                    .map(|backend| SelectedBackend {
640                        backend,
641                        pending_snapshot: None,
642                    })
643            } else {
644                self.select_weighted(tier_filter)
645            };
646            if tracing::enabled!(tracing::Level::DEBUG) {
647                self.debug_log_selection_candidates(
648                    tier,
649                    availability,
650                    selected.as_ref().map(|selected| selected.backend.id),
651                );
652            }
653
654            if let Some(selected) = selected {
655                tracing::debug!(
656                    backend_id = selected.backend.id.as_index(),
657                    backend_name = selected.backend.name.as_str(),
658                    tier = tier,
659                    "Selected backend"
660                );
661                return Some(selected);
662            }
663
664            if availability.is_some()
665                && self.backends.iter().any(|backend| {
666                    backend.tier == tier
667                        && availability.is_none_or(|avail| avail.should_try(backend.id))
668                })
669            {
670                tracing::debug!(
671                    tier = tier,
672                    suppressed_backends = format_args!("{:08b}", suppressed_backends.bits()),
673                    "No selectable backend remains in current tier after transient suppressions"
674                );
675                return None;
676            }
677
678            tracing::debug!(tier = tier, "No available backends in tier, trying next");
679        }
680
681        // All tiers exhausted
682        tracing::debug!("All tiers exhausted, no backends available");
683        None
684    }
685
686    fn availability_missing_in_tier(&self, availability: &ArticleAvailability, tier: u8) -> bool {
687        self.backends.iter().any(|backend| {
688            backend.tier == tier && availability.missing_bits() & backend.id.availability_bit() != 0
689        })
690    }
691
692    /// Select a backend by capacity weight, independent of current pending load.
693    fn select_capacity_weighted<F>(&self, filter: F) -> Option<&BackendInfo>
694    where
695        F: Fn(&&BackendInfo) -> bool,
696    {
697        let total_weight: usize = self
698            .backends
699            .iter()
700            .filter(&filter)
701            .map(|b| b.provider.max_size())
702            .sum();
703
704        if total_weight == 0 {
705            return None;
706        }
707
708        let position = self
709            .initial_article_probe_counter
710            .fetch_add(1, Ordering::Relaxed)
711            % total_weight;
712
713        self.backends
714            .iter()
715            .filter(&filter)
716            .scan(0, |cumulative, backend| {
717                *cumulative += backend.provider.max_size();
718                Some((*cumulative, backend))
719            })
720            .find(|(cumulative_weight, _)| position < *cumulative_weight)
721            .map(|(_, backend)| backend)
722            .or_else(|| self.backends.iter().find(&filter))
723    }
724
725    #[allow(clippy::cast_precision_loss)]
726    fn debug_log_selection_candidates(
727        &self,
728        tier: u8,
729        availability: Option<&ArticleAvailability>,
730        selected_backend: Option<BackendId>,
731    ) {
732        let availability_missing_bits = availability.map_or(0, ArticleAvailability::missing_bits);
733
734        for backend in self.backends.iter().filter(|backend| backend.tier == tier) {
735            let status = backend.provider.status_counts();
736            let checked_out = status.size.saturating_sub(status.available);
737            let pending = backend.pending_count.get();
738            let active_for_score = pending.max(checked_out);
739            let load_ratio = if status.max_size > 0 {
740                active_for_score as f64 / status.max_size as f64
741            } else {
742                f64::MAX
743            };
744
745            tracing::debug!(
746                backend_id = backend.id.as_index(),
747                backend_name = backend.name.as_str(),
748                pool = %backend.provider.name(),
749                tier,
750                selected = selected_backend == Some(backend.id),
751                should_try = availability.is_none_or(|avail| avail.should_try(backend.id)),
752                availability_missing_bits,
753                pending,
754                checked_out,
755                active_for_score,
756                load_ratio,
757                pool_available = status.available,
758                pool_size = status.size,
759                pool_max_size = status.max_size,
760                pool_waiting = status.waiting,
761                weight = status.max_size,
762                "Backend selection candidate"
763            );
764        }
765    }
766
767    #[allow(clippy::cast_precision_loss)]
768    fn backend_load_ratio_with_pending(backend: &BackendInfo, pending: usize) -> LoadRatio {
769        let status = backend.provider.status_counts();
770        let max_conns = status.max_size as f64;
771        if max_conns > 0.0 {
772            let checked_out = status.size.saturating_sub(status.available);
773            let active = pending.max(checked_out) as f64;
774            LoadRatio::new(active / max_conns)
775        } else {
776            LoadRatio::MAX
777        }
778    }
779
780    /// Select a backend using weighted round-robin from backends matching the filter
781    fn select_weighted<F>(&self, filter: F) -> Option<SelectedBackend<'_>>
782    where
783        F: Fn(&&BackendInfo) -> bool,
784    {
785        match &self.strategy {
786            SelectionStrategy::WeightedRoundRobin(wrr) => {
787                // Sum weights for backends passing filter
788                let total_weight: usize = self
789                    .backends
790                    .iter()
791                    .filter(&filter)
792                    .map(|b| b.provider.max_size())
793                    .sum();
794
795                if total_weight == 0 {
796                    return None; // No backends match filter
797                }
798
799                // Select position in weighted distribution
800                let position = wrr.select_with_weight(total_weight)?;
801
802                // Find backend at that position
803                self.backends
804                    .iter()
805                    .filter(&filter)
806                    .scan(0, |cumulative, backend| {
807                        *cumulative += backend.provider.max_size();
808                        Some((*cumulative, backend))
809                    })
810                    .find(|(cumulative_weight, _)| position < *cumulative_weight)
811                    .map(|(_, backend)| backend)
812                    .or_else(|| {
813                        // Fallback: first backend matching filter
814                        self.backends.iter().find(&filter)
815                    })
816                    .map(|backend| SelectedBackend {
817                        backend,
818                        pending_snapshot: None,
819                    })
820            }
821            SelectionStrategy::LeastLoaded(least_loaded) => {
822                let mut selected: Option<SelectedBackend<'_>> = None;
823                let mut selected_load = LoadRatio::MAX;
824                let mut ties = 0usize;
825
826                for backend in self.backends.iter().filter(&filter) {
827                    let pending_snapshot = backend.pending_count.get();
828                    let load = Self::backend_load_ratio_with_pending(backend, pending_snapshot);
829                    match load
830                        .partial_cmp(&selected_load)
831                        .unwrap_or(std::cmp::Ordering::Greater)
832                    {
833                        std::cmp::Ordering::Less => {
834                            selected = Some(SelectedBackend {
835                                backend,
836                                pending_snapshot: Some(pending_snapshot),
837                            });
838                            selected_load = load;
839                            ties = 1;
840                        }
841                        std::cmp::Ordering::Equal => {
842                            ties += 1;
843                            if least_loaded.should_replace_tie(ties) {
844                                selected = Some(SelectedBackend {
845                                    backend,
846                                    pending_snapshot: Some(pending_snapshot),
847                                });
848                            }
849                        }
850                        std::cmp::Ordering::Greater => {}
851                    }
852                }
853
854                selected
855            }
856        }
857    }
858
859    /// Select a backend for a client request.
860    ///
861    /// # Errors
862    /// Returns an error when no backend remains eligible.
863    pub fn route<Mode: RouteMode>(&self, request: RouteRequest<'_, Mode>) -> Result<Mode::Output> {
864        self.route_selected_backend(&request)
865    }
866
867    fn route_selected_backend<Mode: RouteMode>(
868        &self,
869        request: &RouteRequest<'_, Mode>,
870    ) -> Result<Mode::Output> {
871        loop {
872            let availability = Mode::availability(request);
873            let selected = self
874                .select_backend(availability, &request.suppressed_backends)
875                .ok_or_else(|| {
876                    anyhow::anyhow!(
877                        "No backends available for routing (total backends: {})",
878                        self.backends.len()
879                    )
880                })?;
881            let backend = selected.backend;
882            let output = Mode::output(request, backend.id)?;
883
884            let reserved = if let Some(observed) = selected.pending_snapshot {
885                backend.pending_count.try_increment_from(observed)
886            } else {
887                backend.pending_count.increment();
888                true
889            };
890            if !reserved {
891                continue;
892            }
893
894            debug!(
895                "Selected backend {:?} ({}) for command",
896                backend.id, backend.name
897            );
898
899            return Ok(output);
900        }
901    }
902
903    /// Mark a command as complete, decrementing the pending count
904    pub fn complete_command(&self, backend_id: BackendId) {
905        if let Some(backend) = self.find_backend(backend_id) {
906            backend.pending_count.decrement();
907        }
908    }
909
910    /// Manually increment pending count for a specific backend
911    /// Used when directly selecting a backend instead of using `route`.
912    pub fn mark_backend_pending(&self, backend_id: BackendId) {
913        if let Some(backend) = self.find_backend(backend_id) {
914            backend.pending_count.increment();
915        }
916    }
917
918    /// Get the connection provider for a backend
919    #[must_use]
920    pub fn backend_provider(&self, backend_id: BackendId) -> Option<&DeadpoolConnectionProvider> {
921        self.find_backend(backend_id).map(|b| &b.provider)
922    }
923
924    /// Get the number of backends
925    #[must_use]
926    #[inline]
927    pub fn backend_count(&self) -> BackendCount {
928        BackendCount::from_router_len(self.backends.len())
929    }
930
931    /// Get total weight (sum of all `max_connections`)
932    /// Only applicable for weighted round-robin strategy
933    #[must_use]
934    #[inline]
935    pub fn total_weight(&self) -> TotalWeight {
936        match &self.strategy {
937            SelectionStrategy::WeightedRoundRobin(wrr) => TotalWeight::new(wrr.total_weight()),
938            SelectionStrategy::LeastLoaded(_) => {
939                // Least-loaded does not use weights; expose aggregate capacity.
940                TotalWeight::new(self.backends.iter().map(|b| b.provider.max_size()).sum())
941            }
942        }
943    }
944
945    /// Get backend load (pending requests) for monitoring
946    ///
947    /// Returns a clone of the `PendingCount` for the backend, allowing the caller
948    /// to query the current value or track it over time.
949    #[must_use]
950    pub fn backend_load(&self, backend_id: BackendId) -> Option<PendingCount> {
951        self.find_backend(backend_id)
952            .map(|b| b.pending_count.clone())
953    }
954
955    /// Try to acquire a stateful connection slot for hybrid mode
956    /// Returns true if acquisition succeeded (within max_connections-1 limit)
957    /// Returns false if all stateful slots are taken (need to keep 1 for PCR)
958    pub fn try_acquire_stateful(&self, backend_id: BackendId) -> bool {
959        self.find_backend(backend_id).is_some_and(|backend| {
960            // Get max connections from the provider's pool
961            let max_connections = backend.provider.max_size();
962
963            // Reserve 1 connection for per-command routing
964            let max_stateful = max_connections.saturating_sub(1);
965
966            // Try to acquire slot using StatefulCount's atomic logic
967            let acquired = backend.stateful_count.try_acquire(max_stateful);
968
969            if acquired {
970                debug!(
971                    "Backend {:?} ({}) acquired stateful slot: {}/{}",
972                    backend_id,
973                    backend.name,
974                    backend.stateful_count.get(),
975                    max_stateful
976                );
977            } else {
978                debug!(
979                    "Backend {:?} ({}) stateful limit reached: {}/{}",
980                    backend_id,
981                    backend.name,
982                    backend.stateful_count.get(),
983                    max_stateful
984                );
985            }
986
987            acquired
988        })
989    }
990
991    /// Release a stateful connection slot
992    pub fn release_stateful(&self, backend_id: BackendId) {
993        if let Some(backend) = self.find_backend(backend_id) {
994            // Atomically decrement using StatefulCount's release method
995            match backend.stateful_count.release() {
996                Ok(prev) => {
997                    debug!(
998                        "Backend {:?} ({}) released stateful slot: {}/{}",
999                        backend_id,
1000                        backend.name,
1001                        prev - 1,
1002                        backend.provider.max_size().saturating_sub(1)
1003                    );
1004                }
1005                Err(0) => {
1006                    debug!(
1007                        "Backend {:?} ({}) release_stateful called when count already 0",
1008                        backend_id, backend.name
1009                    );
1010                }
1011                Err(other) => unreachable!(
1012                    "Unexpected error in release: got Err({other}), expected only Err(0)"
1013                ),
1014            }
1015        }
1016    }
1017
1018    /// Get the number of stateful connections for a backend
1019    ///
1020    /// Returns a clone of the `StatefulCount` for the backend, allowing the caller
1021    /// to query the current value or track it over time.
1022    #[must_use]
1023    pub fn stateful_count(&self, backend_id: BackendId) -> Option<StatefulCount> {
1024        self.find_backend(backend_id)
1025            .map(|b| b.stateful_count.clone())
1026    }
1027
1028    /// Get the load ratio for a backend (pending / `max_connections`)
1029    ///
1030    /// Lower ratios indicate less loaded backends. Range: 0.0 (empty) to `f64::MAX` (no capacity).
1031    #[must_use]
1032    pub fn backend_load_ratio(&self, backend_id: BackendId) -> Option<LoadRatio> {
1033        self.find_backend(backend_id)
1034            .map(backend_info::BackendInfo::load_ratio)
1035    }
1036
1037    /// Get the traffic share percentage for a backend
1038    ///
1039    /// Only applicable for weighted round-robin strategy. Returns the percentage
1040    /// of traffic this backend should receive based on its `max_connections`.
1041    #[must_use]
1042    pub fn backend_traffic_share(&self, backend_id: BackendId) -> Option<TrafficShare> {
1043        self.find_backend(backend_id).map(|b| {
1044            let total = self.total_weight();
1045            TrafficShare::from_weight(b.provider.max_size(), total)
1046        })
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    fn suppressed(backends: &[BackendId]) -> SuppressedBackends {
1055        let mut suppressed = SuppressedBackends::empty();
1056        for backend in backends {
1057            suppressed.suppress(*backend);
1058        }
1059        suppressed
1060    }
1061
1062    fn make_router_with_backend() -> (Arc<BackendSelector>, BackendId) {
1063        let mut selector = BackendSelector::new();
1064        let backend_id = BackendId::from_index(0);
1065        let provider = crate::pool::DeadpoolConnectionProvider::new(
1066            "localhost".to_string(),
1067            119,
1068            "test".to_string(),
1069            10,
1070            None,
1071            None,
1072        );
1073        selector.add_backend(
1074            ServerName::try_new("test-server".to_string()).unwrap(),
1075            provider,
1076            0,
1077        );
1078        (Arc::new(selector), backend_id)
1079    }
1080
1081    #[test]
1082    fn backend_count_iterates_only_constructible_backend_ids() {
1083        let count = BackendCount::try_new(3).expect("count fits availability bitmap");
1084        let ids: Vec<_> = count.backend_ids().collect();
1085
1086        assert_eq!(
1087            ids,
1088            vec![
1089                BackendId::from_index(0),
1090                BackendId::from_index(1),
1091                BackendId::from_index(2)
1092            ]
1093        );
1094        assert_eq!(BackendCount::try_new(BackendCount::MAX + 1), None);
1095    }
1096
1097    #[test]
1098    fn command_guard_decrements_on_drop() {
1099        let (router, backend_id) = make_router_with_backend();
1100
1101        // Simulate route incrementing the pending count.
1102        router.mark_backend_pending(backend_id);
1103        assert_eq!(router.backend_load(backend_id).unwrap().get(), 1);
1104
1105        // Guard should decrement on drop
1106        {
1107            let _guard = CommandGuard::new(router.clone(), backend_id);
1108        }
1109        assert_eq!(router.backend_load(backend_id).unwrap().get(), 0);
1110    }
1111
1112    #[test]
1113    fn command_guard_explicit_complete() {
1114        let (router, backend_id) = make_router_with_backend();
1115
1116        router.mark_backend_pending(backend_id);
1117        assert_eq!(router.backend_load(backend_id).unwrap().get(), 1);
1118
1119        let guard = CommandGuard::new(router.clone(), backend_id);
1120        guard.complete();
1121        assert_eq!(router.backend_load(backend_id).unwrap().get(), 0);
1122    }
1123
1124    #[test]
1125    fn command_guard_no_double_decrement() {
1126        let (router, backend_id) = make_router_with_backend();
1127
1128        // Start with pending count of 1
1129        router.mark_backend_pending(backend_id);
1130        assert_eq!(router.backend_load(backend_id).unwrap().get(), 1);
1131
1132        // Explicit complete + drop should only decrement once
1133        let guard = CommandGuard::new(router.clone(), backend_id);
1134        guard.complete();
1135        // After complete(), count should be 0; drop should be a no-op
1136        assert_eq!(router.backend_load(backend_id).unwrap().get(), 0);
1137        // If double-decrement happened, we'd see wrapping (very large number)
1138        // Since we're at 0 already and drop is a no-op, this confirms correctness
1139    }
1140
1141    #[test]
1142    fn command_guard_backend_id_accessor() {
1143        let (router, backend_id) = make_router_with_backend();
1144        let guard = CommandGuard::new(router, backend_id);
1145        assert_eq!(guard.backend_id(), backend_id);
1146    }
1147
1148    #[test]
1149    fn transient_suppression_can_try_same_tier_without_escalating() {
1150        let mut selector = BackendSelector::with_strategy(BackendSelectionStrategy::LeastLoaded);
1151        for (name, tier) in [("tier0-a", 0), ("tier0-b", 0), ("tier1", 1)] {
1152            selector.add_backend(
1153                ServerName::try_new(name.to_string()).unwrap(),
1154                crate::pool::DeadpoolConnectionProvider::new(
1155                    "localhost".to_string(),
1156                    119,
1157                    name.to_string(),
1158                    10,
1159                    None,
1160                    None,
1161                ),
1162                tier,
1163            );
1164        }
1165
1166        let availability = ArticleAvailability::new();
1167        let backend = selector
1168            .route(
1169                RouteRequest::new(ClientId::new())
1170                    .with_availability(&availability)
1171                    .suppressing_backends(suppressed(&[BackendId::from_index(0)])),
1172            )
1173            .unwrap();
1174
1175        assert_eq!(backend.backend_id(), BackendId::from_index(1));
1176
1177        let exhausted_tier0 = selector.route(
1178            RouteRequest::new(ClientId::new())
1179                .with_availability(&availability)
1180                .suppressing_backends(suppressed(&[
1181                    BackendId::from_index(0),
1182                    BackendId::from_index(1),
1183                ])),
1184        );
1185        assert!(
1186            exhausted_tier0.is_err(),
1187            "transient backend failures must not escalate to tier 1 before tier 0 has all 430s"
1188        );
1189    }
1190
1191    #[test]
1192    fn first_probe_selection_is_tier_local() {
1193        let mut selector = BackendSelector::with_strategy(BackendSelectionStrategy::LeastLoaded);
1194        for (name, tier, max_connections) in [
1195            ("tier0", 0, 10),
1196            ("tier1-small", 1, 1),
1197            ("tier1-large", 1, 10),
1198        ] {
1199            selector.add_backend(
1200                ServerName::try_new(name.to_string()).unwrap(),
1201                crate::pool::DeadpoolConnectionProvider::new(
1202                    "localhost".to_string(),
1203                    119,
1204                    name.to_string(),
1205                    max_connections,
1206                    None,
1207                    None,
1208                ),
1209                tier,
1210            );
1211        }
1212        selector.mark_backend_pending(BackendId::from_index(1));
1213
1214        let mut availability = ArticleAvailability::new();
1215        availability.record_missing(BackendId::from_index(0));
1216        let backend = selector
1217            .route(RouteRequest::new(ClientId::new()).with_availability(&availability))
1218            .unwrap();
1219
1220        assert_eq!(
1221            backend.backend_id(),
1222            BackendId::from_index(1),
1223            "first probe in newly eligible tier should be capacity-fair, not biased by load"
1224        );
1225    }
1226
1227    #[test]
1228    fn transient_suppression_does_not_block_escalation_after_real_430s() {
1229        let mut selector = BackendSelector::with_strategy(BackendSelectionStrategy::LeastLoaded);
1230        for (name, tier) in [("tier0-a", 0), ("tier0-b", 0), ("tier1", 1)] {
1231            selector.add_backend(
1232                ServerName::try_new(name.to_string()).unwrap(),
1233                crate::pool::DeadpoolConnectionProvider::new(
1234                    "localhost".to_string(),
1235                    119,
1236                    name.to_string(),
1237                    10,
1238                    None,
1239                    None,
1240                ),
1241                tier,
1242            );
1243        }
1244
1245        let mut availability = ArticleAvailability::new();
1246        availability.record_missing(BackendId::from_index(0));
1247        availability.record_missing(BackendId::from_index(1));
1248
1249        let backend = selector
1250            .route(
1251                RouteRequest::new(ClientId::new())
1252                    .with_availability(&availability)
1253                    .suppressing_backends(suppressed(&[BackendId::from_index(0)])),
1254            )
1255            .unwrap();
1256
1257        assert_eq!(
1258            backend.backend_id(),
1259            BackendId::from_index(2),
1260            "tier escalation is allowed after tier 0 has authoritative 430s"
1261        );
1262    }
1263
1264    #[test]
1265    fn zero_suppression_keeps_large_backend_routing_off_the_availability_bitset() {
1266        let mut selector = BackendSelector::with_strategy(BackendSelectionStrategy::LeastLoaded);
1267        for index in 0..12 {
1268            selector.add_backend(
1269                ServerName::try_new(format!("backend-{index}")).unwrap(),
1270                crate::pool::DeadpoolConnectionProvider::new(
1271                    "localhost".to_string(),
1272                    119,
1273                    format!("backend-{index}"),
1274                    10,
1275                    None,
1276                    None,
1277                ),
1278                0,
1279            );
1280        }
1281
1282        let backend = selector
1283            .route(crate::router::RouteRequest::new(ClientId::new()))
1284            .unwrap();
1285
1286        assert!(
1287            backend.as_index() < 12,
1288            "routing without suppression must not touch the 8-backend availability bitset"
1289        );
1290    }
1291
1292    #[test]
1293    fn transient_suppression_handles_backend_index_at_legacy_u8_boundary() {
1294        let mut selector = BackendSelector::with_strategy(BackendSelectionStrategy::LeastLoaded);
1295        for index in 0..10 {
1296            selector.add_backend(
1297                ServerName::try_new(format!("backend-{index}")).unwrap(),
1298                crate::pool::DeadpoolConnectionProvider::new(
1299                    "localhost".to_string(),
1300                    119,
1301                    format!("backend-{index}"),
1302                    10,
1303                    None,
1304                    None,
1305                ),
1306                0,
1307            );
1308        }
1309
1310        let mut suppressed = SuppressedBackends::empty();
1311        suppressed.suppress(BackendId::from_index(8));
1312        for index in 0..8 {
1313            selector.mark_backend_pending(BackendId::from_index(index));
1314        }
1315        selector.mark_backend_pending(BackendId::from_index(9));
1316
1317        let selected = selector
1318            .route(RouteRequest::new(ClientId::new()).suppressing_backends(suppressed))
1319            .unwrap();
1320
1321        assert_ne!(selected, BackendId::from_index(8));
1322    }
1323}