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}