Skip to main content

offline_protocol_reliability/
deduplicator.rs

1//! Message deduplication using rotating bloom filters for O(1) memory.
2//!
3//! This module implements a scalable deduplication system using bloom filters
4//! that can handle millions of messages with constant memory usage.
5//!
6//! The rotating bloom filter approach uses multiple time-windowed filters:
7//! - Current filter: accepts new message IDs
8//! - Previous filters: used for lookups, rotated out after retention period
9
10use chrono::{DateTime, Utc};
11use offline_protocol_core::MessageId;
12use std::collections::HashMap;
13
14/// Configuration for deduplication.
15#[derive(Debug, Clone)]
16pub struct DeduplicatorConfig {
17    /// Maximum number of message IDs to track (used for HashMap fallback).
18    pub max_tracked_messages: usize,
19
20    /// Time to keep message IDs in memory (seconds).
21    pub retention_time_secs: u64,
22
23    /// Enable bloom filter mode for scalable deduplication.
24    pub use_bloom_filter: bool,
25
26    /// Number of bits in each bloom filter (default: 2^20 = ~1MB per filter).
27    pub bloom_filter_bits: usize,
28
29    /// Number of hash functions for bloom filter (default: 7 for ~1% false positive).
30    pub bloom_hash_count: usize,
31
32    /// Number of rotating bloom filters (default: 4 for hourly windows).
33    pub bloom_filter_count: usize,
34
35    /// Rotation interval in seconds (default: 900 = 15 minutes).
36    pub bloom_rotation_secs: u64,
37}
38
39impl Default for DeduplicatorConfig {
40    fn default() -> Self {
41        Self {
42            // Exact-match mode by default: no false positives (Bloom can drop ~1% of legitimate messages).
43            max_tracked_messages: 1000,
44            retention_time_secs: 3600, // 1 hour
45            use_bloom_filter: false,
46            bloom_filter_bits: 1 << 20, // ~1MB per filter (1,048,576 bits)
47            bloom_hash_count: 7,        // ~1% false positive rate when Bloom is enabled
48            bloom_filter_count: 4,      // 4 windows for 1-hour retention
49            bloom_rotation_secs: 900,   // 15 minutes per window
50        }
51    }
52}
53
54/// A simple bloom filter implementation.
55#[derive(Clone)]
56struct BloomFilter {
57    bits: Vec<u64>,
58    bit_count: usize,
59    hash_count: usize,
60    items_added: usize,
61}
62
63impl BloomFilter {
64    fn new(bit_count: usize, hash_count: usize) -> Self {
65        let word_count = bit_count.div_ceil(64);
66        Self {
67            bits: vec![0u64; word_count],
68            bit_count,
69            hash_count,
70            items_added: 0,
71        }
72    }
73
74    /// Computes multiple hash values for an item using double hashing.
75    fn hashes(&self, item: &str) -> Vec<usize> {
76        let mut hashes = Vec::with_capacity(self.hash_count);
77
78        // First hash using FNV-1a
79        let h1 = fnv1a_hash(item.as_bytes());
80        // Second hash using a different seed, forced odd: with a
81        // power-of-two bit_count an even h2 collapses the probe indices
82        // onto few bits (all onto one when h2 ≡ 0 mod bit_count), inflating
83        // the false-positive rate; an odd step keeps all probes distinct.
84        let h2 = fnv1a_hash_seeded(item.as_bytes(), 0x5f356495) | 1;
85
86        for i in 0..self.hash_count {
87            // Kirsch-Mitzenmacher optimization: h(i) = h1 + i * h2
88            let combined = h1.wrapping_add((i as u64).wrapping_mul(h2));
89            hashes.push((combined as usize) % self.bit_count);
90        }
91
92        hashes
93    }
94
95    fn insert(&mut self, item: &str) {
96        for bit_index in self.hashes(item) {
97            let word_index = bit_index / 64;
98            let bit_offset = bit_index % 64;
99            self.bits[word_index] |= 1u64 << bit_offset;
100        }
101        self.items_added += 1;
102    }
103
104    fn contains(&self, item: &str) -> bool {
105        for bit_index in self.hashes(item) {
106            let word_index = bit_index / 64;
107            let bit_offset = bit_index % 64;
108            if (self.bits[word_index] & (1u64 << bit_offset)) == 0 {
109                return false;
110            }
111        }
112        true
113    }
114
115    fn clear(&mut self) {
116        self.bits.fill(0);
117        self.items_added = 0;
118    }
119
120    fn items_count(&self) -> usize {
121        self.items_added
122    }
123
124    /// Estimates the current false positive probability.
125    fn estimated_false_positive_rate(&self) -> f64 {
126        if self.items_added == 0 {
127            return 0.0;
128        }
129        let m = self.bit_count as f64;
130        let n = self.items_added as f64;
131        let k = self.hash_count as f64;
132        (1.0 - (-k * n / m).exp()).powf(k)
133    }
134}
135
136/// FNV-1a hash function.
137fn fnv1a_hash(data: &[u8]) -> u64 {
138    fnv1a_hash_seeded(data, 0xcbf29ce484222325)
139}
140
141/// FNV-1a hash with custom seed.
142fn fnv1a_hash_seeded(data: &[u8], seed: u64) -> u64 {
143    const FNV_PRIME: u64 = 0x100000001b3;
144    let mut hash = seed;
145    for byte in data {
146        hash ^= *byte as u64;
147        hash = hash.wrapping_mul(FNV_PRIME);
148    }
149    hash
150}
151
152/// A rotating bloom filter with time windows.
153struct RotatingBloomFilter {
154    filters: Vec<BloomFilter>,
155    current_index: usize,
156    last_rotation: DateTime<Utc>,
157    rotation_interval_secs: u64,
158}
159
160impl RotatingBloomFilter {
161    fn new(filter_count: usize, bit_count: usize, hash_count: usize, rotation_secs: u64) -> Self {
162        let filters = (0..filter_count)
163            .map(|_| BloomFilter::new(bit_count, hash_count))
164            .collect();
165        Self {
166            filters,
167            current_index: 0,
168            last_rotation: Utc::now(),
169            rotation_interval_secs: rotation_secs,
170        }
171    }
172
173    fn maybe_rotate(&mut self, now: DateTime<Utc>) {
174        let elapsed = now.signed_duration_since(self.last_rotation);
175        if elapsed.num_seconds() >= self.rotation_interval_secs as i64 {
176            // Move to next filter and clear it
177            self.current_index = (self.current_index + 1) % self.filters.len();
178            self.filters[self.current_index].clear();
179            self.last_rotation = now;
180        }
181    }
182
183    fn insert(&mut self, item: &str) {
184        self.maybe_rotate(Utc::now());
185        self.filters[self.current_index].insert(item);
186    }
187
188    fn contains(&mut self, item: &str) -> bool {
189        self.maybe_rotate(Utc::now());
190        // Check all filters (current and previous windows)
191        self.filters.iter().any(|f| f.contains(item))
192    }
193
194    fn total_items(&self) -> usize {
195        self.filters.iter().map(|f| f.items_count()).sum()
196    }
197
198    fn clear(&mut self) {
199        for filter in &mut self.filters {
200            filter.clear();
201        }
202        self.current_index = 0;
203        self.last_rotation = Utc::now();
204    }
205
206    fn average_false_positive_rate(&self) -> f64 {
207        let rates: Vec<f64> = self
208            .filters
209            .iter()
210            .map(|f| f.estimated_false_positive_rate())
211            .collect();
212        if rates.is_empty() {
213            return 0.0;
214        }
215        rates.iter().sum::<f64>() / rates.len() as f64
216    }
217}
218
219/// Entry for a seen message (used in HashMap mode).
220#[derive(Debug, Clone)]
221struct SeenEntry {
222    /// When this message was first seen.
223    seen_at: DateTime<Utc>,
224    /// When this entry was last accessed (for LRU eviction).
225    last_accessed: DateTime<Utc>,
226}
227
228/// Deduplicator for tracking seen messages and preventing duplicates.
229///
230/// Supports two modes:
231/// - HashMap mode: exact tracking with FIFO eviction (original behavior)
232/// - Bloom filter mode: O(1) memory with probabilistic duplicate detection
233pub struct Deduplicator {
234    config: DeduplicatorConfig,
235    /// HashMap for exact tracking (fallback mode).
236    seen_messages: HashMap<String, SeenEntry>,
237    /// Rotating bloom filter for scalable tracking.
238    bloom_filter: Option<RotatingBloomFilter>,
239}
240
241impl Deduplicator {
242    /// Creates a new deduplicator with default configuration.
243    pub fn new() -> Self {
244        Self::with_config(DeduplicatorConfig::default())
245    }
246
247    /// Creates a new deduplicator with custom configuration.
248    pub fn with_config(config: DeduplicatorConfig) -> Self {
249        // Keep the low-level constructor panic-free for direct users of this
250        // crate. The top-level ProtocolConfig rejects these invalid values;
251        // here we fail safe to exact HashMap mode instead of constructing a
252        // Bloom filter that would divide by zero or rotate an empty list.
253        let valid_bloom_config = config.bloom_filter_bits > 0
254            && config.bloom_hash_count > 0
255            && config.bloom_filter_count > 0
256            && config.bloom_rotation_secs > 0;
257        let bloom_filter = if config.use_bloom_filter && valid_bloom_config {
258            Some(RotatingBloomFilter::new(
259                config.bloom_filter_count,
260                config.bloom_filter_bits,
261                config.bloom_hash_count,
262                config.bloom_rotation_secs,
263            ))
264        } else {
265            None
266        };
267
268        Self {
269            config,
270            seen_messages: HashMap::new(),
271            bloom_filter,
272        }
273    }
274
275    /// Returns `true` when deduplication is exact (HashMap mode): a positive
276    /// [`Self::is_duplicate`] answer is then authoritative. In bloom mode
277    /// (`false`) a positive answer may be a false positive (~1% with default
278    /// settings), so callers must not treat a hit as proof of a prior send.
279    pub fn is_exact(&self) -> bool {
280        self.bloom_filter.is_none()
281    }
282
283    /// Checks if a message has been seen before.
284    ///
285    /// # Arguments
286    ///
287    /// * `message_id` - The message ID to check
288    ///
289    /// # Returns
290    ///
291    /// Returns `true` if the message has been seen, `false` otherwise.
292    ///
293    /// Note: In bloom filter mode, this may return false positives (claiming a
294    /// message is a duplicate when it isn't). The false positive rate is typically
295    /// below 1% with default settings.
296    pub fn is_duplicate(&self, message_id: &MessageId) -> bool {
297        let msg_id_str = message_id.as_str();
298
299        if let Some(ref bloom) = self.bloom_filter {
300            // Note: We need mutable access for rotation check, but this is safe
301            // because we're only reading. We'll handle rotation in mark_seen.
302            // For now, just check all filters.
303            bloom.filters.iter().any(|f| f.contains(&msg_id_str))
304        } else {
305            self.seen_messages.contains_key(&msg_id_str)
306        }
307    }
308
309    /// Checks if a message has been seen before (mutable version for bloom rotation
310    /// and LRU access tracking).
311    pub fn is_duplicate_mut(&mut self, message_id: &MessageId) -> bool {
312        let msg_id_str = message_id.as_str();
313
314        if let Some(ref mut bloom) = self.bloom_filter {
315            bloom.contains(&msg_id_str)
316        } else if let Some(entry) = self.seen_messages.get_mut(&msg_id_str) {
317            entry.last_accessed = Utc::now();
318            true
319        } else {
320            false
321        }
322    }
323
324    /// Marks a message as seen.
325    ///
326    /// # Arguments
327    ///
328    /// * `message_id` - The message ID to mark as seen
329    ///
330    /// # Returns
331    ///
332    /// Returns `true` if this is a new message, `false` if it was already seen.
333    pub fn mark_seen(&mut self, message_id: MessageId) -> bool {
334        let msg_id_str = message_id.as_str();
335
336        if let Some(ref mut bloom) = self.bloom_filter {
337            // Check if already seen
338            if bloom.contains(&msg_id_str) {
339                return false;
340            }
341            // Add to bloom filter
342            bloom.insert(&msg_id_str);
343            true
344        } else {
345            // HashMap mode (original implementation)
346            if self.seen_messages.contains_key(&msg_id_str) {
347                return false;
348            }
349
350            // Check if we've hit the limit
351            if self.seen_messages.len() >= self.config.max_tracked_messages {
352                self.cleanup_expired();
353
354                if self.seen_messages.len() >= self.config.max_tracked_messages {
355                    // LRU eviction: evict the least recently accessed entry
356                    if let Some((lru_id, _)) = self
357                        .seen_messages
358                        .iter()
359                        .min_by_key(|(_, entry)| entry.last_accessed)
360                        .map(|(id, entry)| (id.clone(), entry.clone()))
361                    {
362                        self.seen_messages.remove(&lru_id);
363                    }
364                }
365            }
366
367            let now = Utc::now();
368            self.seen_messages.insert(
369                msg_id_str,
370                SeenEntry {
371                    seen_at: now,
372                    last_accessed: now,
373                },
374            );
375
376            true
377        }
378    }
379
380    /// Removes a message from the seen set so a future copy is processed
381    /// fresh instead of being treated as a duplicate.
382    ///
383    /// Intended for receivers that reject a message *after* marking it seen
384    /// (e.g. an encryption-policy or security-gate rejection): forgetting the
385    /// id makes a replay re-enter the same gate — and be rejected again
386    /// without a delivery ACK — instead of hitting the duplicate re-ACK path,
387    /// which would confirm to an injector that the target is online.
388    ///
389    /// # Returns
390    ///
391    /// Returns `true` if the message was tracked and has been removed.
392    /// Bloom filters do not support removal, so in bloom filter mode this is
393    /// a no-op returning `false`: a replayed rejected message is treated as a
394    /// duplicate until its filter window rotates out.
395    pub fn unmark_seen(&mut self, message_id: &MessageId) -> bool {
396        if self.bloom_filter.is_some() {
397            return false;
398        }
399        self.seen_messages.remove(&message_id.as_str()).is_some()
400    }
401
402    /// Removes expired entries that exceed the retention time.
403    ///
404    /// # Returns
405    ///
406    /// Returns the number of entries removed.
407    ///
408    /// Note: In bloom filter mode, expiration is handled automatically through
409    /// filter rotation, so this returns 0.
410    pub fn cleanup_expired(&mut self) -> usize {
411        if self.bloom_filter.is_some() {
412            // Bloom filter handles expiration through rotation
413            return 0;
414        }
415
416        let now = Utc::now();
417        let retention = chrono::Duration::seconds(self.config.retention_time_secs as i64);
418        let cutoff = now - retention;
419
420        let before_count = self.seen_messages.len();
421        self.seen_messages.retain(|_, entry| entry.seen_at > cutoff);
422        before_count - self.seen_messages.len()
423    }
424
425    /// Gets the number of messages currently being tracked.
426    ///
427    /// Note: In bloom filter mode, this returns an estimate based on insertion count.
428    pub fn tracked_count(&self) -> usize {
429        if let Some(ref bloom) = self.bloom_filter {
430            bloom.total_items()
431        } else {
432            self.seen_messages.len()
433        }
434    }
435
436    /// Clears all tracked messages.
437    pub fn clear(&mut self) {
438        if let Some(ref mut bloom) = self.bloom_filter {
439            bloom.clear();
440        }
441        self.seen_messages.clear();
442    }
443
444    /// Checks if a message ID is being tracked (seen).
445    pub fn is_tracked(&self, message_id: &MessageId) -> bool {
446        self.is_duplicate(message_id)
447    }
448
449    /// Returns whether bloom filter mode is active.
450    pub fn is_bloom_filter_mode(&self) -> bool {
451        self.bloom_filter.is_some()
452    }
453
454    /// Gets statistics about the deduplicator.
455    pub fn stats(&self) -> DeduplicatorStats {
456        if let Some(ref bloom) = self.bloom_filter {
457            let total = bloom.total_items();
458            // Estimate "capacity" based on expected items before FP rate gets too high
459            let estimated_capacity = self.config.bloom_filter_bits / self.config.bloom_hash_count;
460            let capacity_percent =
461                ((total as f32 / estimated_capacity as f32) * 100.0).min(100.0) as u8;
462
463            DeduplicatorStats {
464                total_tracked: total,
465                recent_tracked: total, // In bloom mode, we can't distinguish recent
466                capacity_used_percent: capacity_percent,
467                false_positive_rate: Some(bloom.average_false_positive_rate()),
468                mode: DeduplicatorMode::BloomFilter,
469            }
470        } else {
471            let now = Utc::now();
472            let recent_cutoff = now - chrono::Duration::seconds(60);
473
474            let recent_count = self
475                .seen_messages
476                .values()
477                .filter(|entry| entry.seen_at > recent_cutoff)
478                .count();
479
480            DeduplicatorStats {
481                total_tracked: self.seen_messages.len(),
482                recent_tracked: recent_count,
483                capacity_used_percent: (self.seen_messages.len() as f32
484                    / self.config.max_tracked_messages as f32
485                    * 100.0) as u8,
486                false_positive_rate: None,
487                mode: DeduplicatorMode::HashMap,
488            }
489        }
490    }
491
492    /// Gets the estimated memory usage in bytes.
493    pub fn estimated_memory_bytes(&self) -> usize {
494        if self.bloom_filter.is_some() {
495            // Each filter uses bit_count/8 bytes, times number of filters
496            let filter_bytes = self.config.bloom_filter_bits.div_ceil(8);
497            filter_bytes * self.config.bloom_filter_count
498                + std::mem::size_of::<RotatingBloomFilter>()
499        } else {
500            // Rough estimate: each entry is ~50 bytes (String + DateTime)
501            self.seen_messages.len() * 50 + std::mem::size_of::<HashMap<String, SeenEntry>>()
502        }
503    }
504}
505
506impl Default for Deduplicator {
507    fn default() -> Self {
508        Self::new()
509    }
510}
511
512/// The mode of operation for the deduplicator.
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum DeduplicatorMode {
515    /// Using HashMap for exact tracking (original behavior).
516    HashMap,
517    /// Using bloom filters for O(1) memory.
518    BloomFilter,
519}
520
521/// Statistics about the deduplicator.
522#[derive(Debug, Clone)]
523pub struct DeduplicatorStats {
524    /// Total number of messages being tracked.
525    pub total_tracked: usize,
526    /// Number of messages seen in the last minute.
527    pub recent_tracked: usize,
528    /// Percentage of capacity used (0-100).
529    pub capacity_used_percent: u8,
530    /// Estimated false positive rate (only in bloom filter mode).
531    pub false_positive_rate: Option<f64>,
532    /// Current operating mode.
533    pub mode: DeduplicatorMode,
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use std::thread;
540    use std::time::Duration;
541
542    fn hashmap_config() -> DeduplicatorConfig {
543        DeduplicatorConfig {
544            use_bloom_filter: false,
545            ..Default::default()
546        }
547    }
548
549    #[test]
550    fn test_default_config_is_hashmap_and_capacity_1000() {
551        let config = DeduplicatorConfig::default();
552        assert!(
553            !config.use_bloom_filter,
554            "default should be exact-match HashMap to avoid false positives"
555        );
556        assert_eq!(config.max_tracked_messages, 1000);
557
558        let dedup = Deduplicator::new();
559        assert!(!dedup.is_bloom_filter_mode());
560        let stats = dedup.stats();
561        assert_eq!(stats.mode, DeduplicatorMode::HashMap);
562    }
563
564    #[test]
565    fn test_invalid_bloom_config_falls_back_without_panicking() {
566        let config = DeduplicatorConfig {
567            use_bloom_filter: true,
568            bloom_filter_bits: 0,
569            ..Default::default()
570        };
571
572        let mut dedup = Deduplicator::with_config(config);
573        let message_id = MessageId::new();
574
575        assert!(!dedup.is_bloom_filter_mode());
576        assert!(dedup.mark_seen(message_id.clone()));
577        assert!(dedup.is_duplicate(&message_id));
578    }
579
580    #[test]
581    fn test_default_hashmap_no_false_positives() {
582        let mut dedup = Deduplicator::new();
583        let seen = MessageId::new();
584        dedup.mark_seen(seen.clone());
585
586        // Brand-new ID must never be reported as duplicate (HashMap is exact match)
587        for _ in 0..20 {
588            let fresh = MessageId::new();
589            assert!(
590                !dedup.is_duplicate(&fresh),
591                "fresh message ID must not be false positive"
592            );
593        }
594        assert!(dedup.is_duplicate(&seen));
595    }
596
597    #[test]
598    fn test_basic_deduplication() {
599        let mut dedup = Deduplicator::new();
600        let msg_id = MessageId::new();
601
602        // First time should not be duplicate
603        assert!(!dedup.is_duplicate(&msg_id));
604        assert!(dedup.mark_seen(msg_id.clone()));
605
606        // Second time should be duplicate
607        assert!(dedup.is_duplicate(&msg_id));
608        assert!(!dedup.mark_seen(msg_id.clone()));
609    }
610
611    #[test]
612    fn test_unmark_seen_hashmap_mode() {
613        let mut dedup = Deduplicator::with_config(hashmap_config());
614        let msg_id = MessageId::new();
615
616        dedup.mark_seen(msg_id.clone());
617        assert!(dedup.is_duplicate(&msg_id));
618
619        assert!(dedup.unmark_seen(&msg_id), "tracked id must be removed");
620        assert!(
621            !dedup.is_duplicate(&msg_id),
622            "unmarked message must be processed fresh on replay"
623        );
624        assert!(
625            !dedup.unmark_seen(&msg_id),
626            "second unmark of the same id is a no-op"
627        );
628    }
629
630    #[test]
631    fn test_unmark_seen_noop_in_bloom_mode() {
632        let config = DeduplicatorConfig {
633            use_bloom_filter: true,
634            bloom_filter_bits: 1 << 16,
635            bloom_hash_count: 5,
636            bloom_filter_count: 2,
637            bloom_rotation_secs: 60,
638            ..Default::default()
639        };
640        let mut dedup = Deduplicator::with_config(config);
641        let msg_id = MessageId::new();
642
643        dedup.mark_seen(msg_id.clone());
644        assert!(
645            !dedup.unmark_seen(&msg_id),
646            "bloom filters cannot remove entries"
647        );
648        assert!(
649            dedup.is_duplicate(&msg_id),
650            "bloom mode keeps the id until its window rotates"
651        );
652    }
653
654    #[test]
655    fn test_basic_deduplication_hashmap_mode() {
656        let mut dedup = Deduplicator::with_config(hashmap_config());
657        let msg_id = MessageId::new();
658
659        assert!(!dedup.is_duplicate(&msg_id));
660        assert!(dedup.mark_seen(msg_id.clone()));
661
662        assert!(dedup.is_duplicate(&msg_id));
663        assert!(!dedup.mark_seen(msg_id.clone()));
664    }
665
666    #[test]
667    fn test_multiple_messages() {
668        let mut dedup = Deduplicator::new();
669
670        let msg1 = MessageId::new();
671        let msg2 = MessageId::new();
672        let msg3 = MessageId::new();
673
674        assert!(dedup.mark_seen(msg1.clone()));
675        assert!(dedup.mark_seen(msg2.clone()));
676        assert!(dedup.mark_seen(msg3.clone()));
677
678        assert_eq!(dedup.tracked_count(), 3);
679
680        assert!(dedup.is_duplicate(&msg1));
681        assert!(dedup.is_duplicate(&msg2));
682        assert!(dedup.is_duplicate(&msg3));
683    }
684
685    #[test]
686    fn test_capacity_limit() {
687        // HashMap mode required for FIFO eviction
688        let config = DeduplicatorConfig {
689            max_tracked_messages: 3,
690            retention_time_secs: 3600,
691            use_bloom_filter: false,
692            ..Default::default()
693        };
694        let mut dedup = Deduplicator::with_config(config);
695
696        let msg1 = MessageId::new();
697        let msg2 = MessageId::new();
698        let msg3 = MessageId::new();
699        let msg4 = MessageId::new();
700
701        dedup.mark_seen(msg1.clone());
702        dedup.mark_seen(msg2.clone());
703        dedup.mark_seen(msg3.clone());
704
705        assert_eq!(dedup.tracked_count(), 3);
706
707        // Adding fourth message should evict oldest
708        dedup.mark_seen(msg4.clone());
709        assert_eq!(dedup.tracked_count(), 3);
710
711        // msg1 should have been evicted
712        assert!(!dedup.is_duplicate(&msg1));
713        assert!(dedup.is_duplicate(&msg4));
714    }
715
716    #[test]
717    fn test_cleanup_expired() {
718        // HashMap mode required for explicit cleanup
719        let config = DeduplicatorConfig {
720            max_tracked_messages: 100,
721            retention_time_secs: 1,
722            use_bloom_filter: false,
723            ..Default::default()
724        };
725        let mut dedup = Deduplicator::with_config(config);
726
727        let msg1 = MessageId::new();
728        let msg2 = MessageId::new();
729
730        dedup.mark_seen(msg1.clone());
731
732        thread::sleep(Duration::from_millis(500));
733
734        dedup.mark_seen(msg2.clone());
735
736        thread::sleep(Duration::from_millis(600));
737
738        let removed = dedup.cleanup_expired();
739        assert_eq!(removed, 1);
740        assert_eq!(dedup.tracked_count(), 1);
741
742        assert!(!dedup.is_duplicate(&msg1));
743        assert!(dedup.is_duplicate(&msg2));
744    }
745
746    #[test]
747    fn test_clear() {
748        let mut dedup = Deduplicator::new();
749
750        dedup.mark_seen(MessageId::new());
751        dedup.mark_seen(MessageId::new());
752        dedup.mark_seen(MessageId::new());
753
754        assert_eq!(dedup.tracked_count(), 3);
755
756        dedup.clear();
757        assert_eq!(dedup.tracked_count(), 0);
758    }
759
760    #[test]
761    fn test_is_tracked() {
762        let mut dedup = Deduplicator::new();
763        let msg_id = MessageId::new();
764
765        assert!(!dedup.is_tracked(&msg_id));
766
767        dedup.mark_seen(msg_id.clone());
768        assert!(dedup.is_tracked(&msg_id));
769    }
770
771    #[test]
772    fn test_stats() {
773        let mut dedup = Deduplicator::new();
774
775        for _ in 0..5 {
776            dedup.mark_seen(MessageId::new());
777        }
778
779        let stats = dedup.stats();
780        assert_eq!(stats.total_tracked, 5);
781    }
782
783    #[test]
784    fn test_stats_hashmap_mode() {
785        let mut dedup = Deduplicator::with_config(hashmap_config());
786
787        for _ in 0..5 {
788            dedup.mark_seen(MessageId::new());
789        }
790
791        let stats = dedup.stats();
792        assert_eq!(stats.total_tracked, 5);
793        assert_eq!(stats.recent_tracked, 5);
794        assert!(stats.capacity_used_percent < 1);
795        assert_eq!(stats.mode, DeduplicatorMode::HashMap);
796    }
797
798    #[test]
799    fn test_capacity_percent() {
800        let config = DeduplicatorConfig {
801            max_tracked_messages: 10,
802            retention_time_secs: 3600,
803            use_bloom_filter: false,
804            ..Default::default()
805        };
806        let mut dedup = Deduplicator::with_config(config);
807
808        for _ in 0..5 {
809            dedup.mark_seen(MessageId::new());
810        }
811
812        let stats = dedup.stats();
813        assert_eq!(stats.capacity_used_percent, 50);
814    }
815
816    #[test]
817    fn test_bloom_filter_mode() {
818        let config = DeduplicatorConfig {
819            use_bloom_filter: true,
820            bloom_filter_bits: 1 << 16, // Smaller for testing
821            bloom_hash_count: 5,
822            bloom_filter_count: 2,
823            bloom_rotation_secs: 60,
824            ..Default::default()
825        };
826        let mut dedup = Deduplicator::with_config(config);
827
828        assert!(dedup.is_bloom_filter_mode());
829
830        let msg1 = MessageId::new();
831        let msg2 = MessageId::new();
832
833        assert!(!dedup.is_duplicate(&msg1));
834        dedup.mark_seen(msg1.clone());
835        assert!(dedup.is_duplicate(&msg1));
836
837        assert!(!dedup.is_duplicate(&msg2));
838        dedup.mark_seen(msg2.clone());
839        assert!(dedup.is_duplicate(&msg2));
840
841        let stats = dedup.stats();
842        assert_eq!(stats.mode, DeduplicatorMode::BloomFilter);
843        assert!(stats.false_positive_rate.is_some());
844    }
845
846    #[test]
847    fn test_bloom_filter_constant_memory() {
848        let config = DeduplicatorConfig {
849            use_bloom_filter: true,
850            bloom_filter_bits: 1 << 16,
851            bloom_hash_count: 5,
852            bloom_filter_count: 2,
853            bloom_rotation_secs: 3600,
854            ..Default::default()
855        };
856        let dedup = Deduplicator::with_config(config);
857
858        let initial_memory = dedup.estimated_memory_bytes();
859
860        // Memory should be roughly constant regardless of items added
861        // (Unlike HashMap which grows with items)
862        let expected_filter_bytes = (1 << 16) / 8 * 2; // 2 filters, 64KB each
863        assert!(initial_memory >= expected_filter_bytes);
864        assert!(initial_memory < expected_filter_bytes * 2); // Some overhead
865    }
866
867    #[test]
868    fn test_bloom_filter_high_volume() {
869        let config = DeduplicatorConfig {
870            use_bloom_filter: true,
871            bloom_filter_bits: 1 << 20, // 1MB
872            bloom_hash_count: 7,
873            bloom_filter_count: 4,
874            bloom_rotation_secs: 3600,
875            ..Default::default()
876        };
877        let mut dedup = Deduplicator::with_config(config);
878
879        // Insert 10,000 messages - should work with O(1) memory. The filter
880        // is probabilistic: a fresh id can (rarely) be reported as already
881        // seen, so tolerate a handful of false positives rather than
882        // asserting on every insert.
883        let mut inserted = 0usize;
884        for _ in 0..10_000 {
885            let msg = MessageId::new();
886            if dedup.mark_seen(msg) {
887                inserted += 1;
888            }
889        }
890
891        assert!(
892            inserted >= 9_990,
893            "too many bloom false positives: {}",
894            10_000 - inserted
895        );
896        assert_eq!(dedup.tracked_count(), inserted);
897
898        let stats = dedup.stats();
899        // False positive rate should still be low
900        assert!(stats.false_positive_rate.unwrap() < 0.02);
901    }
902
903    #[test]
904    fn test_lru_eviction_preserves_recently_accessed() {
905        let config = DeduplicatorConfig {
906            max_tracked_messages: 3,
907            retention_time_secs: 3600,
908            use_bloom_filter: false,
909            ..Default::default()
910        };
911        let mut dedup = Deduplicator::with_config(config);
912
913        let msg_a = MessageId::new();
914        let msg_b = MessageId::new();
915        let msg_c = MessageId::new();
916
917        dedup.mark_seen(msg_a.clone());
918        thread::sleep(Duration::from_millis(10));
919        dedup.mark_seen(msg_b.clone());
920        thread::sleep(Duration::from_millis(10));
921        dedup.mark_seen(msg_c.clone());
922
923        // Re-access A so it becomes the most recently accessed
924        assert!(dedup.is_duplicate_mut(&msg_a));
925
926        // Insert D — should evict B (least recently accessed), not A
927        let msg_d = MessageId::new();
928        dedup.mark_seen(msg_d.clone());
929
930        assert_eq!(dedup.tracked_count(), 3);
931        assert!(
932            dedup.is_duplicate(&msg_a),
933            "A was recently accessed, should survive"
934        );
935        assert!(
936            !dedup.is_duplicate(&msg_b),
937            "B should have been evicted (LRU)"
938        );
939        assert!(dedup.is_duplicate(&msg_c), "C should survive");
940        assert!(dedup.is_duplicate(&msg_d), "D was just inserted");
941    }
942}