Skip to main content

laminar_db/ai/
call_log.rs

1//! Bounded in-memory log of inference calls, surfaced as `laminar.ai_calls`.
2//! One record per batch; written from Ring 1, never Ring 0. Oldest record is
3//! dropped when full; a monotonic counter tracks lifetime call count.
4
5use std::collections::VecDeque;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use parking_lot::Mutex;
9
10use crate::ai::provider::Usage;
11use crate::ai::registry::{BackendKind, Task};
12
13/// Outcome of a batch call.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CallOutcome {
16    /// The batch completed.
17    Success,
18    /// The batch failed; carries the surfaced error message.
19    Failure(String),
20}
21
22impl CallOutcome {
23    /// Short status string for the `laminar.ai_calls` view (`ok` / `error`).
24    #[must_use]
25    pub fn status(&self) -> &'static str {
26        match self {
27            CallOutcome::Success => "ok",
28            CallOutcome::Failure(_) => "error",
29        }
30    }
31}
32
33/// One logged inference call.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AiCallRecord {
36    /// Epoch milliseconds at call completion.
37    pub timestamp_ms: i64,
38    /// Registry model name.
39    pub model: String,
40    /// Provider name (e.g. `anthropic`).
41    pub provider: &'static str,
42    /// Task performed.
43    pub task: Task,
44    /// Backend kind.
45    pub kind: BackendKind,
46    /// Number of rows in the batch.
47    pub batch_size: u32,
48    /// Zero for local calls.
49    pub usage: Usage,
50    /// End-to-end latency.
51    pub latency_ms: u64,
52    /// Outcome.
53    pub outcome: CallOutcome,
54}
55
56/// Bounded ring buffer of call records.
57#[derive(Debug)]
58pub struct AiCallLog {
59    records: Mutex<VecDeque<AiCallRecord>>,
60    capacity: usize,
61    recorded: AtomicU64,
62}
63
64impl AiCallLog {
65    /// Retain at most `capacity` of the most recent records.
66    #[must_use]
67    pub fn new(capacity: usize) -> Self {
68        Self {
69            records: Mutex::new(VecDeque::with_capacity(capacity.min(1024))),
70            capacity: capacity.max(1),
71            recorded: AtomicU64::new(0),
72        }
73    }
74
75    /// Create a log with a default capacity of 2 000 records.
76    #[must_use]
77    pub fn with_defaults() -> Self {
78        Self::new(2_000)
79    }
80
81    /// Append a record; evicts the oldest if at capacity.
82    pub fn record(&self, record: AiCallRecord) {
83        let mut records = self.records.lock();
84        if records.len() >= self.capacity {
85            records.pop_front();
86        }
87        records.push_back(record);
88        self.recorded.fetch_add(1, Ordering::Relaxed);
89    }
90
91    /// Snapshot retained records (oldest first). Backs `laminar.ai_calls`.
92    #[must_use]
93    pub fn snapshot(&self) -> Vec<AiCallRecord> {
94        self.records.lock().iter().cloned().collect()
95    }
96
97    /// Number of retained records.
98    #[must_use]
99    pub fn len(&self) -> usize {
100        self.records.lock().len()
101    }
102
103    /// Whether the log holds no records.
104    #[must_use]
105    pub fn is_empty(&self) -> bool {
106        self.records.lock().is_empty()
107    }
108
109    /// Total calls ever logged, including evicted records.
110    #[must_use]
111    pub fn total_recorded(&self) -> u64 {
112        self.recorded.load(Ordering::Relaxed)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn record(model: &str, ts: i64, outcome: CallOutcome) -> AiCallRecord {
121        AiCallRecord {
122            timestamp_ms: ts,
123            model: model.to_string(),
124            provider: "anthropic",
125            task: Task::Classify,
126            kind: BackendKind::Remote,
127            batch_size: 4,
128            usage: Usage {
129                input_tokens: 10,
130                output_tokens: 2,
131                cost_micros: 5,
132            },
133            latency_ms: 42,
134            outcome,
135        }
136    }
137
138    #[test]
139    fn records_and_snapshots_in_order() {
140        let log = AiCallLog::with_defaults();
141        assert!(log.is_empty());
142        log.record(record("haiku", 1, CallOutcome::Success));
143        log.record(record("haiku", 2, CallOutcome::Failure("timeout".into())));
144        let snap = log.snapshot();
145        assert_eq!(snap.len(), 2);
146        assert_eq!(snap[0].timestamp_ms, 1);
147        assert_eq!(snap[1].outcome, CallOutcome::Failure("timeout".into()));
148        assert_eq!(log.total_recorded(), 2);
149    }
150
151    #[test]
152    fn evicts_oldest_when_full() {
153        let log = AiCallLog::new(2);
154        log.record(record("m", 1, CallOutcome::Success));
155        log.record(record("m", 2, CallOutcome::Success));
156        log.record(record("m", 3, CallOutcome::Success));
157        let snap = log.snapshot();
158        assert_eq!(snap.len(), 2);
159        assert_eq!(snap[0].timestamp_ms, 2, "oldest dropped");
160        assert_eq!(snap[1].timestamp_ms, 3);
161        assert_eq!(log.total_recorded(), 3, "monotonic count survives eviction");
162    }
163}