diff --git a/CHANGELOG.md b/CHANGELOG.md index 0195d579..352d1d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -380,6 +380,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The cover pipeline now reports a covers-per-minute throughput (cpm), the analogue of the analysis pipeline's tpm: a rolling one-minute rate derived from the backfill `done` progress. Shown in the Monitor tab "Cover backfill" card (pinnable to the overlay) and in the Cover pipeline overlay block. +### Performance Probe — cover pipeline on-demand (ui) throughput + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#947](https://github.com/Psychotoxical/psysonic/pull/947)** + +* Cover cpm previously measured only the native backfill (lib). On-demand UI cover ensures (grid / now-playing) now report their own covers-per-minute rate, shown as separate **Backfill (lib)** and **On-demand (ui)** cards in the Monitor tab (each pinnable) and as `lib`/`ui` rows in the Cover pipeline overlay block. + diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 5a3eab78..4678a118 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -24,8 +24,27 @@ 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::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; + +/// Cumulative count of covers newly produced by on-demand (UI) ensures — the +/// source for the Performance Probe "on-demand (ui)" throughput. Library +/// backfill (`library_bulk`) is excluded; it reports via `cover:library-progress`. +static UI_ENSURE_PRODUCED: AtomicU64 = AtomicU64::new(0); + +/// Snapshot of covers produced by on-demand UI ensures since process start. +pub fn ui_ensure_produced_total() -> u64 { + UI_ENSURE_PRODUCED.load(Ordering::Relaxed) +} + +/// Count one freshly produced on-demand cover. Called from `ensure_inner` on the +/// produce-success path only (past the early cache-hit gate), so pure cache hits +/// and library backfill (`library_bulk`) are excluded. +fn note_ui_cover_produced(args: &CoverCacheEnsureArgs) { + if !args.library_bulk { + UI_ENSURE_PRODUCED.fetch_add(1, Ordering::Relaxed); + } +} use std::time::Duration; use tokio::sync::{Mutex, Semaphore}; use tauri::{AppHandle, Emitter, Manager}; @@ -61,6 +80,8 @@ pub struct CoverPipelineQueueStatsDto { pub library_backfill_http_max: u32, pub library_backfill_http_active: u32, pub library_backfill_pass_running: bool, + /// Cumulative covers produced by on-demand (UI) ensures since process start. + pub ui_ensured_total: u64, } fn sem_active(sem: &Semaphore, max: u32) -> u32 { @@ -88,6 +109,7 @@ pub(crate) fn cover_pipeline_queue_stats( library_backfill_http_max, library_backfill_http_active, library_backfill_pass_running, + ui_ensured_total: ui_ensure_produced_total(), } } @@ -337,6 +359,10 @@ impl CoverCacheState { let out_path = tier_path(&dir, requested); if wrote_requested || out_path.is_file() { + // Past the early cache-hit gate, so reaching here means this ensure + // decoded + (re)encoded a cover. Count on-demand (non-bulk) work for + // the Performance Probe "on-demand (ui)" throughput. + note_ui_cover_produced(args); if !quiet { if let Some(img) = load_image_from_disk(&dir) { spawn_derive_remaining_tiers( diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index 292a7b62..b7e93018 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -43,6 +43,7 @@ export type CoverPipelineQueueStatsDto = { libraryBackfillHttpMax: number; libraryBackfillHttpActive: number; libraryBackfillPassRunning: boolean; + uiEnsuredTotal: number; }; let coverAutoDownloadEnabled = true; diff --git a/src/components/FpsOverlay.tsx b/src/components/FpsOverlay.tsx index 0dd90cba..d86b0757 100644 --- a/src/components/FpsOverlay.tsx +++ b/src/components/FpsOverlay.tsx @@ -32,7 +32,7 @@ import { } from '../utils/perf/perfOverlayMode'; import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener'; import { useCoverPerfListener } from '../hooks/useCoverPerfListener'; -import { getCoverCachedPerMinute } from '../utils/perf/coverPerfStore'; +import { getCoverCachedPerMinute, getCoverUiPerMinute } from '../utils/perf/coverPerfStore'; const SAMPLE_MS = 500; const TPM_REFRESH_MS = 500; @@ -69,6 +69,7 @@ export default function FpsOverlay() { const [fps, setFps] = useState(0); const [tpm, setTpm] = useState(0); const [cpm, setCpm] = useState(0); + const [cpmUi, setCpmUi] = useState(0); const [queueStats, setQueueStats] = useState(null); const [coverQueueLines, setCoverQueueLines] = useState([]); const last = useAnalysisPerfLast(); @@ -135,9 +136,13 @@ export default function FpsOverlay() { useEffect(() => { if (!showCoverPerfOverlay) { setCpm(0); + setCpmUi(0); return; } - const refresh = () => setCpm(getCoverCachedPerMinute()); + const refresh = () => { + setCpm(getCoverCachedPerMinute()); + setCpmUi(getCoverUiPerMinute()); + }; refresh(); const id = window.setInterval(refresh, TPM_REFRESH_MS); return () => window.clearInterval(id); @@ -270,7 +275,12 @@ export default function FpsOverlay() {
{cpm.toFixed(1)} {' '} - cpm + lib cpm +
+
+ {cpmUi.toFixed(1)} + {' '} + ui cpm
{coverQueueLines.length > 0 ? coverQueueLines.map(line => (
diff --git a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx index ddb47d74..68a6a966 100644 --- a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx +++ b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx @@ -223,9 +223,9 @@ export default function SidebarPerfProbeMonitorTab() { )} {live.cover && ( - + 0 @@ -234,6 +234,14 @@ export default function SidebarPerfProbeMonitorTab() { pinned={livePinned('cover:cpm')} onTogglePin={toggleLive('cover:cpm')} /> + )}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 4300e9e9..059897cd 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -148,6 +148,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Cover art: fix per-song cover over-fetch on Navidrome — only genuine per-disc artwork expands, collapsing ~520k per-track fetches to one cover per album (PR #944)', 'Performance Probe: cover pipeline covers-per-minute (cpm) throughput, the cover analogue of analysis tpm (PR #945)', 'Performance Probe: live runtime logs tab with depth switch, line cap, and ordered include/exclude word filter (PR #946)', + 'Performance Probe: on-demand (ui) cover throughput alongside backfill (lib) cpm (PR #947)', ], }, { diff --git a/src/hooks/useCoverPerfListener.ts b/src/hooks/useCoverPerfListener.ts index dda579fd..f715119c 100644 --- a/src/hooks/useCoverPerfListener.ts +++ b/src/hooks/useCoverPerfListener.ts @@ -1,6 +1,10 @@ import { useEffect } from 'react'; import { listen } from '@tauri-apps/api/event'; -import { recordCoverProgress } from '../utils/perf/coverPerfStore'; +import { coverGetPipelineQueueStats } from '../api/coverCache'; +import { recordCoverProgress, recordCoverUiTotal } from '../utils/perf/coverPerfStore'; + +/** How often to sample the backend's cumulative on-demand (UI) ensure count. */ +const UI_POLL_MS = 1000; type CoverLibraryProgressPayload = { serverIndexKey?: string; @@ -37,3 +41,28 @@ export function useCoverPerfListener(active: boolean): void { }; }, [active]); } + +/** + * Poll the backend's cumulative on-demand (UI) ensure total so the cover store + * can derive a per-minute rate. Mount once (always-mounted probe hook) to avoid + * duplicate polling. + */ +export function useCoverUiThroughputPoll(active: boolean): void { + useEffect(() => { + if (!active) return; + let cancelled = false; + const sample = (): void => { + void coverGetPipelineQueueStats() + .then(stats => { + if (!cancelled) recordCoverUiTotal(stats.uiEnsuredTotal); + }) + .catch(() => {}); + }; + sample(); + const timer = window.setInterval(sample, UI_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [active]); +} diff --git a/src/hooks/useSidebarPerfProbe.ts b/src/hooks/useSidebarPerfProbe.ts index 70876582..25e6e9cb 100644 --- a/src/hooks/useSidebarPerfProbe.ts +++ b/src/hooks/useSidebarPerfProbe.ts @@ -3,7 +3,7 @@ import { acquirePerfLivePoll, patchPerfLiveAnalysis } from '../utils/perf/perfLi import { setPerfProbeTelemetryActive } from '../utils/perf/perfTelemetry'; import { useAnalysisPerfLast } from '../utils/perf/analysisPerfStore'; import { useAnalysisPerfListener } from './useAnalysisPerfListener'; -import { useCoverPerfListener } from './useCoverPerfListener'; +import { useCoverPerfListener, useCoverUiThroughputPoll } from './useCoverPerfListener'; import { getPerfProbeFlags, subscribePerfProbeFlags, @@ -39,6 +39,7 @@ function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet perfProbeOpen, ); @@ -54,6 +55,7 @@ export function useSidebarPerfProbe(): Result { useAnalysisPerfListener(needAnalysis); useCoverPerfListener(needCover); + useCoverUiThroughputPoll(needCover); useEffect(() => { setPerfProbeTelemetryActive(perfProbeOpen); diff --git a/src/utils/perf/coverPerfStore.test.ts b/src/utils/perf/coverPerfStore.test.ts index 6b5013ef..2d99e21d 100644 --- a/src/utils/perf/coverPerfStore.test.ts +++ b/src/utils/perf/coverPerfStore.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; import { getCoverCachedPerMinute, getCoverPerfState, + getCoverUiPerMinute, recordCoverProgress, + recordCoverUiTotal, resetCoverPerfStateForTest, } from './coverPerfStore'; @@ -15,26 +17,29 @@ afterEach(() => { }); describe('coverPerfStore', () => { - it('derives covers-per-minute from done deltas over time', () => { + it('derives covers-per-minute from done deltas over the trailing window', () => { vi.useFakeTimers(); vi.setSystemTime(1_000_000); recordCoverProgress({ done: 100, total: 1000, pending: 900 }); - vi.advanceTimersByTime(30_000); - recordCoverProgress({ done: 130, total: 1000, pending: 870 }); - // +30 covers over 30s ≈ 60 cpm. - expect(getCoverCachedPerMinute()).toBeCloseTo(60, 0); - expect(getCoverPerfState().done).toBe(130); + vi.advanceTimersByTime(1_000); + recordCoverProgress({ done: 110, total: 1000, pending: 890 }); + vi.advanceTimersByTime(1_000); + recordCoverProgress({ done: 120, total: 1000, pending: 880 }); + // +20 covers over the last 2s ≈ 600 cpm (no minute-long inertia). + expect(getCoverCachedPerMinute()).toBeCloseTo(600, 0); + expect(getCoverPerfState().done).toBe(120); }); - it('returns 0 with a single sample and prunes the window after a minute', () => { + it('returns 0 with a single sample and decays once the trailing window empties', () => { vi.useFakeTimers(); vi.setSystemTime(2_000_000); recordCoverProgress({ done: 10 }); expect(getCoverCachedPerMinute()).toBe(0); - vi.advanceTimersByTime(20_000); + vi.advanceTimersByTime(1_000); recordCoverProgress({ done: 20 }); expect(getCoverCachedPerMinute()).toBeGreaterThan(0); - vi.advanceTimersByTime(61_000); + // No fresh samples for >5s → trailing window empties → back to 0. + vi.advanceTimersByTime(6_000); expect(getCoverCachedPerMinute()).toBe(0); }); @@ -48,4 +53,31 @@ describe('coverPerfStore', () => { expect(getCoverCachedPerMinute()).toBe(0); expect(getCoverPerfState().done).toBe(5); }); + + it('derives UI covers-per-minute from backend total deltas over the trailing window', () => { + vi.useFakeTimers(); + vi.setSystemTime(4_000_000); + expect(getCoverUiPerMinute()).toBe(0); + recordCoverUiTotal(100); + // A single sample has no delta yet. + expect(getCoverUiPerMinute()).toBe(0); + vi.advanceTimersByTime(2_000); + recordCoverUiTotal(130); + // +30 produced over the last 2s ≈ 900 cpm; lib series stays untouched. + expect(getCoverUiPerMinute()).toBeCloseTo(900, 0); + expect(getCoverCachedPerMinute()).toBe(0); + // Idle poll keeps reporting the same total → delta 0 → rate decays to 0. + vi.advanceTimersByTime(6_000); + recordCoverUiTotal(130); + expect(getCoverUiPerMinute()).toBe(0); + }); + + it('resets the UI window on a backwards jump (process restart)', () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000_000); + recordCoverUiTotal(500); + vi.advanceTimersByTime(5_000); + recordCoverUiTotal(3); + expect(getCoverUiPerMinute()).toBe(0); + }); }); diff --git a/src/utils/perf/coverPerfStore.ts b/src/utils/perf/coverPerfStore.ts index 4c8e6523..9bf9211a 100644 --- a/src/utils/perf/coverPerfStore.ts +++ b/src/utils/perf/coverPerfStore.ts @@ -3,25 +3,45 @@ import { useSyncExternalStore } from 'react'; /** * Cover-pipeline throughput store — the cover analogue of `analysisPerfStore`. * - * The backfill worker emits cumulative `done` (covers cached) on the - * `cover:library-progress` event. We sample `done` over a rolling one-minute - * window and derive covers-per-minute (cpm), mirroring analysis tpm. + * Two independent throughput series share the one-minute rolling window: + * - **lib**: the native backfill worker emits cumulative `done` (covers + * cached) on `cover:library-progress`; we sample it and derive the delta + * rate, mirroring analysis tpm. + * - **ui**: on-demand cover ensures (grid/now-playing) are counted natively — + * the backend exposes a cumulative `uiEnsuredTotal` in the pipeline stats; + * we sample it and derive the delta rate, exactly like lib. Sourcing the + * count in Rust avoids the webview ensure-queue dedup/HMR pitfalls that made + * a JS-side counter unreliable. */ export type CoverProgressSample = { at: number; done: number; }; +type CoverTotalSample = { + at: number; + total: number; +}; + type CoverPerfState = { samples: CoverProgressSample[]; done: number; total: number; pending: number; + /** Cumulative on-demand (UI) ensure totals sampled from the backend (rolling window). */ + uiSamples: CoverTotalSample[]; }; +/** Sample-retention window (kept generous so backwards-jump detection is robust). */ const WINDOW_MS = 60_000; +/** + * Rate is measured over the trailing few seconds only — a full-minute average + * has too much inertia and flattens real bursts/stalls. We still extrapolate to + * a per-minute figure for display. + */ +const RATE_WINDOW_MS = 5_000; -let state: CoverPerfState = { samples: [], done: 0, total: 0, pending: 0 }; +let state: CoverPerfState = { samples: [], done: 0, total: 0, pending: 0, uiSamples: [] }; const listeners = new Set<() => void>(); function emit(): void { @@ -33,6 +53,11 @@ function pruneSamples(now: number, samples: readonly CoverProgressSample[]): Cov return samples.filter(s => s.at >= cutoff); } +function pruneTotals(now: number, samples: readonly CoverTotalSample[]): CoverTotalSample[] { + const cutoff = now - WINDOW_MS; + return samples.filter(s => s.at >= cutoff); +} + export function recordCoverProgress(payload: { done: number; total?: number; @@ -48,6 +73,7 @@ export function recordCoverProgress(payload: { } samples = [...samples, { at: now, done }]; state = { + ...state, samples, done, total: payload.total ?? state.total, @@ -56,16 +82,49 @@ export function recordCoverProgress(payload: { emit(); } -/** Covers cached per minute over the rolling window (0 when idle). */ -export function getCoverCachedPerMinute(now = Date.now()): number { - const samples = pruneSamples(now, state.samples); - if (samples.length < 2) return 0; - const first = samples[0]; - const last = samples[samples.length - 1]; - const delta = Math.max(0, last.done - first.done); +/** Sample the backend's cumulative on-demand (UI) ensure total. */ +export function recordCoverUiTotal(total: number): void { + const now = Date.now(); + const next = Math.max(0, Math.floor(total)); + let uiSamples = pruneTotals(now, state.uiSamples); + // A backwards jump means the process restarted — drop the stale baseline. + if (uiSamples.length > 0 && next < uiSamples[uiSamples.length - 1].total) { + uiSamples = []; + } + uiSamples = [...uiSamples, { at: now, total: next }]; + state = { ...state, uiSamples }; + emit(); +} + +/** + * Per-minute rate from a cumulative-counter series, measured over the trailing + * `RATE_WINDOW_MS`. Returns 0 when fewer than two recent samples are available + * (so a stalled pipeline drops to 0 within the window instead of coasting). + */ +function recentRatePerMinute( + now: number, + samples: readonly T[], + valueOf: (sample: T) => number, +): number { + const cutoff = now - RATE_WINDOW_MS; + const recent = samples.filter(s => s.at >= cutoff); + if (recent.length < 2) return 0; + const first = recent[0]; + const last = recent[recent.length - 1]; + const delta = Math.max(0, valueOf(last) - valueOf(first)); if (delta === 0) return 0; - const spanMs = Math.max(1, Math.min(WINDOW_MS, now - first.at)); - return (delta / spanMs) * WINDOW_MS; + const spanMs = Math.max(1, last.at - first.at); + return (delta / spanMs) * 60_000; +} + +/** Covers cached per minute, averaged over the trailing few seconds (0 when idle). */ +export function getCoverCachedPerMinute(now = Date.now()): number { + return recentRatePerMinute(now, state.samples, s => s.done); +} + +/** On-demand UI covers produced per minute, averaged over the trailing few seconds. */ +export function getCoverUiPerMinute(now = Date.now()): number { + return recentRatePerMinute(now, state.uiSamples, s => s.total); } export function getCoverPerfState(): CoverPerfState { @@ -83,6 +142,6 @@ export function useCoverPerfState(): CoverPerfState { /** Test-only reset. */ export function resetCoverPerfStateForTest(): void { - state = { samples: [], done: 0, total: 0, pending: 0 }; + state = { samples: [], done: 0, total: 0, pending: 0, uiSamples: [] }; emit(); } diff --git a/src/utils/perf/formatCoverPipelineQueueOverlay.test.ts b/src/utils/perf/formatCoverPipelineQueueOverlay.test.ts index 98026b73..1ae851a3 100644 --- a/src/utils/perf/formatCoverPipelineQueueOverlay.test.ts +++ b/src/utils/perf/formatCoverPipelineQueueOverlay.test.ts @@ -15,6 +15,7 @@ describe('formatCoverPipelineQueueOverlay', () => { libraryBackfillHttpMax: 2, libraryBackfillHttpActive: 1, libraryBackfillPassRunning: true, + uiEnsuredTotal: 0, }, ensure: { queuedHigh: 2, @@ -46,6 +47,7 @@ describe('formatCoverPipelineQueueOverlay', () => { libraryBackfillHttpMax: 2, libraryBackfillHttpActive: 0, libraryBackfillPassRunning: false, + uiEnsuredTotal: 0, }, ensure: { queuedHigh: 0, diff --git a/src/utils/perf/formatLiveOverlayItems.ts b/src/utils/perf/formatLiveOverlayItems.ts index f6ecc35b..284b3202 100644 --- a/src/utils/perf/formatLiveOverlayItems.ts +++ b/src/utils/perf/formatLiveOverlayItems.ts @@ -92,7 +92,14 @@ export function buildLiveOverlayItems( } else if (pin === 'cover:cpm' && live.cover) { items.push({ id: pin, - line: `cover ${live.cover.cachedPerMinute.toFixed(1)} cpm`, + line: `cover lib ${live.cover.cachedPerMinute.toFixed(1)} cpm`, + kind: 'cover', + sparkline: false, + }); + } else if (pin === 'cover:cpm:ui' && live.cover) { + items.push({ + id: pin, + line: `cover ui ${live.cover.uiPerMinute.toFixed(1)} cpm`, kind: 'cover', sparkline: false, }); diff --git a/src/utils/perf/perfLiveStore.ts b/src/utils/perf/perfLiveStore.ts index d59cf498..3b26a179 100644 --- a/src/utils/perf/perfLiveStore.ts +++ b/src/utils/perf/perfLiveStore.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { clearPerfLiveHistory, syncPerfLiveHistoryFromPoll } from './perfLiveHistory'; import { getAnalysisTracksPerMinute } from './analysisPerfStore'; -import { getCoverCachedPerMinute, getCoverPerfState } from './coverPerfStore'; +import { getCoverCachedPerMinute, getCoverUiPerMinute, getCoverPerfState } from './coverPerfStore'; import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot'; import { getPerfLiveOverlayPins } from './perfOverlayPins'; import { @@ -46,6 +46,7 @@ export type PerfAnalysisDiag = { export type PerfCoverDiag = { cachedPerMinute: number; + uiPerMinute: number; done: number; total: number; pending: number; @@ -132,6 +133,7 @@ function analysisEqual(a: PerfAnalysisDiag | null, b: PerfAnalysisDiag | null): function coverEqual(a: PerfCoverDiag | null, b: PerfCoverDiag | null): boolean { if (a == null || b == null) return a === b; return a.cachedPerMinute === b.cachedPerMinute + && a.uiPerMinute === b.uiPerMinute && a.done === b.done && a.total === b.total && a.pending === b.pending; @@ -184,6 +186,7 @@ function buildCoverDiag(): PerfCoverDiag { const cover = getCoverPerfState(); return { cachedPerMinute: getCoverCachedPerMinute(), + uiPerMinute: getCoverUiPerMinute(), done: cover.done, total: cover.total, pending: cover.pending, diff --git a/src/utils/perf/perfOverlayPins.ts b/src/utils/perf/perfOverlayPins.ts index 99ba5f87..6841d1bf 100644 --- a/src/utils/perf/perfOverlayPins.ts +++ b/src/utils/perf/perfOverlayPins.ts @@ -17,7 +17,8 @@ export type PerfLiveOverlayPinId = | 'rate:home' | 'analysis:tpm' | 'analysis:last' - | 'cover:cpm'; + | 'cover:cpm' + | 'cover:cpm:ui'; const PIPELINE_PIN_TO_FLAG = { 'pipeline:fps': 'showFpsOverlay',