Manage the KV cache disc usage from preferences and stats

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-26 09:10:18 +02:00
parent b182e7007c
commit 63e2a74ad5
14 changed files with 794 additions and 35 deletions

View File

@@ -1,8 +1,8 @@
use crate::model::ModelChoice;
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[repr(u8)]
@@ -584,6 +584,22 @@ impl Metrics {
)
}
/// Re-reads the cache directories after files are removed outside the
/// runtime, so the counters do not drift away from the disc.
pub(crate) fn rescan_cache(&self, root: &Path) {
let usage = cache_usage(root);
self.kv_files.store(usage.files, Ordering::Relaxed);
self.kv_bytes.store(usage.bytes, Ordering::Relaxed);
self.local_kv_files
.store(usage.local_files, Ordering::Relaxed);
self.local_kv_bytes
.store(usage.local_bytes, Ordering::Relaxed);
self.http_kv_files
.store(usage.http_files, Ordering::Relaxed);
self.http_kv_bytes
.store(usage.http_bytes, Ordering::Relaxed);
}
fn record_checkpoint(&self, source: WorkSource, previous_bytes: Option<u64>, bytes: u64) {
if bytes == 0 {
return;
@@ -661,6 +677,122 @@ fn model_name(value: u8) -> &'static str {
}
}
/// Age buckets for the disc usage explorer. The six hour boundary is one
/// eviction hit half-life, so the distribution shows what is about to lose its
/// reuse credit rather than just what is old.
pub(crate) const CACHE_AGE_BUCKETS: [(&str, u64); 5] = [
("Under 1h", 3_600),
("16h", 6 * 3_600),
("624h", 24 * 3_600),
("17d", 7 * 86_400),
("Older", u64::MAX),
];
/// Rows shown in the explorer, oldest first — the order eviction works through.
const CACHE_ENTRY_ROWS: usize = 12;
#[derive(Clone, Debug)]
pub(crate) struct KvCacheEntry {
pub(crate) path: PathBuf,
pub(crate) name: String,
pub(crate) bytes: u64,
pub(crate) age_seconds: u64,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct KvCacheReport {
pub(crate) budget_bytes: u64,
pub(crate) session_bytes: u64,
pub(crate) session_files: u64,
pub(crate) transient_bytes: u64,
pub(crate) transient_files: u64,
/// Bytes per [`CACHE_AGE_BUCKETS`] entry, both buckets combined.
pub(crate) age_bytes: [u64; CACHE_AGE_BUCKETS.len()],
pub(crate) entries: Vec<KvCacheEntry>,
}
impl KvCacheReport {
pub(crate) fn total_bytes(&self) -> u64 {
self.session_bytes + self.transient_bytes
}
}
/// Scans the KV cache directories for the stats explorer. The budget applies to
/// the transient store only; session checkpoints are user-owned and are freed
/// with their session.
pub(crate) fn kv_cache_report(root: &Path, budget_bytes: u64) -> KvCacheReport {
let mut report = KvCacheReport {
budget_bytes,
..KvCacheReport::default()
};
let now = SystemTime::now();
for (directory, transient) in [(root.to_path_buf(), false), (root.join("http"), true)] {
let Ok(files) = fs::read_dir(&directory) else {
continue;
};
for file in files.flatten() {
let path = file.path();
let Ok(metadata) = file.metadata() else {
continue;
};
if !metadata.is_file() {
continue;
}
let bytes = metadata.len();
if transient {
report.transient_bytes += bytes;
report.transient_files += 1;
} else {
report.session_bytes += bytes;
report.session_files += 1;
}
let age_seconds = metadata
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.map_or(0, |age| age.as_secs());
let bucket = CACHE_AGE_BUCKETS
.iter()
.position(|(_, limit)| age_seconds < *limit)
.unwrap_or(CACHE_AGE_BUCKETS.len() - 1);
report.age_bytes[bucket] += bytes;
// Index files are counted in the totals but folded into their
// checkpoint's row.
if path.extension().is_some_and(|value| value == "meta") {
continue;
}
report.entries.push(KvCacheEntry {
name: entry_name(&path, transient),
bytes: bytes.saturating_add(
fs::metadata(path.with_extension("meta")).map_or(0, |index| index.len()),
),
path,
age_seconds,
});
}
}
report.entries.sort_by(|left, right| {
right
.age_seconds
.cmp(&left.age_seconds)
.then_with(|| right.bytes.cmp(&left.bytes))
.then_with(|| left.name.cmp(&right.name))
});
report.entries.truncate(CACHE_ENTRY_ROWS);
report
}
fn entry_name(path: &Path, transient: bool) -> String {
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown");
if transient {
format!("Transient {}", &stem[..stem.len().min(12)])
} else {
format!("Session {stem}")
}
}
#[derive(Default)]
struct CacheUsage {
files: u64,
@@ -752,4 +884,47 @@ mod tests {
assert_eq!(snapshot.local_kv_bytes, 4_096);
assert_eq!(snapshot.http_model_requests, 1);
}
#[test]
fn cache_report_splits_buckets_and_folds_index_files_into_their_checkpoint() {
let root = std::env::temp_dir().join(format!(
"ds4-server-cache-report-{}-{:?}",
std::process::id(),
Instant::now()
));
fs::create_dir_all(root.join("http")).unwrap();
fs::write(root.join("7.bin"), vec![0; 300]).unwrap();
fs::write(root.join("http/abc.bin"), vec![0; 100]).unwrap();
fs::write(root.join("http/abc.meta"), vec![0; 40]).unwrap();
// The largest file is also the oldest, so size cannot pass for age.
let aged = fs::File::options()
.write(true)
.open(root.join("http/abc.bin"))
.unwrap();
aged.set_modified(SystemTime::now() - Duration::from_secs(2 * 86_400))
.unwrap();
let report = kv_cache_report(&root, 4 * 1024);
assert_eq!(report.session_bytes, 300);
assert_eq!(report.session_files, 1);
assert_eq!(report.transient_bytes, 140);
assert_eq!(report.transient_files, 2);
// Fresh files in the first bucket, the two-day-old one in "17d".
assert_eq!(report.age_bytes[0], 340);
assert_eq!(report.age_bytes[3], 100);
assert_eq!(
(
report.age_bytes[1],
report.age_bytes[2],
report.age_bytes[4]
),
(0, 0, 0)
);
// Oldest first, and the index file rides along with its checkpoint.
assert_eq!(report.entries.len(), 2);
assert_eq!(report.entries[0].bytes, 140);
assert_eq!(report.entries[1].name, "Session 7");
fs::remove_dir_all(root).unwrap();
}
}