Skip to main content

clawft_kernel/
mesh_dedup.rs

1//! Message deduplication for mesh IPC (K6.3).
2//!
3//! Prevents duplicate delivery of messages that may arrive via
4//! multiple paths in the mesh network. Uses a time-bounded set
5//! of recently seen message/envelope IDs.
6//!
7//! # Status (WEFT-151 audit)
8//!
9//! **Orphan — library only.** No production callers outside this module
10//! and re-exports (`clawft_kernel`, `weftos`). ADR-039 and K6 notes
11//! describe `DedupFilter` as the intended SWIM/IPC dedup path, but
12//! `MeshRuntime::handle_incoming` / `mesh_ipc` never call
13//! [`DedupFilter::check_and_insert`]. Unit tests only.
14//!
15//! **Disposition:** keep; schedule into runtime receive path.
16//! **Wiring schedule (0.8.x):** call `check_and_insert` on envelope
17//! (or message) id inside `MeshRuntime` inbound handling (and any
18//! future MeshAdapter). Intended default: [`DedupFilter::default_mesh`]
19//! (60s TTL, 10k entries).
20
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25use crate::mesh_clock::{Clock, MonoTime, RealClock};
26
27/// Time-bounded deduplication filter.
28///
29/// Tracks recently seen IDs and automatically expires entries
30/// older than the configured TTL. Uses an injectable [`Clock`]
31/// (WEFT-113) so tests can advance TTL without sleeping.
32pub struct DedupFilter {
33    /// Seen IDs with their insertion time.
34    seen: HashMap<String, MonoTime>,
35    /// How long to remember IDs.
36    ttl: Duration,
37    /// Maximum number of entries before forced eviction.
38    max_entries: usize,
39    /// Injectable clock.
40    clock: Arc<dyn Clock>,
41}
42
43impl DedupFilter {
44    /// Create a new dedup filter with the given TTL and max capacity.
45    ///
46    /// Binds the production [`RealClock`].
47    pub fn new(ttl: Duration, max_entries: usize) -> Self {
48        Self::with_clock(ttl, max_entries, RealClock::shared())
49    }
50
51    /// Create a dedup filter with an injectable [`Clock`].
52    pub fn with_clock(ttl: Duration, max_entries: usize, clock: Arc<dyn Clock>) -> Self {
53        Self {
54            seen: HashMap::new(),
55            ttl,
56            max_entries,
57            clock,
58        }
59    }
60
61    /// Create a dedup filter with default settings (60s TTL, 10000 entries).
62    pub fn default_mesh() -> Self {
63        Self::new(Duration::from_secs(60), 10_000)
64    }
65
66    /// Check if an ID has been seen before. If not, mark it as seen.
67    /// Returns `true` if this is a **new** (not duplicate) message.
68    pub fn check_and_insert(&mut self, id: &str) -> bool {
69        self.evict_expired();
70
71        if self.seen.contains_key(id) {
72            return false; // duplicate
73        }
74
75        // Force eviction if at capacity
76        if self.seen.len() >= self.max_entries {
77            self.evict_oldest();
78        }
79
80        self.seen.insert(id.to_string(), self.clock.now());
81        true // new message
82    }
83
84    /// Check if an ID has been seen (without inserting).
85    pub fn is_duplicate(&self, id: &str) -> bool {
86        if let Some(inserted) = self.seen.get(id) {
87            self.clock.now().elapsed_since(*inserted) < self.ttl
88        } else {
89            false
90        }
91    }
92
93    /// Number of tracked IDs.
94    pub fn len(&self) -> usize {
95        self.seen.len()
96    }
97
98    /// Whether the filter is empty.
99    pub fn is_empty(&self) -> bool {
100        self.seen.is_empty()
101    }
102
103    /// Remove expired entries.
104    fn evict_expired(&mut self) {
105        let now = self.clock.now();
106        let ttl = self.ttl;
107        self.seen
108            .retain(|_, inserted| now.elapsed_since(*inserted) < ttl);
109    }
110
111    /// Remove the oldest entry to make room for new ones.
112    fn evict_oldest(&mut self) {
113        if let Some(oldest_key) = self
114            .seen
115            .iter()
116            .min_by_key(|(_, t)| **t)
117            .map(|(k, _)| k.clone())
118        {
119            self.seen.remove(&oldest_key);
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::mesh_clock::MockClock;
128
129    #[test]
130    fn new_message_returns_true() {
131        let mut f = DedupFilter::new(Duration::from_secs(10), 100);
132        assert!(f.check_and_insert("msg-1"));
133    }
134
135    #[test]
136    fn duplicate_returns_false() {
137        let mut f = DedupFilter::new(Duration::from_secs(10), 100);
138        assert!(f.check_and_insert("msg-1"));
139        assert!(!f.check_and_insert("msg-1"));
140    }
141
142    #[test]
143    fn different_ids_both_new() {
144        let mut f = DedupFilter::new(Duration::from_secs(10), 100);
145        assert!(f.check_and_insert("a"));
146        assert!(f.check_and_insert("b"));
147        assert_eq!(f.len(), 2);
148    }
149
150    #[test]
151    fn is_duplicate_without_inserting() {
152        let mut f = DedupFilter::new(Duration::from_secs(10), 100);
153        assert!(!f.is_duplicate("x"));
154        f.check_and_insert("x");
155        assert!(f.is_duplicate("x"));
156    }
157
158    #[test]
159    fn expired_entries_evicted() {
160        let clock = MockClock::new();
161        let mut f = DedupFilter::with_clock(
162            Duration::from_secs(1),
163            100,
164            Arc::new(clock.clone()),
165        );
166        assert!(f.check_and_insert("old"));
167        clock.advance(Duration::from_secs(2));
168        // TTL elapsed → treated as new again.
169        assert!(f.check_and_insert("old"));
170    }
171
172    #[test]
173    fn max_capacity_evicts_oldest() {
174        let clock = MockClock::new();
175        let mut f =
176            DedupFilter::with_clock(Duration::from_secs(60), 2, Arc::new(clock.clone()));
177        f.check_and_insert("a");
178        clock.advance(Duration::from_secs(1));
179        f.check_and_insert("b");
180        clock.advance(Duration::from_secs(1));
181        // At capacity (2), inserting "c" should evict "a"
182        f.check_and_insert("c");
183        assert_eq!(f.len(), 2);
184        assert!(!f.is_duplicate("a"));
185        assert!(f.is_duplicate("b"));
186        assert!(f.is_duplicate("c"));
187    }
188
189    #[test]
190    fn empty_filter() {
191        let f = DedupFilter::default_mesh();
192        assert!(f.is_empty());
193        assert_eq!(f.len(), 0);
194    }
195
196    #[test]
197    fn default_mesh_settings() {
198        let f = DedupFilter::default_mesh();
199        assert_eq!(f.max_entries, 10_000);
200        assert_eq!(f.ttl, Duration::from_secs(60));
201    }
202}