Skip to main content

ipfrs_network/
mesh_repair.rs

1//! GossipSub mesh repair coordination
2//!
3//! [`MeshRepairCoordinator`] tracks the current peer count for every
4//! subscribed topic and decides when the GossipSub mesh needs to be *repaired*
5//! (too few peers) or *pruned* (too many peers), subject to a configurable
6//! cooldown between consecutive repair attempts.
7
8use parking_lot::RwLock;
9use std::collections::HashMap;
10
11// ─── Configuration ───────────────────────────────────────────────────────────
12
13/// Parameters controlling when mesh repairs and prunes are triggered.
14#[derive(Debug, Clone)]
15pub struct MeshRepairConfig {
16    /// Minimum peer count below which a mesh repair is triggered (`D_low`).
17    pub d_low: usize,
18    /// Target peer count after a successful repair (`D`).
19    pub d_target: usize,
20    /// Maximum peer count above which a prune is triggered (`D_high`).
21    pub d_high: usize,
22    /// Minimum milliseconds that must elapse between consecutive repair
23    /// attempts for the same topic.
24    pub repair_interval_ms: u64,
25}
26
27impl Default for MeshRepairConfig {
28    fn default() -> Self {
29        Self {
30            d_low: 6,
31            d_target: 8,
32            d_high: 12,
33            repair_interval_ms: 5_000,
34        }
35    }
36}
37
38// ─── Per-topic state ─────────────────────────────────────────────────────────
39
40/// Repair / prune bookkeeping for a single GossipSub topic.
41#[derive(Debug, Clone)]
42pub struct MeshRepairState {
43    /// Topic identifier string (e.g. `/ipfrs/content/announce/1.0.0`).
44    pub topic: String,
45    /// Most recently recorded peer count for this topic.
46    pub current_peers: usize,
47    /// Unix timestamp (ms) of the last repair action, or `0` if no repair has
48    /// occurred yet.
49    pub last_repair_ms: u64,
50    /// Total number of repair actions recorded for this topic.
51    pub repair_count: u64,
52    /// Total number of prune actions recorded for this topic.
53    pub prune_count: u64,
54}
55
56impl MeshRepairState {
57    fn new(topic: impl Into<String>) -> Self {
58        Self {
59            topic: topic.into(),
60            current_peers: 0,
61            last_repair_ms: 0,
62            repair_count: 0,
63            prune_count: 0,
64        }
65    }
66}
67
68// ─── Coordinator ─────────────────────────────────────────────────────────────
69
70/// Coordinates mesh repair and prune decisions across all subscribed topics.
71///
72/// The coordinator is **policy-only**: it records peer counts and timestamps,
73/// evaluates thresholds, and signals when action is needed.  The actual libp2p
74/// swarm interactions are the caller's responsibility.
75pub struct MeshRepairCoordinator {
76    config: MeshRepairConfig,
77    /// Per-topic repair state, keyed by topic string.
78    topic_states: RwLock<HashMap<String, MeshRepairState>>,
79}
80
81impl MeshRepairCoordinator {
82    /// Create a new coordinator with the supplied configuration.
83    pub fn new(config: MeshRepairConfig) -> Self {
84        Self {
85            config,
86            topic_states: RwLock::new(HashMap::new()),
87        }
88    }
89
90    // ── Mutation helpers ──────────────────────────────────────────────────
91
92    /// Update the tracked peer count for `topic` at time `now_ms`.
93    ///
94    /// If the topic has not been seen before it is automatically registered.
95    pub fn record_peer_count(&self, topic: &str, count: usize, now_ms: u64) {
96        let mut map = self.topic_states.write();
97        let state = map
98            .entry(topic.to_string())
99            .or_insert_with(|| MeshRepairState::new(topic));
100        state.current_peers = count;
101        // Update timestamp so freshness checks work even before first repair.
102        // We do NOT reset last_repair_ms here; that is only set by
103        // record_repair() so the cooldown is measured from the actual repair,
104        // not from every peer-count update.
105        let _ = now_ms; // parameter reserved for future use / logging
106    }
107
108    /// Record that a mesh repair was performed for `topic` at `now_ms`.
109    pub fn record_repair(&self, topic: &str, now_ms: u64) {
110        let mut map = self.topic_states.write();
111        let state = map
112            .entry(topic.to_string())
113            .or_insert_with(|| MeshRepairState::new(topic));
114        state.last_repair_ms = now_ms;
115        state.repair_count = state.repair_count.saturating_add(1);
116    }
117
118    /// Record that a mesh prune was performed for `topic`.
119    pub fn record_prune(&self, topic: &str) {
120        let mut map = self.topic_states.write();
121        let state = map
122            .entry(topic.to_string())
123            .or_insert_with(|| MeshRepairState::new(topic));
124        state.prune_count = state.prune_count.saturating_add(1);
125    }
126
127    // ── Query helpers ─────────────────────────────────────────────────────
128
129    /// Returns `true` when the topic's mesh needs to be repaired.
130    ///
131    /// Both conditions must hold:
132    /// 1. `current_peers < d_low`
133    /// 2. At least `repair_interval_ms` has elapsed since the last repair
134    ///    (or no repair has occurred yet, i.e. `last_repair_ms == 0`).
135    pub fn needs_repair(&self, topic: &str, now_ms: u64) -> bool {
136        let map = self.topic_states.read();
137        match map.get(topic) {
138            None => false,
139            Some(state) => {
140                let below_threshold = state.current_peers < self.config.d_low;
141                let cooldown_elapsed = state.last_repair_ms == 0
142                    || now_ms.saturating_sub(state.last_repair_ms)
143                        >= self.config.repair_interval_ms;
144                below_threshold && cooldown_elapsed
145            }
146        }
147    }
148
149    /// Returns `true` when the topic's mesh is over-populated and should be
150    /// pruned.
151    pub fn needs_prune(&self, topic: &str) -> bool {
152        let map = self.topic_states.read();
153        match map.get(topic) {
154            None => false,
155            Some(state) => state.current_peers > self.config.d_high,
156        }
157    }
158
159    /// Return a snapshot of the repair state for `topic`, or `None` if the
160    /// topic has not been registered yet.
161    pub fn repair_state(&self, topic: &str) -> Option<MeshRepairState> {
162        self.topic_states.read().get(topic).cloned()
163    }
164
165    /// Return the list of topic names that currently need repair.
166    pub fn topics_needing_repair(&self, now_ms: u64) -> Vec<String> {
167        let map = self.topic_states.read();
168        map.values()
169            .filter(|s| {
170                let below = s.current_peers < self.config.d_low;
171                let cooldown = s.last_repair_ms == 0
172                    || now_ms.saturating_sub(s.last_repair_ms) >= self.config.repair_interval_ms;
173                below && cooldown
174            })
175            .map(|s| s.topic.clone())
176            .collect()
177    }
178
179    /// Return the list of topic names that currently need pruning.
180    pub fn topics_needing_prune(&self) -> Vec<String> {
181        let map = self.topic_states.read();
182        map.values()
183            .filter(|s| s.current_peers > self.config.d_high)
184            .map(|s| s.topic.clone())
185            .collect()
186    }
187
188    /// Return snapshots of all tracked topic states.
189    pub fn all_states(&self) -> Vec<MeshRepairState> {
190        self.topic_states.read().values().cloned().collect()
191    }
192
193    /// Reference to the active configuration.
194    pub fn config(&self) -> &MeshRepairConfig {
195        &self.config
196    }
197}
198
199// ─── Tests ───────────────────────────────────────────────────────────────────
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn default_coordinator() -> MeshRepairCoordinator {
206        MeshRepairCoordinator::new(MeshRepairConfig::default())
207    }
208
209    const T: &str = "/ipfrs/test/1.0.0";
210
211    // ── RelayManager tests are in relay.rs; these focus on mesh repair ────
212
213    #[test]
214    fn test_initial_no_repair_needed() {
215        let coord = default_coordinator();
216        // No topic registered → needs_repair must return false.
217        assert!(!coord.needs_repair(T, 10_000));
218    }
219
220    #[test]
221    fn test_needs_repair_below_d_low() {
222        let coord = default_coordinator();
223        // d_low = 6; set count to 5 (below threshold).
224        // last_repair_ms = 0 → cooldown trivially satisfied.
225        coord.record_peer_count(T, 5, 0);
226        assert!(coord.needs_repair(T, 10_000));
227    }
228
229    #[test]
230    fn test_no_repair_if_too_recent() {
231        let coord = default_coordinator();
232        // Record a low peer count.
233        coord.record_peer_count(T, 3, 0);
234        // Record a repair at t=1000 ms.
235        coord.record_repair(T, 1_000);
236        // At t=5999 ms the 5000 ms cooldown has NOT elapsed yet.
237        assert!(!coord.needs_repair(T, 5_999));
238        // At t=6000 ms the cooldown HAS elapsed.
239        assert!(coord.needs_repair(T, 6_000));
240    }
241
242    #[test]
243    fn test_needs_prune_above_d_high() {
244        let coord = default_coordinator();
245        // d_high = 12; set count to 13.
246        coord.record_peer_count(T, 13, 0);
247        assert!(coord.needs_prune(T));
248    }
249
250    #[test]
251    fn test_record_repair_updates_state() {
252        let coord = default_coordinator();
253        coord.record_peer_count(T, 2, 0);
254        coord.record_repair(T, 3_000);
255        coord.record_repair(T, 8_000);
256
257        let state = coord.repair_state(T).expect("state present");
258        assert_eq!(state.last_repair_ms, 8_000);
259        assert_eq!(state.repair_count, 2);
260    }
261
262    #[test]
263    fn test_topics_needing_repair_multiple_topics() {
264        let coord = default_coordinator();
265        let t1 = "/ipfrs/t1/1.0.0";
266        let t2 = "/ipfrs/t2/1.0.0";
267        let t3 = "/ipfrs/t3/1.0.0";
268
269        // t1: 3 peers (needs repair, never repaired)
270        coord.record_peer_count(t1, 3, 0);
271        // t2: 8 peers (healthy, no repair needed)
272        coord.record_peer_count(t2, 8, 0);
273        // t3: 2 peers but repaired very recently → cooldown blocks
274        coord.record_peer_count(t3, 2, 0);
275        coord.record_repair(t3, 99_000);
276
277        let needing = coord.topics_needing_repair(100_000); // only 1 ms elapsed for t3
278        assert_eq!(needing.len(), 1);
279        assert_eq!(needing[0], t1);
280    }
281
282    #[test]
283    fn test_topics_needing_prune() {
284        let coord = default_coordinator();
285        let t1 = "/ipfrs/t1/1.0.0";
286        let t2 = "/ipfrs/t2/1.0.0";
287
288        coord.record_peer_count(t1, 15, 0); // > d_high (12) → prune
289        coord.record_peer_count(t2, 10, 0); // ≤ d_high → fine
290
291        let needing = coord.topics_needing_prune();
292        assert_eq!(needing.len(), 1);
293        assert_eq!(needing[0], t1);
294    }
295
296    // ── Additional correctness checks ─────────────────────────────────────
297
298    #[test]
299    fn test_no_prune_when_at_threshold() {
300        let coord = default_coordinator();
301        coord.record_peer_count(T, 12, 0); // exactly d_high → no prune
302        assert!(!coord.needs_prune(T));
303    }
304
305    #[test]
306    fn test_no_repair_at_d_low_exactly() {
307        let coord = default_coordinator();
308        coord.record_peer_count(T, 6, 0); // exactly d_low → no repair
309        assert!(!coord.needs_repair(T, 10_000));
310    }
311
312    #[test]
313    fn test_record_prune_increments_count() {
314        let coord = default_coordinator();
315        coord.record_peer_count(T, 14, 0);
316        coord.record_prune(T);
317        coord.record_prune(T);
318        let state = coord.repair_state(T).expect("state present");
319        assert_eq!(state.prune_count, 2);
320    }
321
322    #[test]
323    fn test_all_states_returns_all_topics() {
324        let coord = default_coordinator();
325        coord.record_peer_count("/t1", 5, 0);
326        coord.record_peer_count("/t2", 8, 0);
327        coord.record_peer_count("/t3", 14, 0);
328        assert_eq!(coord.all_states().len(), 3);
329    }
330}