Skip to main content

hopr_transport_session/balancer/
controller.rs

1use std::{
2    sync::{
3        Arc, LazyLock,
4        atomic::{AtomicBool, AtomicU8, AtomicU64},
5    },
6    time::{Duration, Instant},
7};
8
9/// Monotonic origin for the degraded-return-path deadline.
10///
11/// An `Instant` cannot live in an atomic, and the deadline is written by one layer and read by
12/// another, so it travels as milliseconds elapsed from a fixed point. Monotonic rather than
13/// wall-clock, so a clock adjustment cannot extend or cancel the window.
14static 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/// Configuration for the `SurbBalancer`.
62#[derive(Clone, Copy, Debug, PartialEq, smart_default::SmartDefault)]
63pub struct SurbBalancerConfig {
64    /// The desired number of SURBs to be always kept as a buffer locally or at the Session counterparty.
65    ///
66    /// The `SurbBalancer` will try to maintain approximately this number of SURBs
67    /// locally or remotely (at the counterparty) at all times.
68    ///
69    /// The local buffer is maintained by regulating (`SurbFlowController`) the egress from the Session.
70    /// The remote buffer (at session counterparty) is maintained by regulating the flow of non-organic SURBs via
71    /// keep-alive messages.
72    ///
73    /// It does not make sense to set this value higher than the [`max_surb_buffer_size`](crate::SessionManagerConfig)
74    /// configuration at the counterparty.
75    ///
76    /// Default is 7000 SURBs.
77    #[default(7_000)]
78    pub target_surb_buffer_size: u64,
79    /// Maximum outflow of SURBs.
80    ///
81    /// - In the context of the local SURB buffer (Entry), this is the maximum egress Session traffic (= SURB
82    ///   consumption).
83    /// - In the context of the remote SURB buffer (Exit), this is the maximum egress of keep-alive messages to the
84    ///   counterparty (= artificial SURB production).
85    ///
86    /// The default is 5000 (which is 2500 packets/second currently)
87    #[default(5_000)]
88    pub max_surbs_per_sec: u64,
89
90    /// Sets what percentage of the target buffer size should be discarded at each window.
91    ///
92    /// The `SurbBalancer` will discard the given percentage of `target_surb_buffer_size` at each
93    /// window with the given `Duration`.
94    ///
95    /// The default is `(60, 0.05)` (5% of the target buffer size is discarded every 60 seconds).
96    #[default(_code = "Some((Duration::from_secs(60), 0.05))")]
97    pub surb_decay: Option<(Duration, f64)>,
98
99    /// Keeps producing SURBs while the return path is known to be failing, instead of reading the
100    /// resulting silence as a full counterparty buffer.
101    ///
102    /// The remote buffer is estimated as *produced − consumed*, and consumption is only observed
103    /// when a reply reaches us. A return path that drops every reply therefore looks exactly like a
104    /// counterparty that is well stocked, so production is throttled at the very moment the
105    /// counterparty is in fact draining towards empty and needs more.
106    ///
107    /// Distinguishing that from a peer which simply has nothing to say is impossible from here --
108    /// both show no consumption -- so this only takes effect once an outside observer marks the
109    /// return path degraded, and it expires on its own if no further evidence arrives.
110    ///
111    /// Off by default: sustaining production spends bandwidth on a path that may be genuinely idle,
112    /// which is only worth it for sessions that value recovery latency over that bandwidth.
113    #[default(false)]
114    pub sustain_on_return_path_loss: bool,
115}
116
117impl SurbBalancerConfig {
118    /// Convenience function to convert the [`SurbBalancerConfig`] into `BalancerControllerBounds`.
119    #[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/// Runtime state of the `SurbBalancer`.
126#[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    /// Whether this session opted into sustaining production through return-path loss.
134    pub sustain_on_return_path_loss: AtomicBool,
135    /// How many SURBs the counterparty can physically hold, or 0 when unknown.
136    ///
137    /// The estimate is `produced - consumed`, and consumption is only observed once a reply
138    /// arrives -- so a return path that drops replies lets the believed level grow without bound.
139    /// The counterparty's store is a ring buffer that evicts the oldest entry on overflow, so
140    /// everything above its capacity was discarded on arrival and was never a real level. Measured
141    /// during an outage: 51 917 believed against a 15 000-entry store.
142    ///
143    /// ## Why evictions are not subtracted from the level
144    ///
145    /// The counterparty reports its evictions (`num_evicted_surbs` on the incoming packet), so the
146    /// level could be corrected to the exact truth instead of merely bounded here. It deliberately
147    /// is not, because the clamp below is `max(capacity, target)` rather than `capacity`: a level
148    /// inflated past a full buffer still climbs to the target and shuts organic production off,
149    /// whereas an accurate level pins at the counterparty's real capacity. If that capacity is below
150    /// the target -- which nothing prevents, since this figure is the *local* store size and the
151    /// counterparty may be smaller -- the accurate level never reaches the target, production never
152    /// stops, and the buffer evicts forever. The imprecise estimate fails safe and the precise one
153    /// does not, so the eviction count stays an observability signal.
154    pub counterparty_buffer_capacity: AtomicU64,
155    /// Milliseconds from the crate-internal `EPOCH` monotonic origin until which the return path
156    /// counts as degraded.
157    ///
158    /// A deadline rather than a flag: it is set by a layer that observes the return path and read
159    /// here, and nothing is guaranteed to come back and clear it. Expiring on its own bounds the
160    /// damage of a marker that is never withdrawn to a short over-production instead of a session
161    /// that mints forever.
162    pub return_path_degraded_until_ms: AtomicU64,
163    /// Whether the counterparty's last packet said it was running low on SURBs of its own.
164    ///
165    /// A plain flag with no deadline, unlike `return_path_degraded_until_ms` above, because the two
166    /// fail in opposite directions: a degraded-path marker that is never withdrawn makes this side
167    /// mint forever, whereas a distress flag that is never withdrawn merely keeps organic production
168    /// at one SURB per packet — the behaviour that predates the gate. A flag whose stuck state is
169    /// the old behaviour does not need to expire.
170    ///
171    /// It clears on its own in the normal case: the counterparty recomputes both SURB signals on
172    /// every return packet and strips them once its pool recovers, so the next healthy packet
173    /// resets this.
174    ///
175    /// Last write wins, and the signal is recorded at dispatch -- ahead of Session sequencing -- so
176    /// a reordered clean packet can clear a distress signal the counterparty sent after it. That
177    /// costs the safety valve rather than the recovery: `surb_decay` subtracts from the level
178    /// estimate on a timer regardless of what any packet says, so once the estimate falls back under
179    /// target both this gate and the keep-alives reopen on their own.
180    ///
181    /// How long that takes is a property of the configuration, not a guarantee of this type. It
182    /// scales with the decay rate and with how far above target the estimate sits, and the
183    /// `max(capacity, target)` clamp bounds the latter only while `counterparty_buffer_capacity` is
184    /// known -- its `0` ("unknown") arm leaves the estimate unclamped. With decay switched off the
185    /// estimate does not drain on its own at all, and only a fresh distress signal or observed
186    /// consumption reopens the gate.
187    ///
188    /// Sequencing the flag would buy a faster reopen, at the cost of making a hot-path signal
189    /// depend on the Session's reassembly.
190    pub counterparty_in_surb_distress: AtomicBool,
191}
192
193impl BalancerStateValues {
194    /// Constructor from a [`SurbBalancerConfig`].
195    pub fn new(cfg: SurbBalancerConfig) -> Self {
196        let state = Self::default();
197        state.update(&cfg);
198        state
199    }
200
201    /// Performs update of the [`BalancerStateValues`] from the [`SurbBalancerConfig`] and
202    /// enables it.
203    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    /// Declares how many SURBs the counterparty's store can hold, bounding the level estimate.
225    ///
226    /// Taken from the session manager's `maximum_surb_buffer_size`, which is the same capacity
227    /// already used to clamp a counterparty's requested target. Zero leaves the estimate unbounded.
228    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    /// Caps `level` at what the counterparty can actually hold.
234    ///
235    /// Never below the configured target: a target above the counterparty's capacity is
236    /// unreachable by construction, and clamping to capacity there would hold the error permanently
237    /// negative and pin production at maximum forever -- a worse failure than the unbounded
238    /// estimate this exists to prevent. In that configuration the capacity figure is simply not
239    /// usable for this session.
240    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    /// Records what the counterparty's latest packet said about *its own* SURB supply.
253    ///
254    /// Takes the whole signal set rather than a `bool` so the containment rule lives here: `OutOfSurbs`
255    /// is a superset of `SurbDistress` on the wire, so `contains` catches both, whereas an equality
256    /// match against `SurbDistress` would silently ignore the more severe of the two.
257    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    /// Returns the number of organic SURBs to attach to an outgoing Session data packet.
265    ///
266    /// Only the Entry uses this value. Return `0` when the estimated counterparty buffer
267    /// has reached its target. Return `1` when balancing is disabled, the estimate is
268    /// below target, or the counterparty signals SURB distress.
269    ///
270    /// This method uses the raw buffer estimate. During return-path loss, a zero estimate
271    /// correctly keeps organic SURB production enabled.
272    pub fn organic_surbs_per_packet(&self) -> usize {
273        // Not defensive boilerplate: nothing rejects a `SurbBalancerConfig` with a zero target, and
274        // without this branch `level >= 0` would hold forever and shut organic production off
275        // permanently — while a PID with a zero output limit produces nothing either.
276        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    /// Marks the return path as degraded for the next `grace` period.
286    ///
287    /// Called by whichever layer can actually tell a dead return path from a quiet peer -- from
288    /// here the two are indistinguishable, since neither delivers replies. Re-marking simply
289    /// extends the window.
290    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    /// Whether [`buffer_level`](Self::buffer_level) is currently an instruction rather than a
297    /// measurement.
298    ///
299    /// While this holds, the controller deliberately writes `0` into the level to drive production
300    /// to its maximum. That zero says "produce flat out", not "the counterparty holds nothing", so
301    /// anything reading the level as a *supply ceiling* must consult this first or it will read the
302    /// instruction as an order to send nothing.
303    ///
304    /// True only when both the opt-in (`sustain_on_return_path_loss`) and live evidence
305    /// ([`mark_return_path_degraded`](Self::mark_return_path_degraded), within its window) are
306    /// present: without the opt-in this is not our behaviour to change, and without evidence there
307    /// is nothing to tell a dead return path from an idle one.
308    ///
309    /// `pub` because it is not only the controller's business — hence the emphasis above on what
310    /// the flag does *not* mean. It is not a general "the return path is degraded" signal.
311    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        // Zero is "never marked", not "marked at the epoch" -- otherwise every session that opted
321        // in would start out believing its return path was already dead.
322        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    /// Extracts the [`SurbBalancerConfig`] from the [`BalancerStateValues`].
330    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    /// Checks if SURB balancing is disabled (no target buffer size set).
342    pub fn is_disabled(&self) -> bool {
343        self.target_surb_buffer_size.load(std::sync::atomic::Ordering::Relaxed) == 0
344    }
345
346    /// Extracts the SURB decay configuration from the [`BalancerStateValues`].
347    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    /// Gets the current estimated SURB buffer level.
357    #[inline]
358    pub fn buffer_level(&self) -> u64 {
359        self.buffer_level.load(std::sync::atomic::Ordering::Relaxed)
360    }
361
362    /// Returns the current `BalancerControllerBounds` from the [`BalancerStateValues`].
363    #[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
378/// Runs a continuous process that attempts to [evaluate](SurbFlowEstimator) and
379/// [regulate](SurbFlowController) the flow of SURBs to the Session counterparty,
380/// to keep the number of SURBs locally or at the counterparty at a certain level.
381///
382/// Internally, the Balancer uses an implementation of [`SurbBalancerController`] to
383/// control the rate of SURBs consumed or sent to the counterparty
384/// each time the [`update`](SurbBalancer::update) method is called:
385///
386/// 1. The size of the SURB buffer at locally or at the counterparty is estimated using [`SurbFlowEstimator`].
387/// 2. Error against a set-point given in [`SurbBalancerConfig`] is evaluated in the `SurbBalancerController`.
388/// 3. The `SurbBalancerController` applies a new SURB flow rate value using the [`SurbFlowController`].
389///
390/// In the local context, the `SurbFlowController` might simply regulate the egress traffic from the
391/// Session, slowing it down to avoid fast SURB drainage.
392///
393/// In the remote context, the `SurbFlowController` might regulate the flow of non-organic SURBs via
394/// Start protocol's `KeepAlive` messages to deliver additional
395/// SURBs to the counterparty.
396pub 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    /// Whether the previous update ran in open loop, so both edges can be acted on.
407    was_degraded: bool,
408    /// DIAGNOSTIC: when the last balancer-state line was emitted, to rate-limit it.
409    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    /// Computes the next control update and adjusts the [`SurbFlowController`] rate accordingly.
450    #[tracing::instrument(level = "trace", skip_all)]
451    fn update(&mut self) -> u64 {
452        let dt = self.last_update.elapsed();
453
454        // Load the updated current buffer level
455        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        // Take a snapshot of the active SURB estimator and calculate the balance change
465        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 SURB decaying is enabled, check if the decay window has elapsed
475        // and calculate the number of SURBs that will be discarded
476        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        // Believing a level the counterparty cannot hold keeps production throttled long after
488        // the surplus was evicted on arrival, so the estimate is bounded by the store it describes.
489        let believed = current;
490        current = self.state.clamp_to_counterparty_capacity(current);
491        if current != believed {
492            // Not the configured capacity: the bound applied is `max(capacity, target)`, so name
493            // the clamped level and the capacity separately rather than conflating them.
494            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            // The estimate stops meaning what it meant on both edges: entering, it is inflated by
508            // production nobody was seen to consume; leaving, it is a level that was never
509            // observed. Either way the accumulated error belongs to a regime that has ended.
510            self.controller.reset();
511            self.was_degraded = degraded;
512
513            if !degraded {
514                // Coming back, treat the counterparty as freshly started rather than as whatever
515                // the outage left behind. It really did drain while replies were lost, and this is
516                // the estimate that self-corrects: consumption is observable again, so the buffer
517                // level climbs on its own as production outruns it.
518                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            // While replies are being lost there is no valid estimate to act on: every SURB the
526            // counterparty spends is invisible from here, so the accumulated `produced - consumed`
527            // reads as a filling buffer precisely when it is emptying. Drop to open loop and assume
528            // the worst, which drives production to the maximum until replies resume.
529            tracing::debug!(
530                believed = current,
531                "return path degraded; ignoring the counterparty buffer estimate"
532            );
533            // Reads as "produce flat out" to the controller below. `SurbSupply` must not read it
534            // as "the buffer is empty, admit nothing" -- see `return_path_estimate_is_stale`.
535            current = 0;
536        }
537
538        self.state
539            .buffer_level
540            .store(current, std::sync::atomic::Ordering::Release);
541
542        // Error from the desired target SURB buffer size at counterparty
543        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        // Both ends run this same loop -- the Entry with the PID driving production, the Exit with
566        // the proportional controller gating egress -- so one line covers both and the session id
567        // tells them apart. Rate-limited to one per second so it can run under a full-rate session.
568        //
569        // At `debug` rather than `info`: one line per session per second is fine for a handful of
570        // sessions and is a lot of formatting work for a node carrying many, none of which an
571        // operator needs to see during healthy operation.
572        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    /// Spawns a new task that performs updates of the given [`SurbBalancer`] at the given `sampling_interval`.
606    ///
607    /// If `cfg_feedback` is given, [`SurbBalancerConfig`] can be queried for updates and also updated
608    /// if the underlying [`SurbBalancerController`] also does target updates.
609    ///
610    /// Returns a stream of current estimated buffer levels, and also an `AbortHandle`
611    /// to terminate the loop. If `abort_reg` was given, the returned `AbortHandle` corresponds
612    /// to it.
613    #[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        // Start an interval stream at which the balancer will sample and perform updates
621
622        // DropAbortable not needed because the stream only generates items when polled
623        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                    // Check if the balancer controller needs to be reconfigured
644                    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                    // Perform controller update (this internally samples the SurbFlowEstimator)
651                    // and send an update about the current level to the outgoing stream.
652                    // If the other party has closed the stream, we don't care about the update.
653                    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    /// State with a given target sitting at a given level, for the organic-gate tests below.
763    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    /// The `>=` boundary: at target is already too many, because the next SURB is the one that
782    /// evicts. Pinned at exactly the target and well past it.
783    #[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    /// The safety valve. Our level estimate counts SURBs as delivered when they are *sent*, so
790    /// forward-path loss inflates it — the counterparty's own word about its supply has to win over
791    /// an estimate that can be wrong in exactly that direction.
792    #[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    /// `OutOfSurbs` is `0b11` and `SurbDistress` is `0b01`, so the former *contains* the latter.
802    /// Matching on equality instead of containment would ignore the more severe of the two signals
803    /// and leave production shut off for a counterparty that has nothing left to reply with.
804    #[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    /// Distress is not sticky once the counterparty recovers: it recomputes both signals per return
812    /// packet and strips them when its pool is healthy, so the next such packet re-arms the gate.
813    #[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    /// A zero target means "no balancing", not "the target is already met". Nothing rejects such a
824    /// config, so without the explicit branch `level >= 0` would hold forever and starve the session
825    /// of organic SURBs permanently — while a PID with a zero output limit produces none either.
826    #[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    /// Sessions opened without SURB management hold a default state and route their outgoing packets
834    /// through the same policy as balanced ones, relying on it to answer 1. A `Default` that ever
835    /// gained a non-zero target would silently stop those sessions producing organic SURBs, with
836    /// nothing at the call site to show why.
837    #[test]
838    fn a_default_state_should_keep_producing_organic_surbs() {
839        assert_eq!(1, BalancerStateValues::default().organic_surbs_per_packet());
840    }
841
842    /// Mirror image of `a_degraded_return_path_should_not_zero_the_supply_ceiling` in `flow_control`:
843    /// there, the degraded-path `0` must be suppressed because it would read as "admit no bytes";
844    /// here it must be honoured, because it reads as "below target, keep producing" — which is
845    /// exactly what opting into `sustain_on_return_path_loss` asks for. Same stored value, opposite
846    /// polarity, so this must *not* grow the guard its counterpart needs.
847    #[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        // What the control loop writes while it drives production flat out.
856        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)), // > 1.0 should be clamped
904            ..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    /// A balancer whose production follows its own control output, as it does in a live Session.
1020    ///
1021    /// Returns the balancer, the shared estimator and the latest control output. Production must be
1022    /// fed back rather than held constant: with production pinned to consumption the buffer never
1023    /// fills, maximum output is the correct answer, and every phase of the test reads the same.
1024    #[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    /// One sampling interval: mint at the rate last commanded, and consume `consumed` of them.
1054    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    /// SURBs the counterparty spends per interval while it is answering normally.
1074    const REPLIES_PER_TICK: u64 = 40;
1075
1076    /// Drives a balancer through a healthy stretch, then through one where no reply comes back.
1077    ///
1078    /// Returns the control output at the end of each stretch. The two phases are deliberately
1079    /// indistinguishable from inside the balancer -- consumption simply stops -- which is the whole
1080    /// point: only the caller's `sustain` choice separates a dead return path from an idle peer.
1081    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        // Replies stop while production continues.
1094        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            // Small enough that the healthy phase actually reaches the setpoint and backs off
1104            // within the test's tick budget; saturated-at-maximum makes every phase read alike.
1105            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    /// A peer with nothing to say really is filling up, so throttling it is correct.
1113    ///
1114    /// This is the case that makes the estimate impossible to fix locally: it is byte-for-byte the
1115    /// same observation as a dead return path.
1116    #[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    /// Once told the return path is dead, the same observation must not be read as a full buffer.
1128    ///
1129    /// `consumed` advances only when a reply reaches the entry (`manager.rs`, the `session_rx`
1130    /// inspect counting "received packets = SURB consumption estimate"). A return path that drops
1131    /// every reply therefore looks like a well-stocked counterparty, and production is cut at the
1132    /// exact moment the counterparty is draining towards empty -- the feedback signal travels on
1133    /// the very path whose failure it is meant to reveal.
1134    #[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    /// Both edges of a degraded window: open loop must engage at once, and let go afterwards.
1147    #[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        // The mark lapses and the counterparty starts answering again.
1165        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    /// After the outage the counterparty must be refilled, not merely un-throttled.
1183    ///
1184    /// Returning to closed loop is only half the claim: production has to actually climb the curve
1185    /// again and restore the buffer. Resetting the controller is what makes that prompt -- the error
1186    /// accumulated while the estimate was meaningless would otherwise have to be unwound first.
1187    #[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        // A band, not the setpoint itself: the controller oscillates around its target, so a
1196        // sample taken at an arbitrary tick legitimately sits either side of it.
1197        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        // The return path dies: replies stop, and open loop takes over.
1204        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        // The mark lapses, the counterparty answers again, and the belief restarts from empty.
1210        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        // Each tick is one sampling interval; the balancer samples far more often than this in a
1223        // live Session, so a bound in ticks is a bound in sampling intervals, not in wall clock.
1224        assert!(
1225            ticks <= 30,
1226            "refilling must ramp rather than crawl: took {ticks} sampling intervals"
1227        );
1228    }
1229
1230    /// The estimate must not claim a level the counterparty's store could never have held.
1231    ///
1232    /// `produced - consumed` only decreases when a reply arrives, so production that nobody is
1233    /// seen to consume accumulates without bound. The counterparty's store is a ring buffer that
1234    /// evicts the oldest entry on overflow, so everything above its capacity was discarded on
1235    /// arrival. Measured during a live outage: 51 917 believed against a 15 000-entry store, which
1236    /// keeps the controller throttling against a buffer that is in fact draining.
1237    #[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        // Replies stop, while production continues from a source the controller does not drive --
1250        // keep-alives mint on their own schedule, which is how the live estimate ran away.
1251        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    /// The bound must not become the setpoint: a store larger than the target changes nothing.
1267    #[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    /// The opt-in is what enables it; evidence alone must not change a session's behaviour.
1286    #[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    // --- return-path-degraded deadline primitive (Test C, gap 1) ---------------------------------
1342    //
1343    // The behavioural loop tests above (sustain / ignore-unless-opted-in / return-to-closed-loop /
1344    // refill) already cover how the balancer *reacts* to the signal. What they do not isolate are
1345    // three edge cases of the `mark_return_path_degraded` / `return_path_estimate_is_stale`
1346    // deadline primitive itself, each guarding a specific correctness invariant.
1347
1348    /// The zero-guard: never marked is never degraded. `return_path_degraded_until_ms == 0` means
1349    /// "never marked", not "marked at the epoch" — without the explicit `deadline > 0` check every
1350    /// opted-in session would believe its return path was dead from the first packet.
1351    #[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    /// The window expires on its own, independently of any recovery signal. The behavioural tests
1361    /// clear the degraded state by resuming replies; this pins the other exit — a marker nobody
1362    /// ever withdraws must still lapse, bounding over-production to the grace window. Marking with a
1363    /// zero grace sets the deadline to "now"; the monotonic clock only moves forward, so the
1364    /// subsequent read is already past it — no sleep, no flake.
1365    #[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    /// Re-marking extends the window: an already-expired deadline followed by a fresh mark is stale
1376    /// again. Guards the `fetch_max` in `mark_return_path_degraded`.
1377    #[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}