Manage the KV cache disc usage from preferences and stats
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
179
src/metrics.rs
179
src/metrics.rs
@@ -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),
|
||||
("1–6h", 6 * 3_600),
|
||||
("6–24h", 24 * 3_600),
|
||||
("1–7d", 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 "1–7d".
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user