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}