Skip to main content

hopr_transport/protocol/
return_path_recovery.rs

1//! Sequences the response to a return path that has gone silent.
2//!
3//! Two mechanisms react to the same evidence and, left unsequenced, work against each other. The
4//! planner learns to route around the relay that stopped delivering, while the SURB balancer floods
5//! the counterparty to refill a buffer it believes is empty. Run in the wrong order the flood mints
6//! its SURBs onto the very route the planner is in the middle of abandoning -- measured at ~38 000
7//! SURBs in 14 s against a 15 000-entry ring buffer, which is 2.5x the counterparty's whole store,
8//! so everything older is evicted and a LIFO reader reaches preferentially for the poisoned ones.
9//!
10//! The rule this module enforces is therefore: **re-plan first, refill only if re-planning actually
11//! moved traffic.** A re-plan that moves nothing means the silent relay sits on every remaining
12//! candidate -- return-path diversity caps at `HoprPacket::PAYLOAD_SIZE / HoprSurb::SIZE` = 2, so
13//! this is an ordinary case, not a corner -- and refilling then only buys more SURBs bound for the
14//! same dead route.
15
16use std::{
17    collections::HashMap,
18    future::Future,
19    hash::Hash,
20    time::{Duration, Instant},
21};
22
23/// One action taken on behalf of one destination, recorded in the order it happened.
24///
25/// Exists so the ordering guarantee is observable: a caller (or a test) can see that no refill was
26/// ever issued for a destination before the re-plan that justified it.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum RecoveryStep<K> {
29    /// The destination's cached return paths were rebuilt; `moved` entries came back re-weighted.
30    Replanned {
31        /// The destination whose paths were rebuilt.
32        destination: K,
33        /// How many cached entries the re-plan actually replaced.
34        moved: usize,
35    },
36    /// The destination's Sessions were told to refill; `sessions` of them were marked.
37    Refilled {
38        /// The destination whose Sessions were marked.
39        destination: K,
40        /// How many Sessions routed there were marked.
41        sessions: usize,
42    },
43}
44
45/// Tracks which destinations already have an open recovery episode.
46///
47/// An episode opens the first tick a destination is reported silent and lapses after `grace`. While
48/// it is open the destination is ignored, which bounds re-planning -- a full path rediscovery per
49/// cached entry -- to once per grace window however long the silence lasts. A destination that is
50/// still silent when the episode lapses simply opens a fresh one, so a re-plan that could not move
51/// traffic the first time is retried later rather than abandoned.
52pub struct ReturnPathEpisodes<K> {
53    open: HashMap<K, Instant>,
54    grace: Duration,
55}
56
57impl<K: Eq + Hash + Copy> ReturnPathEpisodes<K> {
58    /// Creates a tracker whose episodes lapse after `grace`.
59    pub fn new(grace: Duration) -> Self {
60        Self {
61            open: HashMap::new(),
62            grace,
63        }
64    }
65
66    /// Drives one flush tick over the destinations the detector reported silent.
67    ///
68    /// `replan` returns how many cached entries it re-weighted, and `refill` is invoked only when
69    /// that count is non-zero. Both are injected so the sequencing can be exercised without a graph,
70    /// a planner or a cluster.
71    pub async fn tick<S, R, RFut, F, FFut>(&mut self, silent: S, mut replan: R, mut refill: F) -> Vec<RecoveryStep<K>>
72    where
73        S: IntoIterator<Item = K>,
74        R: FnMut(K) -> RFut,
75        RFut: Future<Output = usize>,
76        F: FnMut(K) -> FFut,
77        FFut: Future<Output = usize>,
78    {
79        let now = Instant::now();
80        self.open.retain(|_, lapses_at| *lapses_at > now);
81
82        let mut steps = Vec::new();
83        for destination in silent {
84            if self.open.contains_key(&destination) {
85                continue;
86            }
87            self.open.insert(destination, now + self.grace);
88
89            let moved = replan(destination).await;
90            steps.push(RecoveryStep::Replanned { destination, moved });
91
92            // Nothing moved: the silent relay is on every candidate that remains, so refilling
93            // would only mint more SURBs onto the same route. Wait for the next episode instead.
94            if moved > 0 {
95                let sessions = refill(destination).await;
96                steps.push(RecoveryStep::Refilled { destination, sessions });
97            }
98        }
99        steps
100    }
101}
102
103/// Runs one SURB-flush tick in the exact order the flush task requires: **detect, then flush, then
104/// sequence recovery**.
105///
106/// Extracted from `HoprTransport::run` so the ordering can be exercised deterministically instead of
107/// only inside a live node. Two orderings here are load-bearing and this function is what pins them:
108/// detection must read the per-path counters *before* [`flush_into`](crate::protocol::surb_telemetry::flush_into)
109/// drains them (otherwise every path reads as idle and nothing is ever silent), and the graph must
110/// see this interval's counts *before* any re-plan reads it (otherwise the re-plan the silence just
111/// triggered runs a whole tick behind the evidence). Returns the recovery steps taken, for the
112/// caller to log.
113pub async fn run_flush_tick<G, R, RFut, F, FFut>(
114    surb_round_trips: &crate::protocol::surb_telemetry::SurbRoundTripRegistry,
115    graph: &G,
116    now_ms: u128,
117    episodes: &mut ReturnPathEpisodes<hopr_api::types::crypto::types::OffchainPublicKey>,
118    replan: R,
119    refill: F,
120) -> Vec<RecoveryStep<hopr_api::types::crypto::types::OffchainPublicKey>>
121where
122    G: hopr_api::graph::NetworkGraphUpdate,
123    R: FnMut(hopr_api::types::crypto::types::OffchainPublicKey) -> RFut,
124    RFut: Future<Output = usize>,
125    F: FnMut(hopr_api::types::crypto::types::OffchainPublicKey) -> FFut,
126    FFut: Future<Output = usize>,
127{
128    // Detection before the drain: `degraded_destinations` reads the counts the flush is about to
129    // reset.
130    let silent = surb_round_trips.degraded_destinations();
131    // The graph has to see this interval's counts before anything re-plans on it.
132    crate::protocol::surb_telemetry::flush_into(surb_round_trips, graph, now_ms);
133    episodes.tick(silent, replan, refill).await
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    /// Counts calls so a test can assert how often each side ran, not merely what it returned.
141    #[derive(Default)]
142    struct Calls {
143        replans: std::cell::Cell<usize>,
144        refills: std::cell::Cell<usize>,
145    }
146
147    /// Refilling before the re-plan is the defect this module exists to prevent: it is what put
148    /// ~38 000 SURBs onto a route the planner was abandoning.
149    #[tokio::test]
150    async fn a_refill_should_never_precede_the_replan_that_justifies_it() {
151        let mut episodes = ReturnPathEpisodes::new(Duration::from_secs(10));
152
153        let steps = episodes.tick([1u32, 2u32], |_| async { 3 }, |_| async { 1 }).await;
154
155        assert_eq!(steps.len(), 4, "each destination should re-plan and then refill");
156        for destination in [1u32, 2u32] {
157            let replanned = steps
158                .iter()
159                .position(|s| matches!(s, RecoveryStep::Replanned { destination: d, .. } if *d == destination))
160                .expect("destination should have been re-planned");
161            let refilled = steps
162                .iter()
163                .position(|s| matches!(s, RecoveryStep::Refilled { destination: d, .. } if *d == destination))
164                .expect("destination should have been refilled");
165            assert!(
166                replanned < refilled,
167                "destination {destination} refilled at step {refilled} before re-planning at {replanned}"
168            );
169        }
170    }
171
172    /// Re-planning rediscovers every path to the destination, so a destination that stays silent
173    /// must not pay that cost on every flush tick.
174    #[tokio::test]
175    async fn an_open_episode_should_replan_once_however_long_the_silence_lasts() {
176        let mut episodes = ReturnPathEpisodes::new(Duration::from_secs(10));
177        let calls = Calls::default();
178
179        for _ in 0..5 {
180            episodes
181                .tick(
182                    [7u32],
183                    |_| {
184                        calls.replans.set(calls.replans.get() + 1);
185                        async { 2 }
186                    },
187                    |_| {
188                        calls.refills.set(calls.refills.get() + 1);
189                        async { 1 }
190                    },
191                )
192                .await;
193        }
194
195        assert_eq!(calls.replans.get(), 1, "one episode must mean one re-plan");
196        assert_eq!(calls.refills.get(), 1, "one episode must mean one refill");
197    }
198
199    /// When the silent relay sits on every remaining candidate, re-planning cannot move traffic —
200    /// and refilling then buys nothing but more SURBs bound for the same dead route.
201    #[tokio::test]
202    async fn a_replan_that_moves_nothing_should_not_refill() {
203        let mut episodes = ReturnPathEpisodes::new(Duration::from_secs(10));
204        let calls = Calls::default();
205
206        let steps = episodes
207            .tick(
208                [7u32],
209                |_| async { 0 },
210                |_| {
211                    calls.refills.set(calls.refills.get() + 1);
212                    async { 1 }
213                },
214            )
215            .await;
216
217        assert_eq!(calls.refills.get(), 0, "a re-plan that moved nothing must not refill");
218        assert_eq!(
219            steps,
220            vec![RecoveryStep::Replanned {
221                destination: 7u32,
222                moved: 0
223            }],
224            "the re-plan itself must still be recorded"
225        );
226
227        // Vacuity guard: the same fixture with a re-plan that *did* move traffic must refill,
228        // otherwise this test would pass against a machine that never refills at all.
229        let mut moved_episodes = ReturnPathEpisodes::new(Duration::from_secs(10));
230        let moved_steps = moved_episodes.tick([7u32], |_| async { 1 }, |_| async { 4 }).await;
231        assert!(
232            moved_steps
233                .iter()
234                .any(|s| matches!(s, RecoveryStep::Refilled { sessions: 4, .. })),
235            "a re-plan that moved traffic must refill: {moved_steps:?}"
236        );
237    }
238
239    /// A destination that is still silent once its episode lapses has to be handled afresh — that
240    /// retry is what lets a re-plan which could not move traffic the first time succeed later.
241    #[tokio::test]
242    async fn a_lapsed_episode_should_be_handled_afresh() {
243        let mut episodes = ReturnPathEpisodes::new(Duration::from_millis(50));
244        let calls = Calls::default();
245
246        let bump = |_| {
247            calls.replans.set(calls.replans.get() + 1);
248            async { 2 }
249        };
250
251        episodes.tick([7u32], bump, |_| async { 1 }).await;
252        assert_eq!(calls.replans.get(), 1, "the first tick opens an episode");
253
254        tokio::time::sleep(Duration::from_millis(80)).await;
255
256        episodes.tick([7u32], bump, |_| async { 1 }).await;
257        assert_eq!(
258            calls.replans.get(),
259            2,
260            "once the grace lapses the same destination must be handled afresh"
261        );
262    }
263}