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