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:
12 files changed
+392
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user