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: 5000,
65 retention_time_secs: crate::constants::REDELIVERY_WINDOW_MS / 1000, use_bloom_filter: false,
67 bloom_filter_bits: 1 << 20, bloom_hash_count: 7, bloom_filter_count: 4, bloom_rotation_secs: 900, }
72 }
73}
74
75#[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 fn hashes(&self, item: &str) -> Vec<usize> {
97 let mut hashes = Vec::with_capacity(self.hash_count);
98
99 let h1 = fnv1a_hash(item.as_bytes());
101 let h2 = fnv1a_hash_seeded(item.as_bytes(), 0x5f356495) | 1;
106
107 for i in 0..self.hash_count {
108 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 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
157fn fnv1a_hash(data: &[u8]) -> u64 {
159 fnv1a_hash_seeded(data, 0xcbf29ce484222325)
160}
161
162fn 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
173struct 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct SeenId {
245 pub id: String,
247 pub seen_at_ms: i64,
249}
250
251#[derive(Debug, Clone)]
253struct SeenEntry {
254 seen_at: DateTime<Utc>,
256 last_accessed: DateTime<Utc>,
258 exportable: bool,
263}
264
265pub struct Deduplicator {
271 config: DeduplicatorConfig,
272 seen_messages: HashMap<String, SeenEntry>,
274 bloom_filter: Option<RotatingBloomFilter>,
276}
277
278impl Deduplicator {
279 pub fn new() -> Self {
281 Self::with_config(DeduplicatorConfig::default())
282 }
283
284 pub fn with_config(config: DeduplicatorConfig) -> Self {
286 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 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 pub fn is_exact(&self) -> bool {
356 self.bloom_filter.is_none()
357 }
358
359 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 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 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 pub fn mark_seen(&mut self, message_id: MessageId) -> bool {
410 self.mark_seen_with(message_id, true)
411 }
412
413 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 if bloom.contains(&msg_id_str) {
438 return false;
439 }
440 bloom.insert(&msg_id_str);
442 true
443 } else {
444 if self.seen_messages.contains_key(&msg_id_str) {
446 return false;
447 }
448
449 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 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 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 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 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 pub fn cleanup_expired(&mut self) -> usize {
587 if self.bloom_filter.is_some() {
588 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 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 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 pub fn is_tracked(&self, message_id: &MessageId) -> bool {
622 self.is_duplicate(message_id)
623 }
624
625 pub fn is_bloom_filter_mode(&self) -> bool {
627 self.bloom_filter.is_some()
628 }
629
630 pub fn stats(&self) -> DeduplicatorStats {
632 if let Some(ref bloom) = self.bloom_filter {
633 let total = bloom.total_items();
634 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, 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 pub fn estimated_memory_bytes(&self) -> usize {
670 if self.bloom_filter.is_some() {
671 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
690pub enum DeduplicatorMode {
691 HashMap,
693 BloomFilter,
695}
696
697#[derive(Debug, Clone)]
699pub struct DeduplicatorStats {
700 pub total_tracked: usize,
702 pub recent_tracked: usize,
704 pub capacity_used_percent: u8,
706 pub false_positive_rate: Option<f64>,
708 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 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 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 assert!(!dedup.is_duplicate(&msg_id));
782 assert!(dedup.mark_seen(msg_id.clone()));
783
784 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 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 dedup.mark_seen(msg4.clone());
887 assert_eq!(dedup.tracked_count(), 3);
888
889 assert!(!dedup.is_duplicate(&msg1));
891 assert!(dedup.is_duplicate(&msg4));
892 }
893
894 #[test]
895 fn test_cleanup_expired() {
896 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, 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 let expected_filter_bytes = (1 << 16) / 8 * 2; assert!(initial_memory >= expected_filter_bytes);
1042 assert!(initial_memory < expected_filter_bytes * 2); }
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, 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 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 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 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 assert!(dedup.is_duplicate_mut(&msg_a));
1133
1134 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 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 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 seen(&expired, 3601),
1204 seen(&live, 10),
1206 seen(&fresh[0], 5),
1207 seen(&fresh[1], 4),
1208 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 assert!(dedup.is_duplicate(&inbound));
1229 assert!(dedup.is_duplicate(&own));
1230 assert!(!dedup.mark_seen_local(own.clone()), "already tracked");
1231 assert!(!dedup.mark_seen(own.clone()));
1233 let exported = dedup.export_seen(usize::MAX);
1235 assert_eq!(exported.len(), 1);
1236 assert_eq!(exported[0].id, inbound.as_str());
1237 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 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 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 let exported = dedup.export_seen(usize::MAX);
1310 assert_eq!(exported.len(), 1);
1311 assert_eq!(exported[0].id, inbound.as_str());
1312
1313 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}