1mod 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#[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#[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#[derive(Debug, Clone, Copy)]
112pub struct RawRoute;
113
114#[derive(Debug, Clone, Copy)]
116pub struct ArticleRoute<'a> {
117 availability: &'a ArticleAvailability,
118}
119
120#[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#[derive(Debug)]
242enum SelectionStrategy {
243 WeightedRoundRobin(WeightedRoundRobin),
244 LeastLoaded(LeastLoaded),
245}
246
247#[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 pub const MAX: usize = BackendId::MAX_COUNT;
272
273 #[must_use]
275 pub const fn zero() -> Self {
276 Self(0)
277 }
278
279 #[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 #[inline]
291 #[must_use]
292 pub const fn get(self) -> usize {
293 self.0
294 }
295
296 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#[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 #[must_use]
327 pub fn zero() -> Self {
328 Self::new(0)
329 }
330
331 #[inline]
333 #[must_use]
334 pub fn get(&self) -> usize {
335 self.into_inner()
336 }
337}
338
339#[nutype(derive(Debug, Clone, Copy, PartialEq, Display, From, AsRef))]
341pub struct TrafficShare(f64);
342
343impl TrafficShare {
344 #[inline]
346 #[must_use]
347 pub fn get(&self) -> f64 {
348 self.into_inner()
349 }
350
351 #[inline]
353 #[must_use]
354 pub fn from_weight(max_connections: usize, total_weight: TotalWeight) -> Self {
355 if total_weight.get() > 0 {
356 #[allow(clippy::cast_precision_loss)] Self::new((max_connections as f64 / total_weight.get() as f64) * 100.0)
361 } else {
362 Self::new(0.0)
363 }
364 }
365}
366
367pub struct CommandGuard {
373 router: Arc<BackendSelector>,
374 backend_id: BackendId,
375 completed: bool,
376}
377
378impl CommandGuard {
379 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 pub fn complete(mut self) {
390 self.router.complete_command(self.backend_id);
391 self.completed = true;
392 }
393
394 #[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#[derive(Debug)]
446pub struct BackendSelector {
447 backends: Vec<BackendInfo>,
449 strategy: SelectionStrategy,
451 sorted_tiers: smallvec::SmallVec<[u8; 4]>,
453 initial_article_probe_counter: AtomicUsize,
455}
456
457impl Default for BackendSelector {
458 fn default() -> Self {
459 Self::new()
460 }
461}
462
463impl BackendSelector {
464 #[inline]
468 fn find_backend(&self, backend_id: BackendId) -> Option<&BackendInfo> {
469 self.backends.iter().find(|b| b.id == backend_id)
470 }
471
472 #[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 pub(crate) fn tiers(&self) -> impl Iterator<Item = u8> + '_ {
486 self.sorted_tiers.iter().copied()
487 }
488
489 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 #[must_use]
499 pub fn new() -> Self {
500 Self::with_strategy(BackendSelectionStrategy::WeightedRoundRobin)
501 }
502
503 #[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 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 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 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 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 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 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 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 for &tier in &self.sorted_tiers {
617 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 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 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 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 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 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; }
798
799 let position = wrr.select_with_weight(total_weight)?;
801
802 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 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 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 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 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 #[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 #[must_use]
926 #[inline]
927 pub fn backend_count(&self) -> BackendCount {
928 BackendCount::from_router_len(self.backends.len())
929 }
930
931 #[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 TotalWeight::new(self.backends.iter().map(|b| b.provider.max_size()).sum())
941 }
942 }
943 }
944
945 #[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 pub fn try_acquire_stateful(&self, backend_id: BackendId) -> bool {
959 self.find_backend(backend_id).is_some_and(|backend| {
960 let max_connections = backend.provider.max_size();
962
963 let max_stateful = max_connections.saturating_sub(1);
965
966 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 pub fn release_stateful(&self, backend_id: BackendId) {
993 if let Some(backend) = self.find_backend(backend_id) {
994 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 #[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 #[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 #[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 router.mark_backend_pending(backend_id);
1103 assert_eq!(router.backend_load(backend_id).unwrap().get(), 1);
1104
1105 {
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 router.mark_backend_pending(backend_id);
1130 assert_eq!(router.backend_load(backend_id).unwrap().get(), 1);
1131
1132 let guard = CommandGuard::new(router.clone(), backend_id);
1134 guard.complete();
1135 assert_eq!(router.backend_load(backend_id).unwrap().get(), 0);
1137 }
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}