1#![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
21const 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
32const 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
44const SELECT_ALL_SQL: &str = "\
46 SELECT signature, first_seen, last_seen, count, sample, reported
47 FROM aggregated_exceptions";
48
49const MARK_REPORTED_SQL: &str = "\
51 UPDATE aggregated_exceptions SET reported = 1 WHERE signature = ?1";
52
53const DELETE_OLD_REPORTED_SQL: &str = "\
55 DELETE FROM aggregated_exceptions WHERE reported = 1 AND last_seen < ?1";
56
57fn format_rfc3339(dt: &DateTime<Utc>) -> String {
59 dt.to_rfc3339()
60}
61
62fn poison_err<E: std::fmt::Display>(e: E) -> BufferError {
64 BufferError::Poisoned(e.to_string())
65}
66
67fn 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#[derive(Debug, thiserror::Error)]
84pub enum BufferError {
85 #[error("sqlite buffer error: {0}")]
87 Sqlite(
88 #[source]
90 rusqlite::Error,
91 ),
92
93 #[error("json buffer error: {0}")]
95 SerdeJson(
96 #[source]
98 serde_json::Error,
99 ),
100
101 #[error("internal mutex poisoned: {0}")]
103 Poisoned(String),
104}
105
106pub type BufferResult<T> = std::result::Result<T, BufferError>;
108
109#[derive(Debug)]
116struct SqliteExceptionRepo {
117 conn: Arc<std::sync::Mutex<Connection>>,
118}
119
120impl SqliteExceptionRepo {
121 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 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 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 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 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
226pub struct ExceptionBuffer {
245 active: DashMap<String, AggregatedException>,
247 db: SqliteExceptionRepo,
249}
250
251impl ExceptionBuffer {
252 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 #[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 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 pub fn flush(&self) -> BufferResult<()> {
326 for entry in &self.active {
327 self.db.upsert(entry.value())?;
328 }
329 Ok(())
330 }
331
332 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 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 pub fn delete_reported_older_than(&self, cutoff: &DateTime<Utc>) -> BufferResult<usize> {
375 self.db.delete_old_reported(cutoff)
376 }
377
378 #[must_use]
380 pub fn len(&self) -> usize {
381 self.active.len()
382 }
383
384 #[must_use]
386 pub fn is_empty(&self) -> bool {
387 self.active.is_empty()
388 }
389
390 #[must_use]
392 pub fn contains_signature(&self, signature: &str) -> bool {
393 self.active.contains_key(signature)
394 }
395
396 #[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#[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#[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 #[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 #[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 {
536 let buffer = ExceptionBuffer::new(&db_path).unwrap();
537 buffer.collect(make_record("comp-a", "persist me"));
538 buffer.flush().unwrap();
539 }
540
541 {
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 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 {
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 {
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 #[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 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 {
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 {
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 buffer
636 .mark_reported(&["nonexistent_sig".to_string()])
637 .unwrap();
638 }
639
640 #[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 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 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 assert_eq!(buffer.len(), 10);
691 }
692
693 #[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 #[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 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 {
738 let buffer = ExceptionBuffer::new(&db_path).unwrap();
739 buffer.collect(make_record("comp", "persisted"));
740 buffer.flush().unwrap();
741 }
742
743 {
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); 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 match result {
761 Ok(buffer) => {
762 assert_eq!(buffer.len(), 0);
764 }
765 Err(_) => {
766 }
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 let future = chrono::Utc::now() + chrono::TimeDelta::days(1);
785 let deleted = buffer.delete_reported_older_than(&future).unwrap();
786 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 buffer.collect(make_record("comp-b", "unreported error"));
798 buffer.flush().unwrap();
799
800 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}