1use async_trait::async_trait;
131use redis::{aio::ConnectionManager, AsyncCommands, Client, RedisResult};
132use serde::{Deserialize, Serialize};
133use std::fmt::Debug;
134use std::hash::{Hash, Hasher};
135use std::marker::PhantomData;
136use std::sync::Arc;
137use std::time::Duration;
138use tokio::sync::RwLock;
139use tracing::{debug, error, trace, warn};
140use uuid::Uuid;
141
142use crate::error::{StateError, StateResult};
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct DeduplicationConfig {
147 pub redis_url: String,
149 pub key_prefix: String,
151 pub default_ttl: Option<Duration>,
153 pub connect_timeout: Duration,
155 pub read_timeout: Duration,
157 pub write_timeout: Duration,
159 pub max_retries: u32,
161 pub retry_base_delay: Duration,
163 pub retry_max_delay: Duration,
165 pub pipeline_batch_size: usize,
167 pub compress_event_ids: bool,
169}
170
171impl Default for DeduplicationConfig {
172 fn default() -> Self {
173 Self {
174 redis_url: "redis://localhost:6379".to_string(),
175 key_prefix: "dedup:".to_string(),
176 default_ttl: Some(Duration::from_secs(3600)), connect_timeout: Duration::from_secs(5),
178 read_timeout: Duration::from_secs(3),
179 write_timeout: Duration::from_secs(3),
180 max_retries: 3,
181 retry_base_delay: Duration::from_millis(100),
182 retry_max_delay: Duration::from_secs(5),
183 pipeline_batch_size: 100,
184 compress_event_ids: false,
185 }
186 }
187}
188
189impl DeduplicationConfig {
190 pub fn builder() -> DeduplicationConfigBuilder {
192 DeduplicationConfigBuilder::default()
193 }
194}
195
196#[derive(Debug, Default)]
198pub struct DeduplicationConfigBuilder {
199 config: DeduplicationConfig,
200}
201
202impl DeduplicationConfigBuilder {
203 pub fn redis_url(mut self, url: impl Into<String>) -> Self {
205 self.config.redis_url = url.into();
206 self
207 }
208
209 pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
211 self.config.key_prefix = prefix.into();
212 self
213 }
214
215 pub fn default_ttl(mut self, ttl: Duration) -> Self {
217 self.config.default_ttl = Some(ttl);
218 self
219 }
220
221 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
223 self.config.connect_timeout = timeout;
224 self
225 }
226
227 pub fn retries(mut self, max: u32, base_delay: Duration, max_delay: Duration) -> Self {
229 self.config.max_retries = max;
230 self.config.retry_base_delay = base_delay;
231 self.config.retry_max_delay = max_delay;
232 self
233 }
234
235 pub fn pipeline_batch_size(mut self, size: usize) -> Self {
237 self.config.pipeline_batch_size = size;
238 self
239 }
240
241 pub fn compress_event_ids(mut self, enabled: bool) -> Self {
243 self.config.compress_event_ids = enabled;
244 self
245 }
246
247 pub fn build(self) -> StateResult<DeduplicationConfig> {
249 if self.config.redis_url.is_empty() {
250 return Err(StateError::StorageError {
251 backend_type: "redis-dedup".to_string(),
252 details: "Redis URL cannot be empty".to_string(),
253 });
254 }
255
256 if self.config.pipeline_batch_size == 0 {
257 return Err(StateError::StorageError {
258 backend_type: "redis-dedup".to_string(),
259 details: "Pipeline batch size must be greater than 0".to_string(),
260 });
261 }
262
263 Ok(self.config)
264 }
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct DeduplicationStats {
270 pub events_checked: u64,
272 pub duplicates_found: u64,
274 pub new_events: u64,
276 pub cache_hits: u64,
278 pub cache_misses: u64,
280 pub batch_operations: u64,
282 pub retry_count: u64,
284 pub error_count: u64,
286 pub avg_latency_us: u64,
288 pub bytes_written: u64,
290 pub bytes_read: u64,
292}
293
294impl DeduplicationStats {
295 pub fn duplicate_rate(&self) -> f64 {
297 if self.events_checked == 0 {
298 0.0
299 } else {
300 self.duplicates_found as f64 / self.events_checked as f64
301 }
302 }
303
304 pub fn cache_hit_rate(&self) -> f64 {
306 let total = self.cache_hits + self.cache_misses;
307 if total == 0 {
308 0.0
309 } else {
310 self.cache_hits as f64 / total as f64
311 }
312 }
313
314 pub fn error_rate(&self) -> f64 {
316 if self.events_checked == 0 {
317 0.0
318 } else {
319 self.error_count as f64 / self.events_checked as f64
320 }
321 }
322
323 pub fn total_operations(&self) -> u64 {
325 self.events_checked + self.new_events
326 }
327}
328
329pub enum EventIdExtractor<T> {
331 Uuid(Box<dyn Fn(&T) -> Uuid + Send + Sync>),
333 String(Box<dyn Fn(&T) -> String + Send + Sync>),
335 Hash(Box<dyn Fn(&T) -> u64 + Send + Sync>),
337 Custom(Box<dyn Fn(&T) -> String + Send + Sync>),
339}
340
341impl<T> EventIdExtractor<T> {
342 pub fn uuid<F>(f: F) -> Self
344 where
345 F: Fn(&T) -> Uuid + Send + Sync + 'static,
346 {
347 Self::Uuid(Box::new(f))
348 }
349
350 pub fn string<F>(f: F) -> Self
352 where
353 F: Fn(&T) -> String + Send + Sync + 'static,
354 {
355 Self::String(Box::new(f))
356 }
357
358 pub fn hash<F>(f: F) -> Self
360 where
361 F: Fn(&T) -> u64 + Send + Sync + 'static,
362 {
363 Self::Hash(Box::new(f))
364 }
365
366 pub fn custom<F>(f: F) -> Self
368 where
369 F: Fn(&T) -> String + Send + Sync + 'static,
370 {
371 Self::Custom(Box::new(f))
372 }
373
374 pub fn extract_id(&self, event: &T) -> String {
376 match self {
377 Self::Uuid(f) => f(event).to_string(),
378 Self::String(f) => f(event),
379 Self::Hash(f) => format!("{:016x}", f(event)),
380 Self::Custom(f) => f(event),
381 }
382 }
383}
384
385impl<T> Debug for EventIdExtractor<T> {
386 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387 match self {
388 Self::Uuid(_) => write!(f, "EventIdExtractor::Uuid"),
389 Self::String(_) => write!(f, "EventIdExtractor::String"),
390 Self::Hash(_) => write!(f, "EventIdExtractor::Hash"),
391 Self::Custom(_) => write!(f, "EventIdExtractor::Custom"),
392 }
393 }
394}
395
396struct RedisDeduplicationBackend {
398 connection: Arc<ConnectionManager>,
400 config: DeduplicationConfig,
402 stats: Arc<RwLock<DeduplicationStats>>,
404}
405
406impl RedisDeduplicationBackend {
407 async fn new(config: DeduplicationConfig) -> StateResult<Self> {
409 let client = Self::create_client(&config)?;
410 let connection = Self::create_connection_manager(client, &config).await?;
411
412 Ok(Self {
413 connection: Arc::new(connection),
414 config,
415 stats: Arc::new(RwLock::new(DeduplicationStats::default())),
416 })
417 }
418
419 fn create_client(config: &DeduplicationConfig) -> StateResult<Client> {
421 Client::open(config.redis_url.as_str()).map_err(|e| StateError::StorageError {
422 backend_type: "redis-dedup".to_string(),
423 details: format!("Failed to create Redis client: {}", e),
424 })
425 }
426
427 async fn create_connection_manager(
429 client: Client,
430 config: &DeduplicationConfig,
431 ) -> StateResult<ConnectionManager> {
432 let mut retries = 0;
433 let mut delay = config.retry_base_delay;
434
435 loop {
436 match ConnectionManager::new(client.clone()).await {
437 Ok(conn) => {
438 debug!("Redis deduplication backend connected");
439 return Ok(conn);
440 }
441 Err(e) => {
442 retries += 1;
443 if retries >= config.max_retries {
444 error!("Failed to connect to Redis after {} retries: {}", retries, e);
445 return Err(StateError::StorageError {
446 backend_type: "redis-dedup".to_string(),
447 details: format!("Connection failed after {} retries: {}", retries, e),
448 });
449 }
450
451 warn!(
452 "Redis connection attempt {} failed: {}, retrying in {:?}",
453 retries, e, delay
454 );
455 tokio::time::sleep(delay).await;
456 delay = std::cmp::min(delay * 2, config.retry_max_delay);
457 }
458 }
459 }
460 }
461
462 fn build_key(&self, event_id: &str) -> String {
464 format!("{}{}", self.config.key_prefix, event_id)
465 }
466
467 async fn with_retry<F, Fut, T>(&self, mut operation: F) -> StateResult<T>
469 where
470 F: FnMut() -> Fut,
471 Fut: std::future::Future<Output = RedisResult<T>>,
472 {
473 let mut retries = 0;
474 let mut delay = self.config.retry_base_delay;
475
476 loop {
477 match operation().await {
478 Ok(result) => return Ok(result),
479 Err(e) => {
480 retries += 1;
481
482 let mut stats = self.stats.write().await;
484 stats.error_count += 1;
485 stats.retry_count += 1;
486 drop(stats);
487
488 if retries >= self.config.max_retries {
489 error!("Deduplication operation failed after {} retries: {}", retries, e);
490 return Err(StateError::StorageError {
491 backend_type: "redis-dedup".to_string(),
492 details: format!("Operation failed after {} retries: {}", retries, e),
493 });
494 }
495
496 warn!(
497 "Deduplication operation attempt {} failed: {}, retrying in {:?}",
498 retries, e, delay
499 );
500 tokio::time::sleep(delay).await;
501 delay = std::cmp::min(delay * 2, self.config.retry_max_delay);
502 }
503 }
504 }
505 }
506
507 async fn exists(&self, event_id: &str) -> StateResult<bool> {
509 let start = std::time::Instant::now();
510 let key = self.build_key(event_id);
511 trace!("Checking existence of key: {}", key);
512
513 let conn = self.connection.as_ref().clone();
514 let key_clone = key.clone();
515
516 let exists: bool = self
517 .with_retry(move || {
518 let mut conn = conn.clone();
519 let key = key_clone.clone();
520 async move { conn.exists(&key).await }
521 })
522 .await?;
523
524 let mut stats = self.stats.write().await;
526 stats.events_checked += 1;
527 if exists {
528 stats.duplicates_found += 1;
529 stats.cache_hits += 1;
530 } else {
531 stats.cache_misses += 1;
532 }
533
534 let latency_us = start.elapsed().as_micros() as u64;
536 stats.avg_latency_us = if stats.events_checked == 1 {
537 latency_us
538 } else {
539 (stats.avg_latency_us * 9 + latency_us) / 10
540 };
541
542 Ok(exists)
543 }
544
545 async fn mark_seen(&self, event_id: &str) -> StateResult<()> {
547 let key = self.build_key(event_id);
548 trace!("Marking key as seen: {}", key);
549
550 let conn = self.connection.as_ref().clone();
551 let key_clone = key.clone();
552 let ttl = self.config.default_ttl;
553
554 self.with_retry(move || {
555 let mut conn = conn.clone();
556 let key = key_clone.clone();
557 async move {
558 if let Some(ttl_duration) = ttl {
559 let ttl_secs = ttl_duration.as_secs();
560 conn.set_ex(&key, "1", ttl_secs).await
561 } else {
562 conn.set(&key, "1").await
563 }
564 }
565 })
566 .await?;
567
568 let mut stats = self.stats.write().await;
570 stats.new_events += 1;
571 stats.bytes_written += event_id.len() as u64;
572
573 Ok(())
574 }
575
576 async fn batch_exists(&self, event_ids: &[String]) -> StateResult<Vec<bool>> {
578 if event_ids.is_empty() {
579 return Ok(Vec::new());
580 }
581
582 let keys: Vec<String> = event_ids.iter().map(|id| self.build_key(id)).collect();
583 let conn = self.connection.as_ref().clone();
584 let keys_clone = keys.clone();
585
586 let results: Vec<bool> = self
587 .with_retry(move || {
588 let mut conn = conn.clone();
589 let keys = keys_clone.clone();
590 async move {
591 let mut pipe = redis::pipe();
592 for key in &keys {
593 pipe.exists(key);
594 }
595 pipe.query_async(&mut conn).await
596 }
597 })
598 .await?;
599
600 let mut stats = self.stats.write().await;
602 stats.events_checked += event_ids.len() as u64;
603 stats.batch_operations += 1;
604
605 for &exists in &results {
606 if exists {
607 stats.duplicates_found += 1;
608 stats.cache_hits += 1;
609 } else {
610 stats.cache_misses += 1;
611 }
612 }
613
614 Ok(results)
615 }
616
617 async fn batch_mark_seen(&self, event_ids: &[String]) -> StateResult<()> {
619 if event_ids.is_empty() {
620 return Ok(());
621 }
622
623 for chunk in event_ids.chunks(self.config.pipeline_batch_size) {
625 let commands: Vec<(String, Option<u64>)> = chunk
626 .iter()
627 .map(|id| {
628 let key = self.build_key(id);
629 let ttl = self.config.default_ttl.map(|d| d.as_secs());
630 (key, ttl)
631 })
632 .collect();
633
634 let conn = self.connection.as_ref().clone();
635
636 self.with_retry(move || {
637 let mut conn = conn.clone();
638 let commands = commands.clone();
639 async move {
640 let mut pipe = redis::pipe();
641 for (key, ttl) in &commands {
642 if let Some(ttl_secs) = ttl {
643 pipe.set_ex(key, "1", *ttl_secs);
644 } else {
645 pipe.set(key, "1");
646 }
647 }
648 pipe.query_async(&mut conn).await
649 }
650 })
651 .await?;
652
653 let mut stats = self.stats.write().await;
655 stats.new_events += chunk.len() as u64;
656 stats.batch_operations += 1;
657 stats.bytes_written += chunk.iter().map(|id| id.len() as u64).sum::<u64>();
658 }
659
660 Ok(())
661 }
662
663 async fn stats(&self) -> DeduplicationStats {
665 self.stats.read().await.clone()
666 }
667
668 async fn health_check(&self) -> StateResult<bool> {
670 let mut conn = self.connection.as_ref().clone();
671 let result: RedisResult<String> = redis::cmd("PING").query_async(&mut conn).await;
672
673 match result {
674 Ok(response) => Ok(response == "PONG"),
675 Err(e) => {
676 error!("Deduplication backend health check failed: {}", e);
677 Err(StateError::StorageError {
678 backend_type: "redis-dedup".to_string(),
679 details: format!("Health check failed: {}", e),
680 })
681 }
682 }
683 }
684
685 async fn clear(&self) -> StateResult<usize> {
687 let pattern = format!("{}*", self.config.key_prefix);
688 let mut conn = self.connection.as_ref().clone();
689 let mut cursor = 0u64;
690 let mut total_deleted = 0;
691
692 loop {
693 let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
694 .arg(cursor)
695 .arg("MATCH")
696 .arg(&pattern)
697 .arg("COUNT")
698 .arg(1000)
699 .query_async(&mut conn)
700 .await
701 .map_err(|e| StateError::StorageError {
702 backend_type: "redis-dedup".to_string(),
703 details: format!("SCAN failed: {}", e),
704 })?;
705
706 if !keys.is_empty() {
707 let deleted: usize = redis::cmd("DEL")
708 .arg(&keys)
709 .query_async(&mut conn)
710 .await
711 .map_err(|e| StateError::StorageError {
712 backend_type: "redis-dedup".to_string(),
713 details: format!("DEL failed: {}", e),
714 })?;
715 total_deleted += deleted;
716 }
717
718 cursor = new_cursor;
719 if cursor == 0 {
720 break;
721 }
722 }
723
724 debug!("Cleared {} deduplication entries", total_deleted);
725 Ok(total_deleted)
726 }
727}
728
729pub struct EventDeduplicator<T> {
734 backend: Arc<RedisDeduplicationBackend>,
736 extractor: EventIdExtractor<T>,
738 _phantom: PhantomData<T>,
740}
741
742impl<T> EventDeduplicator<T> {
743 pub async fn new(
758 config: DeduplicationConfig,
759 extractor: EventIdExtractor<T>,
760 ) -> StateResult<Self> {
761 let backend = RedisDeduplicationBackend::new(config).await?;
762
763 Ok(Self {
764 backend: Arc::new(backend),
765 extractor,
766 _phantom: PhantomData,
767 })
768 }
769
770 pub async fn is_duplicate(&self, event: &T) -> StateResult<bool> {
784 let event_id = self.extractor.extract_id(event);
785 self.backend.exists(&event_id).await
786 }
787
788 pub async fn mark_seen(&self, event: &T) -> StateResult<()> {
798 let event_id = self.extractor.extract_id(event);
799 self.backend.mark_seen(&event_id).await
800 }
801
802 pub async fn check_and_mark(&self, event: &T) -> StateResult<bool> {
816 let event_id = self.extractor.extract_id(event);
817 let was_duplicate = self.backend.exists(&event_id).await?;
818
819 if !was_duplicate {
820 self.backend.mark_seen(&event_id).await?;
821 }
822
823 Ok(was_duplicate)
824 }
825
826 pub async fn batch_check_duplicates(&self, events: &[T]) -> StateResult<Vec<bool>> {
840 let event_ids: Vec<String> = events.iter().map(|e| self.extractor.extract_id(e)).collect();
841 self.backend.batch_exists(&event_ids).await
842 }
843
844 pub async fn batch_mark_seen(&self, events: &[&T]) -> StateResult<()> {
854 let event_ids: Vec<String> = events.iter().map(|e| self.extractor.extract_id(e)).collect();
855 self.backend.batch_mark_seen(&event_ids).await
856 }
857
858 pub async fn stats(&self) -> DeduplicationStats {
860 self.backend.stats().await
861 }
862
863 pub async fn health_check(&self) -> StateResult<bool> {
865 self.backend.health_check().await
866 }
867
868 pub async fn clear(&self) -> StateResult<usize> {
872 self.backend.clear().await
873 }
874}
875
876impl<T> Clone for EventDeduplicator<T> {
877 fn clone(&self) -> Self {
878 Self {
879 backend: Arc::clone(&self.backend),
880 extractor: EventIdExtractor::Custom(Box::new(|_| {
883 panic!("Cannot clone EventDeduplicator with extractor. Create a new instance instead.")
884 })),
885 _phantom: PhantomData,
886 }
887 }
888}
889
890impl<T> Debug for EventDeduplicator<T> {
891 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
892 f.debug_struct("EventDeduplicator")
893 .field("extractor", &self.extractor)
894 .finish()
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use serde::{Deserialize, Serialize};
902
903 #[derive(Debug, Clone, Serialize, Deserialize)]
904 struct TestEvent {
905 id: String,
906 data: String,
907 }
908
909 async fn create_test_deduplicator() -> Option<EventDeduplicator<TestEvent>> {
911 let config = DeduplicationConfig::builder()
912 .redis_url("redis://localhost:6379")
913 .key_prefix("test:dedup:")
914 .default_ttl(Duration::from_secs(60))
915 .build()
916 .ok()?;
917
918 let extractor = EventIdExtractor::string(|event: &TestEvent| event.id.clone());
919
920 match EventDeduplicator::new(config, extractor).await {
921 Ok(dedup) => {
922 let _ = dedup.clear().await;
924 Some(dedup)
925 }
926 Err(_) => None,
927 }
928 }
929
930 #[tokio::test]
931 async fn test_basic_deduplication() {
932 if let Some(dedup) = create_test_deduplicator().await {
933 let event1 = TestEvent {
934 id: "event-1".to_string(),
935 data: "data1".to_string(),
936 };
937
938 assert!(!dedup.is_duplicate(&event1).await.unwrap());
940
941 dedup.mark_seen(&event1).await.unwrap();
943
944 assert!(dedup.is_duplicate(&event1).await.unwrap());
946 } else {
947 eprintln!("Skipping test: Redis not available");
948 }
949 }
950
951 #[tokio::test]
952 async fn test_check_and_mark() {
953 if let Some(dedup) = create_test_deduplicator().await {
954 let event = TestEvent {
955 id: "event-check-mark".to_string(),
956 data: "data".to_string(),
957 };
958
959 let was_dup1 = dedup.check_and_mark(&event).await.unwrap();
961 assert!(!was_dup1);
962
963 let was_dup2 = dedup.check_and_mark(&event).await.unwrap();
965 assert!(was_dup2);
966 } else {
967 eprintln!("Skipping test: Redis not available");
968 }
969 }
970
971 #[tokio::test]
972 async fn test_batch_operations() {
973 if let Some(dedup) = create_test_deduplicator().await {
974 let events = vec![
975 TestEvent {
976 id: "batch-1".to_string(),
977 data: "data1".to_string(),
978 },
979 TestEvent {
980 id: "batch-2".to_string(),
981 data: "data2".to_string(),
982 },
983 TestEvent {
984 id: "batch-3".to_string(),
985 data: "data3".to_string(),
986 },
987 ];
988
989 let results = dedup.batch_check_duplicates(&events).await.unwrap();
991 assert_eq!(results, vec![false, false, false]);
992
993 let refs: Vec<&TestEvent> = events.iter().collect();
995 dedup.batch_mark_seen(&refs).await.unwrap();
996
997 let results2 = dedup.batch_check_duplicates(&events).await.unwrap();
999 assert_eq!(results2, vec![true, true, true]);
1000 } else {
1001 eprintln!("Skipping test: Redis not available");
1002 }
1003 }
1004
1005 #[tokio::test]
1006 async fn test_stats() {
1007 if let Some(dedup) = create_test_deduplicator().await {
1008 let event1 = TestEvent {
1009 id: "stats-1".to_string(),
1010 data: "data1".to_string(),
1011 };
1012 let event2 = TestEvent {
1013 id: "stats-2".to_string(),
1014 data: "data2".to_string(),
1015 };
1016
1017 dedup.is_duplicate(&event1).await.unwrap();
1019 dedup.mark_seen(&event1).await.unwrap();
1021 dedup.is_duplicate(&event1).await.unwrap();
1023 dedup.is_duplicate(&event2).await.unwrap();
1025
1026 let stats = dedup.stats().await;
1027 assert_eq!(stats.events_checked, 3);
1028 assert_eq!(stats.duplicates_found, 1);
1029 assert_eq!(stats.new_events, 1);
1030 assert_eq!(stats.cache_hits, 1);
1031 assert_eq!(stats.cache_misses, 2);
1032 assert!(stats.duplicate_rate() > 0.0);
1033 } else {
1034 eprintln!("Skipping test: Redis not available");
1035 }
1036 }
1037
1038 #[tokio::test]
1039 async fn test_uuid_extractor() {
1040 if let Some(_) = create_test_deduplicator().await {
1041 #[derive(Debug, Clone)]
1042 struct UuidEvent {
1043 uuid: Uuid,
1044 }
1045
1046 let config = DeduplicationConfig::builder()
1047 .redis_url("redis://localhost:6379")
1048 .key_prefix("test:uuid:")
1049 .build()
1050 .unwrap();
1051
1052 let extractor = EventIdExtractor::uuid(|event: &UuidEvent| event.uuid);
1053
1054 if let Ok(dedup) = EventDeduplicator::new(config, extractor).await {
1055 let uuid = Uuid::new_v4();
1056 let event = UuidEvent { uuid };
1057
1058 assert!(!dedup.is_duplicate(&event).await.unwrap());
1059 dedup.mark_seen(&event).await.unwrap();
1060 assert!(dedup.is_duplicate(&event).await.unwrap());
1061
1062 dedup.clear().await.unwrap();
1063 }
1064 } else {
1065 eprintln!("Skipping test: Redis not available");
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn test_hash_extractor() {
1071 if let Some(_) = create_test_deduplicator().await {
1072 use std::collections::hash_map::DefaultHasher;
1073
1074 #[derive(Debug, Clone)]
1075 struct HashEvent {
1076 data: String,
1077 }
1078
1079 let config = DeduplicationConfig::builder()
1080 .redis_url("redis://localhost:6379")
1081 .key_prefix("test:hash:")
1082 .build()
1083 .unwrap();
1084
1085 let extractor = EventIdExtractor::hash(|event: &HashEvent| {
1086 let mut hasher = DefaultHasher::new();
1087 event.data.hash(&mut hasher);
1088 hasher.finish()
1089 });
1090
1091 if let Ok(dedup) = EventDeduplicator::new(config, extractor).await {
1092 let event1 = HashEvent {
1093 data: "same-data".to_string(),
1094 };
1095 let event2 = HashEvent {
1096 data: "same-data".to_string(),
1097 };
1098
1099 assert!(!dedup.is_duplicate(&event1).await.unwrap());
1100 dedup.mark_seen(&event1).await.unwrap();
1101 assert!(dedup.is_duplicate(&event2).await.unwrap());
1103
1104 dedup.clear().await.unwrap();
1105 }
1106 } else {
1107 eprintln!("Skipping test: Redis not available");
1108 }
1109 }
1110
1111 #[tokio::test]
1112 async fn test_health_check() {
1113 if let Some(dedup) = create_test_deduplicator().await {
1114 let healthy = dedup.health_check().await.unwrap();
1115 assert!(healthy);
1116 } else {
1117 eprintln!("Skipping test: Redis not available");
1118 }
1119 }
1120
1121 #[tokio::test]
1122 async fn test_clear() {
1123 if let Some(dedup) = create_test_deduplicator().await {
1124 let events: Vec<TestEvent> = (0..5)
1125 .map(|i| TestEvent {
1126 id: format!("clear-{}", i),
1127 data: "data".to_string(),
1128 })
1129 .collect();
1130
1131 let refs: Vec<&TestEvent> = events.iter().collect();
1133 dedup.batch_mark_seen(&refs).await.unwrap();
1134
1135 let deleted = dedup.clear().await.unwrap();
1137 assert_eq!(deleted, 5);
1138
1139 let results = dedup.batch_check_duplicates(&events).await.unwrap();
1141 assert_eq!(results, vec![false, false, false, false, false]);
1142 } else {
1143 eprintln!("Skipping test: Redis not available");
1144 }
1145 }
1146
1147 #[test]
1148 fn test_config_builder() {
1149 let config = DeduplicationConfig::builder()
1150 .redis_url("redis://localhost:6379")
1151 .key_prefix("myapp:dedup:")
1152 .default_ttl(Duration::from_secs(7200))
1153 .pipeline_batch_size(200)
1154 .compress_event_ids(true)
1155 .build()
1156 .unwrap();
1157
1158 assert_eq!(config.redis_url, "redis://localhost:6379");
1159 assert_eq!(config.key_prefix, "myapp:dedup:");
1160 assert_eq!(config.default_ttl, Some(Duration::from_secs(7200)));
1161 assert_eq!(config.pipeline_batch_size, 200);
1162 assert!(config.compress_event_ids);
1163 }
1164
1165 #[test]
1166 fn test_stats_calculations() {
1167 let mut stats = DeduplicationStats::default();
1168 assert_eq!(stats.duplicate_rate(), 0.0);
1169 assert_eq!(stats.cache_hit_rate(), 0.0);
1170
1171 stats.events_checked = 100;
1172 stats.duplicates_found = 25;
1173 stats.cache_hits = 25;
1174 stats.cache_misses = 75;
1175
1176 assert_eq!(stats.duplicate_rate(), 0.25);
1177 assert_eq!(stats.cache_hit_rate(), 0.25);
1178 assert_eq!(stats.total_operations(), 100);
1179 }
1180}