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}