mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user