perf(storage): prune error_log on startup with 90-day retention
The error_log table had no retention policy: every backend error was
appended forever, so across months of dogfooding it grew unbounded. That
silently bloats the diagnostic-bundle export and slows the
list_recent_errors query the Settings → About panel runs.
- New `kon_storage::prune_error_log(pool, keep_days)` does a single
`DELETE FROM error_log WHERE timestamp < datetime('now', '-Nd days')`
and returns the row count removed.
- src-tauri/src/lib.rs runs it once during setup() with a const
ERROR_LOG_RETENTION_DAYS = 90. Failure is logged to stderr but does not
block startup — a prune that fails is strictly less important than the
app coming up.
- Test: insert three rows at now / -30d / -200d, verify a 90-day prune
removes only the oldest, and a subsequent 14-day prune removes the
-30d row. Storage suite at 60/60.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
This commit is contained in:
@@ -1155,6 +1155,26 @@ pub struct ErrorLogRow {
|
|||||||
pub metadata: Option<String>,
|
pub metadata: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete `error_log` rows older than `keep_days` whole days. Returns the
|
||||||
|
/// row count removed.
|
||||||
|
///
|
||||||
|
/// Called once on app startup so the table doesn't grow unbounded across
|
||||||
|
/// months of dogfooding — without this it would eventually balloon the
|
||||||
|
/// diagnostic-bundle export and slow the `list_recent_errors` query.
|
||||||
|
/// 90 days is the default: long enough to triage a "this happened a few
|
||||||
|
/// weeks ago" report, short enough that the table stays kilobyte-scale.
|
||||||
|
pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM error_log \
|
||||||
|
WHERE timestamp < datetime('now', ?)",
|
||||||
|
)
|
||||||
|
.bind(format!("-{keep_days} days"))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Prune error_log failed: {e}")))?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<ErrorLogRow>> {
|
pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<ErrorLogRow>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, timestamp, context, error_code, message, metadata \
|
"SELECT id, timestamp, context, error_code, message, metadata \
|
||||||
@@ -2471,4 +2491,44 @@ mod tests {
|
|||||||
"must fail when DB file does not exist (no create_if_missing)",
|
"must fail when DB file does not exist (no create_if_missing)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prune_error_log_removes_old_rows_only() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
|
||||||
|
// Three rows: one from now, one 30 days old, one 200 days old.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO error_log (timestamp, context, error_code, message) VALUES \
|
||||||
|
(datetime('now'), 'recent', NULL, 'now'), \
|
||||||
|
(datetime('now', '-30 days'), 'older', NULL, '30d'), \
|
||||||
|
(datetime('now', '-200 days'), 'oldest', NULL, '200d')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Default 90-day retention removes only the 200-day row.
|
||||||
|
let removed = prune_error_log(&pool, 90).await.unwrap();
|
||||||
|
assert_eq!(removed, 1, "only the 200-day row should be pruned");
|
||||||
|
|
||||||
|
let remaining: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT context FROM error_log ORDER BY id ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]);
|
||||||
|
|
||||||
|
// A 14-day window prunes the 30-day row too.
|
||||||
|
let removed = prune_error_log(&pool, 14).await.unwrap();
|
||||||
|
assert_eq!(removed, 1);
|
||||||
|
|
||||||
|
let remaining: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT context FROM error_log",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining, vec!["recent".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ pub use database::{
|
|||||||
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
||||||
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
||||||
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||||
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
|
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
||||||
|
set_setting,
|
||||||
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||||
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
||||||
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ use tauri::Manager;
|
|||||||
|
|
||||||
use kon_core::types::EngineName;
|
use kon_core::types::EngineName;
|
||||||
use kon_llm::LlmEngine;
|
use kon_llm::LlmEngine;
|
||||||
use kon_storage::{database_path, get_setting, init as init_db, set_setting};
|
use kon_storage::{
|
||||||
|
database_path, get_setting, init as init_db, prune_error_log, set_setting,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// How long to retain `error_log` rows. Pruned once on startup.
|
||||||
|
/// 90 days is long enough to triage "this happened a few weeks ago"
|
||||||
|
/// reports and short enough that the table stays kilobyte-scale.
|
||||||
|
const ERROR_LOG_RETENTION_DAYS: i64 = 90;
|
||||||
use kon_transcription::LocalEngine;
|
use kon_transcription::LocalEngine;
|
||||||
|
|
||||||
/// Shared app state holding the transcription engines and database pool.
|
/// Shared app state holding the transcription engines and database pool.
|
||||||
@@ -158,6 +165,22 @@ pub fn run() {
|
|||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
eprintln!("[startup] DB init: {:?}", t0.elapsed());
|
eprintln!("[startup] DB init: {:?}", t0.elapsed());
|
||||||
|
|
||||||
|
// Prune old `error_log` rows so the table doesn't grow unbounded
|
||||||
|
// across months of dogfooding. Best-effort — a prune failure is
|
||||||
|
// not worth blocking startup over.
|
||||||
|
let t_prune = Instant::now();
|
||||||
|
let pruned = tauri::async_runtime::block_on(async {
|
||||||
|
prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await
|
||||||
|
});
|
||||||
|
match pruned {
|
||||||
|
Ok(n) if n > 0 => eprintln!(
|
||||||
|
"[startup] Error log prune: {n} rows removed (>{ERROR_LOG_RETENTION_DAYS}d) in {:?}",
|
||||||
|
t_prune.elapsed()
|
||||||
|
),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => eprintln!("[startup] Error log prune failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
// Load saved preferences for webview injection
|
// Load saved preferences for webview injection
|
||||||
let t1 = Instant::now();
|
let t1 = Instant::now();
|
||||||
let prefs_json = tauri::async_runtime::block_on(async {
|
let prefs_json = tauri::async_runtime::block_on(async {
|
||||||
|
|||||||
Reference in New Issue
Block a user