fix: Check Health taking 24h+ on large libraries - #324
Merged
Conversation
MountCheck had no tests. Pins current behaviour before optimising the mount lookups it performs: the null-mount and null-MountOptions guards (MountOptions is always null on Windows, since DriveInfoMount is built without them), the DistinctBy on root directory, and the reported path ordering.
GetMount() re-enumerated the entire OS mount table on every call. On Linux that reads /proc/mounts and stats every mount point, so on a host with many network mounts it is hundreds of syscalls per call. MountCheck calls GetMount() once per movie path, which made Check Health take over 24 hours on a 264k scene library. DiskTransferService also pays this twice per file transfer. Cache the mount list for 15 seconds. That collapses a health check run from hundreds of thousands of enumerations to roughly one every 15s, while staying short enough that a newly mounted share still shows up in the folder browser without any invalidation hooks. GetMount() stays per-path: MountCheck resolves mounts from movie paths specifically so symlinks and junctions are handled by the provider, so the number of lookups is unchanged, only their cost. Only topology is cached. Free space is unaffected, since IMount reads it live from the underlying DriveInfo/UnixDriveInfo on each property access. Fixes Whisparr/Whisparr#1102
RootFolderCheck and DiskSpaceService called GetBestRootFolderPath without passing the root folders. The result is cached per path, but on a miss it falls back to querying every root folder from the database, so a cold cache meant one query per movie. RootFolderCheck runs on startup, when the cache is always cold, which on a 264k scene library is 264k queries. Pass the root folders in via the existing overload, which is there for exactly this.
GetMount tested every mount with IsParentPath, which walks the path back up to the root each time. That walk only depends on the path, so it was being repeated identically for every mount on the system. Hoist it: build the ancestors once per lookup and compare each mount root against them. PathEquals is untouched, and the comparison is the same OsPath equality IsParentPath performed, so paths resolve exactly as before, including relative segments that PathEquals resolves via CleanFilePath. Measured over 130 mounts, roughly 160us -> 62us per lookup, or ~42s -> ~16s across a 264k scene library.
plz12345
force-pushed
the
fix/1102-mount-check-performance
branch
from
July 15, 2026 19:56
1aca315 to
09f0744
Compare
None of these sit on lines the mount check perf work touches; they are
pre-existing findings inherited from upstream. Keeping them in their own
commit so that PR stays reviewable and the divergence is easy to drop.
- Pass the caught exception to the logger in FolderWritable (S6667). The
NLog layout renders ${exception:format=ToString} on any call carrying
one, so the inline copy of e.Message goes away rather than being
printed twice. Net gain is the exception type, which the bare message
did not carry.
- Rename IsFileLocked's parameter to path, matching IDiskProvider (S927).
No caller passes it by name.
- Drop RemoveReadOnlyFolder, which has no callers (S1144).
- Make GetDriveInfoMounts static (S2325). It was never virtual, so no
subclass could have overridden it.
- Make SetWritePermissionsInternal static in the Mono test fixture (S2325).
|
sampulsar
approved these changes
Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Fixes Whisparr/Whisparr#1102
CheckHealthCommandruns for 24+ hours on a 264k scene library (~130 NFS/ZFS mounts), pinning CPU throughout, then re-triggers 6 hours later. Health checks run serially on the command executor thread, so this blocks every other scheduled task.Note: Radarr
developis byte-identical inMountCheck.csandDiskProviderBase.cs, so this is not Whisparr specific and there was no upstream fix to mirror. Worth upstreaming.The bug
MountCheckcallsGetMount()once per movie path, andGetMount()re-enumerated the entire OS mount table on every call, with no caching. On Linux that means reading/proc/mountsand stat'ing every mount point, so at 264k paths it did 264k full mount table enumerations, each hundreds of syscalls (and network round trips, on NFS).The changes
Each is a separate commit and reviewable on its own.
1. Cache the mount list (the actual fix).
GetAllMounts()becomes a private caching wrapper with a 15s absolute TTL over a newprotected virtual FetchAllMounts(), which Mono overrides. Private so a subclass cannot accidentally bypass the cache, and so a strayoverride GetAllMountsfails loudly at compile time rather than silently doing nothing.GetMount()deliberately stays per-path.MountCheckresolves mounts from movie paths specifically so the provider handles symlinks and junctions, so the number of lookups is unchanged, only their cost.Only topology is cached. Free space is unaffected:
DriveInfoMountandProcMountread it live from the underlyingDriveInfo/UnixDriveInfoon each property access.15s is short enough that a newly mounted share still turns up in the folder browser without needing any invalidation hooks, while still collapsing a health check run from hundreds of thousands of enumerations to roughly one per 15s. This also speeds up imports, since
DiskTransferServicepays two lookups per file transfer.2. Fix a
RootFolderCheckN+1 (independent bug found on the way).RootFolderCheckandDiskSpaceServicecalledGetBestRootFolderPathwithout passing the root folders. The result is cached per path, but on a miss it falls back to querying every root folder from the database.RootFolderCheckruns on startup, when the cache is always cold, so that was one query per movie. Fixed via the existing overload, which exists for exactly this.3. Cheapen the per-mount scan.
GetMounttested every mount withIsParentPath, which walks the path back up to the root each time. That walk only depends on the path, so it was repeated identically for all ~130 mounts. Hoisted to once per lookup.I originally planned to also hoist
PathEquals's normalization, but benchmarking showed that assumption was wrong: normalization was only ~14% of the cost, while the ancestor walk was the bulk. SoPathEqualsis left completely untouched, which avoids precomputingCleanFilePathper mount (it can throw, which would have changed when exceptions fire on the import hot path).Measured over 130 mounts: ~160us -> ~62us per lookup, or ~42s -> ~16s across 264k paths.
Testing
MountCheckhad no coverage at all, so the first commit adds a fixture pinning current behaviour before anything changed: the null-mount and null-MountOptionsguards (MountOptionsis always null on Windows, sinceDriveInfoMountis constructed without them), theDistinctByon root directory, and the reported path ordering.Both regression tests were confirmed to fail without their fix:
New
GetMountsemantics tests (longest matching mount wins, exact mount root, paths with relative segments) were confirmed to pass against the old code first, so they pin existing behaviour rather than the new implementation.I also ran a differential check of old vs new resolution across 34 real paths against this machine's 11 real mounts, including a nested pair (
/System/Volumes/Dataand/System/Volumes/Data/home),..segments and trailing slashes. Identical results.Green:
Common.Test,Mono.Test,Host.Test(ContainerFixtureresolves the whole DI graph, which covers the constructor plumbing), and the 144Core.Testhealth check tests. TwoHttpClientFixtureCloudflare tests fail, but they fail identically oneros-developwithout these changes; they hit a live network endpoint and are unrelated.Caveat
I could not reproduce a 264k library on ~130 NFS mounts locally, so the headline timing is extrapolated from benchmarks. Merging this auto-closes Whisparr/Whisparr#1102, so it may be worth having the reporter confirm the improvement on their library first.
Follow-up (not in this PR)
RemovedMovieCheckmaterializes 264kMovie+MovieMetadata+ alt-title rows just to read one enum per row. Not an N+1, but a large allocation spike. Proper fix is a status filtered repo query.