Skip to main content

hdds_micro/transport/mesh/
seen.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3
4//! Duplicate message detection cache
5
6/// Cache entry for seen messages
7#[derive(Debug, Clone, Copy, Default)]
8struct SeenEntry {
9    /// Message ID (src << 16 | seq)
10    message_id: u32,
11    /// Timestamp when seen (tick counter)
12    seen_at: u32,
13    /// Valid flag
14    valid: bool,
15}
16
17/// Fixed-size cache for duplicate detection
18///
19/// Uses a simple ring buffer with LRU eviction.
20pub struct SeenCache<const N: usize> {
21    /// Cache entries
22    entries: [SeenEntry; N],
23    /// Next insertion index
24    next: usize,
25    /// Current tick (for expiry)
26    tick: u32,
27    /// Entry lifetime in ticks
28    lifetime: u32,
29}
30
31impl<const N: usize> SeenCache<N> {
32    /// Create a new seen cache
33    ///
34    /// # Arguments
35    /// * `lifetime` - How many ticks before entries expire
36    pub fn new(lifetime: u32) -> Self {
37        Self {
38            entries: [SeenEntry::default(); N],
39            next: 0,
40            tick: 0,
41            lifetime,
42        }
43    }
44
45    /// Advance the tick counter
46    pub fn tick(&mut self) {
47        self.tick = self.tick.wrapping_add(1);
48    }
49
50    /// Check if a message was recently seen
51    ///
52    /// Returns `true` if the message is a duplicate (was seen before).
53    pub fn is_duplicate(&self, message_id: u32) -> bool {
54        for entry in &self.entries {
55            if entry.valid && entry.message_id == message_id {
56                // Check if not expired
57                let age = self.tick.wrapping_sub(entry.seen_at);
58                if age <= self.lifetime {
59                    return true;
60                }
61            }
62        }
63        false
64    }
65
66    /// Check if duplicate and mark as seen if not
67    ///
68    /// Returns `true` if the message is a duplicate.
69    /// If not a duplicate, adds it to the cache.
70    pub fn check_and_mark(&mut self, message_id: u32) -> bool {
71        // First check if already seen
72        for entry in &mut self.entries {
73            if entry.valid && entry.message_id == message_id {
74                let age = self.tick.wrapping_sub(entry.seen_at);
75                if age <= self.lifetime {
76                    return true; // Duplicate
77                }
78                // Expired, will be replaced
79            }
80        }
81
82        // Not a duplicate, add to cache
83        self.mark_seen(message_id);
84        false
85    }
86
87    /// Mark a message as seen
88    pub fn mark_seen(&mut self, message_id: u32) {
89        // Try to find an expired or invalid slot first
90        for entry in &mut self.entries {
91            if !entry.valid {
92                entry.message_id = message_id;
93                entry.seen_at = self.tick;
94                entry.valid = true;
95                return;
96            }
97
98            let age = self.tick.wrapping_sub(entry.seen_at);
99            if age > self.lifetime {
100                entry.message_id = message_id;
101                entry.seen_at = self.tick;
102                entry.valid = true;
103                return;
104            }
105        }
106
107        // No free slot, use LRU (next index)
108        self.entries[self.next] = SeenEntry {
109            message_id,
110            seen_at: self.tick,
111            valid: true,
112        };
113        self.next = (self.next + 1) % N;
114    }
115
116    /// Clear all entries
117    pub fn clear(&mut self) {
118        for entry in &mut self.entries {
119            entry.valid = false;
120        }
121        self.next = 0;
122    }
123
124    /// Get number of valid (non-expired) entries
125    pub fn len(&self) -> usize {
126        self.entries
127            .iter()
128            .filter(|e| e.valid && self.tick.wrapping_sub(e.seen_at) <= self.lifetime)
129            .count()
130    }
131
132    /// Check if cache is empty
133    pub fn is_empty(&self) -> bool {
134        self.len() == 0
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_seen_cache_basic() {
144        let mut cache: SeenCache<8> = SeenCache::new(100);
145
146        // First time should not be duplicate
147        assert!(!cache.check_and_mark(0x0001_0001));
148
149        // Second time should be duplicate
150        assert!(cache.check_and_mark(0x0001_0001));
151
152        // Different message should not be duplicate
153        assert!(!cache.check_and_mark(0x0001_0002));
154    }
155
156    #[test]
157    fn test_seen_cache_expiry() {
158        let mut cache: SeenCache<8> = SeenCache::new(10);
159
160        assert!(!cache.check_and_mark(0x0001_0001));
161        assert!(cache.check_and_mark(0x0001_0001));
162
163        // Advance time past lifetime
164        for _ in 0..15 {
165            cache.tick();
166        }
167
168        // Should no longer be duplicate (expired)
169        assert!(!cache.check_and_mark(0x0001_0001));
170    }
171
172    #[test]
173    fn test_seen_cache_lru() {
174        let mut cache: SeenCache<4> = SeenCache::new(1000);
175
176        // Fill cache
177        for i in 0..4 {
178            assert!(!cache.check_and_mark(i));
179        }
180
181        // All should still be duplicates
182        for i in 0..4 {
183            assert!(cache.check_and_mark(i));
184        }
185
186        // Add more (should evict oldest)
187        for i in 4..8 {
188            assert!(!cache.check_and_mark(i));
189        }
190
191        // Old entries should have been evicted
192        assert!(!cache.check_and_mark(0)); // Re-added as new
193    }
194
195    #[test]
196    fn test_seen_cache_clear() {
197        let mut cache: SeenCache<8> = SeenCache::new(100);
198
199        cache.mark_seen(0x0001_0001);
200        cache.mark_seen(0x0001_0002);
201        assert_eq!(cache.len(), 2);
202
203        cache.clear();
204        assert_eq!(cache.len(), 0);
205        assert!(cache.is_empty());
206    }
207
208    #[test]
209    fn test_seen_cache_len() {
210        let mut cache: SeenCache<8> = SeenCache::new(5);
211
212        assert_eq!(cache.len(), 0);
213
214        cache.mark_seen(1);
215        cache.mark_seen(2);
216        cache.mark_seen(3);
217        assert_eq!(cache.len(), 3);
218
219        // Expire entries
220        for _ in 0..10 {
221            cache.tick();
222        }
223        assert_eq!(cache.len(), 0);
224    }
225}