1use chrono::{DateTime, Utc};
11use offline_protocol_core::MessageId;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone)]
17pub struct DeduplicatorConfig {
18 pub max_tracked_messages: usize,
20
21 pub retention_time_secs: u64,
23
24 pub use_bloom_filter: bool,
26
27 pub bloom_filter_bits: usize,
29
30 pub bloom_hash_count: usize,
32
33 pub bloom_filter_count: usize,
35
36 pub bloom_rotation_secs: u64,
38}
39
40impl Default for DeduplicatorConfig {
41 fn default() -> Self {
42 Self {
43 max_tracked_messages: 2000,
54 retention_time_secs: 86_400, use_bloom_filter: false,
56 bloom_filter_bits: 1 << 20, bloom_hash_count: 7, bloom_filter_count: 4, bloom_rotation_secs: 900, }
61 }
62}
63
64#[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 fn hashes(&self, item: &str) -> Vec<usize> {
86 let mut hashes = Vec::with_capacity(self.hash_count);
87
88 let h1 = fnv1a_hash(item.as_bytes());
90 let h2 = fnv1a_hash_seeded(item.as_bytes(), 0x5f356495) | 1;
95
96 for i in 0..self.hash_count {
97 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 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
146fn fnv1a_hash(data: &[u8]) -> u64 {
148 fnv1a_hash_seeded(data, 0xcbf29ce484222325)
149}
150
151fn 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
162struct 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct SeenId {
234 pub id: String,
236 pub seen_at_ms: i64,
238}
239
240#[derive(Debug, Clone)]
242struct SeenEntry {
243 seen_at: DateTime<Utc>,
245 last_accessed: DateTime<Utc>,
247 exportable: bool,
252}
253
254pub struct Deduplicator {
260 config: DeduplicatorConfig,
261 seen_messages: HashMap<String, SeenEntry>,
263 bloom_filter: Option<RotatingBloomFilter>,
265}
266
267impl Deduplicator {
268 pub fn new() -> Self {
270 Self::with_config(DeduplicatorConfig::default())
271 }
272
273 pub fn with_config(config: DeduplicatorConfig) -> Self {
275 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 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 pub fn is_exact(&self) -> bool {
345 self.bloom_filter.is_none()
346 }
347
348 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 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 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 pub fn mark_seen(&mut self, message_id: MessageId) -> bool {
399 self.mark_seen_with(message_id, true)
400 }
401
402 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 if bloom.contains(&msg_id_str) {
427 return false;
428 }
429 bloom.insert(&msg_id_str);
431 true
432 } else {
433 if self.seen_messages.contains_key(&msg_id_str) {
435 return false;
436 }
437
438 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 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 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 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 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 pub fn cleanup_expired(&mut self) -> usize {
569 if self.bloom_filter.is_some() {
570 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 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 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 pub fn is_tracked(&self, message_id: &MessageId) -> bool {
604 self.is_duplicate(message_id)
605 }
606
607 pub fn is_bloom_filter_mode(&self) -> bool {
609 self.bloom_filter.is_some()
610 }
611
612 pub fn stats(&self) -> DeduplicatorStats {
614 if let Some(ref bloom) = self.bloom_filter {
615 let total = bloom.total_items();
616 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, 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 pub fn estimated_memory_bytes(&self) -> usize {
652 if self.bloom_filter.is_some() {
653 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672pub enum DeduplicatorMode {
673 HashMap,
675 BloomFilter,
677}
678
679#[derive(Debug, Clone)]
681pub struct DeduplicatorStats {
682 pub total_tracked: usize,
684 pub recent_tracked: usize,
686 pub capacity_used_percent: u8,
688 pub false_positive_rate: Option<f64>,
690 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 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 assert!(!dedup.is_duplicate(&msg_id));
763 assert!(dedup.mark_seen(msg_id.clone()));
764
765 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 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 dedup.mark_seen(msg4.clone());
868 assert_eq!(dedup.tracked_count(), 3);
869
870 assert!(!dedup.is_duplicate(&msg1));
872 assert!(dedup.is_duplicate(&msg4));
873 }
874
875 #[test]
876 fn test_cleanup_expired() {
877 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, 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 let expected_filter_bytes = (1 << 16) / 8 * 2; assert!(initial_memory >= expected_filter_bytes);
1023 assert!(initial_memory < expected_filter_bytes * 2); }
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, 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 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 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 assert!(dedup.is_duplicate_mut(&msg_a));
1084
1085 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 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 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 seen(&expired, 3601),
1155 seen(&live, 10),
1157 seen(&fresh[0], 5),
1158 seen(&fresh[1], 4),
1159 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 assert!(dedup.is_duplicate(&inbound));
1180 assert!(dedup.is_duplicate(&own));
1181 assert!(!dedup.mark_seen_local(own.clone()), "already tracked");
1182 assert!(!dedup.mark_seen(own.clone()));
1184 let exported = dedup.export_seen(usize::MAX);
1186 assert_eq!(exported.len(), 1);
1187 assert_eq!(exported[0].id, inbound.as_str());
1188 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 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 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 let exported = dedup.export_seen(usize::MAX);
1261 assert_eq!(exported.len(), 1);
1262 assert_eq!(exported[0].id, inbound.as_str());
1263
1264 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}