1use 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;
22const CLEANUP_FLUSH_INTERVAL: u32 = 500;
24
25pub struct RetrievalFailureLogger {
34 tx: Option<mpsc::Sender<RetrievalFailureRecord>>,
35 done: Arc<Notify>,
37}
38
39impl RetrievalFailureLogger {
40 #[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 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 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 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 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 let logger =
286 RetrievalFailureLogger::new(sqlite.clone(), 1, 16, Duration::from_mins(1), 90, &sup);
287 logger.log(no_hit_record());
289 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 let logger: Option<RetrievalFailureLogger> = None;
339 if let Some(l) = &logger {
340 l.log(no_hit_record());
341 }
342 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}