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.
This commit is contained in:
cucadmuh
2026-06-02 11:05:28 +03:00
committed by GitHub
parent 42aec6720c
commit c6df05e576
11 changed files with 279 additions and 4 deletions
+6
View File
@@ -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.
+20
View File
@@ -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<AnalysisPipelineQueueStatsDto | null>(null);
const [coverQueueLines, setCoverQueueLines] = useState<string[]>([]);
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 && (
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Cover pipeline</div>
<div className="fps-overlay__row">
{cpm.toFixed(1)}
{' '}
<span className="fps-overlay__unit">cpm</span>
</div>
{coverQueueLines.length > 0 ? coverQueueLines.map(line => (
<div key={line} className="fps-overlay__row fps-overlay__row--steps">
{line}
@@ -221,6 +221,21 @@ export default function SidebarPerfProbeMonitorTab() {
)}
</PerfProbeMetricSection>
)}
{live.cover && (
<PerfProbeMetricSection title="Cover backfill" defaultOpen={false}>
<PerfProbeMetricCard
label="Throughput"
value={live.cover.cachedPerMinute.toFixed(1)}
unit="cpm"
detail={live.cover.total > 0
? `${live.cover.done.toLocaleString()} / ${live.cover.total.toLocaleString()} cached`
: 'covers cached per minute'}
pinned={livePinned('cover:cpm')}
onTogglePin={toggleLive('cover:cpm')}
/>
</PerfProbeMetricSection>
)}
</div>
);
}
+1
View File
@@ -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)',
],
},
{
+39
View File
@@ -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<CoverLibraryProgressPayload>('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]);
}
+15
View File
@@ -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<string>): 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);
+51
View File
@@ -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);
});
});
+88
View File
@@ -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();
}
+8 -1
View File
@@ -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,
});
}
}
+33 -1
View File
@@ -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<void> {
cpu: UNSUPPORTED_CPU,
diagRates,
analysis: buildAnalysisDiag(),
cover: buildCoverDiag(),
collecting: false,
updatedAt: completedAt,
sampleAt: snapshot.sampleAt,
@@ -295,6 +326,7 @@ async function pollOnce(): Promise<void> {
},
diagRates,
analysis: buildAnalysisDiag(),
cover: buildCoverDiag(),
collecting: false,
updatedAt: nextUpdatedAt,
sampleAt: nextSampleAt,
+3 -2
View File
@@ -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<string>): 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;
}