Cap scan/import parallelism via LIDARR_MEDIA_IO_PARALLELISM

Default max degree 2 to avoid IO storms on NFS/remote storage.
Applies to folder scan, tag reads, and candidate scoring PLINQ.
Document env var and Docker usage in MULTITHREAD_README.md.

Made-with: Cursor
This commit is contained in:
Sean Parsons 2026-03-25 22:51:49 +00:00
parent 20e8ab9754
commit a98d331b20
5 changed files with 101 additions and 3 deletions

57
MULTITHREAD_README.md Normal file
View file

@ -0,0 +1,57 @@
# Multithreaded library scan / import (this fork)
This branch adds a faster, **parallel** disk scan and import path. Upstream Lidarr does much of this work sequentially; this fork parallelizes folder scanning, tag reads, and release-candidate scoring.
A **Dockerfile.multithread** in this repository builds a self-contained binary and overlays it on `ghcr.io/linuxserver/lidarr:nightly` (see CI or build from repo root per that files comments). A wrapper layout that keeps this tree in a `lidarr-src/` subdirectory can use the parent `Dockerfile` instead.
## `LIDARR_MEDIA_IO_PARALLELISM`
Parallel import work is **not** limited by Lidarrs download bandwidth or rate settings (those apply to indexers/clients only). On **slow or remote storage** (especially NFS), too much concurrency can saturate IOPS and make the host feel stuck. This variable caps how many workers run at once for the forks parallel paths.
| | |
| --- | --- |
| **Name** | `LIDARR_MEDIA_IO_PARALLELISM` |
| **Default** | `2` (used when the variable is unset, empty, or not a valid integer) |
| **Allowed** | Integers **1****64**; values below **1** fall back to the default |
| **Scope** | Process environment (read once at first use) |
It applies to:
- parallel **folder scans** when collecting audio files;
- parallel **tag / metadata reads** when building import decisions;
- parallel **candidate release scoring** during identification.
**Docker:** fully supported. Set the variable on the container like any other env; the .NET process reads the container environment.
### Docker Compose
```yaml
services:
lidarr:
image: your-registry/lidarr-nightly-multithread:latest
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
# Gentle on NFS / network mounts; omit for default (2)
- LIDARR_MEDIA_IO_PARALLELISM=1
```
### `docker run`
```bash
docker run -e LIDARR_MEDIA_IO_PARALLELISM=4 … your-image
```
### When to change it
- **NFS, SMB, or sluggish disks:** try `1` or leave default `2`.
- **Library and app on fast local storage (e.g. same NAS app dataset, local SSD):** try `4``8` or higher (up to 64) and watch CPU, I/O, and responsiveness.
### Implementation reference
Logic and constant name: `src/NzbDrone.Common/MediaImportParallelism.cs`.
## Relationship to upstream
Behavior outside scan/import parallelism matches your chosen base (e.g. nightly image + overlaid build). For upstream docs and support channels, see [Lidarr](https://github.com/Lidarr/Lidarr) and the [Servarr wiki](https://wiki.servarr.com/lidarr).

View file

@ -0,0 +1,40 @@
using System;
namespace NzbDrone.Common
{
/// <summary>
/// Limits parallel disk work during library scan/import (tag reads, folder scans, candidate scoring).
/// Unrelated to download bandwidth limits in Lidarr settings.
/// </summary>
public static class MediaImportParallelism
{
public const string EnvironmentVariableName = "LIDARR_MEDIA_IO_PARALLELISM";
private const int DefaultMaxDegree = 2;
private const int MinDegree = 1;
private const int MaxDegreeCap = 64;
private static readonly Lazy<int> MaxDegreeLazy = new Lazy<int>(ReadMaxDegree);
/// <summary>
/// Maximum concurrent workers for scan/import parallelism. Default 2; override with LIDARR_MEDIA_IO_PARALLELISM (164).
/// </summary>
public static int MaxDegreeOfParallelism => MaxDegreeLazy.Value;
private static int ReadMaxDegree()
{
var raw = Environment.GetEnvironmentVariable(EnvironmentVariableName);
if (string.IsNullOrWhiteSpace(raw) || !int.TryParse(raw.Trim(), out var parsed))
{
return DefaultMaxDegree;
}
if (parsed < MinDegree)
{
return DefaultMaxDegree;
}
return Math.Min(parsed, MaxDegreeCap);
}
}
}

View file

@ -126,7 +126,7 @@ public void Scan(List<string> folders = null, FilterFilesType filter = FilterFil
var musicFilesStopwatch = Stopwatch.StartNew();
Parallel.ForEach(foldersToScan, folder =>
Parallel.ForEach(foldersToScan, new ParallelOptions { MaxDegreeOfParallelism = MediaImportParallelism.MaxDegreeOfParallelism }, folder =>
{
_logger.ProgressInfo("Scanning {0}", folder);

View file

@ -305,7 +305,7 @@ private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List<CandidateA
_logger.Debug("Matching {0} track files against {1} candidates", localAlbumRelease.TrackCount, candidateReleases.Count);
_logger.Trace("Processing files:\n{0}", string.Join("\n", localAlbumRelease.LocalTracks.Select(x => x.Path)));
var maxParallelism = Math.Max(1, Environment.ProcessorCount);
var maxParallelism = MediaImportParallelism.MaxDegreeOfParallelism;
var scoredCandidates = candidateReleases
.Select((candidateRelease, index) => new { candidateRelease, index })
.AsParallel()

View file

@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.DecisionEngine;
@ -106,7 +107,7 @@ public Tuple<List<LocalTrack>, List<ImportDecision<LocalTrack>>> GetLocalTracks(
var processedTracks = new ConcurrentBag<(int Index, LocalTrack Track)>();
var processedDecisions = new ConcurrentBag<(int Index, ImportDecision<LocalTrack> Decision)>();
var progress = 0;
var maxParallelism = Math.Max(1, Environment.ProcessorCount);
var maxParallelism = MediaImportParallelism.MaxDegreeOfParallelism;
var filesWithIndex = files.Select((file, index) => new { file, index }).ToList();
Parallel.ForEach(filesWithIndex, new ParallelOptions { MaxDegreeOfParallelism = maxParallelism }, item =>