fix(artwork): bound the image cache during use, make the budget a setting, sweep orphans

The boot trim alone let a long session grow the folder past the budget
again (an early release reached 1 GB). A trim now also runs on the
blocking pool every 16 MB of new originals, the budget comes from
ui_prefs image_cache_max_mb (100/200/500/1000 MB, 200 default), .img files
with no row older than an hour are swept at boot, and the settings
document carries the stats, the size options and a clear action.

cc
This commit is contained in:
vicrodh committed 2026-09-13 09:09:29 -06:00
1 parent 903dfbefc6
commit 903952cacb
12 files changed
+392 -8

No files matched your search

+111 -8
View File
@@ -51,7 +51,9 @@
//! [`housekeeping`] runs on the blocking pool at boot and bounds BOTH caches:
//! the SHARED `~/.cache/qbz/images` (the full-size originals `store` writes;
//! `qbz_cache::ImageCacheService`, SQLite `last_accessed` LRU, budget
//! [`MAX_SHARED_BYTES`], trimmed in [`EVICT_BATCH_ENTRIES`]-row batches so the
//! [`shared_budget_bytes`] (a Settings preference, 200 MB by default), trimmed
//! at boot and again every [`TRIM_EVERY_BYTES`] of new originals, in
//! [`EVICT_BATCH_ENTRIES`]-row batches so the
//! GUI thread is never parked behind a long unlink loop) and this module's
//! OWN derivative directory `images/scaled` ([`MAX_SCALED_BYTES`], LRU by
//! mtime, plus the one-time `.jpg` orphan sweep). The shared trim was the
@@ -61,6 +63,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock};
use qbz_cache::ImageCacheService;
@@ -583,6 +586,7 @@ async fn fetch_jobs_with_ready(jobs: Vec<(String, String)>, ready: Option<Artwor
}
});
if let Some(path) = stored {
note_stored(bytes.len());
// Seed the memo so the republish that follows resolves from
// RAM instead of re-querying SQLite for every card.
memo_put(&key, path.clone());
@@ -922,6 +926,7 @@ async fn download_one(key: String, fetch: String) {
}
});
if let Some(path) = stored {
note_stored(bytes.len());
memo_put(&key, path);
}
}
@@ -994,6 +999,19 @@ mod tests {
assert!(rx.recv().await.is_none(), "duplicate URLs must emit once");
}
#[test]
fn a_trim_is_due_once_the_stored_bytes_cross_the_step() {
let counter = AtomicU64::new(0);
assert!(!trim_due_after(&counter, TRIM_EVERY_BYTES / 2));
assert!(!trim_due_after(&counter, TRIM_EVERY_BYTES / 2 - 1));
assert!(trim_due_after(&counter, 1));
}
#[test]
fn the_shared_budget_defaults_to_200_mb() {
assert_eq!(DEFAULT_SHARED_BUDGET_MB * 1024 * 1024, 200 * 1024 * 1024);
}
#[test]
fn http_urls_are_fetchable() {
assert_eq!(
@@ -1233,13 +1251,85 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option<std::path::PathBuf> {
/// order of magnitude under the 260 MB the parent image cache already holds.
const MAX_SCALED_BYTES: u64 = 64 * 1024 * 1024;
/// Byte budget for the SHARED cache (`~/.cache/qbz/images`): the Slint app's
/// `MAX_CACHE_BYTES` ("matches the Tauri default"), restored unchanged.
const MAX_SHARED_BYTES: u64 = 200 * 1024 * 1024;
/// Default byte budget for the SHARED cache (`~/.cache/qbz/images`): the
/// Tauri/Slint default, now a preference (`ui_prefs` `image_cache_max_mb`,
/// Settings > Offline > Artwork cache).
pub(crate) const DEFAULT_SHARED_BUDGET_MB: u64 = 200;
/// The budget in bytes, from the preference (0 / absent = the default).
pub(crate) fn shared_budget_bytes() -> u64 {
crate::settings_qt::pref_json("image_cache_max_mb")
.and_then(|v| v.as_u64())
.filter(|mb| *mb > 0)
.unwrap_or(DEFAULT_SHARED_BUDGET_MB)
* 1024
* 1024
}
/// Rows deleted per `evict` call; the cache mutex is released between calls.
const EVICT_BATCH_ENTRIES: usize = 64;
/// Bytes stored since the last trim. The Tauri app evicted after EVERY
/// store; here a trim runs on the blocking pool once this many new bytes
/// have landed, so the directory can never sit more than this far above
/// the budget between the boot trim and the next one (an early release
/// grew the folder to 1 GB — never again).
const TRIM_EVERY_BYTES: u64 = 16 * 1024 * 1024;
static STORED_SINCE_TRIM: AtomicU64 = AtomicU64::new(0);
static TRIM_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
/// Account for a freshly stored original. Returns true when a trim is due
/// (the counter is reset by the caller that takes the trim).
fn trim_due_after(counter: &AtomicU64, stored: u64) -> bool {
counter.fetch_add(stored, Ordering::Relaxed) + stored >= TRIM_EVERY_BYTES
}
fn note_stored(stored: usize) {
if !trim_due_after(&STORED_SINCE_TRIM, stored as u64) {
return;
}
STORED_SINCE_TRIM.store(0, Ordering::Relaxed);
trim_shared_now();
}
/// Trim the shared cache to its budget on the blocking pool (a budget
/// change, or the running counter above); one trim in flight at a time.
pub(crate) fn trim_shared_now() {
if TRIM_IN_FLIGHT.swap(true, Ordering::SeqCst) {
return;
}
tokio::task::spawn_blocking(|| {
if let Some(freed) = evict_shared(shared_budget_bytes()) {
if freed > 0 {
log::info!("[qbz-qt][perf] image cache trimmed {freed} B (budget {} B)", shared_budget_bytes());
}
}
TRIM_IN_FLIGHT.store(false, Ordering::SeqCst);
});
}
/// (files, bytes) held by the shared cache, for the Settings stats line.
pub(crate) fn shared_stats() -> Option<(u64, u64)> {
let guard = cache()?.lock().ok()?;
let stats = guard.stats().ok()?;
Some((stats.file_count, stats.total_bytes))
}
/// Delete every original in the shared cache (Settings > Clear). Blocking —
/// call from `spawn_blocking`. The memo is dropped too so no row can keep
/// serving a path that is gone.
pub(crate) fn clear_shared() -> Result<u64, String> {
let cache = cache().ok_or_else(|| "image cache unavailable".to_string())?;
let freed = cache
.lock()
.map_err(|_| "image cache lock poisoned".to_string())?
.clear()?;
if let Ok(mut memo) = RESOLVED.write() {
memo.clear();
}
Ok(freed)
}
/// Trim the shared cache to `max_bytes`, LRU by `last_accessed`, in batches.
/// Blocking (SQLite + unlinks) — call from `spawn_blocking`. Returns the
/// bytes freed, `None` when the cache could not be opened (already logged).
@@ -1342,11 +1432,24 @@ fn sweep_orphan_scaled(dir: &Path) -> (usize, u64) {
/// "Cache eviction". Blocking; call from `spawn_blocking`.
pub fn housekeeping() {
// 1. The SHARED cache (full-size originals): LRU trim to the budget.
if let Some(freed) = evict_shared(MAX_SHARED_BYTES) {
let budget = shared_budget_bytes();
if let Some(freed) = evict_shared(budget) {
if freed > 0 {
log::info!(
"[qbz-qt][perf] image cache evicted {freed} B (budget {MAX_SHARED_BYTES} B)"
);
log::info!("[qbz-qt][perf] image cache evicted {freed} B (budget {budget} B)");
}
}
// 1b. Files the database does not know (a crash mid-store, a lost db):
// never evicted by the LRU, so they are swept here — anything under
// an hour old is left alone in case its store is still in flight.
if let Some(cache) = cache() {
if let Ok(guard) = cache.lock() {
match guard.sweep_orphans(std::time::Duration::from_secs(3_600)) {
Ok((files, bytes)) if files > 0 => {
log::info!("[qbz-qt][perf] image cache swept {files} orphan files ({bytes} B)");
}
Ok(_) => {}
Err(e) => log::warn!("[qbz-qt] image cache orphan sweep failed: {e}"),
}
}
}
// 2. This module's OWN derivative directory: orphan sweep, then byte cap.
+9
View File
@@ -4247,6 +4247,14 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
};
save_pref("startup_page", serde_json::json!(v));
}
"image-cache-max" => {
let Some(mb) = offline::IMAGE_CACHE_MB.get(index) else {
return;
};
save_pref("image_cache_max_mb", serde_json::json!(mb));
// A smaller budget applies right away, not at the next boot.
crate::artwork_qt::trim_shared_now();
}
"genre-filters-position" => {
let Some(v) = LOCAL_GENRE_FILTER_POSITION_VALUES.get(index) else {
return;
@@ -4533,6 +4541,7 @@ pub async fn settings_string(key: &str, value: String) {
"plex-clear-cache" => library::plex_clear_cache().await,
// --- Offline --------------------------------------------------------
"lyrics-cache-clear" => offline::clear_lyrics_cache().await,
"image-cache-clear" => offline::clear_image_cache().await,
// Offline > "Check now": nudge the connectivity actor. The status it
// publishes flows back through offline_fwd's forwarder, so there is
// nothing to await and nothing to republish here.
+40
View File
@@ -30,8 +30,23 @@ pub struct Snapshot {
pub lyrics_entries: i64,
#[serde(rename = "lyricsSize")]
pub lyrics_size: String,
/// Shared artwork cache (`~/.cache/qbz/images`): stats line + budget.
#[serde(rename = "imageCacheLoaded")]
pub image_cache_loaded: bool,
#[serde(rename = "imageCacheFiles")]
pub image_cache_files: i64,
#[serde(rename = "imageCacheSize")]
pub image_cache_size: String,
#[serde(rename = "imageCacheSizes")]
pub image_cache_sizes: Vec<String>,
#[serde(rename = "imageCacheSizeIndex")]
pub image_cache_size_index: i32,
}
/// Budget choices for the artwork cache, in MB (index 1 = the default).
pub(crate) const IMAGE_CACHE_MB: &[u64] = &[100, 200, 500, 1000];
const IMAGE_CACHE_LABELS: &[&str] = &["100 MB", "200 MB", "500 MB", "1 GB"];
fn lyrics_db_path() -> Option<std::path::PathBuf> {
let uid = qbz_app::user_data::UserDataPaths::load_last_user_id()?;
Some(
@@ -81,6 +96,17 @@ pub fn snapshot() -> Snapshot {
_ => (false, 0, String::new()),
};
let (image_cache_loaded, image_cache_files, image_cache_size) =
match crate::artwork_qt::shared_stats() {
Some((files, bytes)) => (true, files as i64, human_size(bytes)),
None => (false, 0, String::new()),
};
let budget_mb = crate::artwork_qt::shared_budget_bytes() / (1024 * 1024);
let image_cache_size_index = IMAGE_CACHE_MB
.iter()
.position(|mb| *mb == budget_mb)
.unwrap_or(1) as i32;
Snapshot {
mode_enabled: settings.manual_offline_mode,
allow_immediate_scrobbling: settings.allow_immediate_scrobbling,
@@ -88,9 +114,23 @@ pub fn snapshot() -> Snapshot {
lyrics_loaded,
lyrics_entries,
lyrics_size,
image_cache_loaded,
image_cache_files,
image_cache_size,
image_cache_sizes: IMAGE_CACHE_LABELS.iter().map(|l| l.to_string()).collect(),
image_cache_size_index,
}
}
/// Settings > Offline > "Clear" on the artwork cache.
pub async fn clear_image_cache() {
let _ = tokio::task::spawn_blocking(|| match crate::artwork_qt::clear_shared() {
Ok(freed) => log::info!("[qbz-qt] image cache cleared ({freed} B)"),
Err(e) => log::error!("[qbz-qt] image cache clear failed: {e}"),
})
.await;
}
pub fn set_allow_immediate_scrobbling(value: bool) -> Result<(), String> {
let result = crate::offline_fwd::engine().set_allow_immediate_scrobbling(value);
if result.is_ok() {