Analyse tracks in a pool of worker processes
A first pass over an unanalysed library is hours of librosa, and there was no reason for a desktop to spend them one core at a time. Analysis now runs in a process pool sized by `general.library.analysis_workers`, defaulting to one per core bar one (capped at 8) when the key is absent. Two consequences worth knowing before touching this: whatever an `Analyzer` returns has to be picklable, and an analyzer that keeps state on its own instance - a test double counting calls - only behaves as written with `analysis_workers=1`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,16 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||
from musicmouse.library import Album, Analyzer, MusicLibrary
|
||||
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
|
||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||
from musicmouse.library.cache import LibraryCache
|
||||
from musicmouse.library.colors import colors_from_id
|
||||
@@ -52,6 +54,19 @@ class FakeAnalyzer:
|
||||
return analysis, BeatGrid((0.1,), (1.0,)), curves
|
||||
|
||||
|
||||
class PidAnalyzer(FakeAnalyzer):
|
||||
"""Reports *which process* analyzed each track, as `tempo`.
|
||||
|
||||
The point of a pool is that this is never the process running the test - and a
|
||||
class at module scope is also the only kind of analyzer a worker can be handed,
|
||||
since it has to survive being pickled over to one.
|
||||
"""
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
analysis, grid, curves = super().analyze(path)
|
||||
return replace(analysis, tempo=float(os.getpid())), grid, curves
|
||||
|
||||
|
||||
def album_named(library: MusicLibrary, title: str) -> Album:
|
||||
return next(album for album in library.albums if album.title == title)
|
||||
|
||||
@@ -641,3 +656,73 @@ async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
|
||||
|
||||
assert pass_count == 2
|
||||
assert len(analyzer.calls) == 5
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ worker processes
|
||||
|
||||
|
||||
async def test_a_parallel_pass_analyzes_every_track_in_worker_processes(
|
||||
config_dir: Path,
|
||||
) -> None:
|
||||
"""The whole point of `analysis_workers`: a machine with cores gets to use them.
|
||||
|
||||
Two workers rather than the real default, because what is being checked is that
|
||||
the work left this process at all - not how fast a five-track fixture goes.
|
||||
"""
|
||||
library = await build(config_dir, analyzer=PidAnalyzer(fails={"00 - lied.mp3"}))
|
||||
|
||||
done = await library.analyze_pending(workers=2)
|
||||
|
||||
assert done == 5
|
||||
analyzed = [
|
||||
track.analysis
|
||||
for album in library.albums
|
||||
if album.kind == "music"
|
||||
for track in album.tracks
|
||||
]
|
||||
assert all(a is not None and a.version == ANALYZER_VERSION for a in analyzed)
|
||||
# The one file the analyzer chokes on is recorded as attempted, exactly as on the
|
||||
# in-process path - a raise in a worker must not abort the other four.
|
||||
failed = [a for a in analyzed if a is not None and a.tempo is None]
|
||||
assert len(failed) == 1
|
||||
workers = {a.tempo for a in analyzed if a is not None and a.tempo is not None}
|
||||
assert workers
|
||||
assert os.getpid() not in workers
|
||||
|
||||
|
||||
async def test_a_parallel_pass_starts_no_workers_while_playback_is_busy(
|
||||
config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Being busy holds the pass *before* the pool exists, so a device playing an album
|
||||
is not also hosting a set of idle analyzer processes."""
|
||||
import musicmouse.library as library_module
|
||||
|
||||
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
|
||||
library = await build(config_dir, analyzer=PidAnalyzer())
|
||||
|
||||
busy = True
|
||||
task = asyncio.create_task(library.analyze_pending(workers=2, is_busy=lambda: busy))
|
||||
try:
|
||||
await asyncio.sleep(0.2)
|
||||
assert list(library.cache.analysis.glob("*.json")) == []
|
||||
|
||||
busy = False
|
||||
assert await asyncio.wait_for(task, timeout=30) == 5
|
||||
finally:
|
||||
if not task.done():
|
||||
await _cancel(task)
|
||||
|
||||
|
||||
async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> None:
|
||||
"""Shutdown cancels this task like any other, and it must not hang waiting for
|
||||
worker processes to finish the tracks they are on."""
|
||||
library = await build(config_dir, analyzer=PidAnalyzer())
|
||||
|
||||
task = asyncio.create_task(library.analyze_pending(workers=2))
|
||||
await asyncio.sleep(0.1) # long enough to have workers starting up
|
||||
await asyncio.wait_for(_cancel(task), timeout=10)
|
||||
|
||||
|
||||
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
|
||||
count = default_worker_count()
|
||||
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
|
||||
|
||||
Reference in New Issue
Block a user