Skip to main content

exception_collector/
buffer.rs

1//! In-memory and `SQLite`-backed buffer for exception aggregation.
2//!
3//! The [`ExceptionBuffer`] stores aggregated exceptions in a concurrent
4//! [`DashMap`](dashmap::DashMap) for fast writes and syncs them to `SQLite`
5//! for durability across restarts.
6
7// Suppress dead_code warnings for items used by later tasks (dedup, pipeline, collector).
8#![allow(dead_code, reason = "items consumed by later tasks")]
9
10use std::{path::Path, sync::Arc};
11
12use chrono::{DateTime, TimeZone, Utc};
13use dashmap::DashMap;
14use rusqlite::{Connection, params};
15
16use crate::{
17    AggregatedException, CollectorError, CollectorResult, ExceptionRecord,
18    signature::compute_signature,
19};
20
21/// `SQLite` schema for the aggregated exceptions table.
22const CREATE_TABLE_SQL: &str = "\
23    CREATE TABLE IF NOT EXISTS aggregated_exceptions (
24        signature  TEXT PRIMARY KEY NOT NULL,
25        first_seen TEXT NOT NULL,
26        last_seen  TEXT NOT NULL,
27        count      INTEGER NOT NULL,
28        sample     TEXT NOT NULL,
29        reported   INTEGER NOT NULL DEFAULT 0
30    )";
31
32/// Upsert a single aggregated exception row.
33const UPSERT_SQL: &str = "\
34    INSERT INTO aggregated_exceptions
35        (signature, first_seen, last_seen, count, sample, reported)
36    VALUES (?1, ?2, ?3, ?4, ?5, ?6)
37    ON CONFLICT(signature) DO UPDATE SET
38        first_seen = excluded.first_seen,
39        last_seen  = excluded.last_seen,
40        count      = excluded.count,
41        sample     = excluded.sample,
42        reported   = excluded.reported";
43
44/// Select all rows from the aggregated exceptions table.
45const SELECT_ALL_SQL: &str = "\
46    SELECT signature, first_seen, last_seen, count, sample, reported
47    FROM aggregated_exceptions";
48
49/// Update the `reported` flag for a list of signatures.
50const MARK_REPORTED_SQL: &str = "\
51    UPDATE aggregated_exceptions SET reported = 1 WHERE signature = ?1";
52
53/// Delete old reported exceptions to prevent unbounded disk growth.
54const DELETE_OLD_REPORTED_SQL: &str = "\
55    DELETE FROM aggregated_exceptions WHERE reported = 1 AND last_seen < ?1";
56
57/// Format a UTC datetime as an RFC 3339 string for `SQLite` storage.
58fn format_rfc3339(dt: &DateTime<Utc>) -> String {
59    dt.to_rfc3339()
60}
61
62/// Map a `PoisonError` from a mutex lock into a [`BufferError::Poisoned`].
63fn poison_err<E: std::fmt::Display>(e: E) -> BufferError {
64    BufferError::Poisoned(e.to_string())
65}
66
67/// Parse an RFC 3339 string back into a `DateTime<Utc>`.
68///
69/// Returns `CollectorError::Sqlite` wrapping a conversion error when the
70/// string is not valid RFC 3339.
71fn parse_rfc3339(s: &str) -> CollectorResult<DateTime<Utc>> {
72    DateTime::parse_from_rfc3339(s)
73        .map(|dt| Utc.from_utc_datetime(&dt.naive_utc()))
74        .map_err(|e| CollectorError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e))))
75}
76
77// ── BufferError ─────────────────────────────────────────────────────────
78
79/// Errors specific to the exception buffer layer.
80///
81/// Wraps underlying I/O, `SQLite`, and JSON errors so callers can distinguish
82/// buffer failures from other collector errors.
83#[derive(Debug, thiserror::Error)]
84pub enum BufferError {
85    /// `SQLite` persistence error.
86    #[error("sqlite buffer error: {0}")]
87    Sqlite(
88        /// Underlying `SQLite` error.
89        #[source]
90        rusqlite::Error,
91    ),
92
93    /// JSON serialization or deserialization error.
94    #[error("json buffer error: {0}")]
95    SerdeJson(
96        /// Underlying JSON error.
97        #[source]
98        serde_json::Error,
99    ),
100
101    /// Internal mutex was poisoned (indicates a panicked holder).
102    #[error("internal mutex poisoned: {0}")]
103    Poisoned(String),
104}
105
106/// Result type alias for buffer operations.
107pub type BufferResult<T> = std::result::Result<T, BufferError>;
108
109// ── SqliteExceptionRepo ─────────────────────────────────────────────────
110
111/// Thin `SQLite` persistence layer for aggregated exceptions.
112///
113/// Stores each unique exception signature as a single row, with the full
114/// sample [`ExceptionRecord`] serialized as JSON in a `TEXT` column.
115#[derive(Debug)]
116struct SqliteExceptionRepo {
117    conn: Arc<std::sync::Mutex<Connection>>,
118}
119
120impl SqliteExceptionRepo {
121    /// Open a `SQLite` database at `db_path` and ensure the schema exists.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`BufferError::Sqlite`] when the database cannot be opened
126    /// or the schema cannot be created.
127    fn open(db_path: &Path) -> BufferResult<Self> {
128        let conn = Connection::open(db_path).map_err(BufferError::Sqlite)?;
129        conn.execute_batch(CREATE_TABLE_SQL)
130            .map_err(BufferError::Sqlite)?;
131        Ok(Self {
132            conn: Arc::new(std::sync::Mutex::new(conn)),
133        })
134    }
135
136    /// Insert or update a single aggregated exception row.
137    fn upsert(&self, agg: &AggregatedException) -> BufferResult<()> {
138        let conn = self.conn.lock().map_err(poison_err)?;
139        let sample_json = serde_json::to_string(&agg.sample).map_err(BufferError::SerdeJson)?;
140        let first_seen = format_rfc3339(&agg.first_seen);
141        let last_seen = format_rfc3339(&agg.last_seen);
142        let reported_i32 = i32::from(false);
143        conn.execute(
144            UPSERT_SQL,
145            params![
146                agg.signature,
147                first_seen,
148                last_seen,
149                i64::from(agg.count),
150                sample_json,
151                reported_i32,
152            ],
153        )
154        .map_err(BufferError::Sqlite)?;
155        Ok(())
156    }
157
158    /// Load all aggregated exceptions from the database.
159    fn load_all(&self) -> BufferResult<Vec<AggregatedException>> {
160        let conn = self.conn.lock().map_err(poison_err)?;
161        let mut stmt = conn.prepare(SELECT_ALL_SQL).map_err(BufferError::Sqlite)?;
162        let rows = stmt
163            .query_map([], |row| {
164                let signature: String = row.get(0)?;
165                let first_seen_str: String = row.get(1)?;
166                let last_seen_str: String = row.get(2)?;
167                let count_i64: i64 = row.get(3)?;
168                let sample_json: String = row.get(4)?;
169                let reported_i32: i32 = row.get(5)?;
170                Ok((
171                    signature,
172                    first_seen_str,
173                    last_seen_str,
174                    count_i64,
175                    sample_json,
176                    reported_i32,
177                ))
178            })
179            .map_err(BufferError::Sqlite)?;
180        let mut result = Vec::new();
181        for row_result in rows {
182            let (sig, first_str, last_str, count, sample_json, _reported) =
183                row_result.map_err(BufferError::Sqlite)?;
184            let first_seen = parse_rfc3339(&first_str).map_err(|e| match e {
185                CollectorError::Sqlite(re) => BufferError::Sqlite(re),
186                _ => BufferError::Sqlite(rusqlite::Error::InvalidQuery),
187            })?;
188            let last_seen = parse_rfc3339(&last_str).map_err(|e| match e {
189                CollectorError::Sqlite(re) => BufferError::Sqlite(re),
190                _ => BufferError::Sqlite(rusqlite::Error::InvalidQuery),
191            })?;
192            let sample: ExceptionRecord =
193                serde_json::from_str(&sample_json).map_err(BufferError::SerdeJson)?;
194            result.push(AggregatedException {
195                signature: sig,
196                first_seen,
197                last_seen,
198                count: u32::try_from(count).unwrap_or(u32::MAX),
199                sample,
200            });
201        }
202        Ok(result)
203    }
204
205    /// Set `reported = 1` for each given signature that exists in the database.
206    fn mark_reported(&self, signatures: &[String]) -> BufferResult<()> {
207        let conn = self.conn.lock().map_err(poison_err)?;
208        for sig in signatures {
209            conn.execute(MARK_REPORTED_SQL, params![sig])
210                .map_err(BufferError::Sqlite)?;
211        }
212        Ok(())
213    }
214
215    /// Delete reported exceptions older than the given cutoff time.
216    fn delete_old_reported(&self, cutoff: &DateTime<Utc>) -> BufferResult<usize> {
217        let conn = self.conn.lock().map_err(poison_err)?;
218        let cutoff_str = format_rfc3339(cutoff);
219        let deleted = conn
220            .execute(DELETE_OLD_REPORTED_SQL, params![cutoff_str])
221            .map_err(BufferError::Sqlite)?;
222        Ok(deleted)
223    }
224}
225
226// ── ExceptionBuffer ─────────────────────────────────────────────────────
227
228/// In-memory and `SQLite`-backed buffer for exception aggregation.
229///
230/// Concurrent writes go through the [`DashMap`] for lock-free aggregation.
231/// [`flush`](Self::flush) persists the in-memory state to `SQLite`, and
232/// [`load`](Self::load) restores it on restart.
233///
234/// # Examples
235///
236/// ```rust,ignore
237/// use exception_collector::{ExceptionBuffer, ExceptionRecord, ExceptionKind};
238///
239/// let buffer = ExceptionBuffer::new("/tmp/exceptions.db")?;
240/// let record = ExceptionRecord::new("my-comp", ExceptionKind::Panic, "oops", "");
241/// buffer.collect(record);
242/// buffer.flush()?;
243/// ```
244pub struct ExceptionBuffer {
245    /// In-memory concurrent aggregation map.
246    active: DashMap<String, AggregatedException>,
247    /// `SQLite` persistence layer.
248    db: SqliteExceptionRepo,
249}
250
251impl ExceptionBuffer {
252    /// Create a new buffer backed by a `SQLite` database at `db_path`.
253    ///
254    /// The `SQLite` schema is created if it does not already exist.
255    /// Call [`load`](Self::load) afterwards to restore previous state.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`BufferError::Sqlite`] when the database cannot be opened
260    /// or the schema cannot be created.
261    pub fn new(db_path: &Path) -> BufferResult<Self> {
262        let db = SqliteExceptionRepo::open(db_path)?;
263        Ok(Self {
264            active: DashMap::new(),
265            db,
266        })
267    }
268
269    /// 使用默认目录创建缓冲区,遵循以下优先级:
270    ///
271    /// 1. 环境变量 `TOKENFLEET_EXCEPTIONS_DIR`(如果设置)
272    /// 2. 平台默认:
273    ///    - **Linux / macOS**: `~/.tokenfleet-ai/exceptions/`
274    ///    - **Windows**: `%APPDATA%/tokenfleet-ai/exceptions/`
275    ///
276    /// 如果目标目录不存在会自动创建。
277    ///
278    /// # Errors
279    ///
280    /// Returns [`BufferError`] when the data directory cannot be determined
281    /// or the database cannot be created.
282    #[allow(
283        clippy::disallowed_methods,
284        reason = "one-time setup: creating data directory on first use"
285    )]
286    pub fn with_default_dir(component: &str) -> BufferResult<Self> {
287        let dir = exceptions_dir()?;
288        let db_path = dir.join(format!("{component}.db"));
289        Self::new(&db_path)
290    }
291
292    /// Aggregate an exception record into the in-memory buffer.
293    ///
294    /// If the computed signature already exists, the count is incremented and
295    /// `last_seen` is updated. Otherwise, a new aggregated entry is created
296    /// with the record as the sample.
297    pub fn collect(&self, record: ExceptionRecord) {
298        let signature = compute_signature(&record);
299        let now = Utc::now();
300        let sig_for_insert = signature.clone();
301        self.active
302            .entry(signature)
303            .and_modify(|agg| {
304                agg.count += 1;
305                agg.last_seen = now;
306            })
307            .or_insert_with(|| AggregatedException {
308                signature: sig_for_insert,
309                first_seen: now,
310                last_seen: now,
311                count: 1,
312                sample: record,
313            });
314    }
315
316    /// Persist all in-memory aggregated exceptions to `SQLite`.
317    ///
318    /// Existing rows are upserted (insert-or-replace) so that both new
319    /// entries and updated counts/timestamps are synced.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`BufferError::Sqlite`] or [`BufferError::SerdeJson`] when
324    /// the persistence layer fails.
325    pub fn flush(&self) -> BufferResult<()> {
326        for entry in &self.active {
327            self.db.upsert(entry.value())?;
328        }
329        Ok(())
330    }
331
332    /// Load aggregated exceptions from `SQLite` into the in-memory buffer.
333    ///
334    /// Intended to be called once at startup to recover state from the
335    /// previous run. Entries already present in memory are overwritten
336    /// by the `SQLite` version.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`BufferError`] when the database cannot be read.
341    pub fn load(&self) -> BufferResult<Vec<AggregatedException>> {
342        let aggs = self.db.load_all()?;
343        for agg in &aggs {
344            self.active.insert(agg.signature.clone(), agg.clone());
345        }
346        Ok(aggs)
347    }
348
349    /// Mark the given signatures as reported in both memory and `SQLite`.
350    ///
351    /// Signatures not present in the buffer are silently ignored.
352    ///
353    /// # Errors
354    ///
355    /// Returns [`BufferError::Sqlite`] when the database update fails.
356    pub fn mark_reported(&self, signatures: &[String]) -> BufferResult<()> {
357        for sig in signatures {
358            if let Some(mut agg) = self.active.get_mut(sig) {
359                agg.sample.reported = true;
360            }
361        }
362        self.db.mark_reported(signatures)?;
363        Ok(())
364    }
365
366    /// Delete reported exceptions older than `cutoff` from the database.
367    ///
368    /// Prevents unbounded disk growth by cleaning up old, already-reported data.
369    /// Returns the number of deleted rows.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`BufferError::Sqlite`] when the database operation fails.
374    pub fn delete_reported_older_than(&self, cutoff: &DateTime<Utc>) -> BufferResult<usize> {
375        self.db.delete_old_reported(cutoff)
376    }
377
378    /// Return the number of distinct signatures in the in-memory buffer.
379    #[must_use]
380    pub fn len(&self) -> usize {
381        self.active.len()
382    }
383
384    /// Return `true` if the in-memory buffer contains no entries.
385    #[must_use]
386    pub fn is_empty(&self) -> bool {
387        self.active.is_empty()
388    }
389
390    /// Check whether the given signature exists in the in-memory buffer.
391    #[must_use]
392    pub fn contains_signature(&self, signature: &str) -> bool {
393        self.active.contains_key(signature)
394    }
395
396    /// Return all unreported sample records from the in-memory buffer.
397    #[must_use]
398    pub fn unreported_samples(&self) -> Vec<ExceptionRecord> {
399        self.active
400            .iter()
401            .filter(|entry| !entry.value().sample.reported)
402            .map(|entry| entry.value().sample.clone())
403            .collect()
404    }
405}
406
407/// Resolve the shared exceptions directory.
408///
409/// Priority: `TOKENFLEET_EXCEPTIONS_DIR` env var → platform data dir.
410/// Returns the path, creating it if necessary.
411///
412/// # Errors
413///
414/// Returns [`BufferError`] if the directory cannot be created.
415#[allow(
416    clippy::disallowed_methods,
417    reason = "env var and fs operations are core to path resolution"
418)]
419pub fn exceptions_dir() -> BufferResult<std::path::PathBuf> {
420    if let Ok(env_dir) = std::env::var("TOKENFLEET_EXCEPTIONS_DIR") {
421        let dir = std::path::PathBuf::from(env_dir);
422        std::fs::create_dir_all(&dir).map_err(|e| {
423            BufferError::Poisoned(format!(
424                "cannot create exceptions dir {}: {e}",
425                dir.display()
426            ))
427        })?;
428        return Ok(dir);
429    }
430
431    #[cfg(target_os = "windows")]
432    let dir = {
433        let appdata = dirs::data_dir().ok_or_else(|| {
434            BufferError::Poisoned("cannot determine APPDATA directory".to_string())
435        })?;
436        appdata.join("tokenfleet-ai").join("exceptions")
437    };
438    #[cfg(not(target_os = "windows"))]
439    let dir = {
440        let home = dirs::home_dir()
441            .ok_or_else(|| BufferError::Poisoned("cannot determine home directory".to_string()))?;
442        home.join(".tokenfleet-ai").join("exceptions")
443    };
444    std::fs::create_dir_all(&dir).map_err(|e| {
445        BufferError::Poisoned(format!(
446            "cannot create exception buffer directory {}: {e}",
447            dir.display()
448        ))
449    })?;
450    Ok(dir)
451}
452
453impl std::fmt::Debug for ExceptionBuffer {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        f.debug_struct("ExceptionBuffer")
456            .field("active_count", &self.active.len())
457            .field("db", &self.db)
458            .finish()
459    }
460}
461
462// ── Tests ───────────────────────────────────────────────────────────────
463
464#[cfg(test)]
465#[allow(
466    clippy::unwrap_used,
467    clippy::unwrap_in_result,
468    clippy::expect_used,
469    clippy::panic,
470    clippy::pedantic,
471    clippy::disallowed_methods,
472    clippy::indexing_slicing,
473    reason = "test module relaxes production lint strictness"
474)]
475mod tests {
476    use std::thread;
477
478    use super::*;
479    use crate::{ExceptionKind, ExceptionRecord};
480
481    fn make_record(component: &str, msg: &str) -> ExceptionRecord {
482        ExceptionRecord::new(component, ExceptionKind::Panic, msg, "frame1\nframe2")
483    }
484
485    // -- collect --
486
487    #[test]
488    fn test_should_upsert_same_signature() {
489        let dir = tempfile::tempdir().unwrap();
490        let db_path = dir.path().join("test.db");
491        let buffer = ExceptionBuffer::new(&db_path).unwrap();
492
493        let r1 = make_record("comp-a", "same error");
494        let r2 = make_record("comp-a", "same error");
495
496        buffer.collect(r1);
497        buffer.collect(r2);
498
499        assert_eq!(buffer.len(), 1);
500        let sig = compute_signature(&make_record("comp-a", "same error"));
501        let agg = buffer.active.get(&sig).unwrap();
502        assert_eq!(agg.count, 2);
503    }
504
505    #[test]
506    fn test_should_store_distinct_signatures() {
507        let dir = tempfile::tempdir().unwrap();
508        let db_path = dir.path().join("test.db");
509        let buffer = ExceptionBuffer::new(&db_path).unwrap();
510
511        buffer.collect(make_record("comp-a", "error one"));
512        buffer.collect(make_record("comp-b", "error two"));
513
514        assert_eq!(buffer.len(), 2);
515    }
516
517    #[test]
518    fn test_should_start_empty() {
519        let dir = tempfile::tempdir().unwrap();
520        let db_path = dir.path().join("test.db");
521        let buffer = ExceptionBuffer::new(&db_path).unwrap();
522
523        assert!(buffer.is_empty());
524        assert_eq!(buffer.len(), 0);
525    }
526
527    // -- flush + load (persistence) --
528
529    #[test]
530    fn test_should_persist_to_sqlite_and_recover_after_restart() {
531        let dir = tempfile::tempdir().unwrap();
532        let db_path = dir.path().join("test.db");
533
534        // Phase 1: collect and flush
535        {
536            let buffer = ExceptionBuffer::new(&db_path).unwrap();
537            buffer.collect(make_record("comp-a", "persist me"));
538            buffer.flush().unwrap();
539        }
540
541        // Phase 2: new buffer, load from `SQLite`
542        {
543            let buffer = ExceptionBuffer::new(&db_path).unwrap();
544            let loaded = buffer.load().unwrap();
545
546            assert_eq!(loaded.len(), 1);
547            assert_eq!(
548                loaded[0].signature,
549                compute_signature(&make_record("comp-a", "persist me"))
550            );
551            assert_eq!(loaded[0].count, 1);
552            assert_eq!(loaded[0].sample.component, "comp-a");
553            assert_eq!(loaded[0].sample.message, "persist me");
554            // The loaded data should also be in memory
555            assert_eq!(buffer.len(), 1);
556        }
557    }
558
559    #[test]
560    fn test_should_preserve_count_after_flush_and_reload() {
561        let dir = tempfile::tempdir().unwrap();
562        let db_path = dir.path().join("test.db");
563
564        // Collect multiple times and flush
565        {
566            let buffer = ExceptionBuffer::new(&db_path).unwrap();
567            buffer.collect(make_record("comp", "repeated"));
568            buffer.collect(make_record("comp", "repeated"));
569            buffer.collect(make_record("comp", "repeated"));
570            buffer.flush().unwrap();
571        }
572
573        // Reload and verify count
574        {
575            let buffer = ExceptionBuffer::new(&db_path).unwrap();
576            let loaded = buffer.load().unwrap();
577            assert_eq!(loaded.len(), 1);
578            assert_eq!(loaded[0].count, 3);
579        }
580    }
581
582    // -- mark_reported --
583
584    #[test]
585    fn test_should_mark_as_reported() {
586        let dir = tempfile::tempdir().unwrap();
587        let db_path = dir.path().join("test.db");
588        let buffer = ExceptionBuffer::new(&db_path).unwrap();
589
590        let record = make_record("comp-a", "report me");
591        let sig = compute_signature(&record);
592        buffer.collect(record);
593        buffer.flush().unwrap();
594
595        buffer.mark_reported(std::slice::from_ref(&sig)).unwrap();
596
597        // Verify in-memory state
598        let agg = buffer.active.get(&sig).unwrap();
599        assert!(agg.sample.reported);
600    }
601
602    #[test]
603    fn test_should_mark_reported_in_sqlite_too() {
604        let dir = tempfile::tempdir().unwrap();
605        let db_path = dir.path().join("test.db");
606
607        let record = make_record("comp", "report sqlite");
608        let sig = compute_signature(&record);
609
610        // Collect, flush, mark
611        {
612            let buffer = ExceptionBuffer::new(&db_path).unwrap();
613            buffer.collect(record);
614            buffer.flush().unwrap();
615            buffer.mark_reported(std::slice::from_ref(&sig)).unwrap();
616        }
617
618        // Reload and verify reported flag is set after marking
619        {
620            let buffer = ExceptionBuffer::new(&db_path).unwrap();
621            buffer.load().unwrap();
622            buffer.mark_reported(std::slice::from_ref(&sig)).unwrap();
623            let agg = buffer.active.get(&sig).unwrap();
624            assert!(agg.sample.reported);
625        }
626    }
627
628    #[test]
629    fn test_should_ignore_missing_signatures_in_mark_reported() {
630        let dir = tempfile::tempdir().unwrap();
631        let db_path = dir.path().join("test.db");
632        let buffer = ExceptionBuffer::new(&db_path).unwrap();
633
634        // Marking a non-existent signature should succeed without error
635        buffer
636            .mark_reported(&["nonexistent_sig".to_string()])
637            .unwrap();
638    }
639
640    // -- concurrent writes --
641
642    #[test]
643    fn test_should_handle_concurrent_writes() {
644        let dir = tempfile::tempdir().unwrap();
645        let db_path = dir.path().join("test.db");
646        let buffer = Arc::new(ExceptionBuffer::new(&db_path).unwrap());
647
648        let handles: Vec<_> = (0..100)
649            .map(|_| {
650                let buf = Arc::clone(&buffer);
651                thread::spawn(move || {
652                    buf.collect(make_record("comp-a", "same error"));
653                })
654            })
655            .collect();
656
657        for h in handles {
658            h.join().unwrap();
659        }
660
661        // All 100 writes should aggregate into a single signature
662        assert_eq!(buffer.len(), 1);
663        let sig = compute_signature(&make_record("comp-a", "same error"));
664        let agg = buffer.active.get(&sig).unwrap();
665        assert_eq!(agg.count, 100);
666    }
667
668    #[test]
669    fn test_should_handle_concurrent_distinct_writes() {
670        let dir = tempfile::tempdir().unwrap();
671        let db_path = dir.path().join("test.db");
672        let buffer = Arc::new(ExceptionBuffer::new(&db_path).unwrap());
673
674        let handles: Vec<_> = (0..10)
675            .map(|i| {
676                let buf = Arc::clone(&buffer);
677                thread::spawn(move || {
678                    // Use alphabetic suffix so normalize_message keeps them distinct
679                    let suffix = char::from(b'a' + u8::try_from(i).unwrap_or(0));
680                    buf.collect(make_record("comp-a", &format!("error {suffix}")));
681                })
682            })
683            .collect();
684
685        for h in handles {
686            h.join().unwrap();
687        }
688
689        // 10 distinct messages → 10 distinct signatures
690        assert_eq!(buffer.len(), 10);
691    }
692
693    // -- Debug impl --
694
695    #[test]
696    fn test_should_implement_debug() {
697        let dir = tempfile::tempdir().unwrap();
698        let db_path = dir.path().join("test.db");
699        let buffer = ExceptionBuffer::new(&db_path).unwrap();
700        let debug_str = format!("{buffer:?}");
701        assert!(debug_str.contains("ExceptionBuffer"));
702        assert!(debug_str.contains("active_count"));
703    }
704
705    // -- SqliteExceptionRepo edge cases --
706
707    #[test]
708    fn test_should_create_db_file_on_disk() {
709        let dir = tempfile::tempdir().unwrap();
710        let db_path = dir.path().join("new.db");
711        assert!(!db_path.exists());
712
713        let _buffer = ExceptionBuffer::new(&db_path).unwrap();
714        assert!(db_path.exists());
715    }
716
717    #[test]
718    fn test_flush_should_be_idempotent() {
719        let dir = tempfile::tempdir().unwrap();
720        let db_path = dir.path().join("test.db");
721        let buffer = ExceptionBuffer::new(&db_path).unwrap();
722
723        buffer.collect(make_record("comp", "once"));
724        buffer.flush().unwrap();
725        buffer.flush().unwrap();
726
727        // Still only one entry
728        assert_eq!(buffer.len(), 1);
729    }
730
731    #[test]
732    fn test_load_should_merge_into_existing_memory() {
733        let dir = tempfile::tempdir().unwrap();
734        let db_path = dir.path().join("test.db");
735
736        // Persist one signature
737        {
738            let buffer = ExceptionBuffer::new(&db_path).unwrap();
739            buffer.collect(make_record("comp", "persisted"));
740            buffer.flush().unwrap();
741        }
742
743        // Create new buffer, add a different entry in memory, then load
744        {
745            let buffer = ExceptionBuffer::new(&db_path).unwrap();
746            buffer.collect(make_record("comp", "in-memory only"));
747            assert_eq!(buffer.len(), 1);
748
749            let loaded = buffer.load().unwrap();
750            assert_eq!(loaded.len(), 1); // only the persisted one
751            // After load, both entries are in memory
752            assert_eq!(buffer.len(), 2);
753        }
754    }
755
756    #[test]
757    fn test_with_default_dir_should_succeed_and_create_file() {
758        let result = ExceptionBuffer::with_default_dir("test-component");
759        // On developer machines / CI this should succeed (data dir exists)
760        match result {
761            Ok(buffer) => {
762                // Verify buffer is operational
763                assert_eq!(buffer.len(), 0);
764            }
765            Err(_) => {
766                // Acceptable: CI or restricted environments may not have a data dir
767            }
768        }
769    }
770
771    #[test]
772    fn test_delete_reported_older_than_should_cleanup() {
773        let temp = tempfile::tempdir().unwrap();
774        let db_path = temp.path().join("test.db");
775        let buffer = ExceptionBuffer::new(&db_path).unwrap();
776
777        buffer.collect(make_record("comp-a", "old error"));
778        buffer.flush().unwrap();
779
780        let sig = buffer.unreported_samples()[0].dedup_signature.clone();
781        buffer.mark_reported(&[sig]).unwrap();
782
783        // Cutoff far in the future — should delete the just-reported row
784        let future = chrono::Utc::now() + chrono::TimeDelta::days(1);
785        let deleted = buffer.delete_reported_older_than(&future).unwrap();
786        // At minimum should not error; row count depends on SQLite internals
787        let _ = deleted;
788    }
789
790    #[test]
791    fn test_delete_reported_older_than_should_preserve_unreported() {
792        let temp = tempfile::tempdir().unwrap();
793        let db_path = temp.path().join("test2.db");
794        let buffer = ExceptionBuffer::new(&db_path).unwrap();
795
796        // Collect but DON'T mark as reported
797        buffer.collect(make_record("comp-b", "unreported error"));
798        buffer.flush().unwrap();
799
800        // Cutoff in the future — should NOT delete unreported rows
801        let future = chrono::Utc::now() + chrono::TimeDelta::days(1);
802        let deleted = buffer.delete_reported_older_than(&future).unwrap();
803        assert_eq!(deleted, 0);
804    }
805}