Skip to main content

exception_collector/
dedup.rs

1//! Local signature deduplication engine.
2//!
3//! [`DedupEngine`] wraps an [`Arc<ExceptionBuffer>`](crate::ExceptionBuffer)
4//! and provides fast lookups for duplicate detection, distinct signature
5//! counting, and pending batch extraction.
6
7use std::sync::Arc;
8
9use crate::{ExceptionBuffer, ExceptionRecord};
10
11/// Local signature deduplication engine.
12///
13/// Wraps an [`ExceptionBuffer`] to provide:
14/// - Duplicate detection by signature
15/// - Distinct signature counting
16/// - Pending (unreported) batch extraction
17#[derive(Debug)]
18pub struct DedupEngine {
19    buffer: Arc<ExceptionBuffer>,
20}
21
22impl DedupEngine {
23    /// Create a new dedup engine wrapping the given buffer.
24    #[must_use]
25    pub fn new(buffer: Arc<ExceptionBuffer>) -> Self {
26        Self { buffer }
27    }
28
29    /// Check whether the given signature already exists in the buffer.
30    #[must_use]
31    pub fn is_duplicate(&self, signature: &str) -> bool {
32        self.buffer.contains_signature(signature)
33    }
34
35    /// Return the number of distinct signatures in the buffer.
36    #[must_use]
37    pub fn distinct_count(&self) -> usize {
38        self.buffer.len()
39    }
40
41    /// Return all unreported sample records from the buffer.
42    #[must_use]
43    pub fn pending_batch(&self) -> Vec<ExceptionRecord> {
44        self.buffer.unreported_samples()
45    }
46}
47
48#[cfg(test)]
49#[allow(
50    clippy::unwrap_used,
51    clippy::unwrap_in_result,
52    clippy::expect_used,
53    clippy::panic,
54    clippy::pedantic,
55    clippy::disallowed_methods,
56    clippy::indexing_slicing,
57    reason = "test module relaxes production lint strictness"
58)]
59mod tests {
60    use super::*;
61    use crate::{ExceptionKind, ExceptionRecord};
62
63    struct TestCtx {
64        buffer: Arc<ExceptionBuffer>,
65        #[allow(dead_code, reason = "field keeps TempDir alive for test scope")]
66        _dir: tempfile::TempDir,
67    }
68
69    fn make_ctx() -> TestCtx {
70        let dir = tempfile::tempdir().unwrap();
71        let db_path = dir.path().join("test.db");
72        TestCtx {
73            buffer: Arc::new(ExceptionBuffer::new(&db_path).unwrap()),
74            _dir: dir,
75        }
76    }
77
78    fn make_record(component: &str, msg: &str) -> ExceptionRecord {
79        ExceptionRecord::new(component, ExceptionKind::Panic, msg, "frame1\nframe2")
80    }
81
82    #[test]
83    fn test_should_detect_duplicate_by_signature() {
84        let ctx = make_ctx();
85        let engine = DedupEngine::new(ctx.buffer.clone());
86
87        let record = make_record("comp-a", "same error");
88        ctx.buffer.collect(record);
89
90        let sig = crate::compute_signature(&make_record("comp-a", "same error"));
91        assert!(engine.is_duplicate(&sig));
92    }
93
94    #[test]
95    fn test_should_not_detect_nonexistent_signature() {
96        let ctx = make_ctx();
97        let engine = DedupEngine::new(ctx.buffer);
98
99        assert!(!engine.is_duplicate("nonexistent_signature_here"));
100    }
101
102    #[test]
103    fn test_should_count_distinct_signatures() {
104        let ctx = make_ctx();
105        let engine = DedupEngine::new(ctx.buffer.clone());
106
107        ctx.buffer.collect(make_record("comp-a", "error one"));
108        ctx.buffer.collect(make_record("comp-b", "error two"));
109        ctx.buffer.collect(make_record("comp-a", "error one")); // duplicate
110
111        assert_eq!(engine.distinct_count(), 2);
112    }
113
114    #[test]
115    fn test_should_count_zero_when_empty() {
116        let ctx = make_ctx();
117        let engine = DedupEngine::new(ctx.buffer);
118
119        assert_eq!(engine.distinct_count(), 0);
120    }
121
122    #[test]
123    fn test_should_return_correct_pending_batch() {
124        let ctx = make_ctx();
125        let engine = DedupEngine::new(ctx.buffer.clone());
126
127        ctx.buffer.collect(make_record("comp-a", "error one"));
128        ctx.buffer.collect(make_record("comp-b", "error two"));
129
130        let pending = engine.pending_batch();
131        assert_eq!(pending.len(), 2);
132    }
133
134    #[test]
135    fn test_should_not_include_reported_in_pending_batch() {
136        let ctx = make_ctx();
137        let engine = DedupEngine::new(ctx.buffer.clone());
138
139        let r1 = make_record("comp-a", "error one");
140        let r2 = make_record("comp-b", "error two");
141        ctx.buffer.collect(r1.clone());
142        ctx.buffer.collect(r2.clone());
143        ctx.buffer.flush().unwrap();
144
145        let sig1 = crate::compute_signature(&r1);
146        ctx.buffer
147            .mark_reported(std::slice::from_ref(&sig1))
148            .unwrap();
149
150        let pending = engine.pending_batch();
151        assert_eq!(pending.len(), 1);
152        assert_eq!(pending[0].component, "comp-b");
153    }
154
155    #[test]
156    fn test_should_return_empty_pending_when_all_reported() {
157        let ctx = make_ctx();
158        let engine = DedupEngine::new(ctx.buffer.clone());
159
160        let r = make_record("comp", "reported");
161        ctx.buffer.collect(r.clone());
162        ctx.buffer.flush().unwrap();
163
164        let sig = crate::compute_signature(&r);
165        ctx.buffer
166            .mark_reported(std::slice::from_ref(&sig))
167            .unwrap();
168
169        let pending = engine.pending_batch();
170        assert!(pending.is_empty());
171    }
172
173    #[test]
174    fn test_should_return_empty_pending_when_buffer_empty() {
175        let ctx = make_ctx();
176        let engine = DedupEngine::new(ctx.buffer);
177
178        let pending = engine.pending_batch();
179        assert!(pending.is_empty());
180    }
181}