< Summary - Jellyfin

Information
Class: MediaBrowser.Controller.LibraryTaskScheduler.LimitedConcurrencyLibraryScheduler
Assembly: MediaBrowser.Controller
File(s): /srv/git/jellyfin/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
Line coverage
93%
Covered lines: 135
Uncovered lines: 9
Coverable lines: 144
Total lines: 406
Line coverage: 93.7%
Branch coverage
84%
Covered branches: 32
Total branches: 38
Branch coverage: 84.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3145/19/2026 - 12:15:13 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3145/20/2026 - 12:15:44 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3145/26/2026 - 12:15:12 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3145/28/2026 - 12:15:50 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3146/24/2026 - 12:15:23 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3146/25/2026 - 12:12:37 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3146/27/2026 - 12:15:59 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3146/28/2026 - 12:15:35 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/7/2026 - 12:16:09 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/8/2026 - 12:16:24 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/9/2026 - 12:13:25 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/10/2026 - 12:11:57 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/11/2026 - 12:11:44 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/12/2026 - 12:12:21 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/17/2026 - 12:11:47 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/18/2026 - 12:15:19 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/29/2026 - 12:15:53 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/30/2026 - 12:15:56 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 31410/29/2026 - 12:17:11 AM Line coverage: 93.7% (135/144) Branch coverage: 84.2% (32/38) Total lines: 40610/30/2026 - 12:16:57 AM Line coverage: 92.3% (133/144) Branch coverage: 84.2% (32/38) Total lines: 40610/31/2026 - 12:17:46 AM Line coverage: 93.7% (135/144) Branch coverage: 84.2% (32/38) Total lines: 40611/1/2026 - 12:15:27 AM Line coverage: 92.3% (133/144) Branch coverage: 84.2% (32/38) Total lines: 40611/2/2026 - 12:18:06 AM Line coverage: 93% (134/144) Branch coverage: 81.5% (31/38) Total lines: 40611/4/2026 - 12:18:26 AM Line coverage: 93.7% (135/144) Branch coverage: 84.2% (32/38) Total lines: 406 5/16/2026 - 12:15:55 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3145/19/2026 - 12:15:13 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3145/20/2026 - 12:15:44 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3145/26/2026 - 12:15:12 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3145/28/2026 - 12:15:50 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3146/24/2026 - 12:15:23 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3146/25/2026 - 12:12:37 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3146/27/2026 - 12:15:59 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3146/28/2026 - 12:15:35 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/7/2026 - 12:16:09 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/8/2026 - 12:16:24 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/9/2026 - 12:13:25 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/10/2026 - 12:11:57 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/11/2026 - 12:11:44 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/12/2026 - 12:12:21 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/17/2026 - 12:11:47 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/18/2026 - 12:15:19 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 3147/29/2026 - 12:15:53 AM Line coverage: 46.6% (56/120) Branch coverage: 42.1% (16/38) Total lines: 3147/30/2026 - 12:15:56 AM Line coverage: 45.8% (55/120) Branch coverage: 39.4% (15/38) Total lines: 31410/29/2026 - 12:17:11 AM Line coverage: 93.7% (135/144) Branch coverage: 84.2% (32/38) Total lines: 40610/30/2026 - 12:16:57 AM Line coverage: 92.3% (133/144) Branch coverage: 84.2% (32/38) Total lines: 40610/31/2026 - 12:17:46 AM Line coverage: 93.7% (135/144) Branch coverage: 84.2% (32/38) Total lines: 40611/1/2026 - 12:15:27 AM Line coverage: 92.3% (133/144) Branch coverage: 84.2% (32/38) Total lines: 40611/2/2026 - 12:18:06 AM Line coverage: 93% (134/144) Branch coverage: 81.5% (31/38) Total lines: 40611/4/2026 - 12:18:26 AM Line coverage: 93.7% (135/144) Branch coverage: 84.2% (32/38) Total lines: 406

Coverage delta

Coverage delta 48 -48

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
get_ActiveRunnerCount()100%11100%
ScheduleTaskCleanup()50%2271.42%
StopRunners()100%22100%
ShouldForceSequentialOperation()75%44100%
CalculateScanConcurrencyLimit()50%2275%
Worker()100%22100%
ItemWorker()100%22100%
ProcessItem()50%2290%
Enqueue()87.5%161691.11%
DisposeAsync()66.66%6682.35%

File(s)

