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}