From c6df05e576b4dd0f09a0b83cd684758ee3cf0e6d Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:05:28 +0300 Subject: [PATCH] feat(perf): cover pipeline throughput (cpm) in performance probe (#945) * feat(perf): cover pipeline throughput (cpm) in performance probe Mirror the analysis pipeline's tpm for covers. A new coverPerfStore samples the backfill `done` progress from cover:library-progress events and derives a rolling one-minute covers-per-minute rate. Surfaced as a live diag in perfLiveStore, a pinnable "Cover backfill" throughput card in the Monitor tab, and a cpm row in the Cover pipeline overlay block. * docs(changelog): note cover pipeline cpm metric (PR #945) Add CHANGELOG entry and credits line for the cover-pipeline covers-per-minute throughput metric in the Performance Probe. --- CHANGELOG.md | 6 ++ src/components/FpsOverlay.tsx | 20 +++++ .../perfProbe/SidebarPerfProbeMonitorTab.tsx | 15 ++++ src/config/settingsCredits.ts | 1 + src/hooks/useCoverPerfListener.ts | 39 ++++++++ src/hooks/useSidebarPerfProbe.ts | 15 ++++ src/utils/perf/coverPerfStore.test.ts | 51 +++++++++++ src/utils/perf/coverPerfStore.ts | 88 +++++++++++++++++++ src/utils/perf/formatLiveOverlayItems.ts | 9 +- src/utils/perf/perfLiveStore.ts | 34 ++++++- src/utils/perf/perfOverlayPins.ts | 5 +- 11 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useCoverPerfListener.ts create mode 100644 src/utils/perf/coverPerfStore.test.ts create mode 100644 src/utils/perf/coverPerfStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8a7fef..227e8ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -367,6 +367,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Performance Probe** gains a runtime cover-thread control (`library_cover_backfill_set_parallel`) that resizes the HTTP/encode pools live; "Run full pass now" forces a pass and clears fetch-failed backoff. * Clearing the active server's cover cache re-arms the idle gate and wakes the worker, and in-pass progress is emitted on a ticker so the offline & cache view keeps counting through the whole scan. +### Performance Probe — cover pipeline throughput (cpm) + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#945](https://github.com/Psychotoxical/psysonic/pull/945)** + +* 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. + diff --git a/src/components/FpsOverlay.tsx b/src/components/FpsOverlay.tsx index 9bfec8f2..0dd90cba 100644 --- a/src/components/FpsOverlay.tsx +++ b/src/components/FpsOverlay.tsx @@ -31,6 +31,8 @@ import { usePerfOverlayMode, } from '../utils/perf/perfOverlayMode'; import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener'; +import { useCoverPerfListener } from '../hooks/useCoverPerfListener'; +import { getCoverCachedPerMinute } from '../utils/perf/coverPerfStore'; const SAMPLE_MS = 500; const TPM_REFRESH_MS = 500; @@ -66,6 +68,7 @@ export default function FpsOverlay() { const overlayAppearance = usePerfOverlayAppearance(); const [fps, setFps] = useState(0); const [tpm, setTpm] = useState(0); + const [cpm, setCpm] = useState(0); const [queueStats, setQueueStats] = useState(null); const [coverQueueLines, setCoverQueueLines] = useState([]); const last = useAnalysisPerfLast(); @@ -93,6 +96,7 @@ export default function FpsOverlay() { ); useAnalysisPerfListener(showAnalysisPerfOverlay || livePins.has('analysis:tpm') || livePins.has('analysis:last')); + useCoverPerfListener(showCoverPerfOverlay || livePins.has('cover:cpm')); useEffect(() => { if (!showAnalysisPerfOverlay) { @@ -128,6 +132,17 @@ export default function FpsOverlay() { }; }, [showAnalysisPerfOverlay]); + useEffect(() => { + if (!showCoverPerfOverlay) { + setCpm(0); + return; + } + const refresh = () => setCpm(getCoverCachedPerMinute()); + refresh(); + const id = window.setInterval(refresh, TPM_REFRESH_MS); + return () => window.clearInterval(id); + }, [showCoverPerfOverlay]); + useEffect(() => { if (!showCoverPerfOverlay) { setCoverQueueLines([]); @@ -252,6 +267,11 @@ export default function FpsOverlay() { {showCoverPerfOverlay && (
Cover pipeline
+
+ {cpm.toFixed(1)} + {' '} + cpm +
{coverQueueLines.length > 0 ? coverQueueLines.map(line => (
{line} diff --git a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx index 5239f5de..ddb47d74 100644 --- a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx +++ b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx @@ -221,6 +221,21 @@ export default function SidebarPerfProbeMonitorTab() { )} )} + + {live.cover && ( + + 0 + ? `${live.cover.done.toLocaleString()} / ${live.cover.total.toLocaleString()} cached` + : 'covers cached per minute'} + pinned={livePinned('cover:cpm')} + onTogglePin={toggleLive('cover:cpm')} + /> + + )}
); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 72f95501..f3b549d0 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -146,6 +146,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Performance: idle Rust CPU — backfill coordinator park, probe overlay stability, throttled idle polls, lazy cover prefetch restore (PR #939)', 'Cover backfill: disk-free idle gate, snapshot-diff worklist, live-tunable parallelism, transient-error retries, and memoized offline & cache stats to stop idle CPU spin (PR #943)', '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)', ], }, { diff --git a/src/hooks/useCoverPerfListener.ts b/src/hooks/useCoverPerfListener.ts new file mode 100644 index 00000000..dda579fd --- /dev/null +++ b/src/hooks/useCoverPerfListener.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { recordCoverProgress } from '../utils/perf/coverPerfStore'; + +type CoverLibraryProgressPayload = { + serverIndexKey?: string; + done?: number; + total?: number; + pending?: number; +}; + +/** Wire Rust `cover:library-progress` events into the cover perf store. */ +export function useCoverPerfListener(active: boolean): void { + useEffect(() => { + if (!active) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void listen('cover:library-progress', ({ payload }) => { + if (cancelled || typeof payload?.done !== 'number') return; + recordCoverProgress({ + done: payload.done, + total: payload.total, + pending: payload.pending, + }); + }) + .then(fn => { + if (cancelled) { + fn(); + return; + } + unlisten = fn; + }) + .catch(() => {}); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [active]); +} diff --git a/src/hooks/useSidebarPerfProbe.ts b/src/hooks/useSidebarPerfProbe.ts index 20fd278f..70876582 100644 --- a/src/hooks/useSidebarPerfProbe.ts +++ b/src/hooks/useSidebarPerfProbe.ts @@ -3,6 +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 { getPerfProbeFlags, subscribePerfProbeFlags, @@ -31,14 +32,28 @@ function useNeedAnalysisTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet< ); } +function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet): boolean { + return useSyncExternalStore( + subscribePerfProbeFlags, + () => ( + perfProbeOpen + || getPerfProbeFlags().showCoverPerfOverlay + || livePins.has('cover:cpm') + ), + () => perfProbeOpen, + ); +} + /** Wires Ctrl+Shift+D probe modal and shared live metric polling. */ export function useSidebarPerfProbe(): Result { const [perfProbeOpen, setPerfProbeOpen] = useState(false); const livePins = usePerfLiveOverlayPins(); const analysisLast = useAnalysisPerfLast(); const needAnalysis = useNeedAnalysisTelemetry(perfProbeOpen, livePins); + const needCover = useNeedCoverTelemetry(perfProbeOpen, livePins); useAnalysisPerfListener(needAnalysis); + useCoverPerfListener(needCover); useEffect(() => { setPerfProbeTelemetryActive(perfProbeOpen); diff --git a/src/utils/perf/coverPerfStore.test.ts b/src/utils/perf/coverPerfStore.test.ts new file mode 100644 index 00000000..6b5013ef --- /dev/null +++ b/src/utils/perf/coverPerfStore.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import { + getCoverCachedPerMinute, + getCoverPerfState, + recordCoverProgress, + resetCoverPerfStateForTest, +} from './coverPerfStore'; + +beforeEach(() => { + resetCoverPerfStateForTest(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('coverPerfStore', () => { + it('derives covers-per-minute from done deltas over time', () => { + 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); + }); + + it('returns 0 with a single sample and prunes the window after a minute', () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000_000); + recordCoverProgress({ done: 10 }); + expect(getCoverCachedPerMinute()).toBe(0); + vi.advanceTimersByTime(20_000); + recordCoverProgress({ done: 20 }); + expect(getCoverCachedPerMinute()).toBeGreaterThan(0); + vi.advanceTimersByTime(61_000); + expect(getCoverCachedPerMinute()).toBe(0); + }); + + it('resets the window on a backwards jump (server switch / cache clear)', () => { + vi.useFakeTimers(); + vi.setSystemTime(3_000_000); + recordCoverProgress({ done: 500 }); + vi.advanceTimersByTime(5_000); + recordCoverProgress({ done: 5 }); + // Only the new baseline remains → no rate yet. + expect(getCoverCachedPerMinute()).toBe(0); + expect(getCoverPerfState().done).toBe(5); + }); +}); diff --git a/src/utils/perf/coverPerfStore.ts b/src/utils/perf/coverPerfStore.ts new file mode 100644 index 00000000..4c8e6523 --- /dev/null +++ b/src/utils/perf/coverPerfStore.ts @@ -0,0 +1,88 @@ +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. + */ +export type CoverProgressSample = { + at: number; + done: number; +}; + +type CoverPerfState = { + samples: CoverProgressSample[]; + done: number; + total: number; + pending: number; +}; + +const WINDOW_MS = 60_000; + +let state: CoverPerfState = { samples: [], done: 0, total: 0, pending: 0 }; +const listeners = new Set<() => void>(); + +function emit(): void { + listeners.forEach(fn => fn()); +} + +function pruneSamples(now: number, samples: readonly CoverProgressSample[]): CoverProgressSample[] { + const cutoff = now - WINDOW_MS; + return samples.filter(s => s.at >= cutoff); +} + +export function recordCoverProgress(payload: { + done: number; + total?: number; + pending?: number; +}): void { + const now = Date.now(); + const done = Math.max(0, Math.floor(payload.done)); + let samples = pruneSamples(now, state.samples); + // A backwards jump means a different pass (server switch / cache clear) — start + // a fresh window so the old baseline doesn't inflate or zero out the rate. + if (samples.length > 0 && done < samples[samples.length - 1].done) { + samples = []; + } + samples = [...samples, { at: now, done }]; + state = { + samples, + done, + total: payload.total ?? state.total, + pending: payload.pending ?? state.pending, + }; + 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); + if (delta === 0) return 0; + const spanMs = Math.max(1, Math.min(WINDOW_MS, now - first.at)); + return (delta / spanMs) * WINDOW_MS; +} + +export function getCoverPerfState(): CoverPerfState { + return state; +} + +export function subscribeCoverPerf(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +export function useCoverPerfState(): CoverPerfState { + return useSyncExternalStore(subscribeCoverPerf, getCoverPerfState, () => state); +} + +/** Test-only reset. */ +export function resetCoverPerfStateForTest(): void { + state = { samples: [], done: 0, total: 0, pending: 0 }; + emit(); +} diff --git a/src/utils/perf/formatLiveOverlayItems.ts b/src/utils/perf/formatLiveOverlayItems.ts index 852c5991..f6ecc35b 100644 --- a/src/utils/perf/formatLiveOverlayItems.ts +++ b/src/utils/perf/formatLiveOverlayItems.ts @@ -1,6 +1,6 @@ import type { PerfLiveSnapshot } from './perfLiveStore'; -export type LiveOverlayItemKind = 'cpu' | 'memory' | 'rate' | 'analysis'; +export type LiveOverlayItemKind = 'cpu' | 'memory' | 'rate' | 'analysis' | 'cover'; export type LiveOverlayItem = { id: string; @@ -89,6 +89,13 @@ export function buildLiveOverlayItems( kind: 'analysis', sparkline: false, }); + } else if (pin === 'cover:cpm' && live.cover) { + items.push({ + id: pin, + line: `cover ${live.cover.cachedPerMinute.toFixed(1)} cpm`, + kind: 'cover', + sparkline: false, + }); } } diff --git a/src/utils/perf/perfLiveStore.ts b/src/utils/perf/perfLiveStore.ts index 8173bc81..d59cf498 100644 --- a/src/utils/perf/perfLiveStore.ts +++ b/src/utils/perf/perfLiveStore.ts @@ -2,6 +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 { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot'; import { getPerfLiveOverlayPins } from './perfOverlayPins'; import { @@ -43,10 +44,18 @@ export type PerfAnalysisDiag = { lastBpmMs: number | null; }; +export type PerfCoverDiag = { + cachedPerMinute: number; + done: number; + total: number; + pending: number; +}; + export type PerfLiveSnapshot = { cpu: PerfLiveCpu | null; diagRates: PerfDiagRates | null; analysis: PerfAnalysisDiag | null; + cover: PerfCoverDiag | null; collecting: boolean; /** Wall time of the last displayed sample change (memory / diag / rates). */ updatedAt: number; @@ -68,6 +77,7 @@ const EMPTY: PerfLiveSnapshot = { cpu: null, diagRates: null, analysis: null, + cover: null, collecting: false, updatedAt: 0, sampleAt: 0, @@ -119,6 +129,14 @@ function analysisEqual(a: PerfAnalysisDiag | null, b: PerfAnalysisDiag | null): && a.lastBpmMs === b.lastBpmMs; } +function coverEqual(a: PerfCoverDiag | null, b: PerfCoverDiag | null): boolean { + if (a == null || b == null) return a === b; + return a.cachedPerMinute === b.cachedPerMinute + && a.done === b.done + && a.total === b.total + && a.pending === b.pending; +} + function cpuEqual(a: PerfLiveCpu | null, b: PerfLiveCpu | null): boolean { if (a == null || b == null) return a === b; return a.app === b.app @@ -132,7 +150,8 @@ function publishLiveSnapshot(next: PerfLiveSnapshot): void { const cpuChanged = !cpuEqual(snapshot.cpu, next.cpu); const diagChanged = !diagRatesEqual(snapshot.diagRates, next.diagRates); const analysisChanged = !analysisEqual(snapshot.analysis, next.analysis); - if (!cpuChanged && !diagChanged && !analysisChanged && next.updatedAt === snapshot.updatedAt) { + const coverChanged = !coverEqual(snapshot.cover, next.cover); + if (!cpuChanged && !diagChanged && !analysisChanged && !coverChanged && next.updatedAt === snapshot.updatedAt) { return; } if (next.sampleAt > snapshot.sampleAt && next.cpu?.supported) { @@ -161,6 +180,16 @@ function buildAnalysisDiag(): PerfAnalysisDiag { }; } +function buildCoverDiag(): PerfCoverDiag { + const cover = getCoverPerfState(); + return { + cachedPerMinute: getCoverCachedPerMinute(), + done: cover.done, + total: cover.total, + pending: cover.pending, + }; +} + function nextDiagRates( nextCounters: { progress: number; waveform: number; home: number }, now: number, @@ -191,6 +220,7 @@ function applyJsMetricsSnapshot(now: number): void { cpu: snapshot.cpu ?? UNSUPPORTED_CPU, diagRates, analysis: buildAnalysisDiag(), + cover: buildCoverDiag(), collecting: false, updatedAt: now, sampleAt: snapshot.sampleAt, @@ -221,6 +251,7 @@ async function pollOnce(): Promise { cpu: UNSUPPORTED_CPU, diagRates, analysis: buildAnalysisDiag(), + cover: buildCoverDiag(), collecting: false, updatedAt: completedAt, sampleAt: snapshot.sampleAt, @@ -295,6 +326,7 @@ async function pollOnce(): Promise { }, diagRates, analysis: buildAnalysisDiag(), + cover: buildCoverDiag(), collecting: false, updatedAt: nextUpdatedAt, sampleAt: nextSampleAt, diff --git a/src/utils/perf/perfOverlayPins.ts b/src/utils/perf/perfOverlayPins.ts index 61482d39..99ba5f87 100644 --- a/src/utils/perf/perfOverlayPins.ts +++ b/src/utils/perf/perfOverlayPins.ts @@ -16,7 +16,8 @@ export type PerfLiveOverlayPinId = | 'rate:waveform' | 'rate:home' | 'analysis:tpm' - | 'analysis:last'; + | 'analysis:last' + | 'cover:cpm'; const PIPELINE_PIN_TO_FLAG = { 'pipeline:fps': 'showFpsOverlay', @@ -129,7 +130,7 @@ export function hasAnyPerfOverlayVisible(): boolean { function livePinsNeedJsPoll(pins: ReadonlySet): boolean { for (const id of pins) { - if (id.startsWith('rate:') || id.startsWith('analysis:')) return true; + if (id.startsWith('rate:') || id.startsWith('analysis:') || id.startsWith('cover:')) return true; } return false; }