Skip to main content

ipfrs_network/
announcement_manager.rs

1//! Peer Announcement Manager
2//!
3//! Manages CID announcements to the DHT and GossipSub, tracking which content
4//! has been announced, when, and with what success rate.
5
6use std::collections::HashMap;
7
8/// Channel through which a CID is announced.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum AnnouncementChannel {
11    /// Announce via the Kademlia DHT.
12    Dht,
13    /// Announce via GossipSub pub/sub.
14    Gossipsub,
15    /// Announce via both DHT and GossipSub.
16    Both,
17}
18
19/// A single record tracking the announcement of a CID.
20#[derive(Clone, Debug)]
21pub struct AnnouncementRecord {
22    /// The content identifier being announced.
23    pub cid: String,
24    /// The channel(s) used for announcement.
25    pub channel: AnnouncementChannel,
26    /// The tick at which the announcement was created or last re-announced.
27    pub announced_at_tick: u64,
28    /// The tick at which this record expires.
29    pub expiry_tick: u64,
30    /// Whether the announcement succeeded.
31    pub successful: bool,
32    /// How many times this announcement has been retried after failure.
33    pub retry_count: u32,
34}
35
36impl AnnouncementRecord {
37    /// Returns `true` when `current_tick` has reached or passed `expiry_tick`.
38    pub fn is_expired(&self, current_tick: u64) -> bool {
39        current_tick >= self.expiry_tick
40    }
41
42    /// Returns `true` when the record has not succeeded and has retries remaining.
43    pub fn should_retry(&self, max_retries: u32) -> bool {
44        !self.successful && self.retry_count < max_retries
45    }
46}
47
48/// Configuration for the `PeerAnnouncementManager`.
49#[derive(Clone, Debug)]
50pub struct AnnouncementConfig {
51    /// How many ticks a record lives before it is considered expired.
52    pub ttl_ticks: u64,
53    /// Maximum number of retry attempts for a failed announcement.
54    pub max_retries: u32,
55    /// Ticks between successive re-announcements of already-successful records.
56    pub reannounce_interval_ticks: u64,
57}
58
59impl Default for AnnouncementConfig {
60    fn default() -> Self {
61        Self {
62            ttl_ticks: 1000,
63            max_retries: 3,
64            reannounce_interval_ticks: 500,
65        }
66    }
67}
68
69/// Aggregate statistics derived from the current state of the manager.
70#[derive(Clone, Debug, Default)]
71pub struct AnnouncementStats {
72    /// Total number of active (non-evicted) records.
73    pub total_announced: usize,
74    /// Records that have `successful == true`.
75    pub successful_count: usize,
76    /// Records that have failed (not successful, exhausted retries, or still pending failure).
77    pub failed_count: usize,
78    /// Records moved to `expired_records` (evicted).
79    pub expired_count: usize,
80    /// Active records that still have retries remaining.
81    pub pending_retry_count: usize,
82}
83
84impl AnnouncementStats {
85    /// Returns `successful / (successful + failed)`, or `0.0` when both are zero.
86    pub fn success_rate(&self) -> f64 {
87        let total = self.successful_count + self.failed_count;
88        if total == 0 {
89            0.0
90        } else {
91            self.successful_count as f64 / total as f64
92        }
93    }
94}
95
96/// Manages CID announcements to DHT and GossipSub.
97pub struct PeerAnnouncementManager {
98    /// Active announcement records, keyed by CID string.
99    pub records: HashMap<String, AnnouncementRecord>,
100    /// Configuration controlling TTL, retries, and re-announce intervals.
101    pub config: AnnouncementConfig,
102    /// Records that have been evicted due to expiry.
103    pub expired_records: Vec<AnnouncementRecord>,
104}
105
106impl PeerAnnouncementManager {
107    /// Creates a new manager with the given configuration and no records.
108    pub fn new(config: AnnouncementConfig) -> Self {
109        Self {
110            records: HashMap::new(),
111            config,
112            expired_records: Vec::new(),
113        }
114    }
115
116    /// Attempts to announce `cid` on `channel` at logical time `tick`.
117    ///
118    /// Returns `false` if the CID already has a non-expired, successful record
119    /// (indicating re-announcement is not needed yet).  Returns `true` when a
120    /// new record is created or an existing failed/expired record is overwritten.
121    pub fn announce(&mut self, cid: &str, channel: AnnouncementChannel, tick: u64) -> bool {
122        if let Some(existing) = self.records.get(cid) {
123            if existing.successful && !existing.is_expired(tick) {
124                return false;
125            }
126        }
127
128        let record = AnnouncementRecord {
129            cid: cid.to_owned(),
130            channel,
131            announced_at_tick: tick,
132            expiry_tick: tick + self.config.ttl_ticks,
133            successful: false,
134            retry_count: 0,
135        };
136        self.records.insert(cid.to_owned(), record);
137        true
138    }
139
140    /// Marks the record for `cid` as successful.
141    ///
142    /// Returns `true` on success, `false` if `cid` is not tracked.
143    pub fn mark_success(&mut self, cid: &str) -> bool {
144        match self.records.get_mut(cid) {
145            Some(record) => {
146                record.successful = true;
147                true
148            }
149            None => false,
150        }
151    }
152
153    /// Increments the `retry_count` for the record belonging to `cid`.
154    ///
155    /// Returns `true` on success, `false` if `cid` is not tracked.
156    pub fn mark_failure(&mut self, cid: &str) -> bool {
157        match self.records.get_mut(cid) {
158            Some(record) => {
159                record.retry_count += 1;
160                true
161            }
162            None => false,
163        }
164    }
165
166    /// Returns `true` when `cid` has a successful, non-expired record whose age
167    /// (in ticks since `announced_at_tick`) has met or exceeded `reannounce_interval_ticks`.
168    pub fn needs_reannounce(&self, cid: &str, current_tick: u64) -> bool {
169        match self.records.get(cid) {
170            Some(record) => {
171                record.successful
172                    && !record.is_expired(current_tick)
173                    && current_tick.saturating_sub(record.announced_at_tick)
174                        >= self.config.reannounce_interval_ticks
175            }
176            None => false,
177        }
178    }
179
180    /// Moves all expired records out of `records` and into `expired_records`.
181    pub fn evict_expired(&mut self, current_tick: u64) {
182        let expired_keys: Vec<String> = self
183            .records
184            .iter()
185            .filter(|(_, r)| r.is_expired(current_tick))
186            .map(|(k, _)| k.clone())
187            .collect();
188
189        for key in expired_keys {
190            if let Some(record) = self.records.remove(&key) {
191                self.expired_records.push(record);
192            }
193        }
194    }
195
196    /// Returns references to all active records that should be retried,
197    /// sorted by `retry_count` ascending (fewest retries first).
198    pub fn pending_retries(&self, current_tick: u64) -> Vec<&AnnouncementRecord> {
199        let max_retries = self.config.max_retries;
200        let mut pending: Vec<&AnnouncementRecord> = self
201            .records
202            .values()
203            .filter(|r| r.should_retry(max_retries) && !r.is_expired(current_tick))
204            .collect();
205        pending.sort_by_key(|r| r.retry_count);
206        pending
207    }
208
209    /// Returns a reference to the record for `cid`, if it exists.
210    pub fn get(&self, cid: &str) -> Option<&AnnouncementRecord> {
211        self.records.get(cid)
212    }
213
214    /// Computes aggregate statistics from the current state.
215    pub fn stats(&self, current_tick: u64) -> AnnouncementStats {
216        let max_retries = self.config.max_retries;
217        let mut successful_count = 0usize;
218        let mut failed_count = 0usize;
219        let mut pending_retry_count = 0usize;
220
221        for record in self.records.values() {
222            if record.successful {
223                successful_count += 1;
224            } else {
225                failed_count += 1;
226            }
227            if record.should_retry(max_retries) && !record.is_expired(current_tick) {
228                pending_retry_count += 1;
229            }
230        }
231
232        AnnouncementStats {
233            total_announced: self.records.len(),
234            successful_count,
235            failed_count,
236            expired_count: self.expired_records.len(),
237            pending_retry_count,
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn default_manager() -> PeerAnnouncementManager {
247        PeerAnnouncementManager::new(AnnouncementConfig::default())
248    }
249
250    // ── construction ─────────────────────────────────────────────────────────
251
252    #[test]
253    fn test_new_starts_empty() {
254        let mgr = default_manager();
255        assert!(mgr.records.is_empty());
256        assert!(mgr.expired_records.is_empty());
257    }
258
259    // ── announce ─────────────────────────────────────────────────────────────
260
261    #[test]
262    fn test_announce_creates_record() {
263        let mut mgr = default_manager();
264        let result = mgr.announce("cid1", AnnouncementChannel::Dht, 0);
265        assert!(result);
266        assert!(mgr.get("cid1").is_some());
267    }
268
269    #[test]
270    fn test_announce_sets_fields_correctly() {
271        let mut mgr = default_manager();
272        mgr.announce("cid1", AnnouncementChannel::Gossipsub, 10);
273        let record = mgr
274            .get("cid1")
275            .expect("test: record should exist after announce");
276        assert_eq!(record.channel, AnnouncementChannel::Gossipsub);
277        assert_eq!(record.announced_at_tick, 10);
278        assert_eq!(record.expiry_tick, 10 + mgr.config.ttl_ticks);
279        assert!(!record.successful);
280        assert_eq!(record.retry_count, 0);
281    }
282
283    #[test]
284    fn test_announce_returns_false_for_successful_non_expired() {
285        let mut mgr = default_manager();
286        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
287        mgr.mark_success("cid1");
288        // Second announce while still successful and not expired => false
289        let result = mgr.announce("cid1", AnnouncementChannel::Dht, 1);
290        assert!(!result);
291    }
292
293    #[test]
294    fn test_announce_returns_true_for_failed_record() {
295        let mut mgr = default_manager();
296        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
297        // record is failed (successful == false) => re-announce allowed
298        let result = mgr.announce("cid1", AnnouncementChannel::Both, 1);
299        assert!(result);
300    }
301
302    #[test]
303    fn test_announce_returns_true_for_expired_successful_record() {
304        let config = AnnouncementConfig {
305            ttl_ticks: 10,
306            ..Default::default()
307        };
308        let mut mgr = PeerAnnouncementManager::new(config);
309        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
310        mgr.mark_success("cid1");
311        // Tick 10 => expiry_tick == 10 => is_expired
312        let result = mgr.announce("cid1", AnnouncementChannel::Dht, 10);
313        assert!(result);
314    }
315
316    // ── mark_success ─────────────────────────────────────────────────────────
317
318    #[test]
319    fn test_mark_success_sets_flag() {
320        let mut mgr = default_manager();
321        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
322        assert!(mgr.mark_success("cid1"));
323        assert!(
324            mgr.get("cid1")
325                .expect("test: record should exist after mark_success")
326                .successful
327        );
328    }
329
330    #[test]
331    fn test_mark_success_false_for_unknown() {
332        let mut mgr = default_manager();
333        assert!(!mgr.mark_success("unknown"));
334    }
335
336    // ── mark_failure ─────────────────────────────────────────────────────────
337
338    #[test]
339    fn test_mark_failure_increments_retry_count() {
340        let mut mgr = default_manager();
341        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
342        mgr.mark_failure("cid1");
343        assert_eq!(
344            mgr.get("cid1")
345                .expect("test: record should exist after first mark_failure")
346                .retry_count,
347            1
348        );
349        mgr.mark_failure("cid1");
350        assert_eq!(
351            mgr.get("cid1")
352                .expect("test: record should exist after second mark_failure")
353                .retry_count,
354            2
355        );
356    }
357
358    #[test]
359    fn test_mark_failure_false_for_unknown() {
360        let mut mgr = default_manager();
361        assert!(!mgr.mark_failure("unknown"));
362    }
363
364    // ── needs_reannounce ─────────────────────────────────────────────────────
365
366    #[test]
367    fn test_needs_reannounce_true_when_interval_elapsed() {
368        let config = AnnouncementConfig {
369            reannounce_interval_ticks: 100,
370            ..Default::default()
371        };
372        let mut mgr = PeerAnnouncementManager::new(config);
373        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
374        mgr.mark_success("cid1");
375        assert!(mgr.needs_reannounce("cid1", 100));
376    }
377
378    #[test]
379    fn test_needs_reannounce_false_before_interval() {
380        let config = AnnouncementConfig {
381            reannounce_interval_ticks: 100,
382            ..Default::default()
383        };
384        let mut mgr = PeerAnnouncementManager::new(config);
385        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
386        mgr.mark_success("cid1");
387        assert!(!mgr.needs_reannounce("cid1", 99));
388    }
389
390    #[test]
391    fn test_needs_reannounce_false_for_failed_record() {
392        let mut mgr = default_manager();
393        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
394        // Record is NOT marked successful
395        assert!(!mgr.needs_reannounce("cid1", 600));
396    }
397
398    #[test]
399    fn test_needs_reannounce_false_for_unknown() {
400        let mgr = default_manager();
401        assert!(!mgr.needs_reannounce("nonexistent", 999));
402    }
403
404    // ── is_expired / should_retry ─────────────────────────────────────────────
405
406    #[test]
407    fn test_is_expired_true_at_expiry() {
408        let record = AnnouncementRecord {
409            cid: "cid1".to_owned(),
410            channel: AnnouncementChannel::Dht,
411            announced_at_tick: 0,
412            expiry_tick: 100,
413            successful: false,
414            retry_count: 0,
415        };
416        assert!(record.is_expired(100));
417        assert!(record.is_expired(101));
418    }
419
420    #[test]
421    fn test_is_expired_false_before_expiry() {
422        let record = AnnouncementRecord {
423            cid: "cid1".to_owned(),
424            channel: AnnouncementChannel::Dht,
425            announced_at_tick: 0,
426            expiry_tick: 100,
427            successful: false,
428            retry_count: 0,
429        };
430        assert!(!record.is_expired(99));
431    }
432
433    #[test]
434    fn test_should_retry_true_when_not_successful_and_retries_remaining() {
435        let record = AnnouncementRecord {
436            cid: "cid1".to_owned(),
437            channel: AnnouncementChannel::Dht,
438            announced_at_tick: 0,
439            expiry_tick: 1000,
440            successful: false,
441            retry_count: 2,
442        };
443        assert!(record.should_retry(3));
444    }
445
446    #[test]
447    fn test_should_retry_false_at_max_retries() {
448        let record = AnnouncementRecord {
449            cid: "cid1".to_owned(),
450            channel: AnnouncementChannel::Dht,
451            announced_at_tick: 0,
452            expiry_tick: 1000,
453            successful: false,
454            retry_count: 3,
455        };
456        assert!(!record.should_retry(3));
457    }
458
459    #[test]
460    fn test_should_retry_false_when_successful() {
461        let record = AnnouncementRecord {
462            cid: "cid1".to_owned(),
463            channel: AnnouncementChannel::Dht,
464            announced_at_tick: 0,
465            expiry_tick: 1000,
466            successful: true,
467            retry_count: 0,
468        };
469        assert!(!record.should_retry(3));
470    }
471
472    // ── evict_expired ─────────────────────────────────────────────────────────
473
474    #[test]
475    fn test_evict_expired_moves_records() {
476        let config = AnnouncementConfig {
477            ttl_ticks: 10,
478            ..Default::default()
479        };
480        let mut mgr = PeerAnnouncementManager::new(config);
481        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
482        mgr.evict_expired(10); // expiry_tick == 10 => expired
483        assert!(!mgr.records.contains_key("cid1"));
484        assert_eq!(mgr.expired_records.len(), 1);
485        assert_eq!(mgr.expired_records[0].cid, "cid1");
486    }
487
488    #[test]
489    fn test_evict_expired_keeps_fresh_records() {
490        let config = AnnouncementConfig {
491            ttl_ticks: 100,
492            ..Default::default()
493        };
494        let mut mgr = PeerAnnouncementManager::new(config);
495        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
496        mgr.evict_expired(50); // expiry_tick == 100 > 50 => fresh
497        assert!(mgr.records.contains_key("cid1"));
498        assert!(mgr.expired_records.is_empty());
499    }
500
501    #[test]
502    fn test_evict_expired_mixed() {
503        let config = AnnouncementConfig {
504            ttl_ticks: 10,
505            ..Default::default()
506        };
507        let mut mgr = PeerAnnouncementManager::new(config);
508        mgr.announce("old", AnnouncementChannel::Dht, 0); // expires at 10
509        mgr.announce("new", AnnouncementChannel::Dht, 5); // expires at 15
510        mgr.evict_expired(12);
511        assert!(!mgr.records.contains_key("old"));
512        assert!(mgr.records.contains_key("new"));
513        assert_eq!(mgr.expired_records.len(), 1);
514    }
515
516    // ── pending_retries ───────────────────────────────────────────────────────
517
518    #[test]
519    fn test_pending_retries_filtered_correctly() {
520        let config = AnnouncementConfig {
521            max_retries: 3,
522            ttl_ticks: 1000,
523            ..Default::default()
524        };
525        let mut mgr = PeerAnnouncementManager::new(config);
526        mgr.announce("a", AnnouncementChannel::Dht, 0);
527        mgr.mark_failure("a"); // retry_count=1, max_retries=3 => pending
528        mgr.announce("b", AnnouncementChannel::Dht, 0);
529        mgr.mark_success("b"); // successful => not pending
530
531        let pending = mgr.pending_retries(0);
532        assert_eq!(pending.len(), 1);
533        assert_eq!(pending[0].cid, "a");
534    }
535
536    #[test]
537    fn test_pending_retries_sorted_by_retry_count_asc() {
538        let config = AnnouncementConfig {
539            max_retries: 5,
540            ttl_ticks: 1000,
541            ..Default::default()
542        };
543        let mut mgr = PeerAnnouncementManager::new(config);
544        mgr.announce("a", AnnouncementChannel::Dht, 0);
545        mgr.mark_failure("a");
546        mgr.mark_failure("a"); // retry_count=2
547
548        mgr.announce("b", AnnouncementChannel::Dht, 0);
549        mgr.mark_failure("b"); // retry_count=1
550
551        mgr.announce("c", AnnouncementChannel::Dht, 0);
552        mgr.mark_failure("c");
553        mgr.mark_failure("c");
554        mgr.mark_failure("c"); // retry_count=3
555
556        let pending = mgr.pending_retries(0);
557        assert_eq!(pending.len(), 3);
558        // Should be sorted ascending: 1, 2, 3
559        assert_eq!(pending[0].retry_count, 1);
560        assert_eq!(pending[1].retry_count, 2);
561        assert_eq!(pending[2].retry_count, 3);
562    }
563
564    #[test]
565    fn test_pending_retries_excludes_expired() {
566        let config = AnnouncementConfig {
567            max_retries: 3,
568            ttl_ticks: 10,
569            ..Default::default()
570        };
571        let mut mgr = PeerAnnouncementManager::new(config);
572        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
573        mgr.mark_failure("cid1");
574        // At tick 10 the record is expired => not in pending_retries
575        let pending = mgr.pending_retries(10);
576        assert!(pending.is_empty());
577    }
578
579    // ── stats ─────────────────────────────────────────────────────────────────
580
581    #[test]
582    fn test_stats_success_rate_computed() {
583        let mut mgr = default_manager();
584        mgr.announce("a", AnnouncementChannel::Dht, 0);
585        mgr.mark_success("a");
586        mgr.announce("b", AnnouncementChannel::Dht, 0);
587        // b remains failed
588
589        let stats = mgr.stats(0);
590        assert_eq!(stats.successful_count, 1);
591        assert_eq!(stats.failed_count, 1);
592        assert!((stats.success_rate() - 0.5).abs() < f64::EPSILON);
593    }
594
595    #[test]
596    fn test_stats_success_rate_zero_when_both_zero() {
597        let mgr = default_manager();
598        let stats = mgr.stats(0);
599        assert_eq!(stats.success_rate(), 0.0);
600    }
601
602    #[test]
603    fn test_stats_expired_count_reflects_evictions() {
604        let config = AnnouncementConfig {
605            ttl_ticks: 5,
606            ..Default::default()
607        };
608        let mut mgr = PeerAnnouncementManager::new(config);
609        mgr.announce("cid1", AnnouncementChannel::Dht, 0);
610        mgr.evict_expired(5);
611
612        let stats = mgr.stats(5);
613        assert_eq!(stats.expired_count, 1);
614        assert_eq!(stats.total_announced, 0);
615    }
616
617    #[test]
618    fn test_stats_pending_retry_count() {
619        let config = AnnouncementConfig {
620            max_retries: 3,
621            ttl_ticks: 1000,
622            ..Default::default()
623        };
624        let mut mgr = PeerAnnouncementManager::new(config);
625        mgr.announce("a", AnnouncementChannel::Dht, 0);
626        mgr.mark_failure("a");
627
628        let stats = mgr.stats(0);
629        assert_eq!(stats.pending_retry_count, 1);
630    }
631}