Skip to main content

hopr_transport_session/supervision/
fill.rs

1//! The PIX fill planner — the pure rate law behind the Exit's own keep-alives.
2//!
3//! A PIX Exit is paid per return packet: each Exit → Entry packet spends one SURB carrying one
4//! share, and a funded cycle only recovers once its whole emission has gone back. Nothing in the
5//! protocol makes the *application* send those packets, so an idle Session strands the deposit it has
6//! already been paid. The Exit therefore sends its own, at a rate this module derives once per
7//! [`SAMPLING_INTERVAL`] from three quantities and nothing else:
8//!
9//! ```text
10//! remaining  = (E − shares seen on the target cycle) × (1 + loss_margin)
11//! finish_by  = target.hard_deadline − (1 − finish_fraction) × max_recovery_time
12//! required   = remaining / max(finish_by − now, SAMPLING_INTERVAL)          packets/s
13//! organic    = EMA of the gate's served counter over ORGANIC_WINDOW         packets/s
14//! fill       = clamp(required − organic, heartbeat, max_rate)
15//! ```
16//!
17//! Everything here is pure: no clock is read, no I/O is performed, and every input arrives as an
18//! argument. The supervisor owns the target selection — which cycle the Exit is currently working
19//! for — because that is a lifecycle question; this module owns only the arithmetic and the
20//! decision of whether the answer is worth emitting.
21//!
22//! # Why `organic` is the gate counter
23//!
24//! `served_total` counts packets that passed the [`ServiceGate`](super::gate::ServiceGate), which is
25//! every application data packet and nothing else — fill itself is ungated, exactly as the SURB-level
26//! keep-alives it reuses are. So the counter measures what the application is contributing and never
27//! what this planner is, which is what makes `required − organic` a subtraction rather than a
28//! feedback loop that would drive itself to zero.
29//!
30//! # Why the stall rule cannot be the idle rule
31//!
32//! [`SupervisorConfig::max_recovery_idle`] closes a Session whose cycle stops progressing *while
33//! service is being consumed*, and it reads the same gate counter. Fill bypasses the gate, so a cycle
34//! that is being filled and making no progress is invisible to it: the idle rule keeps re-arming
35//! because, as far as it can see, nothing was served. Left alone, an Exit would fill a doomed cycle —
36//! one whose polynomial failed, or whose Entry is supplying SURBs that carry no shares — at
37//! `max_rate` for the whole of `max_recovery_time`. The rule below is the bound that the gate-based
38//! one cannot supply: once the target has not moved for `max_recovery_idle`, fill falls back to the
39//! heartbeat and stays there until the cycle moves again.
40
41use std::time::{Duration, Instant};
42
43use hopr_api::types::internal::prelude::HoprPseudonym;
44use hopr_protocol_pix::{PixParams, SsaId};
45
46use super::{FillRate, ORGANIC_WINDOW, PixFillConfig, SAMPLING_INTERVAL, SupervisorConfig};
47
48/// The cycle the Exit is currently filling for, as the supervisor sees it.
49///
50/// Deliberately a snapshot rather than a borrow of the supervisor's own record: the planner must not
51/// be able to reach a cycle's lifecycle state, only the three numbers the rate law is a function of.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub(crate) struct FillTarget {
54    /// Which cycle this is, so the planner can tell a stall from a handoff.
55    pub ssa_id: SsaId<HoprPseudonym>,
56    /// Shares the cycle has accepted so far — surplus included, since every emitted share rides one
57    /// packet and it is packets that are being planned.
58    pub largest_shares_seen: u64,
59    /// The immutable per-cycle recovery deadline this cycle must finish before.
60    pub hard_deadline: Instant,
61    /// Whether this target is a post-close drain.
62    ///
63    /// A live Session fills only fast enough to meet its deadline. A closed one has no future
64    /// traffic and no SURB replenishment, so the planner spends the remainder at
65    /// [`PixFillConfig::max_rate`]. See "Draining a closed Session" in `supervision`.
66    pub drain: bool,
67}
68
69/// The pure rate law. See the module documentation for what it computes and why.
70pub(crate) struct FillPlanner {
71    cfg: PixFillConfig,
72    /// [`SupervisorConfig::max_recovery_time`], the budget `finish_fraction` is a fraction of.
73    max_recovery_time: Duration,
74    /// [`SupervisorConfig::max_recovery_idle`], reused as the stall horizon so that fill gives up on
75    /// a motionless cycle at the same point the idle rule would have, had it been able to see it.
76    max_recovery_idle: Duration,
77    /// `E` — every share one cycle of the negotiated dimensions emits, and therefore every packet it
78    /// takes to complete one.
79    cycle_shares: u64,
80    /// When [`plan`](Self::plan) last ran. Also the base of [`next_tick`](Self::next_tick).
81    last_tick: Instant,
82    /// The gate's served counter at `last_tick`, so the next tick can difference it.
83    last_served_total: u64,
84    /// Exponential average of organic egress, in packets per second.
85    organic_pps: f64,
86    /// The cycle the counters below belong to, so a handoff resets them rather than reading the
87    /// successor's progress as the predecessor's stall.
88    target: Option<SsaId<HoprPseudonym>>,
89    /// `largest_shares_seen` as of the last tick that observed movement.
90    target_seen: u64,
91    /// When the target last moved, or when it became the target. `None` while there is no target.
92    target_progress_at: Option<Instant>,
93    /// The rate the stream is believed to be running at, and the value hysteresis compares against.
94    last_rate: FillRate,
95    /// Whether the stall has already been reported, so a stalled cycle warns once rather than once a
96    /// second for the whole of `max_recovery_time`.
97    stall_warned: bool,
98    /// Set on the tick a stall begins, and taken by the worker so it can count the event.
99    ///
100    /// An edge rather than a level, because the metric it feeds counts stalls: a level would count
101    /// one per second for as long as the cycle stayed motionless, which is a measure of how long the
102    /// operator waited rather than of how often this happened.
103    stall_onset: bool,
104}
105
106impl FillPlanner {
107    /// Builds a planner for a Session of the given dimensions.
108    ///
109    /// `now` seeds [`next_tick`](Self::next_tick), so the first plan happens one sampling interval
110    /// after the Session starts rather than immediately. Nothing is lost by that: a Session has no
111    /// funded cycle to fill for until at least a commitment and a deposit have crossed the wire.
112    pub(crate) fn new(cfg: &SupervisorConfig, dims: &PixParams, now: Instant) -> Self {
113        Self {
114            cfg: cfg.fill.clone(),
115            max_recovery_time: cfg.max_recovery_time,
116            max_recovery_idle: cfg.max_recovery_idle,
117            cycle_shares: dims.polys_per_ssa() as u64 * dims.emitted_shares_per_poly() as u64,
118            last_tick: now,
119            last_served_total: 0,
120            organic_pps: 0.0,
121            target: None,
122            target_seen: 0,
123            target_progress_at: None,
124            last_rate: FillRate::ZERO,
125            stall_warned: false,
126            stall_onset: false,
127        }
128    }
129
130    /// Whether fill is configured on at all.
131    pub(crate) fn is_enabled(&self) -> bool {
132        self.cfg.enabled
133    }
134
135    /// Reports, once, that the target cycle has gone motionless and fill has fallen back.
136    ///
137    /// Consumed rather than read so the caller counts the stall rather than its duration. The caller
138    /// is the worker, which is the layer allowed to have side effects; this module stays pure.
139    pub(crate) fn take_stall_onset(&mut self) -> bool {
140        std::mem::take(&mut self.stall_onset)
141    }
142
143    /// Whether the target has gone motionless and fill has fallen back to its heartbeat.
144    ///
145    /// The level behind [`take_stall_onset`](Self::take_stall_onset)'s edge, so the census can
146    /// report a stall for as long as it lasts without the caller tracking it.
147    pub(crate) fn is_stalled(&self) -> bool {
148        self.stall_warned
149    }
150
151    /// Whether the stream this planner drives is currently emitting nothing.
152    ///
153    /// The supervisor uses it to decide whether a tick is worth waking for when there is no target:
154    /// a planner that has already stopped has nothing left to say, while one that is still running
155    /// owes the stream a zero.
156    pub(crate) fn is_idle(&self) -> bool {
157        self.last_rate.is_zero()
158    }
159
160    /// When the next plan is due.
161    pub(crate) fn next_tick(&self) -> Instant {
162        self.last_tick.checked_add(SAMPLING_INTERVAL).unwrap_or(self.last_tick)
163    }
164
165    /// Silences the stream, if it is running, and forgets the target.
166    ///
167    /// Returns the action's payload only when there is something to stop, so a Session that never
168    /// filled — a disabled one, or one that closed before its first cycle funded — emits nothing and
169    /// its action stream is byte-for-byte what it was before fill existed.
170    pub(crate) fn stop(&mut self) -> Option<FillRate> {
171        self.target = None;
172        self.target_seen = 0;
173        self.target_progress_at = None;
174        self.stall_warned = false;
175        (!self.last_rate.is_zero()).then(|| {
176            self.last_rate = FillRate::ZERO;
177            FillRate::ZERO
178        })
179    }
180
181    /// Re-derives the fill rate, returning it only if it is worth emitting.
182    ///
183    /// `served_total` is the service gate's counter — organic egress and nothing else. `target` is
184    /// the cycle the Exit is working for, or `None` when it has none, which is the case that stops
185    /// the stream.
186    pub(crate) fn plan(&mut self, now: Instant, served_total: u64, target: Option<FillTarget>) -> Option<FillRate> {
187        if !self.cfg.enabled {
188            return None;
189        }
190
191        self.observe_organic(now, served_total);
192
193        let Some(target) = target else {
194            return self.stop();
195        };
196
197        self.observe_target(now, &target);
198
199        let required = self.required_rate(now, &target);
200        // Organic egress is subtracted because the application is expected to keep contributing it.
201        // A drained Session's will not: the egress gate is poisoned, the ingress is aborted, and
202        // `served_total` can never move again, so the average above is a memory of traffic that
203        // cannot recur. Subtracting it would credit the drain with a contribution nothing is left to
204        // make — and it bites hardest in the case the drain exists for. A client that hangs up on a
205        // cycle it has all but paid off leaves a small remainder and a high average, so `required`
206        // lands *below* what the Session was doing a moment earlier, the drain is suppressed to its
207        // heartbeat for the whole of the averaging window, and the deadline arrives first.
208        let wanted = if target.drain {
209            required
210        } else {
211            (required - self.organic_pps).max(0.0)
212        };
213        let rate = self.bound(now, wanted);
214
215        self.should_emit(rate).then(|| {
216            tracing::debug!(
217                ssa_id = %target.ssa_id,
218                seen = target.largest_shares_seen,
219                cycle_shares = self.cycle_shares,
220                required,
221                organic = self.organic_pps,
222                packets = rate.packets,
223                per = ?rate.per,
224                "planned a new PIX fill rate"
225            );
226            self.last_rate = rate;
227            rate
228        })
229    }
230
231    /// Folds this interval's organic egress into the exponential average.
232    ///
233    /// `alpha = dt / ORGANIC_WINDOW`, capped at one, so the average has the same time constant
234    /// however irregularly the ticks land — a worker that was busy for five seconds contributes five
235    /// seconds of weight, not one tick's worth. A tick with no elapsed time contributes nothing
236    /// rather than dividing by zero.
237    fn observe_organic(&mut self, now: Instant, served_total: u64) {
238        let dt = now.saturating_duration_since(self.last_tick).as_secs_f64();
239        if dt > 0.0 {
240            // Saturating because the gate counter is monotonic, so a decrease can only mean the
241            // sample was taken across a reset; reading it as a huge positive delta would suppress
242            // fill for the whole of the averaging window.
243            let sample = served_total.saturating_sub(self.last_served_total) as f64 / dt;
244            let alpha = (dt / ORGANIC_WINDOW.as_secs_f64()).min(1.0);
245            self.organic_pps += alpha * (sample - self.organic_pps);
246        }
247        self.last_served_total = served_total;
248        self.last_tick = now;
249    }
250
251    /// Tracks whether the target has moved, which is the input to the stall rule.
252    ///
253    /// A change of target resets the clock rather than inheriting the predecessor's: a successor that
254    /// has just reached the front has made no progress *yet*, and reading that as a stall would hold
255    /// every cycle after the first at the heartbeat.
256    fn observe_target(&mut self, now: Instant, target: &FillTarget) {
257        if self.target != Some(target.ssa_id) {
258            self.target = Some(target.ssa_id);
259            self.target_seen = target.largest_shares_seen;
260            self.target_progress_at = Some(now);
261            self.stall_warned = false;
262        } else if target.largest_shares_seen > self.target_seen {
263            self.target_seen = target.largest_shares_seen;
264            self.target_progress_at = Some(now);
265            self.stall_warned = false;
266        }
267    }
268
269    /// The rate at which the cycle's remaining emission clears the aim point, in packets per second.
270    ///
271    /// The aim point sits `(1 − finish_fraction) × max_recovery_time` before the hard deadline, so
272    /// the last part of the budget is margin: for loss beyond `loss_margin`, for a mixnet delay
273    /// spike, and for the commitment and deposit round trip the successor still needs. An aim point
274    /// already in the past collapses to one sampling interval, which asks for the whole remainder at
275    /// once and is then bounded by `max_rate` — the correct behaviour for a cycle that is out of time,
276    /// since there is no rate at which it can still be saved and no reason to stop trying.
277    ///
278    /// A [drain](FillTarget::drain) collapses the horizon the same way for a different reason: pacing
279    /// a cycle to its deadline only makes sense while the Session is still there to be paced, and a
280    /// drained one is spending a buffer nothing will refill. The ceiling in
281    /// [`bound`](Self::bound) then becomes the only thing setting the rate, which is the intent
282    /// rather than a side effect.
283    fn required_rate(&self, now: Instant, target: &FillTarget) -> f64 {
284        let remaining =
285            self.cycle_shares.saturating_sub(target.largest_shares_seen) as f64 * (1.0 + self.cfg.loss_margin);
286        let horizon = if target.drain {
287            SAMPLING_INTERVAL
288        } else {
289            let slack = self.max_recovery_time.mul_f64(1.0 - self.cfg.finish_fraction);
290            target
291                .hard_deadline
292                .checked_sub(slack)
293                .map(|finish_by| finish_by.saturating_duration_since(now))
294                .unwrap_or(SAMPLING_INTERVAL)
295                .max(SAMPLING_INTERVAL)
296        };
297        remaining / horizon.as_secs_f64()
298    }
299
300    /// Applies the stall rule and the two bounds, and expresses the result as a [`FillRate`].
301    ///
302    /// The heartbeat is a floor rather than a special case, so a Session whose application is keeping
303    /// up still emits one packet per period: it refreshes the Entry's idle eviction, it carries the
304    /// SURB level the Entry's balancer acts on, and it is what distinguishes a planner that decided
305    /// on nothing from one that has stopped planning.
306    ///
307    /// The stall rule still applies while draining.
308    ///
309    /// Without it, a doomed drain could sit at `max_rate` until the deadline and waste SURBs. A
310    /// healthy drain pays nothing for the rule, because progress refreshes
311    /// [`target_progress_at`](Self::target_progress_at). And `max_recovery_idle` does not replace
312    /// it: that deadline ends the drain, this bounds what it spends before then.
313    fn bound(&mut self, now: Instant, wanted: f64) -> FillRate {
314        let heartbeat = FillRate::once_per(self.cfg.heartbeat);
315        let heartbeat_pps = heartbeat.as_packets_per_sec();
316        let ceiling = self.cfg.max_rate as f64;
317
318        let stalled = self
319            .target_progress_at
320            .is_some_and(|at| now.saturating_duration_since(at) >= self.max_recovery_idle);
321        let wanted = if stalled && wanted > heartbeat_pps {
322            if !self.stall_warned {
323                self.stall_warned = true;
324                self.stall_onset = true;
325                tracing::warn!(
326                    ssa_id = ?self.target,
327                    seen = self.target_seen,
328                    cycle_shares = self.cycle_shares,
329                    stalled_for = ?self.max_recovery_idle,
330                    "PIX fill fell back to its heartbeat: the cycle it is filling for has stopped progressing"
331                );
332            }
333            heartbeat_pps
334        } else {
335            wanted
336        };
337
338        // `clamp` would panic on an inverted pair, and the pair is invertible: a heartbeat period
339        // shorter than `1 / max_rate` puts the floor above the ceiling. That configuration is
340        // nonsense rather than impossible, and the ceiling is the one that has to win — it is the
341        // bound on what this node will put on the wire.
342        let bounded = wanted.max(heartbeat_pps.min(ceiling)).min(ceiling);
343
344        if bounded >= 1.0 {
345            // Rounding up rather than down: a rate rounded down never finishes the cycle, which is
346            // the one outcome the whole mechanism exists to prevent. The cast is bounded by the
347            // clamp above, and the explicit floor keeps the cast honest.
348            FillRate::per_second((bounded.ceil() as u32).clamp(1, self.cfg.max_rate))
349        } else if bounded <= heartbeat_pps {
350            heartbeat
351        } else {
352            // Between the heartbeat and one packet a second, the rate is expressed as a *period*
353            // instead of being rounded up to 1/s. Rounding here is not a small error: a cycle with a
354            // few hundred packets left over an hour needs about a packet a minute, and quoting that
355            // as one a second is sixty times the traffic — spent, moreover, on the tail of a cycle
356            // that has nearly finished. `FillRate` carries its own unit precisely so this case is
357            // exact. The `min` is a guard rather than a rule: `bounded` is at least `heartbeat_pps`
358            // here, so the period is already at most the heartbeat's.
359            FillRate::once_per(Duration::from_secs_f64(
360                (1.0 / bounded).min(self.cfg.heartbeat.as_secs_f64()),
361            ))
362        }
363    }
364
365    /// Whether `rate` differs from what the stream is already doing by enough to be worth an action.
366    ///
367    /// The planner runs every second for the whole life of a cycle — hours, at production dimensions
368    /// — and the required rate drifts continuously as the remainder and the horizon both shrink.
369    /// Emitting every one of those would put a per-second action on a channel sized for lifecycle
370    /// transitions, and the driver would reconfigure a rate controller for a change no packet
371    /// schedule can express. So a change is reported when it is a real one:
372    ///
373    /// * any change to or from zero, because that is the difference between filling and not;
374    /// * any crossing of the heartbeat, because that is the difference between filling and idling;
375    /// * otherwise, a relative change of more than ten per cent.
376    fn should_emit(&self, rate: FillRate) -> bool {
377        if rate == self.last_rate {
378            return false;
379        }
380        if rate.is_zero() || self.last_rate.is_zero() {
381            return true;
382        }
383
384        let heartbeat = FillRate::once_per(self.cfg.heartbeat);
385        if (rate == heartbeat) != (self.last_rate == heartbeat) {
386            return true;
387        }
388
389        let old = self.last_rate.as_packets_per_sec();
390        let new = rate.as_packets_per_sec();
391        // `old` is non-zero here, both arms above having returned on a zero rate.
392        (new - old).abs() > 0.1 * old
393    }
394}
395
396// ---------------------------------------------------------------------------
397// Tests
398// ---------------------------------------------------------------------------
399
400#[cfg(test)]
401mod tests {
402    use hopr_api::types::crypto_random::Randomizable;
403    use hopr_protocol_pix::SsaIndex;
404
405    use super::*;
406
407    /// The three fields [`FillPlanner::bound`] reads, and nothing else that matters to it.
408    fn cfg(heartbeat: Duration, max_rate: u32, max_recovery_idle: Duration) -> SupervisorConfig {
409        SupervisorConfig {
410            max_recovery_idle,
411            // Only has to stay above the idle deadline; `bound` never reads it.
412            max_recovery_time: max_recovery_idle * 2,
413            fill: PixFillConfig {
414                heartbeat,
415                max_rate,
416                ..PixFillConfig::default()
417            },
418            ..SupervisorConfig::default()
419        }
420    }
421
422    /// See the identically-named helper in `super::supervisor` for why the surplus is non-zero.
423    /// `bound` reads none of it, but `FillPlanner::new` needs dimensions to size a cycle.
424    fn dims() -> PixParams {
425        PixParams::try_new(10, 5, 7, crate::types::LOCAL_PIX_SUITE).expect("test dimensions must be valid")
426    }
427
428    fn target(now: Instant) -> FillTarget {
429        FillTarget {
430            ssa_id: SsaId::new(
431                HoprPseudonym::random(),
432                SsaIndex::new(1).expect("index one is non-zero"),
433            ),
434            largest_shares_seen: 0,
435            hard_deadline: now + Duration::from_secs(3600),
436            drain: false,
437        }
438    }
439
440    /// An inverted heartbeat/ceiling pair resolves ceiling-wins rather than panicking.
441    ///
442    /// A heartbeat period shorter than `1 / max_rate` puts the floor above the ceiling, which is
443    /// nonsense rather than impossible — nothing rejects the pair, since each field is legal on its
444    /// own. `f64::clamp` panics on it. The ceiling has to be the one that survives: it is the bound
445    /// on what this node will put on the wire, and a floor that overrode it would turn the one guard
446    /// on self-generated egress into its opposite.
447    #[test]
448    fn an_inverted_heartbeat_and_ceiling_resolve_to_the_ceiling() {
449        let now = Instant::now();
450        let mut planner = FillPlanner::new(
451            &cfg(Duration::from_millis(10), 5, Duration::from_secs(30)),
452            &dims(),
453            now,
454        );
455
456        // The heartbeat asks for 100 packets/s against a ceiling of five, from both directions: with
457        // nothing wanted, so only the floor is in play, and with more wanted than the ceiling allows.
458        for wanted in [0.0, 50.0] {
459            let rate = planner.bound(now, wanted);
460            assert_eq!(
461                FillRate::per_second(5),
462                rate,
463                "an inverted pair must resolve to the ceiling, got {rate:?} for a wanted rate of {wanted}"
464            );
465        }
466    }
467
468    /// Fractional rates round *up*, and the ceiling still binds after the rounding.
469    ///
470    /// Rounding down is the one direction that cannot be tolerated: a rate a hair below what the
471    /// cycle needs never finishes it, which is the whole outcome this mechanism exists to prevent.
472    /// The ceiling is applied before the rounding, so a `max_rate` of ten can never be rounded to
473    /// eleven.
474    #[test]
475    fn fractional_rates_round_up_and_stay_under_the_ceiling() {
476        let now = Instant::now();
477        let mut planner = FillPlanner::new(&cfg(Duration::from_secs(60), 10, Duration::from_secs(30)), &dims(), now);
478
479        assert_eq!(FillRate::per_second(5), planner.bound(now, 4.2));
480        assert_eq!(FillRate::per_second(10), planner.bound(now, 9.001));
481        assert_eq!(
482            FillRate::per_second(10),
483            planner.bound(now, 10.4),
484            "the ceiling must bind before the rounding, not after it"
485        );
486        assert_eq!(
487            FillRate::per_second(10),
488            planner.bound(now, f64::MAX),
489            "no wanted rate may produce more than the ceiling"
490        );
491    }
492
493    /// Between the heartbeat and one packet a second, the rate is a *period* rather than a rounding.
494    ///
495    /// A cycle with a few hundred packets left over an hour needs about a packet a minute. Rounding
496    /// that up to one a second is sixty times the traffic, spent on the tail of a cycle that has
497    /// nearly finished — so [`FillRate`] carries its own unit and this range uses it. The period must
498    /// never come out longer than the heartbeat, which is the floor.
499    #[test]
500    fn sub_hertz_rates_are_expressed_as_a_period_no_longer_than_the_heartbeat() {
501        let heartbeat = Duration::from_secs(60);
502        let now = Instant::now();
503        let mut planner = FillPlanner::new(&cfg(heartbeat, 250, Duration::from_secs(30)), &dims(), now);
504
505        let rate = planner.bound(now, 0.1);
506        assert_eq!(1, rate.packets, "a sub-hertz rate must stay one packet per period");
507        assert_eq!(Duration::from_secs(10), rate.per);
508
509        // Just above the heartbeat, where the `min` in that branch is closest to binding.
510        let barely = planner.bound(now, 1.0 / 50.0);
511        assert_eq!(1, barely.packets);
512        assert!(
513            barely.per <= heartbeat,
514            "a period longer than the heartbeat would put the rate under its own floor, got {:?}",
515            barely.per
516        );
517
518        // And nothing wanted at all is the heartbeat itself rather than silence.
519        assert_eq!(FillRate::once_per(heartbeat), planner.bound(now, 0.0));
520    }
521
522    /// A motionless target drops fill to the heartbeat, and to no more than the ceiling.
523    ///
524    /// This is the bound the gate-based idle rule cannot supply: fill bypasses the service gate, so a
525    /// cycle being filled and making no progress looks perfectly quiet to `max_recovery_idle` and it
526    /// re-arms forever. Without this rule a doomed cycle — a failed polynomial, or an Entry supplying
527    /// SURBs that carry no shares — would be filled at `max_rate` for the whole of
528    /// `max_recovery_time`.
529    #[test]
530    fn a_stalled_target_falls_back_to_the_heartbeat() {
531        let heartbeat = Duration::from_secs(60);
532        let idle = Duration::from_secs(30);
533        let now = Instant::now();
534        let mut planner = FillPlanner::new(&cfg(heartbeat, 250, idle), &dims(), now);
535
536        planner.observe_target(now, &target(now));
537        assert_eq!(
538            FillRate::per_second(200),
539            planner.bound(now + idle - Duration::from_secs(1), 200.0),
540            "a target that has moved inside the idle window is not stalled"
541        );
542        assert!(!planner.take_stall_onset(), "no stall has begun yet");
543
544        let stalled = planner.bound(now + idle, 200.0);
545        assert_eq!(FillRate::once_per(heartbeat), stalled);
546        assert!(
547            stalled.as_packets_per_sec() <= FillRate::once_per(heartbeat).as_packets_per_sec(),
548            "the stall fallback must never exceed the heartbeat rate"
549        );
550        assert!(planner.take_stall_onset(), "the stall must be reported exactly once");
551        assert!(!planner.take_stall_onset(), "and not once per tick thereafter");
552
553        // With an inverted pair the ceiling is below the heartbeat, and it still wins on this path.
554        let now = Instant::now();
555        let mut inverted = FillPlanner::new(&cfg(Duration::from_millis(10), 5, idle), &dims(), now);
556        inverted.observe_target(now, &target(now));
557        let stalled = inverted.bound(now + idle, 200.0);
558        assert_eq!(FillRate::per_second(5), stalled);
559        assert!(
560            stalled.as_packets_per_sec() <= 5.0,
561            "the stall fallback must never exceed the ceiling either"
562        );
563    }
564
565    /// A post-close drain wants its whole remainder at once, and `max_rate` is what paces it.
566    ///
567    /// The ordinary rate law spreads the remainder over the aim point, which is the right answer for
568    /// a Session that will still be there in an hour. A drained Session will not be: it is closed,
569    /// the buffer it is spending is finite and nothing is refilling it, and every SURB in it that is
570    /// not spent on a share is one the deposit being recovered has already paid for. So the horizon
571    /// collapses to a single sampling interval and the ceiling becomes the only thing setting the
572    /// pace — which is the intended reading of "drain", not an accident of the arithmetic.
573    #[test]
574    fn a_draining_target_asks_for_its_whole_remainder_at_once() {
575        let idle = Duration::from_secs(30);
576        let now = Instant::now();
577        let mut planner = FillPlanner::new(&cfg(Duration::from_secs(60), 250, idle), &dims(), now);
578
579        // An hour of runway on both, so the only difference between them is the flag.
580        let patient = target(now);
581        let draining = FillTarget { drain: true, ..patient };
582
583        let whole_remainder = planner.cycle_shares as f64 * (1.0 + planner.cfg.loss_margin);
584        let drained = planner.required_rate(now, &draining);
585        assert!(
586            (drained - whole_remainder / SAMPLING_INTERVAL.as_secs_f64()).abs() < 1e-9,
587            "a drain must ask for its whole remainder inside one sampling interval, got {drained} against a remainder \
588             of {whole_remainder}"
589        );
590
591        let patient_rate = planner.required_rate(now, &patient);
592        assert!(
593            patient_rate < 1.0 && patient_rate * 1_000.0 < drained,
594            "the same target with an hour of runway must still be spread over that hour, got {patient_rate}"
595        );
596
597        // Below the ceiling the drain is the remainder itself, rounded up as every rate is.
598        assert_eq!(
599            FillRate::per_second(whole_remainder.ceil() as u32),
600            planner.bound(now, drained)
601        );
602
603        // And above it, the ceiling binds — the drain is loud, not unbounded.
604        let mut capped = FillPlanner::new(&cfg(Duration::from_secs(60), 100, idle), &dims(), now);
605        assert_eq!(FillRate::per_second(100), capped.bound(now, drained));
606    }
607
608    /// A drain plans its own remainder, not the traffic the Session used to carry.
609    ///
610    /// Organic egress is subtracted from the requirement because the application is expected to go
611    /// on providing it. After a close it cannot: the gate is poisoned and `served_total` is frozen,
612    /// so the average is a memory. Left in, it suppresses precisely the case the drain exists for —
613    /// a client that hangs up on a cycle it has all but paid off, where the remainder is small and
614    /// the average is still high, so the requirement lands *below* it and the drain falls to its
615    /// heartbeat until the average decays. At the shipped ten-second window that is most of a
616    /// `max_recovery_idle`, and the deadline gets there first.
617    #[test]
618    fn a_drain_is_not_suppressed_by_the_traffic_that_preceded_it() {
619        const REMAINING: u64 = 2;
620
621        let idle = Duration::from_secs(30);
622        let t0 = Instant::now();
623        let mut planner = FillPlanner::new(&cfg(Duration::from_secs(60), 250, idle), &dims(), t0);
624
625        // One tick of a busy Session, to put a real average on the books. The cycle is all but done,
626        // which is what puts the requirement below it.
627        let nearly_done = FillTarget {
628            largest_shares_seen: planner.cycle_shares - REMAINING,
629            ..target(t0)
630        };
631        let busy = planner.plan(t0 + SAMPLING_INTERVAL, 600, Some(nearly_done));
632        assert_eq!(
633            Some(FillRate::once_per(Duration::from_secs(60))),
634            busy,
635            "a live Session this far ahead of its deadline is held at the heartbeat, which is what makes the drain \
636             below observable"
637        );
638        assert!(
639            planner.organic_pps > 0.0,
640            "the average must be non-zero to be subtracted"
641        );
642
643        // Now the same cycle, drained. `served_total` does not move again, ever.
644        let rate = planner
645            .plan(
646                t0 + 2 * SAMPLING_INTERVAL,
647                600,
648                Some(FillTarget {
649                    drain: true,
650                    ..nearly_done
651                }),
652            )
653            .expect("a drain that has to finish a remainder must plan a rate for it");
654
655        let expected = (REMAINING as f64 * (1.0 + planner.cfg.loss_margin) / SAMPLING_INTERVAL.as_secs_f64()).ceil();
656        assert_eq!(
657            FillRate::per_second(expected as u32),
658            rate,
659            "the drain must ask for its {REMAINING}-share remainder, not for what is left of it after {} packets/s of \
660             traffic that can never return",
661            planner.organic_pps
662        );
663    }
664}