Skip to main content

offline_protocol_reliability/
relay_seen.rs

1//! Suppression cache for messages that have already been forwarded.
2//!
3//! A forwarding node sees the whole neighborhood's traffic, not just its own:
4//! every frame that crosses it arrives once per link that carries it. Forwarding
5//! each arrival would turn one message into as many copies as the network has
6//! edges, and those copies would arrive back at nodes that already forwarded
7//! them, and so on. This cache is what stops that — the record that says "this
8//! id has been handled, ignore further arrivals."
9//!
10//! It is deliberately **separate from [`Deduplicator`](crate::Deduplicator)**,
11//! which answers a different question. The deduplicator tracks messages
12//! addressed to *us*, and its entries are load-bearing for delivery semantics:
13//! entries are removed again (`unmark_seen`) when a message could not be
14//! delivered, so the sender's retry is reprocessed rather than silently
15//! re-acknowledged. Forwarding traffic has no such lifecycle — an id is either
16//! handled or not — and mixing the two populations would let relay volume evict
17//! the delivery-critical entries.
18//!
19//! # Sizing
20//!
21//! Capacity has to cover the whole *network's* traffic for as long as copies of
22//! a message can still be in flight, not just our own. Two windows matter: a
23//! flood's lifetime (bounded by hop limit times per-hop latency, so seconds),
24//! and the sender's early retransmissions (tens of seconds). The defaults cover
25//! both with margin, and are chosen so that at the maximum rate a node will
26//! accept forwarding traffic, the cache still holds far more than
27//! [`DEFAULT_RELAY_SEEN_RETENTION_SECS`] of it — meaning entries expire by age
28//! rather than being pushed out by volume. That ordering is what makes the
29//! suppression guarantee hold: an id evicted early would be forwarded a second
30//! time, and by every other node too, which is how a flood becomes a storm.
31//!
32//! Eviction is O(1): entries leave in insertion order, which for a
33//! fixed retention window is also expiry order.
34
35use std::collections::{HashMap, VecDeque};
36use std::time::{Duration, Instant};
37
38/// Maximum ids tracked before the oldest is dropped.
39pub const DEFAULT_RELAY_SEEN_CAPACITY: usize = 8192;
40
41/// How long an id stays suppressed.
42pub const DEFAULT_RELAY_SEEN_RETENTION_SECS: u64 = 600;
43
44/// Configuration for [`RelaySeenCache`].
45#[derive(Debug, Clone)]
46pub struct RelaySeenConfig {
47    /// Maximum ids tracked at once.
48    pub capacity: usize,
49    /// How long an id stays suppressed.
50    pub retention: Duration,
51}
52
53impl Default for RelaySeenConfig {
54    fn default() -> Self {
55        Self {
56            capacity: DEFAULT_RELAY_SEEN_CAPACITY,
57            retention: Duration::from_secs(DEFAULT_RELAY_SEEN_RETENTION_SECS),
58        }
59    }
60}
61
62/// What a cache lookup found for an id.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum SeenOutcome {
65    /// Not seen before; it has now been recorded.
66    Fresh,
67    /// Seen before, within the retention window.
68    Duplicate,
69}
70
71/// Bounded, insertion-ordered record of recently handled message ids.
72#[derive(Debug)]
73pub struct RelaySeenCache {
74    config: RelaySeenConfig,
75    entries: HashMap<String, Instant>,
76    order: VecDeque<String>,
77    duplicates_suppressed: u64,
78    capacity_evictions: u64,
79}
80
81impl RelaySeenCache {
82    /// Creates a cache with the default sizing.
83    pub fn new() -> Self {
84        Self::with_config(RelaySeenConfig::default())
85    }
86
87    /// Creates a cache with explicit sizing.
88    pub fn with_config(config: RelaySeenConfig) -> Self {
89        let capacity = config.capacity.max(1);
90        Self {
91            config: RelaySeenConfig { capacity, ..config },
92            entries: HashMap::with_capacity(capacity.min(1024)),
93            order: VecDeque::with_capacity(capacity.min(1024)),
94            duplicates_suppressed: 0,
95            capacity_evictions: 0,
96        }
97    }
98
99    /// Records `message_id`, reporting whether it had been seen already.
100    ///
101    /// This is the single entry point: checking and recording are one step so
102    /// no caller can test an id and then forget to record it, which would
103    /// forward every copy.
104    pub fn observe(&mut self, message_id: &str) -> SeenOutcome {
105        self.expire(Instant::now());
106
107        if self.entries.contains_key(message_id) {
108            self.duplicates_suppressed = self.duplicates_suppressed.saturating_add(1);
109            return SeenOutcome::Duplicate;
110        }
111
112        while self.entries.len() >= self.config.capacity {
113            if self.pop_oldest().is_some() {
114                self.capacity_evictions = self.capacity_evictions.saturating_add(1);
115            } else {
116                break;
117            }
118        }
119
120        self.entries.insert(message_id.to_string(), Instant::now());
121        self.order.push_back(message_id.to_string());
122        SeenOutcome::Fresh
123    }
124
125    /// Forgets `message_id`, so a later copy is treated as new again.
126    ///
127    /// For a frame that was recorded as handled and then dropped **without
128    /// ever being transmitted** — displaced from a queue, abandoned for
129    /// waiting too long, refused room on the way back. The record would
130    /// otherwise refuse every later copy for the rest of the retention window,
131    /// including the sender's own retransmissions, which carry the same id: a
132    /// route closed for ten minutes by a few seconds of congestion, with
133    /// nothing on the air to justify it.
134    ///
135    /// Safe only because nothing was transmitted. An id forgotten after the
136    /// frame reached a neighbor could be forwarded a second time — by every
137    /// node — which is how a flood becomes a storm, so callers must be certain
138    /// the frame travelled nowhere.
139    pub fn forget(&mut self, message_id: &str) {
140        if self.entries.remove(message_id).is_some() {
141            // Keep the insertion-order queue in lockstep with the map. Leaving
142            // the stale entry would be tolerated by `expire`, but it would also
143            // be counted as a capacity eviction by `observe`, and that counter
144            // is the signal that the cache is undersized.
145            self.order.retain(|id| id != message_id);
146        }
147    }
148
149    /// Whether `message_id` is currently suppressed, without recording it.
150    pub fn contains(&self, message_id: &str) -> bool {
151        match self.entries.get(message_id) {
152            Some(seen_at) => seen_at.elapsed() < self.config.retention,
153            None => false,
154        }
155    }
156
157    /// Drops entries older than the retention window.
158    ///
159    /// Called automatically by [`Self::observe`]; exposed so an idle node can
160    /// release memory from its process tick.
161    pub fn expire(&mut self, now: Instant) {
162        while let Some(oldest_id) = self.order.front() {
163            let expired = match self.entries.get(oldest_id) {
164                Some(seen_at) => now.duration_since(*seen_at) >= self.config.retention,
165                // Not in the map: a stale order entry, drop it.
166                None => true,
167            };
168            if !expired {
169                break;
170            }
171            self.pop_oldest();
172        }
173    }
174
175    /// Number of ids currently tracked.
176    pub fn len(&self) -> usize {
177        self.entries.len()
178    }
179
180    /// Whether the cache holds no ids.
181    pub fn is_empty(&self) -> bool {
182        self.entries.is_empty()
183    }
184
185    /// How many arrivals were suppressed as already-handled.
186    pub fn duplicates_suppressed(&self) -> u64 {
187        self.duplicates_suppressed
188    }
189
190    /// How many ids were dropped for capacity rather than age.
191    ///
192    /// Expected to stay at zero: an id evicted while copies of it are still in
193    /// flight would be forwarded again. A rising count means the cache is too
194    /// small for the traffic the node is seeing.
195    pub fn capacity_evictions(&self) -> u64 {
196        self.capacity_evictions
197    }
198
199    /// Removes every entry.
200    pub fn clear(&mut self) {
201        self.entries.clear();
202        self.order.clear();
203    }
204
205    fn pop_oldest(&mut self) -> Option<String> {
206        let oldest = self.order.pop_front()?;
207        self.entries.remove(&oldest);
208        Some(oldest)
209    }
210}
211
212impl Default for RelaySeenCache {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn first_sighting_is_fresh_and_repeats_are_duplicates() {
224        let mut cache = RelaySeenCache::new();
225        assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
226        assert_eq!(cache.observe("m1"), SeenOutcome::Duplicate);
227        assert_eq!(cache.observe("m1"), SeenOutcome::Duplicate);
228        assert_eq!(cache.duplicates_suppressed(), 2);
229        assert_eq!(cache.len(), 1);
230    }
231
232    #[test]
233    fn distinct_ids_are_tracked_independently() {
234        let mut cache = RelaySeenCache::new();
235        assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
236        assert_eq!(cache.observe("m2"), SeenOutcome::Fresh);
237        assert!(cache.contains("m1"));
238        assert!(cache.contains("m2"));
239    }
240
241    #[test]
242    fn capacity_evicts_oldest_first_and_is_counted() {
243        let mut cache = RelaySeenCache::with_config(RelaySeenConfig {
244            capacity: 3,
245            retention: Duration::from_secs(600),
246        });
247
248        cache.observe("m1");
249        cache.observe("m2");
250        cache.observe("m3");
251        assert_eq!(cache.capacity_evictions(), 0);
252
253        // Fourth id pushes out the first.
254        cache.observe("m4");
255        assert_eq!(cache.len(), 3);
256        assert_eq!(cache.capacity_evictions(), 1);
257        assert!(!cache.contains("m1"));
258        assert!(cache.contains("m2"));
259        assert!(cache.contains("m4"));
260
261        // And the evicted id is treated as new again — the condition the
262        // capacity is sized to keep out of reach in production.
263        assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
264    }
265
266    #[test]
267    fn entries_expire_by_age() {
268        let mut cache = RelaySeenCache::with_config(RelaySeenConfig {
269            capacity: 128,
270            retention: Duration::from_millis(0),
271        });
272
273        cache.observe("m1");
274        // A zero retention means the entry is already expired when the next
275        // observation sweeps.
276        assert_eq!(cache.observe("m2"), SeenOutcome::Fresh);
277        assert!(!cache.contains("m1"));
278        assert_eq!(cache.capacity_evictions(), 0);
279    }
280
281    #[test]
282    fn expiry_never_leaves_orphaned_order_entries() {
283        let mut cache = RelaySeenCache::with_config(RelaySeenConfig {
284            capacity: 4,
285            retention: Duration::from_millis(0),
286        });
287
288        for i in 0..16 {
289            cache.observe(&format!("m{i}"));
290        }
291
292        // The insertion-order queue must stay in lockstep with the map, or the
293        // cache leaks memory that no expiry pass can reach.
294        assert_eq!(cache.order.len(), cache.entries.len());
295        assert!(cache.len() <= 4);
296    }
297
298    #[test]
299    fn a_forgotten_id_is_new_again_and_leaves_no_orphan() {
300        let mut cache = RelaySeenCache::new();
301        cache.observe("m1");
302        cache.observe("m2");
303
304        cache.forget("m1");
305
306        assert!(
307            !cache.contains("m1"),
308            "a forgotten id must not be suppressed"
309        );
310        assert!(cache.contains("m2"), "and its neighbors are untouched");
311        assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
312        // The order queue must not keep the forgotten id, or `observe` would
313        // count popping it as a capacity eviction.
314        assert_eq!(cache.order.len(), cache.entries.len());
315        assert_eq!(cache.capacity_evictions(), 0);
316    }
317
318    #[test]
319    fn forgetting_an_unknown_id_is_a_no_op() {
320        let mut cache = RelaySeenCache::new();
321        cache.observe("m1");
322
323        cache.forget("never-seen");
324
325        assert!(cache.contains("m1"));
326        assert_eq!(cache.order.len(), 1);
327        assert_eq!(cache.entries.len(), 1);
328    }
329
330    #[test]
331    fn clear_drops_everything() {
332        let mut cache = RelaySeenCache::new();
333        cache.observe("m1");
334        cache.observe("m2");
335        cache.clear();
336        assert!(cache.is_empty());
337        assert!(!cache.contains("m1"));
338    }
339}