1use std::{
2 sync::{
3 Arc, LazyLock,
4 atomic::{AtomicBool, AtomicU8, AtomicU64},
5 },
6 time::{Duration, Instant},
7};
8
9static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
15
16use futures::{StreamExt, pin_mut};
17use hopr_crypto_packet::prelude::{PacketSignal, PacketSignals};
18use hopr_utils::runtime::AbortHandle;
19use tracing::{Instrument, instrument};
20
21use super::{
22 BalancerControllerBounds, MIN_BALANCER_SAMPLING_INTERVAL, SimpleSurbFlowEstimator, SurbBalancerController,
23 SurbFlowController, SurbFlowEstimator,
24};
25use crate::SessionId;
26
27#[cfg(all(feature = "telemetry", not(test)))]
28lazy_static::lazy_static! {
29 static ref METRIC_TARGET_ERROR_ESTIMATE: hopr_api::types::telemetry::MultiGauge =
30 hopr_api::types::telemetry::MultiGauge::new(
31 "hopr_surb_balancer_target_error_estimate",
32 "Target error estimation by the SURB balancer",
33 &["session_id"]
34 ).unwrap();
35 static ref METRIC_CONTROL_OUTPUT: hopr_api::types::telemetry::MultiGauge =
36 hopr_api::types::telemetry::MultiGauge::new(
37 "hopr_surb_balancer_control_output",
38 "Control output of the SURB balancer",
39 &["session_id"]
40 ).unwrap();
41 static ref METRIC_CURRENT_BUFFER: hopr_api::types::telemetry::MultiGauge =
42 hopr_api::types::telemetry::MultiGauge::new(
43 "hopr_surb_balancer_current_buffer_estimate",
44 "Estimated number of SURBs in the buffer",
45 &["session_id"]
46 ).unwrap();
47 static ref METRIC_CURRENT_TARGET: hopr_api::types::telemetry::MultiGauge =
48 hopr_api::types::telemetry::MultiGauge::new(
49 "hopr_surb_balancer_current_buffer_target",
50 "Current target (setpoint) number of SURBs in the buffer",
51 &["session_id"]
52 ).unwrap();
53 static ref METRIC_SURB_RATE: hopr_api::types::telemetry::MultiGauge =
54 hopr_api::types::telemetry::MultiGauge::new(
55 "hopr_surb_balancer_surbs_rate",
56 "Estimation of SURB rate per second (positive is buffer surplus, negative is buffer loss)",
57 &["session_id"]
58 ).unwrap();
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, smart_default::SmartDefault)]
63pub struct SurbBalancerConfig {
64 #[default(7_000)]
78 pub target_surb_buffer_size: u64,
79 #[default(5_000)]
88 pub max_surbs_per_sec: u64,
89
90 #[default(_code = "Some((Duration::from_secs(60), 0.05))")]
97 pub surb_decay: Option<(Duration, f64)>,
98
99 #[default(false)]
114 pub sustain_on_return_path_loss: bool,
115}
116
117impl SurbBalancerConfig {
118 #[inline]
120 pub fn as_controller_bounds(&self) -> BalancerControllerBounds {
121 BalancerControllerBounds::new(self.target_surb_buffer_size, self.max_surbs_per_sec)
122 }
123}
124
125#[derive(Debug, Default)]
127pub struct BalancerStateValues {
128 pub target_surb_buffer_size: AtomicU64,
129 pub max_surbs_per_sec: AtomicU64,
130 pub decay_duration_msec: AtomicU64,
131 pub decay_volume_pct: AtomicU8,
132 pub buffer_level: AtomicU64,
133 pub sustain_on_return_path_loss: AtomicBool,
135 pub counterparty_buffer_capacity: AtomicU64,
155 pub return_path_degraded_until_ms: AtomicU64,
163 pub counterparty_in_surb_distress: AtomicBool,
191}
192
193impl BalancerStateValues {
194 pub fn new(cfg: SurbBalancerConfig) -> Self {
196 let state = Self::default();
197 state.update(&cfg);
198 state
199 }
200
201 pub fn update(&self, cfg: &SurbBalancerConfig) {
204 self.target_surb_buffer_size
205 .store(cfg.target_surb_buffer_size, std::sync::atomic::Ordering::Relaxed);
206 self.max_surbs_per_sec
207 .store(cfg.max_surbs_per_sec, std::sync::atomic::Ordering::Relaxed);
208 self.decay_duration_msec.store(
209 cfg.surb_decay
210 .map(|(d, _)| d.as_millis().min(u64::MAX as u128) as u64)
211 .unwrap_or_default(),
212 std::sync::atomic::Ordering::Relaxed,
213 );
214 self.decay_volume_pct.store(
215 cfg.surb_decay
216 .map(|(_, p)| (p.clamp(0.0, 1.0) * 100.0).round() as u8)
217 .unwrap_or_default(),
218 std::sync::atomic::Ordering::Relaxed,
219 );
220 self.sustain_on_return_path_loss
221 .store(cfg.sustain_on_return_path_loss, std::sync::atomic::Ordering::Relaxed);
222 }
223
224 pub fn set_counterparty_buffer_capacity(&self, capacity: u64) {
229 self.counterparty_buffer_capacity
230 .store(capacity, std::sync::atomic::Ordering::Relaxed);
231 }
232
233 fn clamp_to_counterparty_capacity(&self, level: u64) -> u64 {
241 match self
242 .counterparty_buffer_capacity
243 .load(std::sync::atomic::Ordering::Relaxed)
244 {
245 0 => level,
246 capacity => {
247 level.min(capacity.max(self.target_surb_buffer_size.load(std::sync::atomic::Ordering::Relaxed)))
248 }
249 }
250 }
251
252 pub fn observe_counterparty_signals(&self, signals: PacketSignals) {
258 self.counterparty_in_surb_distress.store(
259 signals.contains(PacketSignal::SurbDistress),
260 std::sync::atomic::Ordering::Relaxed,
261 );
262 }
263
264 pub fn organic_surbs_per_packet(&self) -> usize {
273 usize::from(
277 self.is_disabled()
278 || self
279 .counterparty_in_surb_distress
280 .load(std::sync::atomic::Ordering::Relaxed)
281 || self.buffer_level() < self.target_surb_buffer_size.load(std::sync::atomic::Ordering::Relaxed),
282 )
283 }
284
285 pub fn mark_return_path_degraded(&self, grace: Duration) {
291 let until = EPOCH.elapsed().saturating_add(grace).as_millis().min(u64::MAX as u128) as u64;
292 self.return_path_degraded_until_ms
293 .fetch_max(until, std::sync::atomic::Ordering::Relaxed);
294 }
295
296 pub fn return_path_estimate_is_stale(&self) -> bool {
312 self.should_sustain_through_return_path_loss()
313 }
314
315 fn should_sustain_through_return_path_loss(&self) -> bool {
316 let deadline = self
317 .return_path_degraded_until_ms
318 .load(std::sync::atomic::Ordering::Relaxed);
319
320 deadline > 0
323 && (EPOCH.elapsed().as_millis() as u64) < deadline
324 && self
325 .sustain_on_return_path_loss
326 .load(std::sync::atomic::Ordering::Relaxed)
327 }
328
329 pub fn as_config(&self) -> SurbBalancerConfig {
331 SurbBalancerConfig {
332 target_surb_buffer_size: self.target_surb_buffer_size.load(std::sync::atomic::Ordering::Relaxed),
333 max_surbs_per_sec: self.max_surbs_per_sec.load(std::sync::atomic::Ordering::Relaxed),
334 surb_decay: self.surb_decay(),
335 sustain_on_return_path_loss: self
336 .sustain_on_return_path_loss
337 .load(std::sync::atomic::Ordering::Relaxed),
338 }
339 }
340
341 pub fn is_disabled(&self) -> bool {
343 self.target_surb_buffer_size.load(std::sync::atomic::Ordering::Relaxed) == 0
344 }
345
346 pub fn surb_decay(&self) -> Option<(Duration, f64)> {
348 Some((
349 self.decay_duration_msec.load(std::sync::atomic::Ordering::Relaxed),
350 self.decay_volume_pct.load(std::sync::atomic::Ordering::Relaxed),
351 ))
352 .filter(|&(d, p)| d > 0 && p > 0)
353 .map(|(d, p)| (Duration::from_millis(d), p as f64 / 100.0))
354 }
355
356 #[inline]
358 pub fn buffer_level(&self) -> u64 {
359 self.buffer_level.load(std::sync::atomic::Ordering::Relaxed)
360 }
361
362 #[inline]
364 pub fn controller_bounds(&self) -> BalancerControllerBounds {
365 BalancerControllerBounds::new(
366 self.target_surb_buffer_size.load(std::sync::atomic::Ordering::Relaxed),
367 self.max_surbs_per_sec.load(std::sync::atomic::Ordering::Relaxed),
368 )
369 }
370}
371
372impl From<SurbBalancerConfig> for BalancerStateValues {
373 fn from(cfg: SurbBalancerConfig) -> Self {
374 Self::new(cfg)
375 }
376}
377
378pub struct SurbBalancer<C, E, F> {
397 session_id: SessionId,
398 controller: C,
399 surb_estimator: E,
400 flow_control: F,
401 state: Arc<BalancerStateValues>,
402 last_estimator_state: SimpleSurbFlowEstimator,
403 last_update: std::time::Instant,
404 last_decay: std::time::Instant,
405 was_below_target: bool,
406 was_degraded: bool,
408 last_report: std::time::Instant,
410}
411
412impl<C, E, F> SurbBalancer<C, E, F>
413where
414 C: SurbBalancerController + Send + Sync + 'static,
415 E: SurbFlowEstimator + Send + Sync + 'static,
416 F: SurbFlowController + Send + Sync + 'static,
417{
418 pub fn new(
419 session_id: SessionId,
420 mut controller: C,
421 surb_estimator: E,
422 flow_control: F,
423 state: Arc<BalancerStateValues>,
424 ) -> Self {
425 #[cfg(all(feature = "telemetry", not(test)))]
426 {
427 let sid: &str = session_id.as_ref();
428 METRIC_TARGET_ERROR_ESTIMATE.set(&[sid], 0.0);
429 METRIC_CONTROL_OUTPUT.set(&[sid], 0.0);
430 }
431
432 controller.set_target_and_limit(state.controller_bounds());
433
434 Self {
435 surb_estimator,
436 flow_control,
437 controller,
438 session_id,
439 state,
440 last_estimator_state: Default::default(),
441 last_update: std::time::Instant::now(),
442 last_decay: std::time::Instant::now(),
443 was_below_target: true,
444 was_degraded: false,
445 last_report: std::time::Instant::now(),
446 }
447 }
448
449 #[tracing::instrument(level = "trace", skip_all)]
451 fn update(&mut self) -> u64 {
452 let dt = self.last_update.elapsed();
453
454 let mut current = self.state.buffer_level.load(std::sync::atomic::Ordering::Acquire);
456
457 if dt < Duration::from_millis(10) {
458 tracing::debug!("time elapsed since last update is too short, skipping update");
459 return current;
460 }
461
462 self.last_update = std::time::Instant::now();
463
464 let snapshot = SimpleSurbFlowEstimator::from(&self.surb_estimator);
466 let Some(target_buffer_change) = snapshot.estimated_surb_buffer_change(&self.last_estimator_state) else {
467 tracing::error!("non-monotonic change in SURB estimators");
468 return current;
469 };
470
471 self.last_estimator_state = snapshot;
472 current = current.saturating_add_signed(target_buffer_change);
473
474 if let Some(num_decayed_surbs) = self
477 .state
478 .surb_decay()
479 .filter(|(decay_window, _)| &self.last_decay.elapsed() >= decay_window)
480 .map(|(_, decay_coeff)| (self.controller.bounds().target() as f64 * decay_coeff).round() as u64)
481 {
482 current = current.saturating_sub(num_decayed_surbs);
483 self.last_decay = std::time::Instant::now();
484 tracing::trace!(num_decayed_surbs, "SURBs were discarded due to automatic decay");
485 }
486
487 let believed = current;
490 current = self.state.clamp_to_counterparty_capacity(current);
491 if current != believed {
492 tracing::debug!(
495 believed,
496 clamped_to = current,
497 counterparty_capacity = self
498 .state
499 .counterparty_buffer_capacity
500 .load(std::sync::atomic::Ordering::Relaxed),
501 "counterparty SURB estimate exceeded its store; the surplus was never held"
502 );
503 }
504
505 let degraded = self.state.should_sustain_through_return_path_loss();
506 if degraded != self.was_degraded {
507 self.controller.reset();
511 self.was_degraded = degraded;
512
513 if !degraded {
514 current = 0;
519 self.last_decay = std::time::Instant::now();
520 tracing::debug!("return path recovered; restarting closed-loop SURB control");
521 }
522 }
523
524 if degraded {
525 tracing::debug!(
530 believed = current,
531 "return path degraded; ignoring the counterparty buffer estimate"
532 );
533 current = 0;
536 }
537
538 self.state
539 .buffer_level
540 .store(current, std::sync::atomic::Ordering::Release);
541
542 let error = current as i64 - self.controller.bounds().target() as i64;
544
545 if self.was_below_target && error >= 0 {
546 tracing::trace!(current, "reached target SURB buffer size");
547 self.was_below_target = false;
548 } else if !self.was_below_target && error < 0 {
549 tracing::trace!(current, "SURB buffer size is below target");
550 self.was_below_target = true;
551 }
552
553 tracing::trace!(
554 ?dt,
555 delta = target_buffer_change,
556 rate = target_buffer_change as f64 / dt.as_secs_f64(),
557 current,
558 error,
559 "estimated SURB buffer change"
560 );
561
562 let output = self.controller.next_control_output(current);
563 tracing::trace!(output, "next balancer control output for session");
564
565 if self.last_report.elapsed() >= Duration::from_secs(1) {
573 self.last_report = std::time::Instant::now();
574 tracing::debug!(
575 session = %self.session_id,
576 level = current,
577 target = self.controller.bounds().target(),
578 output,
579 produced = self.surb_estimator.estimate_surbs_produced(),
580 consumed = self.surb_estimator.estimate_surbs_consumed(),
581 degraded,
582 distress = self
583 .state
584 .counterparty_in_surb_distress
585 .load(std::sync::atomic::Ordering::Relaxed),
586 "surb balancer state"
587 );
588 }
589
590 self.flow_control.adjust_surb_flow(output as usize);
591
592 #[cfg(all(feature = "telemetry", not(test)))]
593 {
594 let sid: &str = self.session_id.as_ref();
595 METRIC_CURRENT_BUFFER.set(&[sid], current as f64);
596 METRIC_CURRENT_TARGET.set(&[sid], self.controller.bounds().target() as f64);
597 METRIC_TARGET_ERROR_ESTIMATE.set(&[sid], error as f64);
598 METRIC_CONTROL_OUTPUT.set(&[sid], output as f64);
599 METRIC_SURB_RATE.set(&[sid], target_buffer_change as f64 / dt.as_secs_f64());
600 }
601
602 current
603 }
604
605 #[instrument(level = "debug", skip(self), fields(session_id = %self.session_id))]
614 pub fn start_control_loop(
615 mut self,
616 sampling_interval: Duration,
617 ) -> (impl futures::Stream<Item = u64>, AbortHandle) {
618 let (abort_handle, abort_reg) = AbortHandle::new_pair();
619
620 let sampling_stream = futures::stream::Abortable::new(
624 futures_time::stream::interval(sampling_interval.max(MIN_BALANCER_SAMPLING_INTERVAL).into()),
625 abort_reg,
626 );
627
628 let balancer_level_capacity = std::env::var("HOPR_INTERNAL_SESSION_BALANCER_LEVEL_CAPACITY")
629 .ok()
630 .and_then(|s| s.trim().parse::<usize>().ok())
631 .filter(|&c| c > 0)
632 .unwrap_or(32_768);
633
634 tracing::debug!(
635 capacity = balancer_level_capacity,
636 "Creating session balancer level channel"
637 );
638 let (mut level_tx, level_rx) = futures::channel::mpsc::channel(balancer_level_capacity);
639 hopr_utils::runtime::prelude::spawn(
640 async move {
641 pin_mut!(sampling_stream);
642 while sampling_stream.next().await.is_some() {
643 let current_bounds = self.state.controller_bounds();
645 if current_bounds != self.controller.bounds() {
646 self.controller.set_target_and_limit(current_bounds);
647 tracing::debug!(new_cfg = ?self.state.as_config(), "surb balancer has been reconfigured");
648 }
649
650 let level = self.update();
654 if !level_tx.is_closed()
655 && let Err(error) = level_tx.try_send(level)
656 {
657 tracing::error!(%error, "cannot send balancer level update");
658 }
659 }
660
661 tracing::debug!("balancer done");
662 }
663 .in_current_span(),
664 );
665
666 (level_rx, abort_handle)
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use std::sync::{Arc, atomic::AtomicU64};
673
674 use hopr_api::types::{crypto_random::Randomizable, internal::prelude::HoprPseudonym};
675
676 use super::*;
677 use crate::balancer::{AtomicSurbFlowEstimator, MockSurbFlowController, pid::PidBalancerController};
678
679 #[test]
680 fn surb_balancer_config_should_be_convertible_to_atomics() {
681 let cfg = SurbBalancerConfig::default();
682 let state_data = BalancerStateValues::new(cfg);
683 assert_eq!(cfg, state_data.as_config());
684 }
685
686 #[test]
687 fn surb_balancer_config_default_snapshot() {
688 let cfg = SurbBalancerConfig::default();
689 insta::assert_debug_snapshot!(cfg);
690 }
691
692 #[test]
693 fn surb_balancer_config_as_controller_bounds() {
694 let cfg = SurbBalancerConfig {
695 target_surb_buffer_size: 1000,
696 max_surbs_per_sec: 500,
697 surb_decay: None,
698 sustain_on_return_path_loss: false,
699 };
700 let bounds = cfg.as_controller_bounds();
701 assert_eq!(bounds.target(), 1000);
702 assert_eq!(bounds.output_limit(), 500);
703 }
704
705 #[test]
706 fn balancer_state_values_disabled_when_target_is_zero() {
707 let cfg = SurbBalancerConfig {
708 target_surb_buffer_size: 0,
709 max_surbs_per_sec: 0,
710 surb_decay: None,
711 sustain_on_return_path_loss: false,
712 };
713 let state = BalancerStateValues::new(cfg);
714 assert!(state.is_disabled());
715 }
716
717 #[test]
718 fn balancer_state_values_enabled_when_target_is_nonzero() {
719 let state = BalancerStateValues::new(SurbBalancerConfig::default());
720 assert!(!state.is_disabled());
721 }
722
723 #[test]
724 fn balancer_state_values_update_propagates_all_fields() {
725 let state = BalancerStateValues::default();
726 let cfg = SurbBalancerConfig {
727 target_surb_buffer_size: 3000,
728 max_surbs_per_sec: 1500,
729 surb_decay: Some((Duration::from_secs(30), 0.10)),
730 sustain_on_return_path_loss: false,
731 };
732 state.update(&cfg);
733 assert_eq!(state.as_config(), cfg);
734 assert_eq!(state.controller_bounds(), cfg.as_controller_bounds());
735 }
736
737 #[test]
738 fn balancer_state_values_surb_decay_none_maps_to_none() {
739 let cfg = SurbBalancerConfig {
740 target_surb_buffer_size: 1000,
741 max_surbs_per_sec: 500,
742 surb_decay: None,
743 sustain_on_return_path_loss: false,
744 };
745 let state = BalancerStateValues::new(cfg);
746 assert!(state.surb_decay().is_none());
747 }
748
749 #[test]
750 fn balancer_state_values_buffer_level_default_is_zero() {
751 let state = BalancerStateValues::default();
752 assert_eq!(state.buffer_level(), 0);
753 }
754
755 #[test]
756 fn balancer_state_values_buffer_level_can_be_updated() {
757 let state = BalancerStateValues::default();
758 state.buffer_level.store(42, std::sync::atomic::Ordering::Relaxed);
759 assert_eq!(state.buffer_level(), 42);
760 }
761
762 fn gate_state(target: u64, buffer_level: u64) -> BalancerStateValues {
764 let state = BalancerStateValues::new(SurbBalancerConfig {
765 target_surb_buffer_size: target,
766 max_surbs_per_sec: 5_000,
767 ..Default::default()
768 });
769 state
770 .buffer_level
771 .store(buffer_level, std::sync::atomic::Ordering::Relaxed);
772 state
773 }
774
775 #[test]
776 fn organic_surbs_should_be_produced_while_the_counterparty_is_below_target() {
777 assert_eq!(1, gate_state(100, 99).organic_surbs_per_packet());
778 assert_eq!(1, gate_state(100, 0).organic_surbs_per_packet());
779 }
780
781 #[test]
784 fn organic_surbs_should_stop_once_the_counterparty_reaches_its_target() {
785 assert_eq!(0, gate_state(100, 100).organic_surbs_per_packet());
786 assert_eq!(0, gate_state(100, 200).organic_surbs_per_packet());
787 }
788
789 #[test]
793 fn organic_surbs_should_resume_at_one_when_the_counterparty_signals_distress() {
794 let state = gate_state(100, 200);
795 assert_eq!(0, state.organic_surbs_per_packet(), "precondition: gate is closed");
796
797 state.observe_counterparty_signals(PacketSignal::SurbDistress.into());
798 assert_eq!(1, state.organic_surbs_per_packet());
799 }
800
801 #[test]
805 fn out_of_surbs_should_count_as_distress() {
806 let state = gate_state(100, 200);
807 state.observe_counterparty_signals(PacketSignal::OutOfSurbs.into());
808 assert_eq!(1, state.organic_surbs_per_packet());
809 }
810
811 #[test]
814 fn a_recovered_counterparty_should_clear_distress() {
815 let state = gate_state(100, 200);
816 state.observe_counterparty_signals(PacketSignal::OutOfSurbs.into());
817 assert_eq!(1, state.organic_surbs_per_packet(), "precondition: distress is set");
818
819 state.observe_counterparty_signals(PacketSignals::default());
820 assert_eq!(0, state.organic_surbs_per_packet());
821 }
822
823 #[test]
827 fn a_disabled_balancer_should_keep_producing_organic_surbs() {
828 let state = gate_state(0, 0);
829 assert!(state.is_disabled(), "precondition: a zero target disables balancing");
830 assert_eq!(1, state.organic_surbs_per_packet());
831 }
832
833 #[test]
838 fn a_default_state_should_keep_producing_organic_surbs() {
839 assert_eq!(1, BalancerStateValues::default().organic_surbs_per_packet());
840 }
841
842 #[test]
848 fn a_degraded_return_path_should_not_stop_organic_surb_production() {
849 let state = BalancerStateValues::new(SurbBalancerConfig {
850 target_surb_buffer_size: 100,
851 sustain_on_return_path_loss: true,
852 ..Default::default()
853 });
854 state.mark_return_path_degraded(Duration::from_secs(30));
855 state.buffer_level.store(0, std::sync::atomic::Ordering::Relaxed);
857
858 assert!(state.return_path_estimate_is_stale(), "precondition: open loop");
859 assert_eq!(1, state.organic_surbs_per_packet());
860 }
861
862 #[test]
863 fn balancer_state_values_from_config() {
864 let cfg = SurbBalancerConfig {
865 target_surb_buffer_size: 5000,
866 max_surbs_per_sec: 2500,
867 surb_decay: Some((Duration::from_secs(60), 0.05)),
868 sustain_on_return_path_loss: false,
869 };
870 let state: BalancerStateValues = cfg.into();
871 assert_eq!(state.as_config(), cfg);
872 }
873
874 #[test]
875 fn balancer_state_values_decay_zero_duration_should_map_to_none() {
876 let cfg = SurbBalancerConfig {
877 surb_decay: Some((Duration::ZERO, 0.10)),
878 ..Default::default()
879 };
880 let state = BalancerStateValues::new(cfg);
881 assert!(
882 state.surb_decay().is_none(),
883 "zero duration decay should be filtered out"
884 );
885 }
886
887 #[test]
888 fn balancer_state_values_decay_zero_percent_should_map_to_none() {
889 let cfg = SurbBalancerConfig {
890 surb_decay: Some((Duration::from_secs(60), 0.0)),
891 ..Default::default()
892 };
893 let state = BalancerStateValues::new(cfg);
894 assert!(
895 state.surb_decay().is_none(),
896 "zero percent decay should be filtered out"
897 );
898 }
899
900 #[test]
901 fn balancer_state_values_decay_should_clamp_above_one() {
902 let cfg = SurbBalancerConfig {
903 surb_decay: Some((Duration::from_secs(1), 1.5)), ..Default::default()
905 };
906 let state = BalancerStateValues::new(cfg);
907 let (_, pct) = state.surb_decay().expect("decay should be present");
908 assert!((pct - 1.0).abs() < f64::EPSILON, "percentage should be clamped to 1.0");
909 }
910
911 #[test_log::test]
912 fn surb_balancer_should_start_increase_level_when_below_target() {
913 let production_rate = Arc::new(AtomicU64::new(0));
914 let consumption_rate = 100;
915 let steps = 3;
916 let step_duration = std::time::Duration::from_millis(1000);
917
918 let mut controller = MockSurbFlowController::new();
919 let production_rate_clone = production_rate.clone();
920 controller
921 .expect_adjust_surb_flow()
922 .times(steps)
923 .with(mockall::predicate::ge(100))
924 .returning(move |r| {
925 production_rate_clone.store(r as u64, std::sync::atomic::Ordering::Relaxed);
926 });
927
928 let surb_estimator = AtomicSurbFlowEstimator::default();
929 let mut balancer = SurbBalancer::new(
930 HoprPseudonym::random(),
931 PidBalancerController::default(),
932 surb_estimator.clone(),
933 controller,
934 Arc::new(
935 SurbBalancerConfig {
936 target_surb_buffer_size: 5_000,
937 max_surbs_per_sec: 2500,
938 surb_decay: None,
939 sustain_on_return_path_loss: false,
940 }
941 .into(),
942 ),
943 );
944
945 let mut last_update = 0;
946 for i in 0..steps {
947 std::thread::sleep(step_duration);
948 surb_estimator.produced.fetch_add(
949 production_rate.load(std::sync::atomic::Ordering::Relaxed) * step_duration.as_secs(),
950 std::sync::atomic::Ordering::Relaxed,
951 );
952 surb_estimator.consumed.fetch_add(
953 consumption_rate * step_duration.as_secs(),
954 std::sync::atomic::Ordering::Relaxed,
955 );
956
957 let next_update = balancer.update();
958 assert!(
959 i == 0 || next_update > last_update,
960 "{next_update} should be greater than {last_update}"
961 );
962 last_update = next_update;
963 }
964 }
965
966 #[test_log::test]
967 fn surb_balancer_should_start_decrease_level_when_above_target() {
968 let production_rate = Arc::new(AtomicU64::new(11_000));
969 let consumption_rate = 100;
970 let steps = 3;
971 let step_duration = std::time::Duration::from_millis(1000);
972
973 let mut controller = MockSurbFlowController::new();
974 let production_rate_clone = production_rate.clone();
975 controller
976 .expect_adjust_surb_flow()
977 .times(steps)
978 .with(mockall::predicate::ge(0))
979 .returning(move |r| {
980 production_rate_clone.store(r as u64, std::sync::atomic::Ordering::Relaxed);
981 });
982
983 let surb_estimator = AtomicSurbFlowEstimator::default();
984 let mut balancer = SurbBalancer::new(
985 HoprPseudonym::random(),
986 PidBalancerController::default(),
987 surb_estimator.clone(),
988 controller,
989 Arc::new(
990 SurbBalancerConfig {
991 surb_decay: None,
992 ..Default::default()
993 }
994 .into(),
995 ),
996 );
997
998 let mut last_update = 0;
999 for i in 0..steps {
1000 std::thread::sleep(step_duration);
1001 surb_estimator.produced.fetch_add(
1002 production_rate.load(std::sync::atomic::Ordering::Relaxed) * step_duration.as_secs(),
1003 std::sync::atomic::Ordering::Relaxed,
1004 );
1005 surb_estimator.consumed.fetch_add(
1006 consumption_rate * step_duration.as_secs(),
1007 std::sync::atomic::Ordering::Relaxed,
1008 );
1009
1010 let next_update = balancer.update();
1011 assert!(
1012 i == 0 || next_update < last_update,
1013 "{next_update} should be greater than {last_update}"
1014 );
1015 last_update = next_update;
1016 }
1017 }
1018
1019 #[allow(clippy::type_complexity)]
1025 fn balancer_with_feedback(
1026 cfg: SurbBalancerConfig,
1027 ) -> (
1028 SurbBalancer<PidBalancerController, AtomicSurbFlowEstimator, MockSurbFlowController>,
1029 AtomicSurbFlowEstimator,
1030 Arc<BalancerStateValues>,
1031 Arc<AtomicU64>,
1032 ) {
1033 let output = Arc::new(AtomicU64::new(0));
1034 let output_clone = output.clone();
1035 let mut controller = MockSurbFlowController::new();
1036 controller.expect_adjust_surb_flow().returning(move |r| {
1037 output_clone.store(r as u64, std::sync::atomic::Ordering::Relaxed);
1038 });
1039
1040 let surb_estimator = AtomicSurbFlowEstimator::default();
1041 let state: Arc<BalancerStateValues> = Arc::new(cfg.into());
1042 let balancer = SurbBalancer::new(
1043 HoprPseudonym::random(),
1044 PidBalancerController::default(),
1045 surb_estimator.clone(),
1046 controller,
1047 state.clone(),
1048 );
1049
1050 (balancer, surb_estimator, state, output)
1051 }
1052
1053 fn tick(
1055 balancer: &mut SurbBalancer<PidBalancerController, AtomicSurbFlowEstimator, MockSurbFlowController>,
1056 surb_estimator: &AtomicSurbFlowEstimator,
1057 output: &AtomicU64,
1058 consumed: u64,
1059 ) {
1060 let step = Duration::from_millis(50);
1061 std::thread::sleep(step);
1062
1063 let minted = output.load(std::sync::atomic::Ordering::Relaxed) * step.as_millis() as u64 / 1000;
1064 surb_estimator
1065 .produced
1066 .fetch_add(minted, std::sync::atomic::Ordering::Relaxed);
1067 surb_estimator
1068 .consumed
1069 .fetch_add(consumed, std::sync::atomic::Ordering::Relaxed);
1070 balancer.update();
1071 }
1072
1073 const REPLIES_PER_TICK: u64 = 40;
1075
1076 fn drive_until_replies_stop(cfg: SurbBalancerConfig, mark_degraded: bool) -> (u64, u64) {
1082 let (mut balancer, surb_estimator, state, output) = balancer_with_feedback(cfg);
1083
1084 for _ in 0..40 {
1085 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1086 }
1087 let healthy = output.load(std::sync::atomic::Ordering::Relaxed);
1088
1089 if mark_degraded {
1090 state.mark_return_path_degraded(Duration::from_secs(30));
1091 }
1092
1093 for _ in 0..20 {
1095 tick(&mut balancer, &surb_estimator, &output, 0);
1096 }
1097
1098 (healthy, output.load(std::sync::atomic::Ordering::Relaxed))
1099 }
1100
1101 fn sustaining_config(sustain: bool) -> SurbBalancerConfig {
1102 SurbBalancerConfig {
1103 target_surb_buffer_size: 1_000,
1106 max_surbs_per_sec: 2_500,
1107 surb_decay: None,
1108 sustain_on_return_path_loss: sustain,
1109 }
1110 }
1111
1112 #[test_log::test]
1117 fn surb_balancer_should_throttle_when_a_quiet_counterparty_stops_consuming() {
1118 let (healthy, quiet) = drive_until_replies_stop(sustaining_config(false), false);
1119
1120 assert!(healthy > 0, "a balanced session must keep minting");
1121 assert!(
1122 quiet < healthy,
1123 "an idle counterparty accumulates SURBs, so production must back off: healthy={healthy}/s, idle={quiet}/s"
1124 );
1125 }
1126
1127 #[test_log::test]
1135 fn surb_balancer_should_sustain_production_through_a_degraded_return_path() {
1136 let (healthy, degraded) = drive_until_replies_stop(sustaining_config(true), true);
1137
1138 assert!(healthy > 0, "a balanced session must keep minting");
1139 assert!(
1140 degraded >= healthy,
1141 "the counterparty is burning SURBs it cannot replace, so production must not be cut: healthy={healthy}/s, \
1142 degraded={degraded}/s"
1143 );
1144 }
1145
1146 #[test_log::test]
1148 fn surb_balancer_should_return_to_closed_loop_when_the_return_path_recovers() {
1149 let (mut balancer, surb_estimator, state, output) = balancer_with_feedback(sustaining_config(true));
1150
1151 for _ in 0..40 {
1152 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1153 }
1154 let healthy = output.load(std::sync::atomic::Ordering::Relaxed);
1155
1156 state.mark_return_path_degraded(Duration::from_millis(500));
1157 tick(&mut balancer, &surb_estimator, &output, 0);
1158 let first_degraded = output.load(std::sync::atomic::Ordering::Relaxed);
1159
1160 for _ in 0..9 {
1161 tick(&mut balancer, &surb_estimator, &output, 0);
1162 }
1163
1164 for _ in 0..40 {
1166 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1167 }
1168 let recovered = output.load(std::sync::atomic::Ordering::Relaxed);
1169
1170 assert!(
1171 first_degraded > healthy,
1172 "open loop must engage on the first update after the mark, not ramp towards it: healthy={healthy}/s, \
1173 first degraded update={first_degraded}/s"
1174 );
1175 assert!(
1176 recovered < first_degraded,
1177 "once replies are arriving again the controller must return to closed loop rather than stay pinned at \
1178 maximum: degraded={first_degraded}/s, recovered={recovered}/s"
1179 );
1180 }
1181
1182 #[test_log::test]
1188 fn surb_balancer_should_refill_the_counterparty_after_the_return_path_recovers() {
1189 let cfg = sustaining_config(true);
1190 let (mut balancer, surb_estimator, state, output) = balancer_with_feedback(cfg);
1191
1192 for _ in 0..40 {
1193 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1194 }
1195 let refilled = cfg.target_surb_buffer_size / 2;
1198 assert!(
1199 state.buffer_level.load(std::sync::atomic::Ordering::Relaxed) >= refilled,
1200 "the healthy phase must reach the setpoint band before an outage means anything"
1201 );
1202
1203 state.mark_return_path_degraded(Duration::from_millis(400));
1205 for _ in 0..8 {
1206 tick(&mut balancer, &surb_estimator, &output, 0);
1207 }
1208
1209 let mut ticks_to_refill = None;
1211 for n in 1..=60 {
1212 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1213 if state.buffer_level.load(std::sync::atomic::Ordering::Relaxed) >= refilled {
1214 ticks_to_refill = Some(n);
1215 break;
1216 }
1217 }
1218
1219 let ticks = ticks_to_refill.expect("the counterparty must be refilled to the setpoint after recovery");
1220 tracing::info!(ticks, "refilled the counterparty after the outage");
1221
1222 assert!(
1225 ticks <= 30,
1226 "refilling must ramp rather than crawl: took {ticks} sampling intervals"
1227 );
1228 }
1229
1230 #[test_log::test]
1238 fn surb_balancer_should_not_believe_a_level_the_counterparty_cannot_hold() {
1239 const CAPACITY: u64 = 2_000;
1240
1241 let cfg = sustaining_config(false);
1242 let (mut balancer, surb_estimator, state, output) = balancer_with_feedback(cfg);
1243 state.set_counterparty_buffer_capacity(CAPACITY);
1244
1245 for _ in 0..40 {
1246 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1247 }
1248
1249 for _ in 0..20 {
1252 surb_estimator
1253 .produced
1254 .fetch_add(500, std::sync::atomic::Ordering::Relaxed);
1255 tick(&mut balancer, &surb_estimator, &output, 0);
1256 }
1257
1258 let believed = state.buffer_level.load(std::sync::atomic::Ordering::Relaxed);
1259 assert!(
1260 believed <= CAPACITY,
1261 "the estimate must be bounded by the counterparty's store: believed {believed} against a {CAPACITY}-entry \
1262 buffer"
1263 );
1264 }
1265
1266 #[test_log::test]
1268 fn surb_balancer_should_leave_a_healthy_session_untouched_by_the_capacity_bound() {
1269 let cfg = sustaining_config(false);
1270 let (mut balancer, surb_estimator, state, output) = balancer_with_feedback(cfg);
1271 state.set_counterparty_buffer_capacity(cfg.target_surb_buffer_size * 10);
1272
1273 for _ in 0..40 {
1274 tick(&mut balancer, &surb_estimator, &output, REPLIES_PER_TICK);
1275 }
1276
1277 let level = state.buffer_level.load(std::sync::atomic::Ordering::Relaxed);
1278 assert!(
1279 level >= cfg.target_surb_buffer_size / 2,
1280 "a capacity well above the target must not hold the session below its setpoint: level {level}, target {}",
1281 cfg.target_surb_buffer_size
1282 );
1283 }
1284
1285 #[test_log::test]
1287 fn surb_balancer_should_ignore_a_degraded_return_path_unless_configured_to_sustain() {
1288 let (healthy, degraded) = drive_until_replies_stop(sustaining_config(false), true);
1289
1290 assert!(
1291 degraded < healthy,
1292 "without the opt-in this is not our behaviour to change: healthy={healthy}/s, degraded={degraded}/s"
1293 );
1294 }
1295
1296 #[test_log::test(tokio::test)]
1297 async fn surb_balancer_should_start_decrease_level_when_above_target_and_decay_enabled() {
1298 const NUM_STEPS: usize = 5;
1299 let session_id = HoprPseudonym::random();
1300 let cfg = SurbBalancerConfig {
1301 target_surb_buffer_size: 5_000,
1302 max_surbs_per_sec: 2500,
1303 surb_decay: Some((Duration::from_millis(200), 0.05)),
1304 sustain_on_return_path_loss: false,
1305 };
1306
1307 let mut mock_flow_ctl = MockSurbFlowController::new();
1308 mock_flow_ctl
1309 .expect_adjust_surb_flow()
1310 .times(NUM_STEPS)
1311 .returning(|_| ());
1312
1313 let balancer = SurbBalancer::new(
1314 session_id,
1315 PidBalancerController::default(),
1316 SimpleSurbFlowEstimator::default(),
1317 mock_flow_ctl,
1318 Arc::new(cfg.into()),
1319 );
1320
1321 balancer
1322 .state
1323 .buffer_level
1324 .store(5000, std::sync::atomic::Ordering::Relaxed);
1325
1326 let (stream, handle) = balancer.start_control_loop(Duration::from_millis(100));
1327 let levels = stream.take(NUM_STEPS).collect::<Vec<_>>().await;
1328 handle.abort();
1329
1330 assert_eq!(levels.len(), NUM_STEPS);
1331 assert!(
1332 levels.windows(2).all(|w| w[1] <= w[0]),
1333 "buffer levels should be monotonic non-increasing: {levels:?}"
1334 );
1335 assert!(
1336 levels.last().is_some_and(|last| *last < 5_000),
1337 "expected at least one decay step: {levels:?}"
1338 );
1339 }
1340
1341 #[test]
1352 fn return_path_should_not_be_stale_before_it_is_ever_marked() {
1353 let state = BalancerStateValues::new(SurbBalancerConfig {
1354 sustain_on_return_path_loss: true,
1355 ..Default::default()
1356 });
1357 assert!(!state.return_path_estimate_is_stale());
1358 }
1359
1360 #[test]
1366 fn an_expired_degraded_window_should_no_longer_be_stale() {
1367 let state = BalancerStateValues::new(SurbBalancerConfig {
1368 sustain_on_return_path_loss: true,
1369 ..Default::default()
1370 });
1371 state.mark_return_path_degraded(Duration::ZERO);
1372 assert!(!state.return_path_estimate_is_stale());
1373 }
1374
1375 #[test]
1378 fn re_marking_should_reopen_an_expired_window() {
1379 let state = BalancerStateValues::new(SurbBalancerConfig {
1380 sustain_on_return_path_loss: true,
1381 ..Default::default()
1382 });
1383 state.mark_return_path_degraded(Duration::ZERO);
1384 assert!(!state.return_path_estimate_is_stale());
1385 state.mark_return_path_degraded(Duration::from_secs(10));
1386 assert!(state.return_path_estimate_is_stale());
1387 }
1388}