mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(cover-backfill): kill idle CPU spin and offline-cache menu re-walks (#943)
* fix(cover-backfill): snapshot-diff worklist and live-tunable parallelism Aggressive cover backfill pegged one tokio worker at ~100% on large, fully-synced libraries while the download queues stayed empty. - Take two snapshots once per pass — the DB catalog (single GROUP BY) and the on-disk cover bucket (one directory walk) — and download the set-difference. No per-row `stat` syscalls and no re-scan loop; the empty cache case (heavy backfill) costs zero per-item disk hits. - Replace the front-loaded enumeration with a producer/consumer pipeline: the producer streams the catalog in chunks and feeds misses into a bounded channel; a fixed consumer pool keeps the download/encode pools saturated. - Make cover backfill parallelism runtime-tunable from the Performance Probe (threads slider + "Run full pass now"); HTTP download and CPU encode semaphores resize live. Not surfaced in app settings. - Add a "nothing changed" idle gate (catalog signature) so a settled pass is not re-run on every library:sync-idle, mirroring the analysis worker. - Cancel promptly on switch to lazy: consumers bail on enabled/focus change and the producer feeds via try_send so a full channel cannot deadlock. - Drop the per-item recursive disk walk from the ensure hot path. * fix(cover-backfill): cheap idle gate, settle on 404s, transient retries Follow-up to the snapshot-diff backfill: stop the periodic CPU spikes and the 89%-plateau wake storm on libraries whose covers can never reach 100%. - Idle gate is now disk-free: compare only the catalog COUNT(DISTINCT) instead of walking ~all cover dirs on every sync-idle. "Did the server change?" never touches the filesystem. Clear-cache commands re-arm the gate (rearm_idle_gate) since a clear leaves the catalog total unchanged, and the settings UI wakes the active server after a clear. - Settle the gate on any completed pass regardless of pending: remaining items are unfetchable-for-now (404), so the wake/sync-idle storm stops once the fetchable set is exhausted. - Stop auto-clearing .fetch-failed markers every pass (it defeated the 30-min backoff and re-attempted 404s forever). The manual "Run full pass now" sends force=true to clear them and retry; wake/sync-idle/configure stay opportunistic. - Rate-limit sync-idle passes (60s cooldown) as defence against chatty syncs. - Retry cover downloads up to 3x with backoff on transient failures (5xx / 429 / network), but never on a real 4xx so missing covers don't hammer the server. * fix(cover-cache): stop re-walking cover dirs from offline & cache menu The settings cover-cache section polled disk usage + progress every 15s for every server, each call doing a full recursive walk of the per-server cover directory. On a fully populated cache this caused periodic CPU spikes whenever that menu was open. - mod.rs: add a 10s TTL memo around the per-server cover dir walk (cached_dir_usage_for_server), shared by cover_cache_stats_server and library_cover_progress; invalidate on clear (per-server and clear-all). - CoverCacheStrategySection: recompute on entry only; rely on the cover:library-progress and cover:cache-cleared events for live updates; drop the per-cover cover:tier-ready refresh storm; turn the 15s loop into a 5-minute safety net. * fix(cover-backfill): keep emitting progress during the whole pass The producer finishes enumerating the worklist long before the consumer pool finishes downloading it, so progress was only emitted while feeding the channel — the "offline & cache" menu and overlay then froze through the entire drain phase. Replace the per-chunk emit with a 3s progress ticker that runs for the lifetime of the pass and is aborted once the consumers drain (final accurate emit still happens at settle). * docs(changelog): record cover-backfill idle-CPU fix (PR #943) Add [1.47.0] Fixed + Changed entries and a settingsCredits line for the cover-backfill idle CPU / offline & cache menu work.
This commit is contained in:
@@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
@@ -79,10 +80,10 @@ pub(crate) fn cover_pipeline_queue_stats(
|
||||
http_active: sem_active(&cache.http_sem, COVER_HTTP_CONCURRENCY as u32),
|
||||
cpu_ui_max: COVER_CPU_UI_CONCURRENCY as u32,
|
||||
cpu_ui_active: sem_active(&cache.cover_cpu_ui_sem, COVER_CPU_UI_CONCURRENCY as u32),
|
||||
cpu_backfill_max: COVER_CPU_BACKFILL_CONCURRENCY as u32,
|
||||
cpu_backfill_max: cache.cover_backfill_cpu_parallel() as u32,
|
||||
cpu_backfill_active: sem_active(
|
||||
&cache.cover_cpu_backfill_sem,
|
||||
COVER_CPU_BACKFILL_CONCURRENCY as u32,
|
||||
cache.cover_backfill_cpu_parallel() as u32,
|
||||
),
|
||||
library_backfill_http_max,
|
||||
library_backfill_http_active,
|
||||
@@ -117,7 +118,10 @@ const COVER_HTTP_CONCURRENCY: usize = 16;
|
||||
/// UI-visible decode + WebP encode (grid, hero, player) — not shared with library backfill.
|
||||
const COVER_CPU_UI_CONCURRENCY: usize = 2;
|
||||
/// Library backfill encode ladder — separate pool so bulk warm-up cannot starve the webview.
|
||||
/// Default only; runtime-tunable from the perf probe via `set_backfill_cpu_parallel`.
|
||||
const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2;
|
||||
/// Upper bound for the runtime encode-pool knob (matches the worker cap).
|
||||
const COVER_CPU_BACKFILL_MAX: usize = 16;
|
||||
|
||||
pub struct CoverCacheState {
|
||||
pub root: PathBuf,
|
||||
@@ -128,6 +132,9 @@ pub struct CoverCacheState {
|
||||
pub http_sem: Arc<Semaphore>,
|
||||
pub cover_cpu_ui_sem: Arc<Semaphore>,
|
||||
pub cover_cpu_backfill_sem: Arc<Semaphore>,
|
||||
/// Live permit count of `cover_cpu_backfill_sem` (the semaphore itself only
|
||||
/// exposes *available* permits, not the configured ceiling).
|
||||
cover_cpu_backfill_max: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CoverCacheState {
|
||||
@@ -147,9 +154,35 @@ impl CoverCacheState {
|
||||
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
|
||||
cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)),
|
||||
cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)),
|
||||
cover_cpu_backfill_max: AtomicUsize::new(COVER_CPU_BACKFILL_CONCURRENCY),
|
||||
})
|
||||
}
|
||||
|
||||
/// Current configured ceiling of the backfill encode pool.
|
||||
pub fn cover_backfill_cpu_parallel(&self) -> usize {
|
||||
self.cover_cpu_backfill_max.load(Ordering::Relaxed).max(1)
|
||||
}
|
||||
|
||||
/// Retune the backfill encode pool to match the worker's download
|
||||
/// concurrency. Grows/shrinks the semaphore permits in place.
|
||||
pub fn set_backfill_cpu_parallel(&self, threads: usize) {
|
||||
let next = threads.clamp(1, COVER_CPU_BACKFILL_MAX);
|
||||
let prev = self.cover_cpu_backfill_max.swap(next, Ordering::SeqCst);
|
||||
if next > prev {
|
||||
self.cover_cpu_backfill_sem.add_permits(next - prev);
|
||||
} else if next < prev {
|
||||
let sem = self.cover_cpu_backfill_sem.clone();
|
||||
let surplus = prev - next;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for _ in 0..surplus {
|
||||
if let Ok(permit) = sem.acquire().await {
|
||||
permit.forget();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_sem_for(&self, library_bulk: bool) -> Arc<Semaphore> {
|
||||
if library_bulk {
|
||||
self.cover_cpu_backfill_sem.clone()
|
||||
@@ -162,11 +195,6 @@ impl CoverCacheState {
|
||||
("ok".into(), true)
|
||||
}
|
||||
|
||||
fn pressure(&self) -> (String, bool) {
|
||||
let (bytes, _) = dir_usage_at_root(&self.root);
|
||||
self.pressure_from_bytes(bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_inner(
|
||||
state: &Arc<Mutex<CoverCacheState>>,
|
||||
app: &AppHandle,
|
||||
@@ -183,7 +211,11 @@ impl CoverCacheState {
|
||||
});
|
||||
}
|
||||
|
||||
let (_, auto_dl) = this.pressure();
|
||||
// Cheap, no-IO gate. Previously this ran a full recursive disk walk of
|
||||
// the entire cover cache (`pressure()` → `dir_usage_at_root`) on every
|
||||
// ensure, under the global state lock — serializing the whole backfill
|
||||
// pool onto filesystem stat work. The walked bytes were then discarded.
|
||||
let (_, auto_dl) = this.pressure_from_bytes(0);
|
||||
if !auto_dl && args.tier != 2000 {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
@@ -453,6 +485,42 @@ pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64,
|
||||
server_cover_disk_usage(&cover_server_dir(root, server_index_key))
|
||||
}
|
||||
|
||||
/// TTL-memoized per-server cover dir walk. The "offline & cache" settings menu
|
||||
/// polls byte usage + cached count every few seconds for every server; on a full
|
||||
/// cache that is several full directory walks per tick. Reuse a recent walk so we
|
||||
/// don't re-stat thousands of files when nothing has changed. Active backfill still
|
||||
/// pushes live numbers through the `cover:library-progress` event, so a short TTL
|
||||
/// only de-dupes the idle polling, it does not hide real progress.
|
||||
const DIR_USAGE_CACHE_TTL: Duration = Duration::from_secs(10);
|
||||
|
||||
type DirUsageCache = std::sync::Mutex<HashMap<String, (std::time::Instant, (u64, u64))>>;
|
||||
|
||||
fn dir_usage_cache() -> &'static DirUsageCache {
|
||||
static CACHE: std::sync::OnceLock<DirUsageCache> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
pub(crate) fn cached_dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
|
||||
if let Ok(map) = dir_usage_cache().lock() {
|
||||
if let Some((at, value)) = map.get(server_index_key) {
|
||||
if at.elapsed() < DIR_USAGE_CACHE_TTL {
|
||||
return *value;
|
||||
}
|
||||
}
|
||||
}
|
||||
let value = dir_usage_for_server(root, server_index_key);
|
||||
if let Ok(mut map) = dir_usage_cache().lock() {
|
||||
map.insert(server_index_key.to_string(), (std::time::Instant::now(), value));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_dir_usage_cache(server_index_key: &str) {
|
||||
if let Ok(mut map) = dir_usage_cache().lock() {
|
||||
map.remove(server_index_key);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) {
|
||||
cover_root_disk_usage(root)
|
||||
}
|
||||
@@ -512,9 +580,12 @@ pub fn init_cover_cache(app: &AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_run_full_pass(app: AppHandle) -> Result<CoverBackfillRunDto, String> {
|
||||
pub async fn library_cover_backfill_run_full_pass(
|
||||
app: AppHandle,
|
||||
force: Option<bool>,
|
||||
) -> Result<CoverBackfillRunDto, String> {
|
||||
Ok(CoverBackfillRunDto {
|
||||
started: try_schedule_full_pass(&app).await,
|
||||
started: try_schedule_full_pass(&app, force.unwrap_or(false)).await,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,6 +619,24 @@ pub async fn library_cover_backfill_set_ui_priority(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perf-probe tuning knob: set how many threads cover backfill uses (download
|
||||
/// + encode pools move together). Not exposed in app Settings by design.
|
||||
/// Returns the clamped value actually applied.
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_set_parallel(
|
||||
app: AppHandle,
|
||||
threads: usize,
|
||||
) -> Result<u32, String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
let applied = worker.set_parallel(threads);
|
||||
if let Ok(cache) = state(&app) {
|
||||
cache.lock().await.set_backfill_cpu_parallel(applied);
|
||||
}
|
||||
Ok(applied as u32)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_configure(
|
||||
app: AppHandle,
|
||||
@@ -576,7 +665,7 @@ pub async fn library_cover_backfill_configure(
|
||||
.set_session(enabled && session.is_some(), session)
|
||||
.await;
|
||||
if enabled {
|
||||
let _ = try_schedule_full_pass(&app).await;
|
||||
let _ = try_schedule_full_pass(&app, false).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -706,7 +795,7 @@ pub async fn cover_cache_stats_server(
|
||||
) -> Result<CoverCacheStatsDto, String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let (bytes, entry_count) = dir_usage_for_server(&guard.root, &server_index_key);
|
||||
let (bytes, entry_count) = cached_dir_usage_for_server(&guard.root, &server_index_key);
|
||||
let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes);
|
||||
Ok(CoverCacheStatsDto {
|
||||
bytes,
|
||||
@@ -741,7 +830,13 @@ pub async fn cover_cache_clear_server(
|
||||
if path.is_dir() {
|
||||
std::fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
invalidate_dir_usage_cache(&server_index_key);
|
||||
drop(guard);
|
||||
// Clearing drops files the cheap idle-gate signature can't see, so re-arm
|
||||
// the backfill worker — otherwise the next sync-idle would skip the rescan.
|
||||
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
worker.rearm_idle_gate().await;
|
||||
}
|
||||
let _ = app.emit(
|
||||
"cover:cache-cleared",
|
||||
serde_json::json!({ "serverIndexKey": server_index_key }),
|
||||
@@ -904,6 +999,12 @@ pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
if let Ok(mut map) = dir_usage_cache().lock() {
|
||||
map.clear();
|
||||
}
|
||||
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
worker.rearm_idle_gate().await;
|
||||
}
|
||||
let _ = app.emit("cover:cache-cleared", serde_json::json!({}));
|
||||
Ok(())
|
||||
}
|
||||
@@ -956,7 +1057,7 @@ pub async fn library_cover_progress(
|
||||
let index_key = server_index_key.clone();
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let cached_dirs = count_cached_cover_ids(&root, &index_key);
|
||||
let cached_dirs = cached_dir_usage_for_server(&root, &index_key).1 as i64;
|
||||
collect_cover_progress(
|
||||
&store,
|
||||
&library_server_id,
|
||||
|
||||
Reference in New Issue
Block a user