Skip to main content

zeph_memory/
retrieval_failure_logger.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Async fire-and-forget logger for memory retrieval failure events.
5//!
6//! [`RetrievalFailureLogger`] owns a bounded mpsc sender. Callers invoke
7//! [`RetrievalFailureLogger::log`] on the hot path without blocking. A
8//! background task coalesces records into batches and flushes them to `SQLite`.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use tokio::sync::{Notify, mpsc};
14use tracing::Instrument as _;
15use zeph_common::task_supervisor::TaskSupervisor;
16
17use crate::store::SqliteStore;
18use crate::store::retrieval_failures::RetrievalFailureRecord;
19
20const QUERY_TEXT_MAX_CHARS: usize = 512;
21const ERROR_CONTEXT_MAX_CHARS: usize = 256;
22/// How often to check for a cleanup opportunity (every N flushes).
23const CLEANUP_FLUSH_INTERVAL: u32 = 500;
24
25/// Async background writer that batches retrieval failure records to `SQLite`.
26///
27/// Construct with [`RetrievalFailureLogger::new`] and call [`RetrievalFailureLogger::log`]
28/// from the recall hot path. Records are sent via a bounded channel; if the channel is
29/// full the record is silently dropped (zero hot-path latency, per INV-1).
30///
31/// The background writer task is registered with [`TaskSupervisor`] for lifecycle
32/// visibility and graceful shutdown. Call [`shutdown`](Self::shutdown) for a clean drain.
33pub struct RetrievalFailureLogger {
34    tx: Option<mpsc::Sender<RetrievalFailureRecord>>,
35    /// Notified by `writer_task` after the final flush completes.
36    done: Arc<Notify>,
37}
38
39impl RetrievalFailureLogger {
40    /// Spawn the background writer task under `supervisor` and return a logger handle.
41    ///
42    /// `batch_size` records are flushed at once, or after `flush_interval` elapses,
43    /// whichever comes first. Old records are purged every `CLEANUP_FLUSH_INTERVAL`
44    /// batch flushes according to `retention_days`.
45    #[must_use]
46    pub fn new(
47        sqlite: SqliteStore,
48        channel_capacity: usize,
49        batch_size: usize,
50        flush_interval: Duration,
51        retention_days: u32,
52        supervisor: &TaskSupervisor,
53    ) -> Self {
54        let (tx, rx) = mpsc::channel(channel_capacity);
55        let done = Arc::new(Notify::new());
56        let fut = writer_task(
57            sqlite,
58            rx,
59            batch_size,
60            flush_interval,
61            retention_days,
62            Arc::clone(&done),
63        );
64        drop(supervisor.spawn_oneshot(Arc::from("memory.retrieval-failure-logger"), move || fut));
65        Self { tx: Some(tx), done }
66    }
67
68    /// Queue a retrieval failure record for async persistence.
69    ///
70    /// Both `query_text` (512 chars) and `error_context` (256 chars) are truncated
71    /// before enqueueing to bound in-channel memory usage (INV-3). If the channel is
72    /// full the record is dropped and a debug message is emitted (INV-1).
73    pub fn log(&self, mut record: RetrievalFailureRecord) {
74        let _span = tracing::debug_span!("memory.retrieval_failure.log").entered();
75        if record.query_text.chars().count() > QUERY_TEXT_MAX_CHARS {
76            record.query_text = record
77                .query_text
78                .chars()
79                .take(QUERY_TEXT_MAX_CHARS)
80                .collect();
81        }
82        if let Some(ref mut ctx) = record.error_context
83            && ctx.chars().count() > ERROR_CONTEXT_MAX_CHARS
84        {
85            *ctx = ctx.chars().take(ERROR_CONTEXT_MAX_CHARS).collect();
86        }
87        if let Some(tx) = &self.tx
88            && tx.try_send(record).is_err()
89        {
90            tracing::debug!("retrieval_failure_logger: channel full, dropping record");
91        }
92    }
93
94    /// Drain queued records and wait for the writer to flush before returning.
95    ///
96    /// Drops the sender so `writer_task` exits its receive loop, flushes the
97    /// remaining batch, and fires the `done` notify. `TaskSupervisor::shutdown_all`
98    /// should be called after this to clean up the registry entry.
99    pub async fn shutdown(mut self) {
100        drop(self.tx.take());
101        self.done.notified().await;
102    }
103}
104
105async fn writer_task(
106    sqlite: SqliteStore,
107    mut rx: mpsc::Receiver<RetrievalFailureRecord>,
108    batch_size: usize,
109    flush_interval: Duration,
110    retention_days: u32,
111    done: Arc<Notify>,
112) {
113    let mut batch: Vec<RetrievalFailureRecord> = Vec::with_capacity(batch_size);
114    let mut flush_counter: u32 = 0;
115
116    loop {
117        // Collect up to `batch_size` records or until the flush interval elapses.
118        let deadline = tokio::time::sleep(flush_interval);
119        tokio::pin!(deadline);
120
121        loop {
122            tokio::select! {
123                biased;
124                msg = rx.recv() => {
125                    if let Some(record) = msg {
126                        batch.push(record);
127                        if batch.len() >= batch_size {
128                            break;
129                        }
130                    } else {
131                        // Sender dropped — drain remaining and exit.
132                        flush_batch(&sqlite, &mut batch, &mut flush_counter, retention_days).await;
133                        done.notify_one();
134                        return;
135                    }
136                }
137                () = &mut deadline => break,
138            }
139        }
140
141        flush_batch(&sqlite, &mut batch, &mut flush_counter, retention_days).await;
142    }
143}
144
145async fn flush_batch(
146    sqlite: &SqliteStore,
147    batch: &mut Vec<RetrievalFailureRecord>,
148    flush_counter: &mut u32,
149    retention_days: u32,
150) {
151    if batch.is_empty() {
152        return;
153    }
154    let count = batch.len();
155    tracing::debug!(count, "retrieval_failure_logger: flushing batch");
156    let span = tracing::info_span!("memory.retrieval_failure.flush", count);
157    let result = sqlite
158        .record_retrieval_failures_batch(batch)
159        .instrument(span)
160        .await;
161    if let Err(e) = result {
162        tracing::warn!("retrieval_failure_logger: batch write failed: {e:#}");
163    }
164    batch.clear();
165
166    *flush_counter = flush_counter.wrapping_add(1);
167    if (*flush_counter).is_multiple_of(CLEANUP_FLUSH_INTERVAL)
168        && let Err(e) = sqlite.purge_old_retrieval_failures(retention_days).await
169    {
170        tracing::debug!("retrieval_failure_logger: cleanup failed: {e:#}");
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use std::time::Duration;
177
178    use tokio_util::sync::CancellationToken;
179    use zeph_common::task_supervisor::TaskSupervisor;
180
181    use super::*;
182    use crate::store::SqliteStore;
183    use crate::store::retrieval_failures::{RetrievalFailureRecord, RetrievalFailureType};
184
185    fn make_supervisor() -> TaskSupervisor {
186        TaskSupervisor::new(CancellationToken::new())
187    }
188
189    fn no_hit_record() -> RetrievalFailureRecord {
190        RetrievalFailureRecord {
191            conversation_id: None,
192            turn_index: 0,
193            failure_type: RetrievalFailureType::NoHit,
194            retrieval_strategy: "semantic".into(),
195            query_text: "hello world".into(),
196            query_len: 11,
197            top_score: None,
198            confidence_threshold: None,
199            result_count: 0,
200            latency_ms: 5,
201            edge_types: None,
202            error_context: None,
203        }
204    }
205
206    fn low_confidence_record(score: f32, threshold: f32) -> RetrievalFailureRecord {
207        RetrievalFailureRecord {
208            conversation_id: None,
209            turn_index: 0,
210            failure_type: RetrievalFailureType::LowConfidence,
211            retrieval_strategy: "semantic".into(),
212            query_text: "low confidence query".into(),
213            query_len: 20,
214            top_score: Some(score),
215            confidence_threshold: Some(threshold),
216            result_count: 3,
217            latency_ms: 10,
218            edge_types: None,
219            error_context: None,
220        }
221    }
222
223    #[tokio::test]
224    async fn no_hit_failure_is_persisted() {
225        let sqlite = SqliteStore::new(":memory:").await.unwrap();
226        let sup = make_supervisor();
227        let logger = RetrievalFailureLogger::new(
228            sqlite.clone(),
229            256,
230            16,
231            Duration::from_millis(10),
232            90,
233            &sup,
234        );
235        logger.log(no_hit_record());
236        logger.shutdown().await;
237        sup.shutdown_all(Duration::from_secs(5)).await;
238
239        let rows: Vec<(String,)> = sqlx::query_as(
240            "SELECT failure_type FROM memory_retrieval_failures WHERE failure_type = 'no_hit'",
241        )
242        .fetch_all(sqlite.pool())
243        .await
244        .unwrap();
245        assert_eq!(rows.len(), 1, "no_hit record must be persisted");
246    }
247
248    #[tokio::test]
249    async fn low_confidence_failure_is_persisted() {
250        let sqlite = SqliteStore::new(":memory:").await.unwrap();
251        let sup = make_supervisor();
252        let logger = RetrievalFailureLogger::new(
253            sqlite.clone(),
254            256,
255            16,
256            Duration::from_millis(10),
257            90,
258            &sup,
259        );
260        logger.log(low_confidence_record(0.3, 0.7));
261        logger.shutdown().await;
262        sup.shutdown_all(Duration::from_secs(5)).await;
263
264        let rows: Vec<(String, f32, f32)> = sqlx::query_as(
265            "SELECT failure_type, top_score, confidence_threshold \
266             FROM memory_retrieval_failures WHERE failure_type = 'low_confidence'",
267        )
268        .fetch_all(sqlite.pool())
269        .await
270        .unwrap();
271        assert_eq!(rows.len(), 1, "low_confidence record must be persisted");
272        let (_, top_score, threshold) = &rows[0];
273        assert!((*top_score - 0.3_f32).abs() < 1e-5, "top_score must match");
274        assert!(
275            (*threshold - 0.7_f32).abs() < 1e-5,
276            "confidence_threshold must match"
277        );
278    }
279
280    #[tokio::test]
281    async fn log_does_not_block_when_channel_is_full() {
282        let sqlite = SqliteStore::new(":memory:").await.unwrap();
283        let sup = make_supervisor();
284        // capacity = 1 so the second send will be dropped
285        let logger =
286            RetrievalFailureLogger::new(sqlite.clone(), 1, 16, Duration::from_mins(1), 90, &sup);
287        // First log fills the channel (capacity 1).
288        logger.log(no_hit_record());
289        // Second log must not block — try_send drops the record silently.
290        let start = std::time::Instant::now();
291        logger.log(no_hit_record());
292        let elapsed = start.elapsed();
293        assert!(
294            elapsed < Duration::from_millis(100),
295            "log() must be non-blocking even when channel is full, elapsed={elapsed:?}"
296        );
297        logger.shutdown().await;
298        sup.shutdown_all(Duration::from_secs(5)).await;
299    }
300
301    #[tokio::test]
302    async fn query_text_truncated_to_512_chars() {
303        let sqlite = SqliteStore::new(":memory:").await.unwrap();
304        let sup = make_supervisor();
305        let logger = RetrievalFailureLogger::new(
306            sqlite.clone(),
307            256,
308            16,
309            Duration::from_millis(10),
310            90,
311            &sup,
312        );
313        let long_query = "x".repeat(1000);
314        let mut record = no_hit_record();
315        record.query_text = long_query;
316        record.query_len = 1000;
317        logger.log(record);
318        logger.shutdown().await;
319        sup.shutdown_all(Duration::from_secs(5)).await;
320
321        let rows: Vec<(String,)> =
322            sqlx::query_as("SELECT query_text FROM memory_retrieval_failures")
323                .fetch_all(sqlite.pool())
324                .await
325                .unwrap();
326        assert_eq!(rows.len(), 1);
327        assert_eq!(
328            rows[0].0.chars().count(),
329            512,
330            "query_text must be truncated to 512 chars"
331        );
332    }
333
334    #[tokio::test]
335    async fn logger_disabled_when_option_is_none() {
336        let sqlite = SqliteStore::new(":memory:").await.unwrap();
337        // No logger constructed — simulate the disabled path via Option<RetrievalFailureLogger>.
338        let logger: Option<RetrievalFailureLogger> = None;
339        if let Some(l) = &logger {
340            l.log(no_hit_record());
341        }
342        // Nothing written to the store.
343        let rows: Vec<(i64,)> = sqlx::query_as("SELECT COUNT(*) FROM memory_retrieval_failures")
344            .fetch_all(sqlite.pool())
345            .await
346            .unwrap();
347        assert_eq!(
348            rows[0].0, 0,
349            "no records must be written when logger is None"
350        );
351    }
352
353    #[tokio::test]
354    async fn multiple_records_batch_flushed() {
355        let sqlite = SqliteStore::new(":memory:").await.unwrap();
356        let sup = make_supervisor();
357        let logger = RetrievalFailureLogger::new(
358            sqlite.clone(),
359            256,
360            16,
361            Duration::from_millis(10),
362            90,
363            &sup,
364        );
365        for _ in 0..5 {
366            logger.log(no_hit_record());
367        }
368        logger.log(low_confidence_record(0.2, 0.8));
369        logger.shutdown().await;
370        sup.shutdown_all(Duration::from_secs(5)).await;
371
372        let rows: Vec<(i64,)> = sqlx::query_as("SELECT COUNT(*) FROM memory_retrieval_failures")
373            .fetch_all(sqlite.pool())
374            .await
375            .unwrap();
376        assert_eq!(rows[0].0, 6, "all 6 records must be persisted in batch");
377    }
378}