Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs
T
cucadmuh b24a7fc5cb fix(analysis): native library backfill coordinator for advanced strategy (#881)
* feat(analysis): native library backfill coordinator for advanced strategy

Move advanced analytics scheduling from the webview loop into a Rust
background worker (configure + spawn_blocking batch/enqueue), matching
cover backfill. Scan hash+BPM gap tracks instead of the full library;
suppress low-priority analysis UI events; limit loudness refresh IPC to
the playback window.

* fix(analysis): flat backfill configure IPC and restore probe track-perf

Use flattened Tauri args like cover backfill so release/prod invoke works;
keep emitting analysis:track-perf for library low-priority work so Performance
Probe tpm/last-track stats update while waveform/enrichment UI events stay suppressed.

* chore(analysis): fix clippy and drop duplicate TS backfill policy

Allow too_many_arguments on library_analysis_backfill_configure for CI;
remove unused frontend batch API and TS watermark helpers now owned in Rust;
clarify analysis_emits_ui_events comment for low-priority track-perf.

* docs: CHANGELOG and credits for PR #881 native analysis backfill
2026-05-28 10:49:31 +03:00

1775 lines
60 KiB
Rust

use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager};
use psysonic_core::ports::PlaybackQueryHandle;
use psysonic_core::user_agent::subsonic_wire_user_agent;
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
use crate::analysis_cache;
use crate::analysis_perf::{emit_analysis_track_perf, AnalysisSeedTimings};
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WaveformUpdatedPayload {
pub track_id: String,
pub is_partial: bool,
}
pub const ANALYSIS_PIPELINE_PARALLELISM_MIN: usize = 1;
pub const ANALYSIS_PIPELINE_PARALLELISM_MAX: usize = 20;
pub const ANALYSIS_PIPELINE_PARALLELISM_DEFAULT: usize = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AnalysisTierCounts {
pub high: usize,
pub middle: usize,
pub low: usize,
}
impl AnalysisTierCounts {
pub fn total(&self) -> usize {
self.high + self.middle + self.low
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPipelineQueueStatsDto {
pub pipeline_workers: u32,
pub http_queued: usize,
pub http_queued_high: usize,
pub http_queued_middle: usize,
pub http_queued_low: usize,
pub http_download_active: usize,
pub http_download_active_high: usize,
pub http_download_active_middle: usize,
pub http_download_active_low: usize,
pub cpu_queued: usize,
pub cpu_queued_high: usize,
pub cpu_queued_middle: usize,
pub cpu_queued_low: usize,
pub cpu_decode_active: usize,
pub cpu_decode_active_high: usize,
pub cpu_decode_active_middle: usize,
pub cpu_decode_active_low: usize,
}
pub fn clamp_pipeline_parallelism(workers: usize) -> usize {
workers.clamp(
ANALYSIS_PIPELINE_PARALLELISM_MIN,
ANALYSIS_PIPELINE_PARALLELISM_MAX,
)
}
/// Last requested worker count (applied when lazy-init queues and on live updates).
static REQUESTED_PIPELINE_PARALLELISM: AtomicUsize =
AtomicUsize::new(ANALYSIS_PIPELINE_PARALLELISM_DEFAULT);
fn requested_pipeline_parallelism() -> usize {
clamp_pipeline_parallelism(REQUESTED_PIPELINE_PARALLELISM.load(Ordering::Relaxed))
}
// ─── HTTP backfill queue: download tracks + seed analysis cache ──────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AnalysisBackfillPriority {
Low = 0,
Middle = 1,
High = 2,
}
impl AnalysisBackfillPriority {
pub fn from_optional_str(raw: Option<&str>) -> Option<Self> {
let s = raw?.trim();
if s.is_empty() {
return None;
}
match s.to_ascii_lowercase().as_str() {
"high" => Some(Self::High),
"middle" => Some(Self::Middle),
"low" => Some(Self::Low),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnalysisBackfillEnqueueKind {
NewLow,
NewMiddle,
NewHigh,
/// Same track was already waiting; moved to a higher tier with the latest URL.
ReorderedHigher,
/// Same or lower priority while the track is already queued or running.
DuplicateSkipped,
/// High-priority request but that track is already being downloaded+seeded.
RunningSkipped,
}
/// One queued HTTP-backfill job: `(track_id, url, server_id)`. Dedup is by
/// `track_id` (a track is backfilled at most once at a time); `server_id` rides
/// along to scope the eventual cache write and follows the latest enqueue.
type BackfillJob = (String, String, String);
#[derive(Default)]
pub struct AnalysisBackfillQueueState {
high: VecDeque<BackfillJob>,
middle: VecDeque<BackfillJob>,
low: VecDeque<BackfillJob>,
/// Active HTTP downloads keyed by track id (tier kept for pipeline stats).
pub in_progress: HashMap<String, AnalysisBackfillPriority>,
}
impl AnalysisBackfillQueueState {
fn queued_len(&self) -> usize {
self.high.len() + self.middle.len() + self.low.len()
}
fn queued_tier_counts(&self) -> AnalysisTierCounts {
AnalysisTierCounts {
high: self.high.len(),
middle: self.middle.len(),
low: self.low.len(),
}
}
fn in_progress_tier_counts(&self) -> AnalysisTierCounts {
let mut counts = AnalysisTierCounts::default();
for tier in self.in_progress.values() {
match tier {
AnalysisBackfillPriority::High => counts.high += 1,
AnalysisBackfillPriority::Middle => counts.middle += 1,
AnalysisBackfillPriority::Low => counts.low += 1,
}
}
counts
}
fn tier_deque(&self, tier: AnalysisBackfillPriority) -> &VecDeque<BackfillJob> {
match tier {
AnalysisBackfillPriority::High => &self.high,
AnalysisBackfillPriority::Middle => &self.middle,
AnalysisBackfillPriority::Low => &self.low,
}
}
fn tier_deque_mut(&mut self, tier: AnalysisBackfillPriority) -> &mut VecDeque<BackfillJob> {
match tier {
AnalysisBackfillPriority::High => &mut self.high,
AnalysisBackfillPriority::Middle => &mut self.middle,
AnalysisBackfillPriority::Low => &mut self.low,
}
}
fn locate_queued(&self, tid: &str) -> Option<AnalysisBackfillPriority> {
[
AnalysisBackfillPriority::High,
AnalysisBackfillPriority::Middle,
AnalysisBackfillPriority::Low,
]
.into_iter()
.find(|&tier| {
self
.tier_deque(tier)
.iter()
.any(|(t, _, _)| t.as_str() == tid)
})
}
fn remove_queued(&mut self, tid: &str) -> Option<BackfillJob> {
for tier in [
AnalysisBackfillPriority::High,
AnalysisBackfillPriority::Middle,
AnalysisBackfillPriority::Low,
] {
if let Some(pos) = self
.tier_deque(tier)
.iter()
.position(|(t, _, _)| t.as_str() == tid)
{
return self.tier_deque_mut(tier).remove(pos);
}
}
None
}
fn push_new(&mut self, priority: AnalysisBackfillPriority, job: BackfillJob) {
match priority {
AnalysisBackfillPriority::High => self.high.push_front(job),
AnalysisBackfillPriority::Middle => self.middle.push_back(job),
AnalysisBackfillPriority::Low => self.low.push_back(job),
}
}
fn is_reserved(&self, tid: &str) -> bool {
self.in_progress.contains_key(tid) || self.locate_queued(tid).is_some()
}
fn try_pop_next(&mut self, max_concurrent: usize) -> Option<BackfillJob> {
if self.in_progress.len() >= max_concurrent {
return None;
}
for tier in [
AnalysisBackfillPriority::High,
AnalysisBackfillPriority::Middle,
AnalysisBackfillPriority::Low,
] {
if let Some(job) = self.tier_deque_mut(tier).pop_front() {
self.in_progress.insert(job.0.clone(), tier);
return Some(job);
}
}
None
}
fn finish_job(&mut self, tid: &str) {
self.in_progress.remove(tid);
}
pub fn enqueue(
&mut self,
server_id: String,
tid: String,
url: String,
priority: AnalysisBackfillPriority,
) -> AnalysisBackfillEnqueueKind {
let tref = tid.as_str();
if !self.is_reserved(tref) && analysis_track_in_cpu_pipeline(tref) {
return AnalysisBackfillEnqueueKind::DuplicateSkipped;
}
if self.is_reserved(tref) {
if self.in_progress.contains_key(tref) {
if priority == AnalysisBackfillPriority::High {
return AnalysisBackfillEnqueueKind::RunningSkipped;
}
return AnalysisBackfillEnqueueKind::DuplicateSkipped;
}
let existing = self.locate_queued(tref).unwrap_or(AnalysisBackfillPriority::Low);
if priority <= existing {
return AnalysisBackfillEnqueueKind::DuplicateSkipped;
}
self.remove_queued(tref);
self.push_new(priority, (tid, url, server_id));
return AnalysisBackfillEnqueueKind::ReorderedHigher;
}
let kind = match priority {
AnalysisBackfillPriority::High => AnalysisBackfillEnqueueKind::NewHigh,
AnalysisBackfillPriority::Middle => AnalysisBackfillEnqueueKind::NewMiddle,
AnalysisBackfillPriority::Low => AnalysisBackfillEnqueueKind::NewLow,
};
self.push_new(priority, (tid, url, server_id));
kind
}
pub fn prune_queued_not_in(
&mut self,
keep_track_ids: &HashSet<&str>,
server_id: Option<&str>,
) -> usize {
let before = self.queued_len();
for tier in [
AnalysisBackfillPriority::High,
AnalysisBackfillPriority::Middle,
AnalysisBackfillPriority::Low,
] {
self.tier_deque_mut(tier)
.retain(|(track_id, _, job_server_id)| {
let scoped = server_id.is_some_and(|sid| {
job_server_id.is_empty() || job_server_id == sid
});
if server_id.is_some() && !scoped {
return true;
}
keep_track_ids.contains(track_id.as_str())
});
}
before.saturating_sub(self.queued_len())
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
}
impl PlaybackPriorityHints {
pub fn set_middle_track_ids(
&self,
ids: impl IntoIterator<Item = (String, String)>,
) {
let mut set = HashSet::new();
for (server_id, track_id) in ids {
let sid = server_id.trim();
let tid = track_id.trim();
if !tid.is_empty() {
set.insert(priority_hint_key(sid, tid));
}
}
*self.middle_track_ids.lock().unwrap_or_else(|e| e.into_inner()) = set;
}
pub fn is_middle_priority(&self, server_id: &str, track_id: &str) -> bool {
self.middle_track_ids
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&priority_hint_key(server_id, track_id))
}
}
fn priority_hint_key(server_id: &str, track_id: &str) -> String {
format!("{server_id}::{track_id}")
}
pub struct AnalysisBackfillShared {
pub state: Mutex<AnalysisBackfillQueueState>,
wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
max_parallel: AtomicUsize,
}
impl AnalysisBackfillShared {
pub fn ping_worker(&self) {
let _ = self.wake_tx.send(());
}
fn max_parallel(&self) -> usize {
clamp_pipeline_parallelism(self.max_parallel.load(Ordering::Relaxed))
}
}
static ANALYSIS_BACKFILL: OnceLock<Arc<AnalysisBackfillShared>> = OnceLock::new();
/// Lazily spawns the single backfill worker (first caller supplies `AppHandle`).
pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillShared> {
ANALYSIS_BACKFILL
.get_or_init(|| {
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
let shared = Arc::new(AnalysisBackfillShared {
state: Mutex::new(AnalysisBackfillQueueState::default()),
wake_tx,
max_parallel: AtomicUsize::new(requested_pipeline_parallelism()),
});
let app = app.clone();
tauri::async_runtime::spawn(analysis_backfill_worker_loop(app, shared.clone(), wake_rx));
shared.ping_worker();
shared
})
.clone()
}
use crate::track_analysis_plan::plan_track_analysis;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnqueueTrackAnalysisOutcome {
/// Waveform, LUFS, and enrichment facts are all current.
Complete,
/// Symphonia full-file decode queued (enrichment runs after seed when needed).
QueuedFullSeed,
/// Oximedia pass ran inline (waveform + LUFS already cached).
RanEnrichmentOnly,
}
/// **Single entry point** for byte-backed track analysis.
///
/// 1. Plan: waveform / LUFS gaps in analysis cache + enrichment facts in library.
/// 2. If nothing missing → no-op.
/// 3. If waveform or LUFS missing → CPU seed queue (Symphonia + EBU R128).
/// 4. Else if enrichment missing → oximedia 60 s window only.
pub async fn enqueue_track_analysis(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
priority: AnalysisBackfillPriority,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
enqueue_track_analysis_with_fetch(app, server_id, track_id, bytes, priority, 0).await
}
async fn enqueue_track_analysis_with_fetch(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
priority: AnalysisBackfillPriority,
fetch_ms: u64,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if bytes.is_empty() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(bytes);
let plan = plan_track_analysis(app, server_id, track_id, &content_hash);
if !plan.any() {
crate::app_deprintln!(
"[analysis] track complete track_id={} hash={}",
track_id,
content_hash
);
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
if plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
track_id,
content_hash,
plan.need_waveform,
plan.need_loudness,
plan.enrichment.any()
);
submit_analysis_cpu_seed(
app.clone(),
server_id.to_string(),
track_id.to_string(),
bytes.to_vec(),
priority,
fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
app,
server_id,
track_id,
bytes,
analysis_emits_ui_events(priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(app, track_id, fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Re-export for HTTP backfill gate (no bytes yet).
pub use crate::track_analysis_plan::track_analysis_needs_work;
/// Oximedia BPM/mood pass only — prefer [`enqueue_track_analysis`].
pub async fn run_track_enrichment_from_bytes(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
notify_ui: bool,
) -> TrackEnrichmentOutcome {
if server_id.is_empty() {
return TrackEnrichmentOutcome::SkippedNoServer;
}
let app = app.clone();
let sid = server_id.to_string();
let tid = track_id.to_string();
let data = bytes.to_vec();
match tokio::task::spawn_blocking(move || {
crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data, notify_ui)
})
.await
{
Ok(outcome) => outcome,
Err(_) => TrackEnrichmentOutcome::Failed,
}
}
/// Read a local file and run [`enqueue_track_analysis`] (hot cache, offline, spill promote).
pub async fn enqueue_track_analysis_from_file(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
file_path: &std::path::Path,
priority: AnalysisBackfillPriority,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
enqueue_track_analysis(app, server_id, track_id, &bytes, priority).await
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
) -> Result<bool, String> {
let priority = analysis_backfill_resolve_priority(app, server_id, track_id, None);
let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, priority).await?;
Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete))
}
fn analysis_http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(120))
.pool_max_idle_per_host(ANALYSIS_PIPELINE_PARALLELISM_MAX)
.build()
.expect("analysis HTTP client")
})
}
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
let fetch_started = std::time::Instant::now();
let response = analysis_http_client()
.get(url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("empty response".to_string());
}
let fetch_ms = fetch_started.elapsed().as_millis() as u64;
Ok((bytes.to_vec(), fetch_ms))
}
async fn analysis_backfill_worker_loop(
app: tauri::AppHandle,
shared: Arc<AnalysisBackfillShared>,
mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
) {
loop {
if wake_rx.recv().await.is_none() {
break;
}
spawn_backfill_slots(&app, &shared).await;
}
}
/// 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>) {
loop {
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 mut st = shared
.state
.lock()
.unwrap_or_else(|e| e.into_inner());
let high_pending = !st.high.is_empty();
if should_idle_for_cpu_backpressure(cpu_load, cpu_cap, high_pending) {
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 {
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;
};
crate::app_deprintln!(
"[analysis] backfill worker={}/{}: start track_id={}",
worker_slot,
max,
track_id
);
let app = app.clone();
let shared = shared.clone();
tauri::async_runtime::spawn(async move {
let download_result = analysis_backfill_download_bytes(&url).await;
{
let mut st = shared
.state
.lock()
.unwrap_or_else(|e| e.into_inner());
st.finish_job(&track_id);
}
shared.ping_worker();
let result = match download_result {
Ok((bytes, fetch_ms)) => {
crate::app_deprintln!(
"[analysis] backfill worker={}/{}: fetched track_id={} fetch_ms={}",
worker_slot,
max,
track_id,
fetch_ms
);
let priority = analysis_backfill_resolve_priority(&app, &server_id, &track_id, None);
match enqueue_track_analysis_with_fetch(
&app,
&server_id,
&track_id,
&bytes,
priority,
fetch_ms,
)
.await
{
Ok(outcome) => {
Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete))
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
};
match &result {
Ok(has_loudness) => crate::app_deprintln!(
"[analysis] backfill worker={}/{}: ready track_id={} has_loudness={}",
worker_slot,
max,
track_id,
has_loudness
),
Err(e) => crate::app_eprintln!(
"[analysis] backfill worker={}/{}: failed track_id={}: {}",
worker_slot,
max,
track_id,
e
),
}
});
}
}
pub fn analysis_set_pipeline_parallelism(workers: usize) {
let workers = clamp_pipeline_parallelism(workers);
REQUESTED_PIPELINE_PARALLELISM.store(workers, Ordering::Relaxed);
if let Some(shared) = ANALYSIS_BACKFILL.get() {
shared
.max_parallel
.store(workers, Ordering::Relaxed);
shared.ping_worker();
}
if let Some(shared) = ANALYSIS_CPU_SEED.get() {
shared
.max_parallel
.store(workers, Ordering::Relaxed);
shared.ping_worker();
}
}
pub fn analysis_backfill_queue_stats() -> (usize, usize, Option<String>) {
if let Some(shared) = ANALYSIS_BACKFILL.get() {
let st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let in_progress_count = st.in_progress.len();
let first_in_progress = st.in_progress.keys().next().cloned();
(st.queued_len(), in_progress_count, first_in_progress)
} else {
(0, 0, None)
}
}
pub fn analysis_track_in_cpu_pipeline(track_id: &str) -> bool {
let tid = track_id.trim();
if tid.is_empty() {
return false;
}
let Some(shared) = ANALYSIS_CPU_SEED.get() else {
return false;
};
let st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
if st.running.contains_key(tid) {
return true;
}
st.locate_queued(tid).is_some()
}
pub fn analysis_pipeline_queue_stats() -> AnalysisPipelineQueueStatsDto {
let pipeline_workers = ANALYSIS_BACKFILL
.get()
.map(|shared| shared.max_parallel())
.or_else(|| ANALYSIS_CPU_SEED.get().map(|shared| shared.max_parallel()))
.unwrap_or(ANALYSIS_PIPELINE_PARALLELISM_DEFAULT) as u32;
let (http_tiers, http_active_tiers) = if let Some(shared) = ANALYSIS_BACKFILL.get() {
let st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
(st.queued_tier_counts(), st.in_progress_tier_counts())
} else {
(AnalysisTierCounts::default(), AnalysisTierCounts::default())
};
let (cpu_tiers, cpu_active_tiers) = if let Some(shared) = ANALYSIS_CPU_SEED.get() {
let st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
(st.queued_tier_counts(), st.running_tier_counts())
} else {
(AnalysisTierCounts::default(), AnalysisTierCounts::default())
};
AnalysisPipelineQueueStatsDto {
pipeline_workers,
http_queued: http_tiers.total(),
http_queued_high: http_tiers.high,
http_queued_middle: http_tiers.middle,
http_queued_low: http_tiers.low,
http_download_active: http_active_tiers.total(),
http_download_active_high: http_active_tiers.high,
http_download_active_middle: http_active_tiers.middle,
http_download_active_low: http_active_tiers.low,
cpu_queued: cpu_tiers.total(),
cpu_queued_high: cpu_tiers.high,
cpu_queued_middle: cpu_tiers.middle,
cpu_queued_low: cpu_tiers.low,
cpu_decode_active: cpu_active_tiers.total(),
cpu_decode_active_high: cpu_active_tiers.high,
cpu_decode_active_middle: cpu_active_tiers.middle,
cpu_decode_active_low: cpu_active_tiers.low,
}
}
pub fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool {
app.try_state::<psysonic_core::ports::PlaybackQueryHandle>()
.is_some_and(|p| p.is_track_currently_playing(track_id))
}
pub fn analysis_backfill_resolve_priority(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
explicit: Option<AnalysisBackfillPriority>,
) -> AnalysisBackfillPriority {
if let Some(priority) = explicit {
return priority;
}
if analysis_backfill_is_current_track(app, track_id) {
return AnalysisBackfillPriority::High;
}
if app
.try_state::<PlaybackPriorityHints>()
.is_some_and(|h| h.is_middle_priority(server_id, track_id))
{
return AnalysisBackfillPriority::Middle;
}
AnalysisBackfillPriority::Low
}
/// Library backfill uses `Low` — skip waveform / enrichment refresh IPC (`analysis:track-perf` still emits for probes).
pub fn analysis_emits_ui_events(priority: AnalysisBackfillPriority) -> bool {
!matches!(priority, AnalysisBackfillPriority::Low)
}
/// Enqueue HTTP download + analysis seed (native coordinator + optional UI invoke).
pub fn enqueue_seed_from_url(
app: &tauri::AppHandle,
track_id: &str,
url: &str,
server_id_hint: Option<&str>,
explicit_priority: Option<AnalysisBackfillPriority>,
force: bool,
) -> Result<(), String> {
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
let server_id = if let Ok(parsed) = reqwest::Url::parse(url) {
if parsed.scheme() == "http" || parsed.scheme() == "https" {
let host = parsed.host_str().unwrap_or_default();
let mut base_path = parsed.path().to_string();
if let Some(idx) = base_path.find("/rest") {
base_path.truncate(idx);
}
while base_path.ends_with('/') {
base_path.pop();
}
if host.is_empty() {
server_id_hint.unwrap_or("").to_string()
} else {
let mut base = host.to_string();
if let Some(port) = parsed.port() {
base.push_str(&format!(":{port}"));
}
if !base_path.is_empty() {
base.push_str(&base_path);
}
base
}
} else {
server_id_hint.unwrap_or("").to_string()
}
} else {
server_id_hint.unwrap_or("").to_string()
};
if !force {
if let Some(playback) = app.try_state::<PlaybackQueryHandle>() {
if playback.ranged_loudness_backfill_should_defer(track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
}
}
}
if !force {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.cpu_seed_redundant_for_track(&server_id, track_id)? {
if server_id.is_empty() {
crate::app_deprintln!(
"[analysis] backfill skip (no server scope): {}",
track_id
);
return Ok(());
}
if !track_analysis_needs_work(app, &server_id, track_id)? {
crate::app_deprintln!(
"[analysis] backfill skip (analysis complete): {}",
track_id
);
return Ok(());
}
crate::app_deprintln!(
"[analysis] backfill enqueue (analysis pending) track_id={}",
track_id
);
}
}
}
let tid_log = track_id.to_string();
let resolved = analysis_backfill_resolve_priority(app, &server_id, track_id, explicit_priority);
let shared = analysis_backfill_shared(app);
let kind = {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
st.enqueue(server_id, track_id.to_string(), url.to_string(), resolved)
};
match kind {
AnalysisBackfillEnqueueKind::NewLow
| AnalysisBackfillEnqueueKind::NewMiddle
| AnalysisBackfillEnqueueKind::NewHigh => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill enqueued: track_id={} priority={resolved:?}",
tid_log,
);
}
AnalysisBackfillEnqueueKind::ReorderedHigher => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill bumped tier track_id={} priority={resolved:?}",
tid_log,
);
}
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
}
Ok(())
}
// ─── Full-track waveform + loudness: CPU seed queue (parallel decode workers) ─
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnalysisCpuSeedEnqueueKind {
NewLow,
NewMiddle,
NewHigh,
ReorderedHigher,
RunningFollower,
MergedQueued,
}
type SeedDoneSender = tokio::sync::oneshot::Sender<
Result<(analysis_cache::SeedFromBytesOutcome, AnalysisSeedTimings), String>,
>;
type SeedDoneReceiver = tokio::sync::oneshot::Receiver<
Result<(analysis_cache::SeedFromBytesOutcome, AnalysisSeedTimings), String>,
>;
type RunningSeedJob = Arc<Mutex<Vec<SeedDoneSender>>>;
struct AnalysisCpuSeedJob {
/// Playback server scope for the write key.
server_id: String,
track_id: String,
bytes: Vec<u8>,
waiters: Vec<SeedDoneSender>,
/// HTTP download time when this job came from the backfill worker.
fetch_ms: u64,
priority: AnalysisBackfillPriority,
}
#[derive(Default)]
struct AnalysisCpuSeedQueueState {
high: VecDeque<AnalysisCpuSeedJob>,
middle: VecDeque<AnalysisCpuSeedJob>,
low: VecDeque<AnalysisCpuSeedJob>,
/// Decodes in progress — same-id callers wait on the matching entry.
running: HashMap<String, RunningSeedJob>,
running_tiers: HashMap<String, AnalysisBackfillPriority>,
}
impl AnalysisCpuSeedQueueState {
fn queued_len(&self) -> usize {
self.high.len() + self.middle.len() + self.low.len()
}
fn queued_tier_counts(&self) -> AnalysisTierCounts {
AnalysisTierCounts {
high: self.high.len(),
middle: self.middle.len(),
low: self.low.len(),
}
}
fn running_tier_counts(&self) -> AnalysisTierCounts {
let mut counts = AnalysisTierCounts::default();
for tier in self.running_tiers.values() {
match tier {
AnalysisBackfillPriority::High => counts.high += 1,
AnalysisBackfillPriority::Middle => counts.middle += 1,
AnalysisBackfillPriority::Low => counts.low += 1,
}
}
counts
}
fn tier_deque(&self, tier: AnalysisBackfillPriority) -> &VecDeque<AnalysisCpuSeedJob> {
match tier {
AnalysisBackfillPriority::High => &self.high,
AnalysisBackfillPriority::Middle => &self.middle,
AnalysisBackfillPriority::Low => &self.low,
}
}
fn tier_deque_mut(&mut self, tier: AnalysisBackfillPriority) -> &mut VecDeque<AnalysisCpuSeedJob> {
match tier {
AnalysisBackfillPriority::High => &mut self.high,
AnalysisBackfillPriority::Middle => &mut self.middle,
AnalysisBackfillPriority::Low => &mut self.low,
}
}
fn locate_queued(&self, tid: &str) -> Option<(AnalysisBackfillPriority, usize)> {
for tier in [
AnalysisBackfillPriority::High,
AnalysisBackfillPriority::Middle,
AnalysisBackfillPriority::Low,
] {
if let Some(pos) = self
.tier_deque(tier)
.iter()
.position(|j| j.track_id == tid)
{
return Some((tier, pos));
}
}
None
}
fn push_new(&mut self, priority: AnalysisBackfillPriority, job: AnalysisCpuSeedJob) {
match priority {
AnalysisBackfillPriority::High => self.high.push_front(job),
AnalysisBackfillPriority::Middle => self.middle.push_back(job),
AnalysisBackfillPriority::Low => self.low.push_back(job),
}
}
fn enqueue(
&mut self,
server_id: String,
track_id: String,
bytes: Vec<u8>,
priority: AnalysisBackfillPriority,
fetch_ms: u64,
) -> (
AnalysisCpuSeedEnqueueKind,
SeedDoneReceiver,
) {
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let tid = track_id.as_str();
if let Some(followers) = self.running.get(tid) {
followers
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(done_tx);
return (AnalysisCpuSeedEnqueueKind::RunningFollower, done_rx);
}
if let Some((existing_tier, pos)) = self.locate_queued(tid) {
let mut job = self.tier_deque_mut(existing_tier).remove(pos).unwrap();
job.server_id = server_id;
job.bytes = bytes;
job.fetch_ms = fetch_ms;
job.waiters.push(done_tx);
if priority > existing_tier {
job.priority = priority;
self.push_new(priority, job);
return (AnalysisCpuSeedEnqueueKind::ReorderedHigher, done_rx);
}
job.priority = existing_tier;
self.tier_deque_mut(existing_tier).push_back(job);
return (AnalysisCpuSeedEnqueueKind::MergedQueued, done_rx);
}
let job = AnalysisCpuSeedJob {
server_id,
track_id: track_id.clone(),
bytes,
waiters: vec![done_tx],
fetch_ms,
priority,
};
let kind = match priority {
AnalysisBackfillPriority::High => AnalysisCpuSeedEnqueueKind::NewHigh,
AnalysisBackfillPriority::Middle => AnalysisCpuSeedEnqueueKind::NewMiddle,
AnalysisBackfillPriority::Low => AnalysisCpuSeedEnqueueKind::NewLow,
};
self.push_new(priority, job);
(kind, done_rx)
}
fn prune_queued_not_in(
&mut self,
keep_track_ids: &HashSet<&str>,
server_id: Option<&str>,
) -> (usize, usize) {
let mut removed_jobs = 0usize;
let mut removed_waiters = 0usize;
for tier in [
AnalysisBackfillPriority::High,
AnalysisBackfillPriority::Middle,
AnalysisBackfillPriority::Low,
] {
let mut kept = VecDeque::with_capacity(self.tier_deque(tier).len());
while let Some(job) = self.tier_deque_mut(tier).pop_front() {
let scoped = server_id.is_some_and(|sid| {
job.server_id.is_empty() || job.server_id == sid
});
if server_id.is_some() && !scoped {
kept.push_back(job);
continue;
}
if keep_track_ids.contains(job.track_id.as_str()) {
kept.push_back(job);
continue;
}
removed_jobs += 1;
removed_waiters += job.waiters.len();
for tx in job.waiters {
let _ = tx.send(Err(
"cpu-seed pruned: track no longer in playback queue".to_string(),
));
}
}
*self.tier_deque_mut(tier) = kept;
}
(removed_jobs, removed_waiters)
}
fn try_pop_next(&mut self) -> Option<AnalysisCpuSeedJob> {
self.high
.pop_front()
.or_else(|| self.middle.pop_front())
.or_else(|| self.low.pop_front())
}
}
struct AnalysisCpuSeedShared {
state: Mutex<AnalysisCpuSeedQueueState>,
wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
max_parallel: AtomicUsize,
}
impl AnalysisCpuSeedShared {
fn ping_worker(&self) {
let _ = self.wake_tx.send(());
}
fn max_parallel(&self) -> usize {
clamp_pipeline_parallelism(self.max_parallel.load(Ordering::Relaxed))
}
}
static ANALYSIS_CPU_SEED: OnceLock<Arc<AnalysisCpuSeedShared>> = OnceLock::new();
fn analysis_cpu_seed_shared(app: &tauri::AppHandle) -> Arc<AnalysisCpuSeedShared> {
ANALYSIS_CPU_SEED
.get_or_init(|| {
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
let shared = Arc::new(AnalysisCpuSeedShared {
state: Mutex::new(AnalysisCpuSeedQueueState::default()),
wake_tx,
max_parallel: AtomicUsize::new(requested_pipeline_parallelism()),
});
let app = app.clone();
tauri::async_runtime::spawn(analysis_cpu_seed_worker_loop(app, shared.clone(), wake_rx));
shared.ping_worker();
shared
})
.clone()
}
/// HTTP backfill + CPU seed queue sizes (debug log only — `app_deprintln!`).
fn emit_analysis_queue_snapshot_line() {
let http = if let Some(arc) = ANALYSIS_BACKFILL.get() {
let st = arc.state.lock().unwrap_or_else(|e| e.into_inner());
format!(
"http_backfill={{queued:{} tiers=({},{},{}) download_active:{}}}",
st.queued_len(),
st.high.len(),
st.middle.len(),
st.low.len(),
st.in_progress.len(),
)
} else {
"http_backfill={{not_started}}".to_string()
};
let cpu = if let Some(arc) = ANALYSIS_CPU_SEED.get() {
let st = arc.state.lock().unwrap_or_else(|e| e.into_inner());
let queued_jobs = st.queued_len();
let decoding_count = st.running.len();
let tiers = st.queued_tier_counts();
format!(
"cpu_seed={{queued_jobs:{} tiers=({},{},{}) decoding_active:{}}}",
queued_jobs,
tiers.high,
tiers.middle,
tiers.low,
decoding_count,
)
} else {
"cpu_seed={{not_started}}".to_string()
};
crate::app_deprintln!(
"[analysis] queue_snapshot interval_s=60 note=queues_in_memory_cleared_on_app_restart | {http} | {cpu}"
);
}
pub async fn analysis_queue_snapshot_loop() {
emit_analysis_queue_snapshot_line();
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
emit_analysis_queue_snapshot_line();
}
}
async fn analysis_cpu_seed_worker_loop(
app: tauri::AppHandle,
shared: Arc<AnalysisCpuSeedShared>,
mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
) {
loop {
if wake_rx.recv().await.is_none() {
break;
}
spawn_cpu_seed_slots(&app, &shared).await;
}
}
async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSeedShared>) {
loop {
let max = shared.max_parallel();
let job_bundle = {
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
if st.running.len() >= max {
None
} else {
st.try_pop_next().map(|j| {
let followers = Arc::new(Mutex::new(Vec::new()));
let job_priority = j.priority;
st.running
.insert(j.track_id.clone(), followers.clone());
st.running_tiers
.insert(j.track_id.clone(), job_priority);
let worker_slot = st.running.len();
(j, followers, worker_slot)
})
}
};
let Some((job, followers, worker_slot)) = job_bundle else {
break;
};
let tid_log = job.track_id.clone();
let fetch_ms = job.fetch_ms;
crate::app_deprintln!(
"[analysis] cpu-seed worker={}/{}: start track_id={}",
worker_slot,
max,
tid_log
);
let app_for_decode = app.clone();
let app_for_events = app.clone();
let shared = shared.clone();
let notify_ui = analysis_emits_ui_events(job.priority);
tauri::async_runtime::spawn(async move {
let sid = job.server_id.clone();
let tid = job.track_id.clone();
let bytes = job.bytes;
let seed_result = tokio::task::spawn_blocking(move || {
analysis_cache::seed_from_bytes_execute(
&app_for_decode,
&sid,
&tid,
&bytes,
notify_ui,
)
})
.await
.unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}")));
let mut extra = followers
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain(..)
.collect::<Vec<_>>();
for tx in job.waiters {
let _ = tx.send(seed_result.clone());
}
for tx in extra.drain(..) {
let _ = tx.send(seed_result.clone());
}
{
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
st.running.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 {
Ok((outcome, timings)) => {
let ok = *outcome == analysis_cache::SeedFromBytesOutcome::Upserted;
emit_analysis_track_perf(
&app_for_events,
&tid_log,
fetch_ms,
timings.seed_ms,
timings.bpm_ms,
);
crate::app_deprintln!(
"[analysis] cpu-seed worker={}/{}: done track_id={} upserted={}",
worker_slot,
max,
tid_log,
ok
);
if ok && notify_ui {
let _ = app_for_events.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload {
track_id: tid_log.clone(),
is_partial: false,
},
);
}
}
Err(e) => {
crate::app_eprintln!(
"[analysis] cpu-seed worker={}/{}: failed track_id={}: {e}",
worker_slot,
max,
tid_log
);
}
}
shared.ping_worker();
});
}
}
/// Prune queued items in both analysis queues (HTTP backfill + CPU seed) whose
/// track ids are not in `keep_track_ids`. Items that are *currently running* are
/// untouched; only queued items are removed. Pruned CPU-seed waiters get an Err
/// indicating the prune.
///
/// Returns `(http_removed, cpu_removed_jobs, cpu_removed_waiters)`. Either
/// queue may not have been initialized yet — those slots return 0.
pub fn prune_analysis_queues(
keep_track_ids: &HashSet<&str>,
server_id: Option<&str>,
) -> Result<(usize, usize, usize), String> {
let http_removed = if let Some(shared) = ANALYSIS_BACKFILL.get() {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
st.prune_queued_not_in(keep_track_ids, server_id)
} else {
0
};
let (cpu_removed_jobs, cpu_removed_waiters) = if let Some(shared) = ANALYSIS_CPU_SEED.get() {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis cpu-seed lock poisoned".to_string())?;
st.prune_queued_not_in(keep_track_ids, server_id)
} else {
(0, 0)
};
Ok((http_removed, cpu_removed_jobs, cpu_removed_waiters))
}
/// Submit full-buffer analysis; serializes with other producers. Priority mirrors
/// HTTP backfill tier ordering (high → middle → low).
///
/// Emits `analysis:waveform-updated` when analysis **wrote** new waveform data (`Upserted`).
/// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not
/// re-run loudness refresh / waveform IPC for rows that were already current.
pub async fn submit_analysis_cpu_seed(
app: tauri::AppHandle,
server_id: String,
track_id: String,
bytes: Vec<u8>,
priority: AnalysisBackfillPriority,
fetch_ms: u64,
) -> Result<analysis_cache::SeedFromBytesOutcome, String> {
let shared = analysis_cpu_seed_shared(&app);
let rx = {
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let (kind, rx) = st.enqueue(server_id, track_id.clone(), bytes, priority, fetch_ms);
crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} priority={priority:?}");
drop(st);
shared.ping_worker();
rx
};
let (outcome, _timings) = match rx.await {
Ok(res) => res?,
Err(_) => return Err("cpu-seed: result channel dropped".to_string()),
};
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
// ── AnalysisBackfillQueueState ────────────────────────────────────────────
#[test]
fn backfill_default_state_has_empty_queues_and_no_in_progress() {
let s = AnalysisBackfillQueueState::default();
assert_eq!(s.queued_len(), 0);
assert!(s.in_progress.is_empty());
}
#[test]
fn backfill_is_reserved_checks_all_tiers_and_in_progress() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(
String::new(),
"queued".into(),
"u".into(),
AnalysisBackfillPriority::Middle,
);
s.in_progress.insert("active".into(), AnalysisBackfillPriority::Low);
assert!(s.is_reserved("queued"));
assert!(s.is_reserved("active"));
assert!(!s.is_reserved("other"));
}
#[test]
fn backfill_try_pop_next_drains_high_then_middle_then_low() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(String::new(), "low".into(), "u".into(), AnalysisBackfillPriority::Low);
s.enqueue(String::new(), "mid".into(), "u".into(), AnalysisBackfillPriority::Middle);
s.enqueue(String::new(), "hi".into(), "u".into(), AnalysisBackfillPriority::High);
assert_eq!(s.try_pop_next(4).unwrap().0, "hi");
assert_eq!(s.try_pop_next(4).unwrap().0, "mid");
assert_eq!(s.try_pop_next(4).unwrap().0, "low");
}
#[test]
fn backfill_enqueue_low_priority_appends_to_low_tier() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(
String::new(),
"first".into(),
"u".into(),
AnalysisBackfillPriority::High,
);
let kind = s.enqueue(
String::new(),
"second".into(),
"u2".into(),
AnalysisBackfillPriority::Low,
);
assert_eq!(kind, AnalysisBackfillEnqueueKind::NewLow);
assert_eq!(s.try_pop_next(4).unwrap().0, "first");
assert_eq!(s.try_pop_next(4).unwrap().0, "second");
}
#[test]
fn backfill_enqueue_high_priority_pushes_to_high_tier() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(
String::new(),
"old".into(),
"u".into(),
AnalysisBackfillPriority::Low,
);
let kind = s.enqueue(
String::new(),
"hot".into(),
"u2".into(),
AnalysisBackfillPriority::High,
);
assert_eq!(kind, AnalysisBackfillEnqueueKind::NewHigh);
assert_eq!(s.try_pop_next(4).unwrap().0, "hot");
}
#[test]
fn backfill_enqueue_middle_priority_appends_to_middle_tier() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(
String::new(),
"old".into(),
"u".into(),
AnalysisBackfillPriority::Low,
);
let kind = s.enqueue(
String::new(),
"next".into(),
"u2".into(),
AnalysisBackfillPriority::Middle,
);
assert_eq!(kind, AnalysisBackfillEnqueueKind::NewMiddle);
assert_eq!(s.try_pop_next(4).unwrap().0, "next");
assert_eq!(s.try_pop_next(4).unwrap().0, "old");
}
#[test]
fn backfill_enqueue_returns_duplicate_skipped_for_same_tier_dup() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(
String::new(),
"dup".into(),
"u".into(),
AnalysisBackfillPriority::Low,
);
let kind = s.enqueue(
String::new(),
"dup".into(),
"u2".into(),
AnalysisBackfillPriority::Low,
);
assert_eq!(kind, AnalysisBackfillEnqueueKind::DuplicateSkipped);
assert_eq!(s.queued_len(), 1);
}
#[test]
fn backfill_enqueue_upgrades_low_to_middle() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(
String::new(),
"dup".into(),
"old_url".into(),
AnalysisBackfillPriority::Low,
);
let kind = s.enqueue(
"server-1".into(),
"dup".into(),
"fresh_url".into(),
AnalysisBackfillPriority::Middle,
);
assert_eq!(kind, AnalysisBackfillEnqueueKind::ReorderedHigher);
let job = s.try_pop_next(4).unwrap();
assert_eq!(job.0, "dup");
assert_eq!(job.1, "fresh_url");
assert_eq!(job.2, "server-1");
assert_eq!(s.queued_len(), 0);
}
#[test]
fn backfill_enqueue_returns_running_skipped_for_high_prio_active_track() {
let mut s = AnalysisBackfillQueueState {
in_progress: HashMap::from([(
String::from("active"),
AnalysisBackfillPriority::Low,
)]),
..Default::default()
};
let kind = s.enqueue(
String::new(),
"active".into(),
"u".into(),
AnalysisBackfillPriority::High,
);
assert_eq!(kind, AnalysisBackfillEnqueueKind::RunningSkipped);
}
#[test]
fn backfill_try_pop_next_respects_max_concurrent() {
let mut s = AnalysisBackfillQueueState::default();
s.enqueue(String::new(), "a".into(), "u".into(), AnalysisBackfillPriority::Low);
s.enqueue(String::new(), "b".into(), "u".into(), AnalysisBackfillPriority::Low);
s.in_progress.insert("active".into(), AnalysisBackfillPriority::Low);
assert!(s.try_pop_next(1).is_none());
assert_eq!(s.try_pop_next(2).unwrap().0, "a");
}
#[test]
fn backfill_prune_queued_not_in_drops_unkept_entries() {
let mut s = AnalysisBackfillQueueState::default();
for tid in ["a", "b", "c", "d"] {
s.enqueue(String::new(), tid.into(), "u".into(), AnalysisBackfillPriority::Low);
}
let keep: HashSet<&str> = ["a", "c"].iter().copied().collect();
let removed = s.prune_queued_not_in(&keep, None);
assert_eq!(removed, 2);
assert_eq!(s.try_pop_next(4).unwrap().0, "a");
assert_eq!(s.try_pop_next(4).unwrap().0, "c");
}
// ── AnalysisCpuSeedQueueState ─────────────────────────────────────────────
#[test]
fn cpu_seed_enqueue_low_prio_appends_to_low_tier() {
let mut s = AnalysisCpuSeedQueueState::default();
let (kind, _rx) = s.enqueue(
String::new(),
"a".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewLow);
assert_eq!(s.queued_len(), 1);
}
#[test]
fn cpu_seed_enqueue_high_prio_pushes_to_high_tier() {
let mut s = AnalysisCpuSeedQueueState::default();
let (_, _r1) = s.enqueue(
String::new(),
"first".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let (kind, _r2) = s.enqueue(
String::new(),
"hot".into(),
vec![],
AnalysisBackfillPriority::High,
0,
);
assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewHigh);
assert_eq!(s.try_pop_next().unwrap().track_id, "hot");
}
#[test]
fn cpu_seed_enqueue_existing_low_prio_merges_at_back() {
let mut s = AnalysisCpuSeedQueueState::default();
let (_, _r1) = s.enqueue(
"server-a".into(),
"dup".into(),
vec![1, 2, 3],
AnalysisBackfillPriority::Low,
0,
);
let (kind, _r2) = s.enqueue(
"server-b".into(),
"dup".into(),
vec![4, 5, 6],
AnalysisBackfillPriority::Low,
0,
);
assert_eq!(kind, AnalysisCpuSeedEnqueueKind::MergedQueued);
assert_eq!(s.queued_len(), 1);
let job = s.try_pop_next().unwrap();
assert_eq!(job.bytes, vec![4, 5, 6], "fresh bytes overwrite");
assert_eq!(job.server_id, "server-b", "latest server scope wins on merge");
assert_eq!(job.waiters.len(), 2, "both waiters attached");
}
#[test]
fn cpu_seed_enqueue_existing_low_prio_upgrades_to_high() {
let mut s = AnalysisCpuSeedQueueState::default();
let (_, _r1) = s.enqueue(
String::new(),
"first".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let (_, _r2) = s.enqueue(
String::new(),
"dup".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let (kind, _r3) = s.enqueue(
String::new(),
"dup".into(),
vec![],
AnalysisBackfillPriority::High,
0,
);
assert_eq!(kind, AnalysisCpuSeedEnqueueKind::ReorderedHigher);
assert_eq!(s.try_pop_next().unwrap().track_id, "dup");
}
#[test]
fn cpu_seed_enqueue_running_id_attaches_as_follower() {
let mut s = AnalysisCpuSeedQueueState::default();
let followers = Arc::new(Mutex::new(Vec::new()));
s.running.insert("active".into(), followers.clone());
let (kind, _rx) = s.enqueue(
String::new(),
"active".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
assert_eq!(kind, AnalysisCpuSeedEnqueueKind::RunningFollower);
assert_eq!(followers.lock().unwrap().len(), 1, "follower channel attached");
assert_eq!(s.queued_len(), 0, "follower does not occupy a queue slot");
}
#[test]
fn cpu_seed_prune_returns_removed_jobs_and_waiter_count() {
let mut s = AnalysisCpuSeedQueueState::default();
let (_, _r1) = s.enqueue(
String::new(),
"a".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let (_, _r2) = s.enqueue(
String::new(),
"b".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let (_, _r3) = s.enqueue(
String::new(),
"a".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let (_, _r4) = s.enqueue(
String::new(),
"c".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let keep: HashSet<&str> = ["a"].iter().copied().collect();
let (removed_jobs, removed_waiters) = s.prune_queued_not_in(&keep, None);
assert_eq!(removed_jobs, 2, "b and c removed");
assert_eq!(removed_waiters, 2, "one waiter on b + one on c");
assert_eq!(s.try_pop_next().unwrap().track_id, "a");
}
#[test]
fn cpu_seed_prune_sends_err_to_dropped_waiters() {
let mut s = AnalysisCpuSeedQueueState::default();
let (_, rx) = s.enqueue(
String::new(),
"doomed".into(),
vec![],
AnalysisBackfillPriority::Low,
0,
);
let keep: HashSet<&str> = HashSet::new();
let _ = s.prune_queued_not_in(&keep, None);
let result = rx.blocking_recv().expect("sender side should have closed cleanly");
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));
}
}