use crate::db::params;
use crate::logs::LogStore;
use anyhow::Context;
use chrono::{Duration as ChronoDuration, Utc};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, warn};
const TELEMETRY_HOURS: i64 = 8;
const FAILED_TOOL_CALL_HOURS: i64 = 24 * 7;
const BATCH_ROWS: u64 = 1_000;
const BATCH_PAUSE: Duration = Duration::from_millis(50);
const SWEEP_BUDGET: Duration = Duration::from_secs(30);
const SWEEP_INTERVAL: Duration = Duration::from_mins(10);
struct RetentionRule {
table: &'static str,
expired: &'static str,
window_hours: i64,
}
const RETENTION_RULES: &[RetentionRule] = &[
RetentionRule {
table: "tool_calls",
expired: "success = 1 AND error_message IS NULL AND recorded_at < ?1",
window_hours: TELEMETRY_HOURS,
},
RetentionRule {
table: "tool_calls",
expired: "(success = 0 OR error_message IS NOT NULL) AND recorded_at < ?1",
window_hours: FAILED_TOOL_CALL_HOURS,
},
RetentionRule {
table: "llm_requests",
expired: "recorded_at < ?1",
window_hours: TELEMETRY_HOURS,
},
RetentionRule {
table: "grep_telemetry",
expired: "recorded_at < ?1",
window_hours: TELEMETRY_HOURS,
},
RetentionRule {
table: "logs",
expired: "level = 'INFO' AND timestamp < ?1",
window_hours: TELEMETRY_HOURS,
},
];
impl RetentionRule {
async fn delete_batch(&self, store: &LogStore) -> anyhow::Result<u64> {
let batch_rows = i64::try_from(BATCH_ROWS).expect("batch size fits in i64");
let cutoff = (Utc::now() - ChronoDuration::hours(self.window_hours)).to_rfc3339();
let sql = format!(
"DELETE FROM {table} WHERE id IN (SELECT id FROM {table} WHERE {expired} LIMIT ?2)",
table = self.table,
expired = self.expired,
);
store
.conn
.execute(&sql, params![cutoff, batch_rows])
.await
.with_context(|| format!("Failed to delete expired rows from {}", self.table))
}
}
async fn sweep_expired(store: &LogStore) -> u64 {
let deadline = Instant::now() + SWEEP_BUDGET;
let mut removed = 0;
let mut failed = vec![false; RETENTION_RULES.len()];
loop {
let mut saturated = false;
for (index, rule) in RETENTION_RULES.iter().enumerate() {
if failed[index] {
continue;
}
match rule.delete_batch(store).await {
Ok(n) => {
removed += n;
saturated |= n == BATCH_ROWS;
}
Err(e) => {
warn!("Log retention: could not remove expired records: {e:#}");
failed[index] = true;
}
}
}
if !saturated || Instant::now() >= deadline {
return removed;
}
if !crate::shutdown::sleep_or_shutdown_or_drain(BATCH_PAUSE).await {
return removed;
}
}
}
pub async fn run_retention_loop(store: Arc<LogStore>) {
loop {
if !crate::shutdown::sleep_or_shutdown_or_drain(SWEEP_INTERVAL).await {
break;
}
match sweep_expired(&store).await {
0 => debug!("Log retention: nothing to remove"),
removed => debug!(removed, "Log retention: removed expired records"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn seed(store: &LogStore, sql: &str, age_hours: i64) {
let at = (Utc::now() - ChronoDuration::hours(age_hours)).to_rfc3339();
store.conn.execute(sql, params![at]).await.unwrap();
}
async fn count(store: &LogStore, table: &str, predicate: &str) -> i64 {
store
.conn
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE {predicate}"),
params![],
|row| row.get::<i64>(0),
)
.await
.unwrap()
}
#[tokio::test]
async fn only_records_past_their_window_are_removed() {
let (store, _dir) = crate::open_test_store!(LogStore, "log");
for age in [1, 9] {
seed(
&store,
"INSERT INTO tool_calls (agent_id, role, tool_name, recorded_at) \
VALUES ('a', 'Worker', 'read', ?1)",
age,
)
.await;
seed(
&store,
"INSERT INTO tool_calls (agent_id, role, tool_name, error_message, recorded_at) \
VALUES ('a', 'Worker', 'read', 'boom', ?1)",
age,
)
.await;
seed(
&store,
"INSERT INTO tool_calls (agent_id, role, tool_name, success, recorded_at) \
VALUES ('a', 'Worker', 'read', 0, ?1)",
age,
)
.await;
seed(
&store,
"INSERT INTO llm_requests (recorded_at, purpose, model, duration_ms, retry_attempts) \
VALUES (?1, 'p', 'm', 1, 0)",
age,
)
.await;
seed(
&store,
"INSERT INTO grep_telemetry (recorded_at, command) VALUES (?1, 'grep x')",
age,
)
.await;
for level in ["INFO", "DEBUG"] {
seed(
&store,
&format!(
"INSERT INTO logs (timestamp, level, target, message) VALUES (?1, '{level}', 't', 'm')"
),
age,
)
.await;
}
}
seed(
&store,
"INSERT INTO tool_calls (agent_id, role, tool_name, error_message, recorded_at) \
VALUES ('a', 'Worker', 'read', 'boom', ?1)",
24 * 8,
)
.await;
seed(
&store,
"INSERT INTO tool_calls (agent_id, role, tool_name, success, recorded_at) \
VALUES ('a', 'Worker', 'read', 0, ?1)",
24 * 8,
)
.await;
seed(
&store,
"INSERT INTO logs (timestamp, level, target, message) VALUES (?1, 'WARN', 't', 'm')",
24 * 30,
)
.await;
seed(
&store,
"INSERT INTO llm_failures (operation_id, recorded_at, purpose, model, attempt, failure_class) \
VALUES ('op', ?1, 'p', 'm', 1, 'c')",
24 * 30,
)
.await;
sweep_expired(&store).await;
assert_eq!(
count(
&store,
"tool_calls",
"success = 1 AND error_message IS NULL"
)
.await,
1
);
assert_eq!(
count(
&store,
"tool_calls",
"success = 0 OR error_message IS NOT NULL"
)
.await,
4
);
assert_eq!(count(&store, "llm_requests", "1 = 1").await, 1);
assert_eq!(count(&store, "grep_telemetry", "1 = 1").await, 1);
assert_eq!(count(&store, "logs", "level = 'INFO'").await, 1);
assert_eq!(count(&store, "logs", "level = 'DEBUG'").await, 2);
assert_eq!(count(&store, "logs", "level = 'WARN'").await, 1);
assert_eq!(count(&store, "llm_failures", "1 = 1").await, 1);
}
#[tokio::test]
#[serial_test::serial(drain)] async fn a_backlog_is_removed_in_bounded_batches() {
let (store, _dir) = crate::open_test_store!(LogStore, "log");
let at = (Utc::now() - ChronoDuration::hours(9)).to_rfc3339();
let tx = store.conn.begin_tx().await.unwrap();
for _ in 0..2_500 {
tx.execute(
"INSERT INTO grep_telemetry (recorded_at, command) VALUES (?1, 'grep x')",
params![at.clone()],
)
.await
.unwrap();
}
tx.commit().await.unwrap();
let rule = RETENTION_RULES
.iter()
.find(|rule| rule.table == "grep_telemetry")
.expect("grep telemetry is a retained class");
assert_eq!(
rule.delete_batch(&store).await.unwrap(),
BATCH_ROWS,
"one statement removes exactly one batch"
);
assert_eq!(
count(&store, "grep_telemetry", "1 = 1").await,
1_500,
"and leaves the rest of the backlog for the next batch"
);
sweep_expired(&store).await;
assert_eq!(count(&store, "grep_telemetry", "1 = 1").await, 0);
}
}