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