/srv/git/jellyfin/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.Diagnostics;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Channels;
 8using System.Threading.Tasks;
 9using MediaBrowser.Controller.Configuration;
 10using Microsoft.Extensions.Hosting;
 11using Microsoft.Extensions.Logging;
 12
 13namespace MediaBrowser.Controller.LibraryTaskScheduler;
 14
 15/// <summary>
 16/// Provides Parallel action interface to process tasks with a set concurrency level.
 17/// </summary>
 18public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable
 19{
 220    private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60);
 21
 22    private readonly IHostApplicationLifetime _hostApplicationLifetime;
 23    private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger;
 24    private readonly IServerConfigurationManager _serverConfigurationManager;
 3525    private readonly Dictionary<CancellationTokenSource, Task> _taskRunners = new();
 26
 227    private static readonly AsyncLocal<CancellationTokenSource> _deadlockDetector = new();
 28
 29    /// <summary>
 30    /// Gets used to lock all operations on the Tasks queue and creating workers.
 31    /// </summary>
 3532    private readonly Lock _taskLock = new();
 33
 3534    private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
 3535    private readonly CancellationTokenSource _disposeTokenSource = new();
 36    private readonly TimeSpan _gracePeriod;
 37
 38    private volatile int _workCounter;
 39    private Task? _cleanupTask;
 40    private bool _disposed;
 41
 42    /// <summary>
 43    /// Initializes a new instance of the <see cref="LimitedConcurrencyLibraryScheduler"/> class.
 44    /// </summary>
 45    /// <param name="hostApplicationLifetime">The hosting lifetime.</param>
 46    /// <param name="logger">The logger.</param>
 47    /// <param name="serverConfigurationManager">The server configuration manager.</param>
 48    public LimitedConcurrencyLibraryScheduler(
 49        IHostApplicationLifetime hostApplicationLifetime,
 50        ILogger<LimitedConcurrencyLibraryScheduler> logger,
 51        IServerConfigurationManager serverConfigurationManager)
 2752        : this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod)
 53    {
 2754    }
 55
 56    internal LimitedConcurrencyLibraryScheduler(
 57        IHostApplicationLifetime hostApplicationLifetime,
 58        ILogger<LimitedConcurrencyLibraryScheduler> logger,
 59        IServerConfigurationManager serverConfigurationManager,
 60        TimeSpan gracePeriod)
 61    {
 3562        _hostApplicationLifetime = hostApplicationLifetime;
 3563        _logger = logger;
 3564        _serverConfigurationManager = serverConfigurationManager;
 3565        _gracePeriod = gracePeriod;
 3566    }
 67
 68    /// <summary>
 69    /// Gets the number of runners the scheduler currently keeps alive.
 70    /// </summary>
 71    internal int ActiveRunnerCount
 72    {
 73        get
 1774        {
 75            lock (_taskLock)
 76            {
 1777                return _taskRunners.Count;
 78            }
 1779        }
 80    }
 81
 82    private void ScheduleTaskCleanup()
 883    {
 84        lock (_taskLock)
 85        {
 886            if (_cleanupTask is not null)
 87            {
 088                _logger.LogDebug("Cleanup task already scheduled.");
 89                // cleanup task is already running.
 090                return;
 91            }
 92
 893            _cleanupTask = RunCleanupTask();
 894        }
 95
 96        async Task RunCleanupTask()
 97        {
 98            while (true)
 99            {
 100                _logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod);
 101                try
 102                {
 103                    await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false);
 104                }
 105                catch (OperationCanceledException)
 106                {
 107                    _logger.LogDebug("Abort cleaning up, already disposed.");
 108                    return;
 109                }
 110
 111                if (_disposed)
 112                {
 113                    _logger.LogDebug("Abort cleaning up, already disposed.");
 114                    return;
 115                }
 116
 117                CancellationTokenSource[] runners;
 118                lock (_taskLock)
 119                {
 120                    if (_tasks.Reader.Count > 0 || _workCounter > 0)
 121                    {
 122                        _logger.LogDebug("Delay cleanup task, operations still running.");
 123                        // tasks are still there so its still in use. Wait another grace period.
 124                        // we cannot just exit here and rely on the other invoker because there is a considerable timefr
 125                        continue;
 126                    }
 127
 128                    runners = [.. _taskRunners.Keys];
 129
 130                    // Retire the runners before they are told to stop: an operation starting while
 131                    // they wind down must spawn its own instead of counting these towards the fanout.
 132                    _taskRunners.Clear();
 133
 134                    // Hand the next operation the ability to schedule a cleanup again. Without this
 135                    // the very first cleanup would be the only one that ever runs.
 136                    _cleanupTask = null;
 137                }
 138
 139                _logger.LogDebug("Cleanup runners.");
 140                await StopRunners(runners).ConfigureAwait(false);
 141                return;
 142            }
 143        }
 8144    }
 145
 146    private static async Task StopRunners(CancellationTokenSource[] runners)
 147    {
 132148        foreach (var runner in runners)
 149        {
 150            try
 151            {
 28152                await runner.CancelAsync().ConfigureAwait(false);
 24153            }
 4154            catch (ObjectDisposedException)
 155            {
 156                // The runner already stopped on its own and disposed its stop source.
 4157            }
 158        }
 38159    }
 160
 161    private bool ShouldForceSequentialOperation()
 162    {
 163        // if the user either set the setting to 1 or it's unset and we have fewer than 4 cores it's better to run seque
 37164        var fanoutSetting = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
 37165        return fanoutSetting == 1 || (fanoutSetting <= 0 && Environment.ProcessorCount <= 3);
 166    }
 167
 168    private int CalculateScanConcurrencyLimit()
 169    {
 170        // when this is invoked, we already checked ShouldForceSequentialOperation for the sequential check.
 8171        var fanoutConcurrency = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
 8172        if (fanoutConcurrency <= 0)
 173        {
 174            // in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by
 0175            return Environment.ProcessorCount - 3;
 176        }
 177
 8178        return fanoutConcurrency;
 179    }
 180
 181    private void Worker()
 8182    {
 183        lock (_taskLock)
 184        {
 8185            var operationFanout = Math.Max(0, CalculateScanConcurrencyLimit() - _taskRunners.Count);
 8186            _logger.LogDebug("Spawn {NumberRunners} new runners.", operationFanout);
 80187            for (int i = 0; i < operationFanout; i++)
 188            {
 32189                var stopToken = new CancellationTokenSource();
 32190                var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLi
 191
 192                // Keyed on its own stop source, because cancelling that is what reaches the linked
 193                // source the runner waits on. Cancellation does not travel the other way.
 194                // Started without the runner's own token: a task cancelled before it is scheduled
 195                // never runs its body, so it would never take itself out of _taskRunners again.
 32196                _taskRunners.Add(
 32197                    stopToken,
 32198                    Task.Factory.StartNew(
 32199                        ItemWorker,
 32200                        (stopToken, combinedSource),
 32201                        CancellationToken.None,
 32202                        TaskCreationOptions.PreferFairness,
 32203                        TaskScheduler.Default));
 204            }
 8205        }
 8206    }
 207
 208    private async Task ItemWorker(object? obj)
 209    {
 32210        var stopToken = ((CancellationTokenSource TaskStop, CancellationTokenSource GlobalStop))obj!;
 32211        _deadlockDetector.Value = stopToken.TaskStop;
 212        try
 213        {
 200214            while (!stopToken.GlobalStop.IsCancellationRequested)
 215            {
 197216                var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
 217                try
 218                {
 168219                    var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
 220                    Debug.Assert(newWorkerLimit, "_workCounter > 0");
 168221                    _logger.LogDebug("Process new item '{Data}'.", item.Data);
 168222                    await ProcessItem(item).ConfigureAwait(false);
 168223                }
 224                finally
 225                {
 168226                    var newWorkerLimit = Interlocked.Decrement(ref _workCounter) >= 0;
 227                    Debug.Assert(newWorkerLimit, "_workCounter > 0");
 228                }
 229            }
 3230        }
 16231        catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested)
 232        {
 233            // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose.
 16234        }
 13235        catch (ChannelClosedException)
 236        {
 237            // the scheduler was disposed and will not hand out any more work.
 13238        }
 239        finally
 240        {
 32241            _logger.LogDebug("Cleanup Runner'.");
 32242            _deadlockDetector.Value = default!;
 243
 244            lock (_taskLock)
 245            {
 32246                _taskRunners.Remove(stopToken.TaskStop);
 32247            }
 248
 32249            stopToken.GlobalStop.Dispose();
 32250            stopToken.TaskStop.Dispose();
 251        }
 32252    }
 253
 254    private async Task ProcessItem(TaskQueueItem item)
 255    {
 256        try
 257        {
 259258            if (item.CancellationToken.IsCancellationRequested)
 259            {
 260                // if item is cancelled, just skip it
 0261                return;
 262            }
 263
 259264            await item.Worker(item.Data).ConfigureAwait(true);
 249265        }
 10266        catch (Exception ex)
 267        {
 10268            _logger.LogError(ex, "Error while performing a library operation");
 10269        }
 270        finally
 271        {
 259272            item.Progress.Report(100);
 259273            item.Done.TrySetResult();
 274        }
 259275    }
 276
 277    /// <inheritdoc/>
 278    public async Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, Cancella
 279    {
 123280        if (_disposed)
 281        {
 1282            return;
 283        }
 284
 122285        if (data.Length == 0 || cancellationToken.IsCancellationRequested)
 286        {
 85287            progress.Report(100);
 85288            return;
 289        }
 290
 37291        _logger.LogDebug("Enqueue new Workset of {NoItems} items.", data.Length);
 292
 37293        TaskQueueItem[] workItems = null!;
 294
 295        void UpdateProgress()
 296        {
 297            progress.Report(workItems.Select(e => e.ProgressValue).Average());
 298        }
 299
 37300        workItems = data.Select(item =>
 37301        {
 37302            TaskQueueItem queueItem = null!;
 37303            return queueItem = new TaskQueueItem()
 37304            {
 37305                Data = item!,
 37306                Progress = new Progress<double>(innerPercent =>
 37307                    {
 37308                        // round the percent and only update progress if it changed to prevent excessive UpdateProgress 
 37309                        var innerPercentRounded = Math.Round(innerPercent);
 37310                        if (queueItem.ProgressValue != innerPercentRounded)
 37311                        {
 37312                            queueItem.ProgressValue = innerPercentRounded;
 37313                            UpdateProgress();
 37314                        }
 37315                    }),
 37316                Worker = (val) => worker((T)val, queueItem.Progress),
 37317                CancellationToken = cancellationToken
 37318            };
 37319        }).ToArray();
 320
 37321        if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
 322        {
 29323            _logger.LogDebug("Process sequentially.");
 324            try
 325            {
 240326                foreach (var item in workItems)
 327                {
 91328                    await ProcessItem(item).ConfigureAwait(false);
 329                }
 29330            }
 0331            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 332            {
 333                // operation is cancelled. Do nothing.
 0334            }
 335
 29336            _logger.LogDebug("Process sequentially done.");
 29337            return;
 338        }
 339
 352340        for (var i = 0; i < workItems.Length; i++)
 341        {
 168342            var item = workItems[i]!;
 168343            await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
 344        }
 345
 8346        Worker();
 8347        _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
 8348        await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
 8349        _logger.LogDebug("{NoWorkers} completed.", workItems.Length);
 8350        ScheduleTaskCleanup();
 123351    }
 352
 353    /// <inheritdoc/>
 354    public async ValueTask DisposeAsync()
 355    {
 35356        if (_disposed)
 357        {
 0358            return;
 359        }
 360
 35361        _disposed = true;
 35362        _tasks.Writer.Complete();
 363
 364        // Nobody is left to run these, so release whoever is waiting on them.
 35365        while (_tasks.Reader.TryRead(out var item))
 366        {
 0367            item.Done.TrySetResult();
 0368        }
 369
 370        CancellationTokenSource[] runners;
 371        Task? cleanupTask;
 372        lock (_taskLock)
 373        {
 35374            runners = [.. _taskRunners.Keys];
 35375            _taskRunners.Clear();
 35376            cleanupTask = _cleanupTask;
 35377        }
 378
 35379        await StopRunners(runners).ConfigureAwait(false);
 380
 381        // Cuts the grace period short instead of holding up shutdown for the rest of it.
 35382        await _disposeTokenSource.CancelAsync().ConfigureAwait(false);
 383
 35384        if (cleanupTask is not null)
 385        {
 5386            await cleanupTask.ConfigureAwait(false);
 387        }
 388
 35389        _disposeTokenSource.Dispose();
 35390    }
 391
 392    private class TaskQueueItem
 393    {
 394        public required object Data { get; init; }
 395
 396        public double ProgressValue { get; set; }
 397
 398        public required Func<object, Task> Worker { get; init; }
 399
 400        public required IProgress<double> Progress { get; init; }
 401
 402        public TaskCompletionSource Done { get; } = new();
 403
 404        public CancellationToken CancellationToken { get; init; }
 405    }
 406}