Skip to main content

beam/
dup.rs

1//! Gun.js DAM-style message deduplication.
2//!
3//! This module implements [`Dup`] — a bounded, TTL-based deduplication
4//! tracker that matches the semantics of Gun.js `dup.js`. It prevents
5//! the same message from being processed or forwarded more than once
6//! across the P2P mesh.
7//!
8//! ## How It Works
9//!
10//! Two layers of dedup, matching Gun.js:
11//!
12//! 1. **Message ID** (`#` field) — prevents echo and re-processing of
13//!    messages this node has already seen.
14//! 2. **Ack + hash** (`@` + `##` fields) — deduplicates identical responses
15//!    to avoid redundant re-sends.
16//!
17//! Entries expire after a configurable TTL (default 9 seconds, matching
18//! Gun.js `opt.age`). Eviction is both **lazy** (on `check`) and **periodic**
19//! (on `track`), preventing unbounded memory growth.
20//!
21//! ## Example
22//!
23//! ```
24//! use beam::Dup;
25//!
26//! let mut dup = Dup::default_gun();
27//! assert!(!dup.check("msg-1"));    // not seen yet
28//! dup.track("msg-1");              // mark as seen
29//! assert!(dup.check("msg-1"));     // now seen
30//! assert!(!dup.check("msg-2"));   // different message not seen
31//! ```
32
33use crate::utils::FxHashMap;
34use web_time::{Duration, Instant};
35
36/// A bounded, TTL-based deduplication tracker matching Gun.js `dup.js`.
37///
38/// Tracks message IDs with timestamps. Entries that haven't been seen
39/// within the TTL are automatically evicted. The map is also bounded by
40/// `max` entries — when exceeded, the oldest third is evicted.
41pub struct Dup {
42    entries: FxHashMap<String, DupEntry>,
43    /// Maximum entries before forced eviction (default: 100,000).
44    max: usize,
45    /// Entry TTL (default: 9 seconds, matching Gun.js `opt.age`).
46    age: Duration,
47    /// Instant of last `drop()` call — rate-limits periodic cleanup.
48    last_drop: Instant,
49}
50
51struct DupEntry {
52    was: Instant,
53}
54
55impl Dup {
56    /// Creates a new dedup tracker with the given capacity and TTL.
57    ///
58    /// # Arguments
59    ///
60    /// * `max` — Maximum number of entries before forced eviction.
61    /// * `age_secs` — TTL in seconds. Entries older than this are evicted.
62    pub fn new(max: usize, age_secs: u64) -> Self {
63        Self {
64            entries: FxHashMap::with_capacity_and_hasher(max, Default::default()),
65            max,
66            age: Duration::from_secs(age_secs),
67            last_drop: Instant::now(),
68        }
69    }
70
71    /// Creates a new dedup tracker with Gun.js defaults: 100,000 entries, 9s TTL.
72    pub fn default_gun() -> Self {
73        Self::new(100_000, 9)
74    }
75
76    /// Gun.js `dup.check(id)`: returns `true` if `id` has been seen
77    /// within the TTL. If expired, removes it and returns `false`.
78    ///
79    /// This is lazy eviction — expired entries are cleaned up on access.
80    pub fn check(&mut self, id: &str) -> bool {
81        if let Some(entry) = self.entries.get(id) {
82            if entry.was.elapsed() < self.age {
83                return true;
84            }
85            // Expired — lazy removal
86            self.entries.remove(id);
87        }
88        false
89    }
90
91    /// Gun.js `dup.track(id)`: marks `id` as seen now.
92    ///
93    /// Also performs periodic cleanup if enough time has passed:
94    /// - If entries exceed `max`, evicts the oldest third.
95    /// - If `last_drop` was more than `age / 2` ago, runs `drop()`.
96    pub fn track(&mut self, id: &str) {
97        self.entries.insert(
98            id.to_string(),
99            DupEntry {
100                was: Instant::now(),
101            },
102        );
103        if self.entries.len() > self.max {
104            self.drop_oldest(self.max / 3);
105        }
106        // Periodic cleanup: every ~age/2 to keep map lean
107        if self.last_drop.elapsed() > self.age / 2 {
108            self.drop(None);
109        }
110    }
111
112    /// Remove entries older than `age`. `force_age` overrides `self.age`.
113    ///
114    /// This is Gun.js `dup.drop(age)` — called periodically to clean
115    /// expired entries from the map.
116    pub fn drop(&mut self, force_age: Option<Duration>) {
117        let cutoff = force_age.unwrap_or(self.age);
118        let now = Instant::now();
119        let expired: Vec<String> = self
120            .entries
121            .iter()
122            .filter(|(_, entry)| now.duration_since(entry.was) > cutoff)
123            .map(|(k, _)| k.clone())
124            .collect();
125        for k in expired {
126            self.entries.remove(&k);
127        }
128        self.last_drop = Instant::now();
129    }
130
131    fn drop_oldest(&mut self, n: usize) {
132        let n = n.min(self.entries.len());
133        if n == 0 {
134            return;
135        }
136        // Collect keys + timestamps, sort by age, remove oldest n
137        let mut pairs: Vec<(String, Instant)> = self
138            .entries
139            .iter()
140            .map(|(k, v)| (k.clone(), v.was))
141            .collect();
142        pairs.sort_by_key(|a| a.1);
143        for (k, _) in pairs.into_iter().take(n) {
144            self.entries.remove(&k);
145        }
146    }
147
148    /// Returns the number of entries currently tracked.
149    pub fn len(&self) -> usize {
150        self.entries.len()
151    }
152
153    /// Returns `true` if no entries are currently tracked.
154    pub fn is_empty(&self) -> bool {
155        self.entries.is_empty()
156    }
157
158    /// Returns the maximum capacity.
159    pub fn max(&self) -> usize {
160        self.max
161    }
162
163    /// Returns the TTL duration.
164    pub fn age(&self) -> Duration {
165        self.age
166    }
167
168    /// Removes all entries, resetting the tracker to empty.
169    pub fn clear(&mut self) {
170        self.entries.clear();
171        self.last_drop = Instant::now();
172    }
173}
174
175impl Default for Dup {
176    fn default() -> Self {
177        Self::default_gun()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_dup_basic() {
187        let mut dup = Dup::new(10, 5);
188        assert!(!dup.check("msg-1"));
189        dup.track("msg-1");
190        assert!(dup.check("msg-1"));
191    }
192
193    #[test]
194    fn test_dup_expiration() {
195        let mut dup = Dup::new(10, 1);
196        dup.track("msg-1");
197        assert!(dup.check("msg-1"));
198        std::thread::sleep(Duration::from_secs(2));
199        assert!(!dup.check("msg-1"));
200    }
201
202    #[test]
203    fn test_dup_max_eviction() {
204        let mut dup = Dup::new(3, 60);
205        dup.track("a");
206        dup.track("b");
207        dup.track("c");
208        assert_eq!(dup.len(), 3);
209        dup.track("d");
210        assert_eq!(dup.len(), 3);
211        assert!(!dup.check("a")); // oldest third evicted
212    }
213
214    #[test]
215    fn test_gun_default() {
216        let dup = Dup::default_gun();
217        assert_eq!(dup.max(), 100_000);
218        assert_eq!(dup.age(), Duration::from_secs(9));
219    }
220
221    #[test]
222    fn test_dup_default_trait() {
223        let dup = Dup::default();
224        assert_eq!(dup.max(), 100_000);
225        assert_eq!(dup.age(), Duration::from_secs(9));
226    }
227
228    #[test]
229    fn test_dup_is_empty() {
230        let mut dup = Dup::new(10, 5);
231        assert!(dup.is_empty());
232        dup.track("x");
233        assert!(!dup.is_empty());
234    }
235
236    #[test]
237    fn test_dup_clear() {
238        let mut dup = Dup::new(10, 60);
239        dup.track("a");
240        dup.track("b");
241        assert_eq!(dup.len(), 2);
242        dup.clear();
243        assert_eq!(dup.len(), 0);
244        assert!(dup.is_empty());
245    }
246
247    #[test]
248    fn test_dup_drop_with_force_age() {
249        let mut dup = Dup::new(100, 60); // long TTL
250        dup.track("a");
251        dup.track("b");
252        assert_eq!(dup.len(), 2);
253        // Force-drop with age 0 — should evict everything
254        dup.drop(Some(Duration::from_secs(0)));
255        assert_eq!(dup.len(), 0);
256    }
257
258    #[test]
259    fn test_dup_retrack_updates_timestamp() {
260        let mut dup = Dup::new(10, 1);
261        dup.track("msg-1");
262        std::thread::sleep(Duration::from_millis(500));
263        dup.track("msg-1"); // re-track, should refresh timestamp
264        std::thread::sleep(Duration::from_millis(600));
265        // Total elapsed: 1.1s, but re-tracked at 0.5s, so only 0.6s since last track
266        assert!(dup.check("msg-1")); // should still be alive
267    }
268
269    #[test]
270    fn test_dup_different_ids_independent() {
271        let mut dup = Dup::new(10, 60);
272        dup.track("msg-1");
273        assert!(dup.check("msg-1"));
274        assert!(!dup.check("msg-2"));
275        dup.track("msg-2");
276        assert!(dup.check("msg-2"));
277        assert!(dup.check("msg-1"));
278    }
279
280    #[test]
281    fn test_dup_expired_entry_removed_on_check() {
282        let mut dup = Dup::new(10, 1);
283        dup.track("msg-1");
284        assert_eq!(dup.len(), 1);
285        std::thread::sleep(Duration::from_secs(2));
286        assert!(!dup.check("msg-1"));
287        assert_eq!(dup.len(), 0); // expired entry was removed
288    }
289}