From 0462129a7ba3273682cb42ff529d61ffa4dc5c04 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Sun, 12 Jul 2026 13:45:37 +0300 Subject: [PATCH] libsql-server: Don't fail namespace load on corrupt stats file If the server loses power, the stats file can end up empty because the stats persist loop did not sync the file to disk before renaming it over the old one. On restart, the stats file failed to parse, which errored out namespace load, and took the whole server down: Error: Internal Error: `EOF while parsing a value at line 1 column 0` Caused by: EOF while parsing a value at line 1 column 0 Stats are disposable telemetry, so fall back to default stats with a warning when the stats file does not parse, and sync the stats file to disk before renaming it to close the truncation window. Fixes #2254 --- libsql-server/src/stats.rs | 55 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/libsql-server/src/stats.rs b/libsql-server/src/stats.rs index e91c2505c9..de38f78ed1 100644 --- a/libsql-server/src/stats.rs +++ b/libsql-server/src/stats.rs @@ -246,7 +246,18 @@ impl Stats { let stats_path = db_path.join("stats.json"); let mut this = if stats_path.try_exists()? { let data = tokio::fs::read_to_string(&stats_path).await?; - serde_json::from_str(&data)? + match serde_json::from_str(&data) { + Ok(stats) => stats, + // stats are disposable: a corrupt or truncated stats file + // (e.g. after a power loss) must not prevent the namespace + // from loading + Err(e) => { + tracing::warn!( + "failed to parse stats file for namespace `{namespace}`, resetting stats: {e}" + ); + Stats::default() + } + } } else { Stats::default() }; @@ -528,7 +539,9 @@ async fn try_persist_stats(stats: Weak, path: &Path) -> anyhow::Result<() .await?; file.set_len(0).await?; file.write_all(&serde_json::to_vec(&stats)?).await?; - file.flush().await?; + // sync the file to disk before the rename, otherwise a power loss can + // leave an empty stats file in place of the old one + file.sync_all().await?; tokio::fs::rename(temp_path, path).await?; Ok(()) } @@ -540,3 +553,41 @@ fn next_hour() -> Instant { .unwrap(); Instant::now() + (next_hour - utc_now).to_std().unwrap() } + +#[cfg(test)] +mod tests { + use super::*; + + // a power loss can truncate the stats file; loading it must fall back to + // default stats instead of failing the namespace + #[tokio::test] + async fn corrupt_stats_file_resets_stats() { + let dir = tempfile::tempdir().unwrap(); + let mut join_set = JoinSet::new(); + + for corrupt in ["", "{\"rows_read\":"] { + std::fs::write(dir.path().join("stats.json"), corrupt).unwrap(); + let stats = Stats::new(NamespaceName::default(), dir.path(), &mut join_set) + .await + .unwrap(); + assert_eq!(stats.rows_read(), 0); + assert!(stats.id().is_some()); + } + + join_set.abort_all(); + } + + #[tokio::test] + async fn valid_stats_file_is_loaded() { + let dir = tempfile::tempdir().unwrap(); + let mut join_set = JoinSet::new(); + + std::fs::write(dir.path().join("stats.json"), "{\"rows_read\":42}").unwrap(); + let stats = Stats::new(NamespaceName::default(), dir.path(), &mut join_set) + .await + .unwrap(); + assert_eq!(stats.rows_read(), 42); + + join_set.abort_all(); + } +}