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 serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15/// Configuration for deduplication.
16#[derive(Debug, Clone)]
17pub struct DeduplicatorConfig {
18    /// Maximum number of message IDs to track (used for HashMap fallback).
19    pub max_tracked_messages: usize,
20
21    /// Time to keep message IDs in memory (seconds).
22    pub retention_time_secs: u64,
23
24    /// Enable bloom filter mode for scalable deduplication.
25    pub use_bloom_filter: bool,
26
27    /// Number of bits in each bloom filter (default: 2^20 = ~1MB per filter).
28    pub bloom_filter_bits: usize,
29
30    /// Number of hash functions for bloom filter (default: 7 for ~1% false positive).
31    pub bloom_hash_count: usize,
32
33    /// Number of rotating bloom filters (default: 4 for hourly windows).
34    pub bloom_filter_count: usize,
35
36    /// Rotation interval in seconds (default: 900 = 15 minutes).
37    pub bloom_rotation_secs: u64,
38}
39
40impl Default for DeduplicatorConfig {
41    fn default() -> Self {
42        Self {
43            // Exact-match mode by default: no false positives (Bloom can drop ~1% of legitimate messages).
44            //
45            // Sized for the seen set to be persisted across restarts
46            // (`export_seen`/`import_seen`): a message can reach a receiver
47            // twice on two paths — a push-injected copy and the relay socket
48            // copy after a reconnect, hours apart if the app was closed in
49            // between — and the second copy is only recognisable as a
50            // duplicate while its id is still tracked. An hour did not cover
51            // "reopened the next day"; a day does, and 2000 ids at ~50 bytes
52            // each is a 100 KB ceiling.
53            max_tracked_messages: 2000,
54            retention_time_secs: 86_400, // 24 hours
55            use_bloom_filter: false,
56            bloom_filter_bits: 1 << 20, // ~1MB per filter (1,048,576 bits)
57            bloom_hash_count: 7,        // ~1% false positive rate when Bloom is enabled
58            bloom_filter_count: 4,      // 4 windows for 1-hour retention
59            bloom_rotation_secs: 900,   // 15 minutes per window
60        }
61    }
62}
63
64/// A simple bloom filter implementation.
65#[derive(Clone)]
66struct BloomFilter {
67    bits: Vec<u64>,
68    bit_count: usize,
69    hash_count: usize,
70    items_added: usize,
71}
72
73impl BloomFilter {
74    fn new(bit_count: usize, hash_count: usize) -> Self {
75        let word_count = bit_count.div_ceil(64);
76        Self {
77            bits: vec![0u64; word_count],
78            bit_count,
79            hash_count,
80            items_added: 0,
81        }
82    }
83
84    /// Computes multiple hash values for an item using double hashing.
85    fn hashes(&self, item: &str) -> Vec<usize> {
86        let mut hashes = Vec::with_capacity(self.hash_count);
87
88        // First hash using FNV-1a
89        let h1 = fnv1a_hash(item.as_bytes());
90        // Second hash using a different seed, forced odd: with a
91        // power-of-two bit_count an even h2 collapses the probe indices
92        // onto few bits (all onto one when h2 ≡ 0 mod bit_count), inflating
93        // the false-positive rate; an odd step keeps all probes distinct.
94        let h2 = fnv1a_hash_seeded(item.as_bytes(), 0x5f356495) | 1;
95
96        for i in 0..self.hash_count {
97            // Kirsch-Mitzenmacher optimization: h(i) = h1 + i * h2
98            let combined = h1.wrapping_add((i as u64).wrapping_mul(h2));
99            hashes.push((combined as usize) % self.bit_count);
100        }
101
102        hashes
103    }
104
105    fn insert(&mut self, item: &str) {
106        for bit_index in self.hashes(item) {
107            let word_index = bit_index / 64;
108            let bit_offset = bit_index % 64;
109            self.bits[word_index] |= 1u64 << bit_offset;
110        }
111        self.items_added += 1;
112    }
113
114    fn contains(&self, item: &str) -> bool {
115        for bit_index in self.hashes(item) {
116            let word_index = bit_index / 64;
117            let bit_offset = bit_index % 64;
118            if (self.bits[word_index] & (1u64 << bit_offset)) == 0 {
119                return false;
120            }
121        }
122        true
123    }
124
125    fn clear(&mut self) {
126        self.bits.fill(0);
127        self.items_added = 0;
128    }
129
130    fn items_count(&self) -> usize {
131        self.items_added
132    }
133
134    /// Estimates the current false positive probability.
135    fn estimated_false_positive_rate(&self) -> f64 {
136        if self.items_added == 0 {
137            return 0.0;
138        }
139        let m = self.bit_count as f64;
140        let n = self.items_added as f64;
141        let k = self.hash_count as f64;
142        (1.0 - (-k * n / m).exp()).powf(k)
143    }
144}
145
146/// FNV-1a hash function.
147fn fnv1a_hash(data: &[u8]) -> u64 {
148    fnv1a_hash_seeded(data, 0xcbf29ce484222325)
149}
150
151/// FNV-1a hash with custom seed.
152fn fnv1a_hash_seeded(data: &[u8], seed: u64) -> u64 {
153    const FNV_PRIME: u64 = 0x100000001b3;
154    let mut hash = seed;
155    for byte in data {
156        hash ^= *byte as u64;
157        hash = hash.wrapping_mul(FNV_PRIME);
158    }
159    hash
160}
161
162/// A rotating bloom filter with time windows.
163struct RotatingBloomFilter {
164    filters: Vec<BloomFilter>,
165    current_index: usize,
166    last_rotation: DateTime<Utc>,
167    rotation_interval_secs: u64,
168}
169
170impl RotatingBloomFilter {
171    fn new(filter_count: usize, bit_count: usize, hash_count: usize, rotation_secs: u64) -> Self {
172        let filters = (0..filter_count)
173            .map(|_| BloomFilter::new(bit_count, hash_count))
174            .collect();
175        Self {
176            filters,
177            current_index: 0,
178            last_rotation: Utc::now(),
179            rotation_interval_secs: rotation_secs,
180        }
181    }
182
183    fn maybe_rotate(&mut self, now: DateTime<Utc>) {
184        let elapsed = now.signed_duration_since(self.last_rotation);
185        if elapsed.num_seconds() >= self.rotation_interval_secs as i64 {
186            // Move to next filter and clear it
187            self.current_index = (self.current_index + 1) % self.filters.len();
188            self.filters[self.current_index].clear();
189            self.last_rotation = now;
190        }
191    }
192
193    fn insert(&mut self, item: &str) {
194        self.maybe_rotate(Utc::now());
195        self.filters[self.current_index].insert(item);
196    }
197
198    fn contains(&mut self, item: &str) -> bool {
199        self.maybe_rotate(Utc::now());
200        // Check all filters (current and previous windows)
201        self.filters.iter().any(|f| f.contains(item))
202    }
203
204    fn total_items(&self) -> usize {
205        self.filters.iter().map(|f| f.items_count()).sum()
206    }
207
208    fn clear(&mut self) {
209        for filter in &mut self.filters {
210            filter.clear();
211        }
212        self.current_index = 0;
213        self.last_rotation = Utc::now();
214    }
215
216    fn average_false_positive_rate(&self) -> f64 {
217        let rates: Vec<f64> = self
218            .filters
219            .iter()
220            .map(|f| f.estimated_false_positive_rate())
221            .collect();
222        if rates.is_empty() {
223            return 0.0;
224        }
225        rates.iter().sum::<f64>() / rates.len() as f64
226    }
227}
228
229/// One id of the exact-mode seen set, as exported for persistence and
230/// imported on the next launch. `seen_at_ms` is wall-clock milliseconds so
231/// the retention window can be re-applied on import.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct SeenId {
234    /// The message id.
235    pub id: String,
236    /// When the id was first seen, unix milliseconds.
237    pub seen_at_ms: i64,
238}
239
240/// Entry for a seen message (used in HashMap mode).
241#[derive(Debug, Clone)]
242struct SeenEntry {
243    /// When this message was first seen.
244    seen_at: DateTime<Utc>,
245    /// When this entry was last accessed (for LRU eviction).
246    last_accessed: DateTime<Utc>,
247    /// Whether [`Deduplicator::export_seen`] includes this id. `false` for
248    /// an id tracked with [`Deduplicator::mark_seen_local`]: a sender's own
249    /// outgoing frame, which dedup only has to recognise while this process
250    /// lives.
251    exportable: bool,
252}
253
254/// Deduplicator for tracking seen messages and preventing duplicates.
255///
256/// Supports two modes:
257/// - HashMap mode: exact tracking with FIFO eviction (original behavior)
258/// - Bloom filter mode: O(1) memory with probabilistic duplicate detection
259pub struct Deduplicator {
260    config: DeduplicatorConfig,
261    /// HashMap for exact tracking (fallback mode).
262    seen_messages: HashMap<String, SeenEntry>,
263    /// Rotating bloom filter for scalable tracking.
264    bloom_filter: Option<RotatingBloomFilter>,
265}
266
267impl Deduplicator {
268    /// Creates a new deduplicator with default configuration.
269    pub fn new() -> Self {
270        Self::with_config(DeduplicatorConfig::default())
271    }
272
273    /// Creates a new deduplicator with custom configuration.
274    pub fn with_config(config: DeduplicatorConfig) -> Self {
275        // Keep the low-level constructor panic-free for direct users of this
276        // crate. The top-level ProtocolConfig rejects these invalid values;
277        // here we fail safe to exact HashMap mode instead of constructing a
278        // Bloom filter that would divide by zero or rotate an empty list.
279        let valid_bloom_config = config.bloom_filter_bits > 0
280            && config.bloom_hash_count > 0
281            && config.bloom_filter_count > 0
282            && config.bloom_rotation_secs > 0;
283        let bloom_filter = if config.use_bloom_filter && valid_bloom_config {
284            Some(RotatingBloomFilter::new(
285                config.bloom_filter_count,
286                config.bloom_filter_bits,
287                config.bloom_hash_count,
288                config.bloom_rotation_secs,
289            ))
290        } else {
291            None
292        };
293
294        Self {
295            config,
296            seen_messages: HashMap::new(),
297            bloom_filter,
298        }
299    }
300
301    /// Applies a new configuration in place, carrying the tracked set across.
302    ///
303    /// Rebuilding from scratch on a runtime configuration change forgets every
304    /// id held, which was equivalent to a restart while the set lived in memory
305    /// only and is a loss now that it is persisted: the ids restored at launch
306    /// would be dropped, and the next export would overwrite the record with a
307    /// near-empty set — so the restart-time duplicate the record exists to
308    /// recognise would be processed after all. The exact-mode entries therefore
309    /// survive, re-bounded by the new configuration: ids past the new retention
310    /// window are dropped, and past the new cap the newest are kept, with each
311    /// entry's exportable flag intact so a carried-over
312    /// [`Self::mark_seen_local`] id still stays out of [`Self::export_seen`].
313    ///
314    /// A change of mode carries nothing: a bloom filter has no ids to hand
315    /// over and cannot take ids in.
316    pub fn reconfigure(&mut self, config: DeduplicatorConfig) {
317        let previous = std::mem::take(&mut self.seen_messages);
318        let was_exact = self.bloom_filter.is_none();
319        *self = Self::with_config(config);
320        if !was_exact || self.bloom_filter.is_some() {
321            return;
322        }
323        let retention = chrono::Duration::seconds(self.config.retention_time_secs as i64);
324        let cutoff = Utc::now() - retention;
325        let mut entries: Vec<(String, SeenEntry)> = previous
326            .into_iter()
327            .filter(|(_, entry)| entry.seen_at > cutoff)
328            .collect();
329        entries.sort_by(|left, right| {
330            right
331                .1
332                .seen_at
333                .cmp(&left.1.seen_at)
334                .then_with(|| left.0.cmp(&right.0))
335        });
336        entries.truncate(self.config.max_tracked_messages);
337        self.seen_messages = entries.into_iter().collect();
338    }
339
340    /// Returns `true` when deduplication is exact (HashMap mode): a positive
341    /// [`Self::is_duplicate`] answer is then authoritative. In bloom mode
342    /// (`false`) a positive answer may be a false positive (~1% with default
343    /// settings), so callers must not treat a hit as proof of a prior send.
344    pub fn is_exact(&self) -> bool {
345        self.bloom_filter.is_none()
346    }
347
348    /// Checks if a message has been seen before.
349    ///
350    /// # Arguments
351    ///
352    /// * `message_id` - The message ID to check
353    ///
354    /// # Returns
355    ///
356    /// Returns `true` if the message has been seen, `false` otherwise.
357    ///
358    /// Note: In bloom filter mode, this may return false positives (claiming a
359    /// message is a duplicate when it isn't). The false positive rate is typically
360    /// below 1% with default settings.
361    pub fn is_duplicate(&self, message_id: &MessageId) -> bool {
362        let msg_id_str = message_id.as_str();
363
364        if let Some(ref bloom) = self.bloom_filter {
365            // Note: We need mutable access for rotation check, but this is safe
366            // because we're only reading. We'll handle rotation in mark_seen.
367            // For now, just check all filters.
368            bloom.filters.iter().any(|f| f.contains(&msg_id_str))
369        } else {
370            self.seen_messages.contains_key(&msg_id_str)
371        }
372    }
373
374    /// Checks if a message has been seen before (mutable version for bloom rotation
375    /// and LRU access tracking).
376    pub fn is_duplicate_mut(&mut self, message_id: &MessageId) -> bool {
377        let msg_id_str = message_id.as_str();
378
379        if let Some(ref mut bloom) = self.bloom_filter {
380            bloom.contains(&msg_id_str)
381        } else if let Some(entry) = self.seen_messages.get_mut(&msg_id_str) {
382            entry.last_accessed = Utc::now();
383            true
384        } else {
385            false
386        }
387    }
388
389    /// Marks a message as seen.
390    ///
391    /// # Arguments
392    ///
393    /// * `message_id` - The message ID to mark as seen
394    ///
395    /// # Returns
396    ///
397    /// Returns `true` if this is a new message, `false` if it was already seen.
398    pub fn mark_seen(&mut self, message_id: MessageId) -> bool {
399        self.mark_seen_with(message_id, true)
400    }
401
402    /// Marks a message as seen for the life of this process only.
403    ///
404    /// Identical to [`Self::mark_seen`] for [`Self::is_duplicate`], eviction
405    /// and retention, but the id is left out of [`Self::export_seen`]. For a
406    /// sender marking its own outgoing frame: the mark exists to stop a
407    /// relayed echo of that frame re-entering the receive path, which a
408    /// restart does not change, and exporting it would spend the persisted
409    /// record's cap on ids no peer will ever push at this node. A later
410    /// [`Self::mark_seen`] of an id already tracked here is a no-op and does
411    /// not make it exportable.
412    ///
413    /// # Returns
414    ///
415    /// Returns `true` if the message was newly marked, `false` if it was
416    /// already tracked.
417    pub fn mark_seen_local(&mut self, message_id: MessageId) -> bool {
418        self.mark_seen_with(message_id, false)
419    }
420
421    fn mark_seen_with(&mut self, message_id: MessageId, exportable: bool) -> bool {
422        let msg_id_str = message_id.as_str();
423
424        if let Some(ref mut bloom) = self.bloom_filter {
425            // Check if already seen
426            if bloom.contains(&msg_id_str) {
427                return false;
428            }
429            // Add to bloom filter
430            bloom.insert(&msg_id_str);
431            true
432        } else {
433            // HashMap mode (original implementation)
434            if self.seen_messages.contains_key(&msg_id_str) {
435                return false;
436            }
437
438            // Check if we've hit the limit
439            if self.seen_messages.len() >= self.config.max_tracked_messages {
440                self.cleanup_expired();
441
442                if self.seen_messages.len() >= self.config.max_tracked_messages {
443                    // LRU eviction: evict the least recently accessed entry
444                    if let Some((lru_id, _)) = self
445                        .seen_messages
446                        .iter()
447                        .min_by_key(|(_, entry)| entry.last_accessed)
448                        .map(|(id, entry)| (id.clone(), entry.clone()))
449                    {
450                        self.seen_messages.remove(&lru_id);
451                    }
452                }
453            }
454
455            let now = Utc::now();
456            self.seen_messages.insert(
457                msg_id_str,
458                SeenEntry {
459                    seen_at: now,
460                    last_accessed: now,
461                    exportable,
462                },
463            );
464
465            true
466        }
467    }
468
469    /// Removes a message from the seen set so a future copy is processed
470    /// fresh instead of being treated as a duplicate.
471    ///
472    /// Intended for receivers that reject a message *after* marking it seen
473    /// (e.g. an encryption-policy or security-gate rejection): forgetting the
474    /// id makes a replay re-enter the same gate — and be rejected again
475    /// without a delivery ACK — instead of hitting the duplicate re-ACK path,
476    /// which would confirm to an injector that the target is online.
477    ///
478    /// # Returns
479    ///
480    /// Returns `true` if the message was tracked and has been removed.
481    /// Bloom filters do not support removal, so in bloom filter mode this is
482    /// a no-op returning `false`: a replayed rejected message is treated as a
483    /// duplicate until its filter window rotates out.
484    pub fn unmark_seen(&mut self, message_id: &MessageId) -> bool {
485        if self.bloom_filter.is_some() {
486            return false;
487        }
488        self.seen_messages.remove(&message_id.as_str()).is_some()
489    }
490
491    /// Exports up to `max` tracked ids, newest first, for persistence.
492    ///
493    /// Exact (HashMap) mode only: a bloom filter has no ids to export, so
494    /// bloom mode returns an empty vector and the persisted set simply stays
495    /// empty. Newest first so that a cap smaller than the tracked set keeps
496    /// the ids most likely to be replayed — the recent ones. Ids tracked with
497    /// [`Self::mark_seen_local`] are left out.
498    pub fn export_seen(&self, max: usize) -> Vec<SeenId> {
499        if self.bloom_filter.is_some() {
500            return Vec::new();
501        }
502        let mut entries: Vec<SeenId> = self
503            .seen_messages
504            .iter()
505            .filter(|(_, entry)| entry.exportable)
506            .map(|(id, entry)| SeenId {
507                id: id.clone(),
508                seen_at_ms: entry.seen_at.timestamp_millis(),
509            })
510            .collect();
511        entries.sort_by(|left, right| {
512            right
513                .seen_at_ms
514                .cmp(&left.seen_at_ms)
515                .then_with(|| left.id.cmp(&right.id))
516        });
517        entries.truncate(max);
518        entries
519    }
520
521    /// Re-admits ids exported by [`Self::export_seen`] on a previous run.
522    ///
523    /// Skips ids already tracked (the live set wins), ids whose retention
524    /// window has closed as of `now`, and stops at `max_tracked_messages` so
525    /// a restore can never push the set over its cap or evict live entries.
526    /// Entries are admitted in the order given, so a caller that wants the
527    /// newest to survive a tight cap passes them newest first — which is the
528    /// order `export_seen` produces. Returns how many were admitted. A no-op
529    /// in bloom mode, which has nothing to import into.
530    pub fn import_seen(&mut self, entries: Vec<SeenId>, now: DateTime<Utc>) -> usize {
531        if self.bloom_filter.is_some() {
532            return 0;
533        }
534        let retention = chrono::Duration::seconds(self.config.retention_time_secs as i64);
535        let cutoff = now - retention;
536        let mut imported = 0;
537        for entry in entries {
538            if self.seen_messages.len() >= self.config.max_tracked_messages {
539                break;
540            }
541            let Some(seen_at) = DateTime::<Utc>::from_timestamp_millis(entry.seen_at_ms) else {
542                continue;
543            };
544            if seen_at <= cutoff || self.seen_messages.contains_key(&entry.id) {
545                continue;
546            }
547            self.seen_messages.insert(
548                entry.id,
549                SeenEntry {
550                    seen_at,
551                    last_accessed: seen_at,
552                    exportable: true,
553                },
554            );
555            imported += 1;
556        }
557        imported
558    }
559
560    /// Removes expired entries that exceed the retention time.
561    ///
562    /// # Returns
563    ///
564    /// Returns the number of entries removed.
565    ///
566    /// Note: In bloom filter mode, expiration is handled automatically through
567    /// filter rotation, so this returns 0.
568    pub fn cleanup_expired(&mut self) -> usize {
569        if self.bloom_filter.is_some() {
570            // Bloom filter handles expiration through rotation
571            return 0;
572        }
573
574        let now = Utc::now();
575        let retention = chrono::Duration::seconds(self.config.retention_time_secs as i64);
576        let cutoff = now - retention;
577
578        let before_count = self.seen_messages.len();
579        self.seen_messages.retain(|_, entry| entry.seen_at > cutoff);
580        before_count - self.seen_messages.len()
581    }
582
583    /// Gets the number of messages currently being tracked.
584    ///
585    /// Note: In bloom filter mode, this returns an estimate based on insertion count.
586    pub fn tracked_count(&self) -> usize {
587        if let Some(ref bloom) = self.bloom_filter {
588            bloom.total_items()
589        } else {
590            self.seen_messages.len()
591        }
592    }
593
594    /// Clears all tracked messages.
595    pub fn clear(&mut self) {
596        if let Some(ref mut bloom) = self.bloom_filter {
597            bloom.clear();
598        }
599        self.seen_messages.clear();
600    }
601
602    /// Checks if a message ID is being tracked (seen).
603    pub fn is_tracked(&self, message_id: &MessageId) -> bool {
604        self.is_duplicate(message_id)
605    }
606
607    /// Returns whether bloom filter mode is active.
608    pub fn is_bloom_filter_mode(&self) -> bool {
609        self.bloom_filter.is_some()
610    }
611
612    /// Gets statistics about the deduplicator.
613    pub fn stats(&self) -> DeduplicatorStats {
614        if let Some(ref bloom) = self.bloom_filter {
615            let total = bloom.total_items();
616            // Estimate "capacity" based on expected items before FP rate gets too high
617            let estimated_capacity = self.config.bloom_filter_bits / self.config.bloom_hash_count;
618            let capacity_percent =
619                ((total as f32 / estimated_capacity as f32) * 100.0).min(100.0) as u8;
620
621            DeduplicatorStats {
622                total_tracked: total,
623                recent_tracked: total, // In bloom mode, we can't distinguish recent
624                capacity_used_percent: capacity_percent,
625                false_positive_rate: Some(bloom.average_false_positive_rate()),
626                mode: DeduplicatorMode::BloomFilter,
627            }
628        } else {
629            let now = Utc::now();
630            let recent_cutoff = now - chrono::Duration::seconds(60);
631
632            let recent_count = self
633                .seen_messages
634                .values()
635                .filter(|entry| entry.seen_at > recent_cutoff)
636                .count();
637
638            DeduplicatorStats {
639                total_tracked: self.seen_messages.len(),
640                recent_tracked: recent_count,
641                capacity_used_percent: (self.seen_messages.len() as f32
642                    / self.config.max_tracked_messages as f32
643                    * 100.0) as u8,
644                false_positive_rate: None,
645                mode: DeduplicatorMode::HashMap,
646            }
647        }
648    }
649
650    /// Gets the estimated memory usage in bytes.
651    pub fn estimated_memory_bytes(&self) -> usize {
652        if self.bloom_filter.is_some() {
653            // Each filter uses bit_count/8 bytes, times number of filters
654            let filter_bytes = self.config.bloom_filter_bits.div_ceil(8);
655            filter_bytes * self.config.bloom_filter_count
656                + std::mem::size_of::<RotatingBloomFilter>()
657        } else {
658            // Rough estimate: each entry is ~50 bytes (String + DateTime)
659            self.seen_messages.len() * 50 + std::mem::size_of::<HashMap<String, SeenEntry>>()
660        }
661    }
662}
663
664impl Default for Deduplicator {
665    fn default() -> Self {
666        Self::new()
667    }
668}
669
670/// The mode of operation for the deduplicator.
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672pub enum DeduplicatorMode {
673    /// Using HashMap for exact tracking (original behavior).
674    HashMap,
675    /// Using bloom filters for O(1) memory.
676    BloomFilter,
677}
678
679/// Statistics about the deduplicator.
680#[derive(Debug, Clone)]
681pub struct DeduplicatorStats {
682    /// Total number of messages being tracked.
683    pub total_tracked: usize,
684    /// Number of messages seen in the last minute.
685    pub recent_tracked: usize,
686    /// Percentage of capacity used (0-100).
687    pub capacity_used_percent: u8,
688    /// Estimated false positive rate (only in bloom filter mode).
689    pub false_positive_rate: Option<f64>,
690    /// Current operating mode.
691    pub mode: DeduplicatorMode,
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use std::thread;
698    use std::time::Duration;
699
700    fn hashmap_config() -> DeduplicatorConfig {
701        DeduplicatorConfig {
702            use_bloom_filter: false,
703            ..Default::default()
704        }
705    }
706
707    #[test]
708    fn test_default_config_is_hashmap_and_capacity_2000() {
709        let config = DeduplicatorConfig::default();
710        assert!(
711            !config.use_bloom_filter,
712            "default should be exact-match HashMap to avoid false positives"
713        );
714        assert_eq!(config.max_tracked_messages, 2000);
715        assert_eq!(config.retention_time_secs, 86_400);
716
717        let dedup = Deduplicator::new();
718        assert!(!dedup.is_bloom_filter_mode());
719        let stats = dedup.stats();
720        assert_eq!(stats.mode, DeduplicatorMode::HashMap);
721    }
722
723    #[test]
724    fn test_invalid_bloom_config_falls_back_without_panicking() {
725        let config = DeduplicatorConfig {
726            use_bloom_filter: true,
727            bloom_filter_bits: 0,
728            ..Default::default()
729        };
730
731        let mut dedup = Deduplicator::with_config(config);
732        let message_id = MessageId::new();
733
734        assert!(!dedup.is_bloom_filter_mode());
735        assert!(dedup.mark_seen(message_id.clone()));
736        assert!(dedup.is_duplicate(&message_id));
737    }
738
739    #[test]
740    fn test_default_hashmap_no_false_positives() {
741        let mut dedup = Deduplicator::new();
742        let seen = MessageId::new();
743        dedup.mark_seen(seen.clone());
744
745        // Brand-new ID must never be reported as duplicate (HashMap is exact match)
746        for _ in 0..20 {
747            let fresh = MessageId::new();
748            assert!(
749                !dedup.is_duplicate(&fresh),
750                "fresh message ID must not be false positive"
751            );
752        }
753        assert!(dedup.is_duplicate(&seen));
754    }
755
756    #[test]
757    fn test_basic_deduplication() {
758        let mut dedup = Deduplicator::new();
759        let msg_id = MessageId::new();
760
761        // First time should not be duplicate
762        assert!(!dedup.is_duplicate(&msg_id));
763        assert!(dedup.mark_seen(msg_id.clone()));
764
765        // Second time should be duplicate
766        assert!(dedup.is_duplicate(&msg_id));
767        assert!(!dedup.mark_seen(msg_id.clone()));
768    }
769
770    #[test]
771    fn test_unmark_seen_hashmap_mode() {
772        let mut dedup = Deduplicator::with_config(hashmap_config());
773        let msg_id = MessageId::new();
774
775        dedup.mark_seen(msg_id.clone());
776        assert!(dedup.is_duplicate(&msg_id));
777
778        assert!(dedup.unmark_seen(&msg_id), "tracked id must be removed");
779        assert!(
780            !dedup.is_duplicate(&msg_id),
781            "unmarked message must be processed fresh on replay"
782        );
783        assert!(
784            !dedup.unmark_seen(&msg_id),
785            "second unmark of the same id is a no-op"
786        );
787    }
788
789    #[test]
790    fn test_unmark_seen_noop_in_bloom_mode() {
791        let config = DeduplicatorConfig {
792            use_bloom_filter: true,
793            bloom_filter_bits: 1 << 16,
794            bloom_hash_count: 5,
795            bloom_filter_count: 2,
796            bloom_rotation_secs: 60,
797            ..Default::default()
798        };
799        let mut dedup = Deduplicator::with_config(config);
800        let msg_id = MessageId::new();
801
802        dedup.mark_seen(msg_id.clone());
803        assert!(
804            !dedup.unmark_seen(&msg_id),
805            "bloom filters cannot remove entries"
806        );
807        assert!(
808            dedup.is_duplicate(&msg_id),
809            "bloom mode keeps the id until its window rotates"
810        );
811    }
812
813    #[test]
814    fn test_basic_deduplication_hashmap_mode() {
815        let mut dedup = Deduplicator::with_config(hashmap_config());
816        let msg_id = MessageId::new();
817
818        assert!(!dedup.is_duplicate(&msg_id));
819        assert!(dedup.mark_seen(msg_id.clone()));
820
821        assert!(dedup.is_duplicate(&msg_id));
822        assert!(!dedup.mark_seen(msg_id.clone()));
823    }
824
825    #[test]
826    fn test_multiple_messages() {
827        let mut dedup = Deduplicator::new();
828
829        let msg1 = MessageId::new();
830        let msg2 = MessageId::new();
831        let msg3 = MessageId::new();
832
833        assert!(dedup.mark_seen(msg1.clone()));
834        assert!(dedup.mark_seen(msg2.clone()));
835        assert!(dedup.mark_seen(msg3.clone()));
836
837        assert_eq!(dedup.tracked_count(), 3);
838
839        assert!(dedup.is_duplicate(&msg1));
840        assert!(dedup.is_duplicate(&msg2));
841        assert!(dedup.is_duplicate(&msg3));
842    }
843
844    #[test]
845    fn test_capacity_limit() {
846        // HashMap mode required for FIFO eviction
847        let config = DeduplicatorConfig {
848            max_tracked_messages: 3,
849            retention_time_secs: 3600,
850            use_bloom_filter: false,
851            ..Default::default()
852        };
853        let mut dedup = Deduplicator::with_config(config);
854
855        let msg1 = MessageId::new();
856        let msg2 = MessageId::new();
857        let msg3 = MessageId::new();
858        let msg4 = MessageId::new();
859
860        dedup.mark_seen(msg1.clone());
861        dedup.mark_seen(msg2.clone());
862        dedup.mark_seen(msg3.clone());
863
864        assert_eq!(dedup.tracked_count(), 3);
865
866        // Adding fourth message should evict oldest
867        dedup.mark_seen(msg4.clone());
868        assert_eq!(dedup.tracked_count(), 3);
869
870        // msg1 should have been evicted
871        assert!(!dedup.is_duplicate(&msg1));
872        assert!(dedup.is_duplicate(&msg4));
873    }
874
875    #[test]
876    fn test_cleanup_expired() {
877        // HashMap mode required for explicit cleanup
878        let config = DeduplicatorConfig {
879            max_tracked_messages: 100,
880            retention_time_secs: 1,
881            use_bloom_filter: false,
882            ..Default::default()
883        };
884        let mut dedup = Deduplicator::with_config(config);
885
886        let msg1 = MessageId::new();
887        let msg2 = MessageId::new();
888
889        dedup.mark_seen(msg1.clone());
890
891        thread::sleep(Duration::from_millis(500));
892
893        dedup.mark_seen(msg2.clone());
894
895        thread::sleep(Duration::from_millis(600));
896
897        let removed = dedup.cleanup_expired();
898        assert_eq!(removed, 1);
899        assert_eq!(dedup.tracked_count(), 1);
900
901        assert!(!dedup.is_duplicate(&msg1));
902        assert!(dedup.is_duplicate(&msg2));
903    }
904
905    #[test]
906    fn test_clear() {
907        let mut dedup = Deduplicator::new();
908
909        dedup.mark_seen(MessageId::new());
910        dedup.mark_seen(MessageId::new());
911        dedup.mark_seen(MessageId::new());
912
913        assert_eq!(dedup.tracked_count(), 3);
914
915        dedup.clear();
916        assert_eq!(dedup.tracked_count(), 0);
917    }
918
919    #[test]
920    fn test_is_tracked() {
921        let mut dedup = Deduplicator::new();
922        let msg_id = MessageId::new();
923
924        assert!(!dedup.is_tracked(&msg_id));
925
926        dedup.mark_seen(msg_id.clone());
927        assert!(dedup.is_tracked(&msg_id));
928    }
929
930    #[test]
931    fn test_stats() {
932        let mut dedup = Deduplicator::new();
933
934        for _ in 0..5 {
935            dedup.mark_seen(MessageId::new());
936        }
937
938        let stats = dedup.stats();
939        assert_eq!(stats.total_tracked, 5);
940    }
941
942    #[test]
943    fn test_stats_hashmap_mode() {
944        let mut dedup = Deduplicator::with_config(hashmap_config());
945
946        for _ in 0..5 {
947            dedup.mark_seen(MessageId::new());
948        }
949
950        let stats = dedup.stats();
951        assert_eq!(stats.total_tracked, 5);
952        assert_eq!(stats.recent_tracked, 5);
953        assert!(stats.capacity_used_percent < 1);
954        assert_eq!(stats.mode, DeduplicatorMode::HashMap);
955    }
956
957    #[test]
958    fn test_capacity_percent() {
959        let config = DeduplicatorConfig {
960            max_tracked_messages: 10,
961            retention_time_secs: 3600,
962            use_bloom_filter: false,
963            ..Default::default()
964        };
965        let mut dedup = Deduplicator::with_config(config);
966
967        for _ in 0..5 {
968            dedup.mark_seen(MessageId::new());
969        }
970
971        let stats = dedup.stats();
972        assert_eq!(stats.capacity_used_percent, 50);
973    }
974
975    #[test]
976    fn test_bloom_filter_mode() {
977        let config = DeduplicatorConfig {
978            use_bloom_filter: true,
979            bloom_filter_bits: 1 << 16, // Smaller for testing
980            bloom_hash_count: 5,
981            bloom_filter_count: 2,
982            bloom_rotation_secs: 60,
983            ..Default::default()
984        };
985        let mut dedup = Deduplicator::with_config(config);
986
987        assert!(dedup.is_bloom_filter_mode());
988
989        let msg1 = MessageId::new();
990        let msg2 = MessageId::new();
991
992        assert!(!dedup.is_duplicate(&msg1));
993        dedup.mark_seen(msg1.clone());
994        assert!(dedup.is_duplicate(&msg1));
995
996        assert!(!dedup.is_duplicate(&msg2));
997        dedup.mark_seen(msg2.clone());
998        assert!(dedup.is_duplicate(&msg2));
999
1000        let stats = dedup.stats();
1001        assert_eq!(stats.mode, DeduplicatorMode::BloomFilter);
1002        assert!(stats.false_positive_rate.is_some());
1003    }
1004
1005    #[test]
1006    fn test_bloom_filter_constant_memory() {
1007        let config = DeduplicatorConfig {
1008            use_bloom_filter: true,
1009            bloom_filter_bits: 1 << 16,
1010            bloom_hash_count: 5,
1011            bloom_filter_count: 2,
1012            bloom_rotation_secs: 3600,
1013            ..Default::default()
1014        };
1015        let dedup = Deduplicator::with_config(config);
1016
1017        let initial_memory = dedup.estimated_memory_bytes();
1018
1019        // Memory should be roughly constant regardless of items added
1020        // (Unlike HashMap which grows with items)
1021        let expected_filter_bytes = (1 << 16) / 8 * 2; // 2 filters, 64KB each
1022        assert!(initial_memory >= expected_filter_bytes);
1023        assert!(initial_memory < expected_filter_bytes * 2); // Some overhead
1024    }
1025
1026    #[test]
1027    fn test_bloom_filter_high_volume() {
1028        let config = DeduplicatorConfig {
1029            use_bloom_filter: true,
1030            bloom_filter_bits: 1 << 20, // 1MB
1031            bloom_hash_count: 7,
1032            bloom_filter_count: 4,
1033            bloom_rotation_secs: 3600,
1034            ..Default::default()
1035        };
1036        let mut dedup = Deduplicator::with_config(config);
1037
1038        // Insert 10,000 messages - should work with O(1) memory. The filter
1039        // is probabilistic: a fresh id can (rarely) be reported as already
1040        // seen, so tolerate a handful of false positives rather than
1041        // asserting on every insert.
1042        let mut inserted = 0usize;
1043        for _ in 0..10_000 {
1044            let msg = MessageId::new();
1045            if dedup.mark_seen(msg) {
1046                inserted += 1;
1047            }
1048        }
1049
1050        assert!(
1051            inserted >= 9_990,
1052            "too many bloom false positives: {}",
1053            10_000 - inserted
1054        );
1055        assert_eq!(dedup.tracked_count(), inserted);
1056
1057        let stats = dedup.stats();
1058        // False positive rate should still be low
1059        assert!(stats.false_positive_rate.unwrap() < 0.02);
1060    }
1061
1062    #[test]
1063    fn test_lru_eviction_preserves_recently_accessed() {
1064        let config = DeduplicatorConfig {
1065            max_tracked_messages: 3,
1066            retention_time_secs: 3600,
1067            use_bloom_filter: false,
1068            ..Default::default()
1069        };
1070        let mut dedup = Deduplicator::with_config(config);
1071
1072        let msg_a = MessageId::new();
1073        let msg_b = MessageId::new();
1074        let msg_c = MessageId::new();
1075
1076        dedup.mark_seen(msg_a.clone());
1077        thread::sleep(Duration::from_millis(10));
1078        dedup.mark_seen(msg_b.clone());
1079        thread::sleep(Duration::from_millis(10));
1080        dedup.mark_seen(msg_c.clone());
1081
1082        // Re-access A so it becomes the most recently accessed
1083        assert!(dedup.is_duplicate_mut(&msg_a));
1084
1085        // Insert D — should evict B (least recently accessed), not A
1086        let msg_d = MessageId::new();
1087        dedup.mark_seen(msg_d.clone());
1088
1089        assert_eq!(dedup.tracked_count(), 3);
1090        assert!(
1091            dedup.is_duplicate(&msg_a),
1092            "A was recently accessed, should survive"
1093        );
1094        assert!(
1095            !dedup.is_duplicate(&msg_b),
1096            "B should have been evicted (LRU)"
1097        );
1098        assert!(dedup.is_duplicate(&msg_c), "C should survive");
1099        assert!(dedup.is_duplicate(&msg_d), "D was just inserted");
1100    }
1101    #[test]
1102    fn test_export_import_round_trip_newest_first() {
1103        let mut dedup = Deduplicator::new();
1104        let ids: Vec<MessageId> = (0..5).map(|_| MessageId::new()).collect();
1105        for id in &ids {
1106            dedup.mark_seen(id.clone());
1107        }
1108        let exported = dedup.export_seen(usize::MAX);
1109        assert_eq!(exported.len(), 5);
1110        assert!(
1111            exported
1112                .windows(2)
1113                .all(|pair| pair[0].seen_at_ms >= pair[1].seen_at_ms),
1114            "export is newest first"
1115        );
1116        // A cap keeps the newest, not an arbitrary subset.
1117        let capped = dedup.export_seen(2);
1118        assert_eq!(capped.len(), 2);
1119        assert_eq!(capped, exported[..2].to_vec());
1120
1121        let mut restored = Deduplicator::new();
1122        assert_eq!(restored.import_seen(exported.clone(), Utc::now()), 5);
1123        for id in &ids {
1124            assert!(restored.is_duplicate(id));
1125        }
1126        // The seen_at survives the trip, so a later export agrees.
1127        let mut again = restored.export_seen(usize::MAX);
1128        let mut original = exported;
1129        again.sort_by(|a, b| a.id.cmp(&b.id));
1130        original.sort_by(|a, b| a.id.cmp(&b.id));
1131        assert_eq!(again, original);
1132    }
1133
1134    #[test]
1135    fn test_import_ignores_expired_existing_and_respects_cap() {
1136        let config = DeduplicatorConfig {
1137            max_tracked_messages: 3,
1138            retention_time_secs: 3600,
1139            ..Default::default()
1140        };
1141        let mut dedup = Deduplicator::with_config(config);
1142        let now = Utc::now();
1143        let live = MessageId::new();
1144        let expired = MessageId::new();
1145        let fresh: Vec<MessageId> = (0..3).map(|_| MessageId::new()).collect();
1146        dedup.mark_seen(live.clone());
1147
1148        let seen = |id: &MessageId, secs_ago: i64| SeenId {
1149            id: id.as_str(),
1150            seen_at_ms: (now - chrono::Duration::seconds(secs_ago)).timestamp_millis(),
1151        };
1152        let entries = vec![
1153            // Expired: past the retention window as of `now`.
1154            seen(&expired, 3601),
1155            // Already tracked: the live entry wins and this does not count.
1156            seen(&live, 10),
1157            seen(&fresh[0], 5),
1158            seen(&fresh[1], 4),
1159            // Over the cap once the two above are in: refused.
1160            seen(&fresh[2], 3),
1161        ];
1162        assert_eq!(dedup.import_seen(entries, now), 2);
1163        assert_eq!(dedup.tracked_count(), 3);
1164        assert!(!dedup.is_duplicate(&expired));
1165        assert!(dedup.is_duplicate(&live));
1166        assert!(dedup.is_duplicate(&fresh[0]));
1167        assert!(dedup.is_duplicate(&fresh[1]));
1168        assert!(!dedup.is_duplicate(&fresh[2]));
1169    }
1170
1171    #[test]
1172    fn test_local_marks_dedup_but_are_not_exported() {
1173        let mut dedup = Deduplicator::new();
1174        let inbound = MessageId::new();
1175        let own = MessageId::new();
1176        assert!(dedup.mark_seen(inbound.clone()));
1177        assert!(dedup.mark_seen_local(own.clone()));
1178        // Both dedup in memory alike.
1179        assert!(dedup.is_duplicate(&inbound));
1180        assert!(dedup.is_duplicate(&own));
1181        assert!(!dedup.mark_seen_local(own.clone()), "already tracked");
1182        // A later plain mark of a local id is a no-op and does not promote it.
1183        assert!(!dedup.mark_seen(own.clone()));
1184        // Only the inbound id reaches the export.
1185        let exported = dedup.export_seen(usize::MAX);
1186        assert_eq!(exported.len(), 1);
1187        assert_eq!(exported[0].id, inbound.as_str());
1188        // A local id can still be released like any other.
1189        assert!(dedup.unmark_seen(&own));
1190        assert!(!dedup.is_duplicate(&own));
1191    }
1192
1193    #[test]
1194    fn test_export_import_are_noops_in_bloom_mode() {
1195        let config = DeduplicatorConfig {
1196            use_bloom_filter: true,
1197            ..Default::default()
1198        };
1199        let mut dedup = Deduplicator::with_config(config);
1200        dedup.mark_seen(MessageId::new());
1201        assert!(dedup.export_seen(usize::MAX).is_empty());
1202        let imported = MessageId::new();
1203        assert_eq!(
1204            dedup.import_seen(
1205                vec![SeenId {
1206                    id: imported.as_str(),
1207                    seen_at_ms: Utc::now().timestamp_millis(),
1208                }],
1209                Utc::now()
1210            ),
1211            0
1212        );
1213        assert!(!dedup.is_duplicate(&imported));
1214    }
1215
1216    #[test]
1217    fn test_reconfigure_carries_tracked_ids_within_the_new_bounds() {
1218        let mut dedup = Deduplicator::new();
1219        let now = Utc::now();
1220        let expired = MessageId::new();
1221        let oldest_live = MessageId::new();
1222        let inbound = MessageId::new();
1223        let own = MessageId::new();
1224        // Plant two ids with explicit ages, then two fresh ones.
1225        assert_eq!(
1226            dedup.import_seen(
1227                vec![
1228                    SeenId {
1229                        id: expired.as_str(),
1230                        seen_at_ms: (now - chrono::Duration::seconds(7200)).timestamp_millis(),
1231                    },
1232                    SeenId {
1233                        id: oldest_live.as_str(),
1234                        seen_at_ms: (now - chrono::Duration::seconds(60)).timestamp_millis(),
1235                    },
1236                ],
1237                now
1238            ),
1239            2
1240        );
1241        assert!(dedup.mark_seen(inbound.clone()));
1242        assert!(dedup.mark_seen_local(own.clone()));
1243
1244        // Tighter retention drops the expired id; a cap of 2 keeps the two
1245        // newest of what is left, whichever way they were marked.
1246        dedup.reconfigure(DeduplicatorConfig {
1247            max_tracked_messages: 2,
1248            retention_time_secs: 3600,
1249            ..Default::default()
1250        });
1251        assert_eq!(dedup.tracked_count(), 2);
1252        assert!(
1253            !dedup.is_duplicate(&expired),
1254            "past the new retention window"
1255        );
1256        assert!(!dedup.is_duplicate(&oldest_live), "past the new cap");
1257        assert!(dedup.is_duplicate(&inbound));
1258        assert!(dedup.is_duplicate(&own));
1259        // The exportable flag rides along: the local mark stays local.
1260        let exported = dedup.export_seen(usize::MAX);
1261        assert_eq!(exported.len(), 1);
1262        assert_eq!(exported[0].id, inbound.as_str());
1263
1264        // A mode change carries nothing either way.
1265        dedup.reconfigure(DeduplicatorConfig {
1266            use_bloom_filter: true,
1267            ..Default::default()
1268        });
1269        assert!(dedup.is_bloom_filter_mode());
1270        assert!(!dedup.is_duplicate(&inbound));
1271        dedup.mark_seen(MessageId::new());
1272        dedup.reconfigure(DeduplicatorConfig::default());
1273        assert!(!dedup.is_bloom_filter_mode());
1274        assert_eq!(dedup.tracked_count(), 0);
1275    }
1276}