mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
fix(analysis): cap HTTP backfill on CPU-seed pipeline load (#873)
* fix(analysis): cap HTTP backfill on CPU-seed pipeline load Aggressive library analytics with multiple workers grew RAM unbounded: HTTP downloads finish in ~500 ms, but Symphonia decode + R128 loudness take seconds, so decoded `Vec<u8>` track buffers piled up in the CPU-seed queue while the HTTP worker kept fetching. On large libraries the process eventually saturated memory and forced the system into swap. Backpressure: the HTTP backfill worker now checks the CPU-seed pipeline depth (queued + running) against a `workers * 2` cap before popping the next job. High-priority (now-playing) jobs bypass the cap so playback prefetch is never starved. The CPU-seed worker pings the HTTP queue after every completed decode, so the gate releases the instant decode catches up. The frontend backfill loop already idles at its own watermark when the HTTP queue is satisfied — this change keeps the pipeline self-limiting end-to-end even when callers (library top-up, playlist enqueue) submit faster than decode drains. Tests cover cap scaling, the floor for workers=1, the idle decision, and the high-priority bypass. * docs(changelog): analytics aggressive scan no longer eats memory (#873)
This commit is contained in:
committed by
GitHub
parent
45b9229ceb
commit
d353482ac5
@@ -365,6 +365,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Analytics — aggressive scan no longer eats memory on big libraries
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#873](https://github.com/Psychotoxical/psysonic/pull/873)**
|
||||||
|
|
||||||
|
* **Settings → Library → Analytics → Aggressive** on multi-server or 100k+ track libraries no longer climbs in memory until the system swaps (Linux) or runs out (Windows OOM mid-scan): the HTTP download stage now waits for Symphonia decode + loudness to catch up instead of buffering tracks faster than they can be processed.
|
||||||
|
* Now-playing prefetch still bypasses the cap, so starting a track during a background scan stays instant.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.46.0] - 2026-05-18
|
## [1.46.0] - 2026-05-18
|
||||||
|
|
||||||
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
||||||
|
|||||||
@@ -554,20 +554,66 @@ async fn analysis_backfill_worker_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queued + currently-decoding CPU-seed jobs. Each retains the full track
|
||||||
|
/// byte buffer, so this counter approximates pipeline memory pressure.
|
||||||
|
fn cpu_seed_pipeline_load() -> usize {
|
||||||
|
let Some(shared) = ANALYSIS_CPU_SEED.get() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
st.queued_len() + st.running.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft cap on in-flight CPU-seed jobs (queued + running). When reached, the
|
||||||
|
/// HTTP backfill worker idles to keep decoded `Vec<u8>` buffers from piling up
|
||||||
|
/// faster than Symphonia + R128 can drain them. Floor of 2 covers `workers=1`.
|
||||||
|
fn cpu_seed_pipeline_cap(max_parallel: usize) -> usize {
|
||||||
|
max_parallel.saturating_mul(2).max(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether the HTTP backfill worker should idle right now. High-tier
|
||||||
|
/// (now-playing) jobs always bypass the cap so playback is never starved.
|
||||||
|
fn should_idle_for_cpu_backpressure(
|
||||||
|
cpu_load: usize,
|
||||||
|
cpu_cap: usize,
|
||||||
|
high_pending: bool,
|
||||||
|
) -> bool {
|
||||||
|
!high_pending && cpu_load >= cpu_cap
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackfillShared>) {
|
async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackfillShared>) {
|
||||||
loop {
|
loop {
|
||||||
let max = shared.max_parallel();
|
let max = shared.max_parallel();
|
||||||
|
// Backpressure against the CPU-seed pipeline: downloaded track bytes
|
||||||
|
// (Vec<u8>, tens of MB for FLAC) sit in `AnalysisCpuSeedJob.bytes` until
|
||||||
|
// Symphonia decode + R128 finish — much slower than HTTP. Without a cap,
|
||||||
|
// aggressive library backfill on large libraries grows RAM unbounded.
|
||||||
|
// High-tier (now-playing) jobs always proceed.
|
||||||
|
let cpu_load = cpu_seed_pipeline_load();
|
||||||
|
let cpu_cap = cpu_seed_pipeline_cap(max);
|
||||||
let job_bundle = {
|
let job_bundle = {
|
||||||
let mut st = shared
|
let mut st = shared
|
||||||
.state
|
.state
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
st.try_pop_next(max).map(|job| {
|
let high_pending = !st.high.is_empty();
|
||||||
let worker_slot = st.in_progress.len();
|
if should_idle_for_cpu_backpressure(cpu_load, cpu_cap, high_pending) {
|
||||||
(job, worker_slot)
|
None
|
||||||
})
|
} else {
|
||||||
|
st.try_pop_next(max).map(|job| {
|
||||||
|
let worker_slot = st.in_progress.len();
|
||||||
|
(job, worker_slot)
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let Some(((track_id, url, server_id), worker_slot)) = job_bundle else {
|
let Some(((track_id, url, server_id), worker_slot)) = job_bundle else {
|
||||||
|
if cpu_load >= cpu_cap {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis] backfill idle: cpu_seed pipeline_load={} cap={} (waiting for decode catch-up)",
|
||||||
|
cpu_load,
|
||||||
|
cpu_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
@@ -1112,6 +1158,11 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
|
|||||||
st.running.remove(&tid_log);
|
st.running.remove(&tid_log);
|
||||||
st.running_tiers.remove(&tid_log);
|
st.running_tiers.remove(&tid_log);
|
||||||
}
|
}
|
||||||
|
// Decode slot freed → wake HTTP backfill in case it was idling on
|
||||||
|
// the `cpu_seed_pipeline_cap` backpressure check.
|
||||||
|
if let Some(http) = ANALYSIS_BACKFILL.get() {
|
||||||
|
http.ping_worker();
|
||||||
|
}
|
||||||
|
|
||||||
match &seed_result {
|
match &seed_result {
|
||||||
Ok((outcome, timings)) => {
|
Ok((outcome, timings)) => {
|
||||||
@@ -1560,4 +1611,36 @@ mod tests {
|
|||||||
let result = rx.blocking_recv().expect("sender side should have closed cleanly");
|
let result = rx.blocking_recv().expect("sender side should have closed cleanly");
|
||||||
assert!(result.is_err(), "pruned job must yield Err, got {result:?}");
|
assert!(result.is_err(), "pruned job must yield Err, got {result:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CPU-seed backpressure ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cpu_seed_pipeline_cap_scales_with_workers() {
|
||||||
|
assert_eq!(cpu_seed_pipeline_cap(1), 2);
|
||||||
|
assert_eq!(cpu_seed_pipeline_cap(3), 6);
|
||||||
|
assert_eq!(cpu_seed_pipeline_cap(6), 12);
|
||||||
|
assert_eq!(cpu_seed_pipeline_cap(20), 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cpu_seed_pipeline_cap_has_floor_of_two() {
|
||||||
|
assert_eq!(cpu_seed_pipeline_cap(0), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backpressure_idles_when_cpu_load_meets_cap_and_no_high() {
|
||||||
|
assert!(should_idle_for_cpu_backpressure(12, 12, false));
|
||||||
|
assert!(should_idle_for_cpu_backpressure(20, 12, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backpressure_allows_pop_when_cpu_load_below_cap() {
|
||||||
|
assert!(!should_idle_for_cpu_backpressure(11, 12, false));
|
||||||
|
assert!(!should_idle_for_cpu_backpressure(0, 12, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backpressure_bypassed_for_high_priority_jobs() {
|
||||||
|
assert!(!should_idle_for_cpu_backpressure(100, 12, true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user