Merge pull request #2553 from arnav-hiwarkar/perf/2551-disk-cache-bounded

Disk cache: 20 GB default and a free-space reserve, so it no longer fills the disk (#2551)
This commit is contained in:
BFlat
2026-10-29 10:09:24 +07:00
committed by GitHub
10 changed files with 325 additions and 45 deletions
+2
View File
@@ -847,10 +847,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
"bytes": st.bytes,
"maxBytes": st.max_bytes,
"limitBytes": st.limit_bytes,
"hits": st.hits,
"misses": st.misses,
"writes": st.writes,
"evictions": st.evictions,
"failed": st.failed,
}))
}
+3
View File
@@ -202,6 +202,8 @@ pub struct Session {
pub key_move: Option<KeyMove>,
/// Reads the system's memory in the background for [`Session::memory_tick`].
pub memory_watch: sysinfo::MemoryWatch,
/// Reads the disk cache's free disk space in the background for [`Session::memory_tick`].
pub disk_watch: sysinfo::DiskWatch,
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
pub applied_nested_switches: Option<bool>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
@@ -327,6 +329,7 @@ impl Default for Session {
sys_memory: None,
key_move: None,
memory_watch: Default::default(),
disk_watch: Default::default(),
applied_nested_switches: None,
offload: None,
offloaded: vec![],
+1 -1
View File
@@ -35,7 +35,7 @@ pub use sections::{
use sections::{Extra, default_labels, lenient_comp_presets};
/// Current settings layout version.
pub const PREFS_VERSION: u32 = 2;
pub const PREFS_VERSION: u32 = 3;
/// Settings file name in the config store.
pub const PREFS_FILE: &str = "prefs.json";
@@ -7,6 +7,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json
///
/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`,
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored
/// old default becomes the new one (a size the user chose is kept).
pub fn migrate(mut v: Value) -> Value {
let Some(obj) = v.as_object_mut() else { return json!({}) };
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
@@ -34,6 +36,12 @@ pub fn migrate(mut v: Value) -> Value {
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
}
}
if version < 3
&& let Some(d) = obj.get_mut("disk").and_then(Value::as_object_mut)
&& d.get("diskCacheMaxGb").and_then(Value::as_u64) == Some(100)
{
d.insert("diskCacheMaxGb".into(), json!(20));
}
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
// Light mode, instead of switching established users to Auto.
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
+1 -1
View File
@@ -179,7 +179,7 @@ page!(Audio {
page!(Disk {
disk_cache_enabled: bool = true,
disk_cache_max_gb: u32 = 100,
disk_cache_max_gb: u32 = 20,
disk_cache_folder: String = String::new(),
media_cache_folder: String = String::new(),
conformed_media_folder: String = String::new(),
+25 -1
View File
@@ -82,7 +82,9 @@ impl Session {
/// Take the latest reading of the system's memory and start the next one in the background
/// (desktop frontends call this every few seconds; it never waits for the system), then
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
/// The disk cache's free disk space is read the same way (it keeps a reserve free).
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
self.poll_disk_space();
let before = self.prefs.cache_budgets(self.sys_memory);
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
self.memory_watch.poll();
@@ -98,6 +100,19 @@ impl Session {
after
}
/// Read the disk cache's free disk space in the background and apply it there (the reserve
/// it keeps free): the free space and the cache's size are taken at the same moment, and the
/// reader holds its own cache, so a reading never reaches another folder's.
fn poll_disk_space(&mut self) {
if let Some(dc) = &self.disk_cache {
let dc = dc.clone();
self.disk_watch.poll_with("ec-disk-space", move || {
dc.set_disk_space(crate::sysinfo::disk_space(dc.folder()));
Some(())
});
}
}
/// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).
@@ -113,6 +128,7 @@ impl Session {
// (no file system in the browser: the disk cache stays off there)
let Some(folder) = folder.filter(|_| d.disk_cache_enabled && !cfg!(target_arch = "wasm32")) else {
self.disk_cache = None;
self.disk_watch = Default::default();
self.layer_cache.set_disk(None);
return;
};
@@ -126,17 +142,25 @@ impl Session {
c.set_max_bytes(max);
c.clone()
}
// A new folder: the free-space reading was for the old one (a reader still running
// holds the old watcher's state, so it can't write into the new one).
_ => match DiskCache::open(&folder, max) {
Ok(c) => c,
Ok(c) => {
self.disk_watch = Default::default();
c
}
Err(e) => {
log::warn!("disk cache at {}: {e}", folder.display());
self.disk_cache = None;
self.disk_watch = Default::default();
self.layer_cache.set_disk(None);
return;
}
},
};
self.disk_cache = Some(dc.clone());
// The reserve applies from the start (a new folder's watcher is idle).
self.poll_disk_space();
let salt = self.refresh_disk_salt(true).unwrap_or_default();
self.layer_cache.set_disk(Some((dc.clone(), salt)));
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
+126 -22
View File
@@ -1,11 +1,12 @@
//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing
//! caches when the system is low on memory) and Help ▸ System Compatibility Report.
//! caches when the system is low on memory), the disk cache's free-space reserve and Help ▸
//! System Compatibility Report.
//!
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
//! configured budgets. The periodic reading behind the cache budgets runs on a background
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `df` on Unix, `wmic`/PowerShell on
//! Windows). Every query may fail (sandboxes, the web): callers treat `None` as "unknown" and
//! keep their configured budgets. The periodic readings behind the cache budgets run on a
//! background thread ([`Watch`]): on Windows they start PowerShell, which takes about a second.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
@@ -27,47 +28,74 @@ impl SysMemory {
}
}
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
#[derive(Clone, Default)]
pub struct MemoryWatch {
latest: Arc<Mutex<Option<SysMemory>>>,
/// A system value read in the background: [`Watch::poll_with`] starts a reading (unless one is
/// running) and [`Watch::latest`] returns the last one without waiting.
#[derive(Clone)]
pub struct Watch<T> {
latest: Arc<Mutex<Option<T>>>,
busy: Arc<AtomicBool>,
}
impl MemoryWatch {
/// Start a new reading in the background (no threads on the web: read inline, it is
/// unknown there anyway).
pub fn poll(&self) {
impl<T> Default for Watch<T> {
fn default() -> Self {
Watch { latest: Arc::new(Mutex::new(None)), busy: Arc::new(AtomicBool::new(false)) }
}
}
/// The system's memory ([`memory`]).
pub type MemoryWatch = Watch<SysMemory>;
/// The disk cache's free-space reading ([`disk_space`]): the reader applies it to its cache
/// itself, so only the busy guard is used.
pub type DiskWatch = Watch<()>;
impl<T: Copy + Send + 'static> Watch<T> {
/// Start a new reading in the background on a thread called `name` (no threads on the web:
/// read inline, it is unknown there anyway).
pub fn poll_with(&self, name: &str, read: impl FnOnce() -> Option<T> + Send + 'static) {
if self.busy.swap(true, Ordering::AcqRel) {
return;
}
let (latest, busy) = (self.latest.clone(), self.busy.clone());
let read = move || {
if let Some(m) = memory() {
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
}
// A failed reading clears the last one: a stale value is worse than none.
*latest.lock().unwrap_or_else(PoisonError::into_inner) = read();
busy.store(false, Ordering::Release);
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
if std::thread::Builder::new().name(name.into()).spawn(read).is_err() {
self.busy.store(false, Ordering::Release);
}
#[cfg(target_arch = "wasm32")]
read();
{
let _ = name;
read();
}
}
/// The last reading (`None` until one finished).
pub fn latest(&self) -> Option<SysMemory> {
/// The last reading (`None` until one finished or when the last one failed).
pub fn latest(&self) -> Option<T> {
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl MemoryWatch {
/// Start a new reading of the system's memory in the background.
pub fn poll(&self) {
self.poll_with("ec-memory", memory);
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run(cmd: &str, args: &[&str]) -> Option<String> {
run_env(cmd, args, &[])
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run_env(cmd: &str, args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> Option<String> {
let mut c = std::process::Command::new(cmd);
c.args(args);
c.args(args).envs(env.iter().copied());
// A console program started from the windowed app would flash a console window.
#[cfg(target_os = "windows")]
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
@@ -139,6 +167,46 @@ pub fn memory() -> Option<SysMemory> {
None
}
/// Parse `df -Pk` output: the disk's total and available bytes.
pub fn parse_df(text: &str) -> Option<(u64, u64)> {
let line = text.lines().skip(1).find(|l| !l.trim().is_empty())?;
let f: Vec<&str> = line.split_whitespace().collect();
// A full disk into its root-reserved blocks reports negative numbers (Linux): none free.
let num = |v: &str| match v.strip_prefix('-') {
Some(d) => d.parse::<u64>().ok().map(|_| 0),
None => v.parse::<u64>().ok(),
};
// Size, used, available and capacity follow the device name (which may contain spaces).
let i = (0..f.len().saturating_sub(3))
.find(|&i| f.get(i + 3).is_some_and(|c| c.ends_with('%')) && f.get(i..i + 3).is_some_and(|n| n.iter().all(|v| num(v).is_some())))?;
let kb = |j: usize| f.get(j).and_then(|v| num(v)).map(|v| v.saturating_mul(1024));
Some((kb(i)?, kb(i + 2)?))
}
/// Total and available bytes of the disk holding `path`, when the system reports them.
pub fn disk_space(path: &std::path::Path) -> Option<(u64, u64)> {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
{
return parse_df(&run("df", &["-Pk", &path.to_string_lossy()])?);
}
#[cfg(target_os = "windows")]
{
// The path goes through the environment: no quoting.
let text = run_env(
"powershell",
&["-NoProfile", "-Command", "$d=[IO.DriveInfo]::new($env:EC_DISK_PATH); \"$($d.TotalSize) $($d.AvailableFreeSpace)\""],
&[("EC_DISK_PATH", path.as_os_str())],
)?;
let mut it = text.split_whitespace().filter_map(|v| v.parse::<u64>().ok());
return Some((it.next()?, it.next()?));
}
#[allow(unreachable_code)]
{
let _ = path;
None
}
}
/// CPU model name.
pub fn cpu_name() -> Option<String> {
#[cfg(target_os = "linux")]
@@ -225,6 +293,42 @@ mod tests {
assert_eq!(m.available, 1000);
}
#[test]
fn a_failed_reading_clears_the_last_one() {
let w = Watch::<u64>::default();
let wait = || {
for _ in 0..500 {
if !w.busy.load(Ordering::Acquire) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
panic!("the reading never finished");
};
w.poll_with("ec-test", || Some(7));
wait();
assert_eq!(w.latest(), Some(7));
w.poll_with("ec-test", || None);
wait();
assert_eq!(w.latest(), None);
}
#[test]
fn parses_df() {
let linux = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/nvme0n1p2 488281250 123456789 364824461 26% /\n";
assert_eq!(parse_df(linux), Some((488_281_250 * 1024, 364_824_461 * 1024)));
// macOS: a device name with spaces and a mount point with spaces.
let mac = "Filesystem 1024-blocks Used Available Capacity Mounted on\nmap auto_home 0 0 0 100% /System/Volumes/Data/home\n";
assert_eq!(parse_df(mac), Some((0, 0)));
let spaced = "Filesystem 1024-blocks Used Available Capacity Mounted on\n//nas/My Share 1000 400 600 40% /Volumes/My Share\n";
assert_eq!(parse_df(spaced), Some((1000 * 1024, 600 * 1024)));
// A full disk into its reserved blocks: nothing available.
let full = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda2 1000 1010 -10 101% /\n";
assert_eq!(parse_df(full), Some((1000 * 1024, 0)));
assert_eq!(parse_df("Filesystem 1024-blocks\n"), None);
assert_eq!(parse_df(""), None);
}
#[test]
fn parses_proc_meminfo_and_vm_stat() {
let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap();
+1 -1
View File
@@ -35,7 +35,7 @@ fn disk_cache_settings_persistence_and_invalidation() {
let mut s = session(&dir);
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
assert_eq!(st["enabled"], json!(true));
assert_eq!(st["maxBytes"], json!(100u64 << 30));
assert_eq!(st["maxBytes"], json!(20u64 << 30));
let cid = project(&mut s);
let t = s.time();
let first = s.render(cid, t, RenderOpts::default());
+8
View File
@@ -71,6 +71,14 @@ fn prefs_migrate_old_layout_and_keep_unknown_keys() {
assert_eq!(Prefs::from_json("not json"), Prefs::default());
}
#[test]
fn prefs_migrate_old_disk_cache_default() {
// The old 100 GB default becomes 20 GB; a size the user chose stays (#2551).
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 20);
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 50}}"#).disk.disk_cache_max_gb, 50);
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
+150 -19
View File
@@ -12,9 +12,11 @@
//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are
//! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and
//! are deleted.
//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the
//! folder when the cache opens (file modification times order it), so the cache survives
//! restarts; reads refresh a file's time.
//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan
//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep
//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is
//! rebuilt from the folder when the cache opens (file modification times order it), so the
//! cache survives restarts; reads refresh a file's time.
//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place
//! (atomic on every desktop OS), so readers in this or another process never see a partial
//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a
@@ -22,7 +24,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use effectcraft_effects::Buf;
@@ -75,11 +77,15 @@ pub struct DiskStats {
pub entries: usize,
pub bytes: u64,
pub max_bytes: u64,
/// The limit in effect: `max_bytes`, or less when the disk is short of free space.
pub limit_bytes: u64,
pub hits: u64,
pub misses: u64,
pub writes: u64,
pub evictions: u64,
pub dropped: u64,
/// Writes that failed (a full disk, permissions).
pub failed: u64,
}
struct Meta {
@@ -157,15 +163,17 @@ impl DiskIndex {
v.into_iter().map(|e| e.1).collect()
}
/// Remove least recently used entries until the total fits `max` bytes; returns them (the
/// caller deletes their files).
/// When the total exceeds `max` bytes, remove least recently used entries down to 90% of
/// it (so the sort runs once per tenth of `max` written, not on every write); returns them
/// (the caller deletes their files).
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
if self.total <= max {
return vec![];
}
let target = max - max / 10;
let mut out = vec![];
for k in self.lru_order() {
if self.total <= max {
if self.total <= target {
break;
}
self.remove(k.0, k.1);
@@ -225,11 +233,16 @@ pub struct DiskCache {
dir: PathBuf,
index: Mutex<DiskIndex>,
max_bytes: AtomicU64,
/// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`].
disk_cap: AtomicU64,
/// Free space is limiting the cache (warned once per episode).
space_limited: AtomicBool,
hits: AtomicU64,
misses: AtomicU64,
writes: AtomicU64,
evictions: AtomicU64,
dropped: AtomicU64,
failed: AtomicU64,
tmp_counter: AtomicU64,
queue: Arc<(Mutex<Queue>, Condvar)>,
/// Layer buffers that took less than this to render are not written (ms).
@@ -298,16 +311,21 @@ impl DiskCache {
dir,
index: Mutex::new(DiskIndex::default()),
max_bytes: AtomicU64::new(max_bytes),
disk_cap: AtomicU64::new(u64::MAX),
space_limited: AtomicBool::new(false),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
writes: AtomicU64::new(0),
evictions: AtomicU64::new(0),
dropped: AtomicU64::new(0),
failed: AtomicU64::new(0),
tmp_counter: AtomicU64::new(0),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())),
min_layer_ms: AtomicU64::new(30),
});
dc.rescan();
// Shrinking a large cache deletes many files: the writer thread does it.
#[cfg(target_arch = "wasm32")]
dc.evict();
#[cfg(not(target_arch = "wasm32"))]
{
@@ -371,6 +389,32 @@ impl DiskCache {
self.evict();
}
/// The limit in effect: the setting, or less when the disk is short of free space.
pub fn limit_bytes(&self) -> u64 {
self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed))
}
/// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least
/// 2 GB or 5% of the disk free, whichever is more (evicting when it must).
pub fn set_disk_space(&self, space: Option<(u64, u64)>) {
let cap = match space {
// (a disk reported as 0 bytes, such as an automount map, is unknown)
Some((total, available)) if total > 0 => {
let reserve = (2u64 << 30).max(total / 20);
let used = self.index.lock().map(|g| g.total()).unwrap_or_default();
// The cache's own files count as free: below the reserve it gives space back.
used.saturating_add(available).saturating_sub(reserve)
}
_ => u64::MAX,
};
self.disk_cap.store(cap, Ordering::Relaxed);
let limited = cap < self.max_bytes();
if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited {
log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20);
}
self.evict();
}
/// Layer buffers faster to render than this are not worth a disk write.
pub fn set_min_layer_ms(&self, ms: u64) {
self.min_layer_ms.store(ms, Ordering::Relaxed);
@@ -395,11 +439,13 @@ impl DiskCache {
entries,
bytes,
max_bytes: self.max_bytes(),
limit_bytes: self.limit_bytes(),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
writes: self.writes.load(Ordering::Relaxed),
evictions: self.evictions.load(Ordering::Relaxed),
dropped: self.dropped.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
}
}
@@ -494,30 +540,44 @@ impl DiskCache {
}
let path = self.path(kind, key);
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.write_failed(&e);
return;
}
let data = seal(kind, payload);
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
if std::fs::write(&tmp, &data).is_err() {
let _ = std::fs::remove_file(&tmp);
// An entry that cannot fit would be evicted straight away.
if data.len() as u64 > self.limit_bytes() {
return;
}
if std::fs::rename(&tmp, &path).is_err() {
// Make room first, so the cache never exceeds its limit (or the free-space reserve).
self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64));
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) {
let _ = std::fs::remove_file(&tmp);
self.write_failed(&e);
return;
}
self.writes.fetch_add(1, Ordering::Relaxed);
if let Ok(mut g) = self.index.lock() {
g.insert(kind, key, data.len() as u64);
}
self.evict();
}
/// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed).
fn write_failed(&self, e: &std::io::Error) {
if self.failed.fetch_add(1, Ordering::Relaxed) == 0 {
log::warn!("disk cache at {}: a write failed: {e}", self.folder().display());
}
}
/// Remove least recently used entries until the cache fits its limit.
fn evict(&self) {
let max = self.max_bytes();
self.evict_to(self.limit_bytes());
}
/// Remove least recently used entries until the cache fits `max` bytes.
fn evict_to(&self, max: u64) {
let victims = match self.index.lock() {
Ok(mut g) => g.evict(max),
Err(_) => return,
@@ -586,6 +646,15 @@ impl DiskCache {
#[cfg(not(target_arch = "wasm32"))]
fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
let (m, cv) = &*q;
// The eviction at open (a lower limit than the folder holds), off the opening thread;
// `busy` starts set, so `flush` waits for it.
if let Some(cache) = dc.upgrade() {
cache.evict();
}
if let Ok(mut g) = m.lock() {
g.busy = false;
}
cv.notify_all();
loop {
let job = {
let Ok(mut g) = m.lock() else { return };
@@ -1030,9 +1099,9 @@ mod tests {
let per = dc.stats().bytes / 6;
// Touch 0 so it is the most recently used.
assert!(dc.get_layer(0).is_some());
dc.set_max_bytes(per * 3 + per / 2);
dc.set_max_bytes(per * 3 + per * 3 / 4);
let st = dc.stats();
assert!(st.bytes <= per * 3 + per / 2, "{st:?}");
assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}");
assert_eq!(st.entries, 3);
assert!(dc.contains(Kind::Layer, 0), "recently used survives");
assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3));
@@ -1042,7 +1111,69 @@ mod tests {
assert_eq!(files, 3);
// New writes keep evicting.
dc.put_layer_now(99, &buf(9, 64, 64));
assert!(dc.stats().bytes <= per * 3 + per / 2);
assert!(dc.stats().bytes <= per * 3 + per * 3 / 4);
}
#[test]
fn eviction_goes_down_to_ninety_percent_in_lru_order() {
let mut ix = DiskIndex::default();
for k in 0..10u128 {
ix.insert(Kind::Layer, k, 100);
}
assert!(ix.touch(Kind::Layer, 0));
// Over 950: down to 855, least recently used first.
ix.insert(Kind::Layer, 10, 100);
assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]);
assert_eq!(ix.total(), 800);
// The slack absorbs the next write without another scan.
ix.insert(Kind::Layer, 11, 100);
assert!(ix.evict(950).is_empty());
}
#[test]
fn free_disk_space_caps_the_limit() {
let dir = tmpdir("space");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
for k in 0..4u128 {
dc.put_layer_now(k, &buf(k as u32, 64, 64));
}
let used = dc.stats().bytes;
// Unknown space: the setting alone.
dc.set_disk_space(None);
assert_eq!(dc.stats().limit_bytes, 1 << 30);
// A disk reported as 0 bytes is unknown.
dc.set_disk_space(Some((0, 0)));
assert_eq!(dc.stats().limit_bytes, 1 << 30);
// 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow.
dc.set_disk_space(Some((100 << 30, 5 << 30)));
let st = dc.stats();
assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used));
dc.put_layer_now(9, &buf(9, 64, 64));
let st = dc.stats();
assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}");
assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used");
// Below the reserve the cache gives its space back, and skips writes that can't fit.
dc.set_disk_space(Some((100 << 30, 4 << 30)));
dc.put_layer_now(10, &buf(10, 64, 64));
let st = dc.stats();
assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}");
// Plenty of space again: back to the setting.
dc.set_disk_space(Some((100 << 30, 50 << 30)));
assert_eq!(dc.stats().limit_bytes, 1 << 30);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_writes_are_counted() {
let dir = tmpdir("failed");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
// A file where the layers folder should be: every layer write fails.
std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap();
dc.put_layer_now(1, &buf(1, 8, 8));
dc.put_layer_now(2, &buf(2, 8, 8));
let st = dc.stats();
assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0));
let _ = std::fs::remove_dir_all(&dir);
}
fn walk(d: &Path) -> usize {