1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
|
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.LibraryTaskScheduler;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests.LibraryTaskScheduler
{
public class LimitedConcurrencyLibrarySchedulerTests
{
private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50);
// Generous, because these only ever wait for something that should already have happened.
private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task Enqueue_ProcessesEveryItem()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
var data = Enumerable.Range(0, 100).ToArray();
var processed = new ConcurrentBag<int>();
await scheduler.Enqueue(
data,
(item, _) =>
{
processed.Add(item);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None);
Assert.Equal(data, processed.Order());
}
}
[Fact]
public async Task Enqueue_WithFailingWorker_StillCompletes()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
await scheduler.Enqueue(
Enumerable.Range(0, 20).ToArray(),
(item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask,
new Progress<double>(),
CancellationToken.None);
}
}
/// <summary>
/// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>,
/// so a shutdown has to reach them. It does not travel from the linked source back to the one
/// the cleanup cancels, which is what made them immortal.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task ApplicationStopping_RetiresRunners()
{
using var appStopping = new CancellationTokenSource();
// Long enough that the cleanup cannot be what retires them.
var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
await using (scheduler)
{
await RunOneOperation(scheduler);
Assert.True(scheduler.ActiveRunnerCount > 0);
await appStopping.CancelAsync();
await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
}
}
/// <summary>
/// The cleanup used to be a one shot: it never released the scheduling slot it took, so
/// every runner spawned after the first pass stayed around for the lifetime of the server.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
for (var round = 0; round < 3; round++)
{
await RunOneOperation(scheduler);
Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}");
await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
}
}
}
/// <summary>
/// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for
/// up to a minute.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
await RunOneOperation(scheduler);
var stopwatch = Stopwatch.StartNew();
await scheduler.DisposeAsync();
Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}");
}
[Fact]
public async Task Enqueue_AfterDispose_DoesNothing()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await scheduler.DisposeAsync();
var processed = 0;
await scheduler.Enqueue(
Enumerable.Range(0, 10).ToArray(),
(_, _) =>
{
Interlocked.Increment(ref processed);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None);
Assert.Equal(0, processed);
}
[Theory]
[InlineData(1)]
[InlineData(4)]
public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout)
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping, fanout: fanout);
await using (scheduler)
{
var inner = 0;
var outer = scheduler.Enqueue(
Enumerable.Range(0, 8).ToArray(),
(_, _) => scheduler.Enqueue(
Enumerable.Range(0, 4).ToArray(),
(_, _) =>
{
Interlocked.Increment(ref inner);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None),
new Progress<double>(),
CancellationToken.None);
await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken);
Assert.Equal(32, inner);
}
}
private static LimitedConcurrencyLibraryScheduler CreateScheduler(
CancellationTokenSource appStopping,
int fanout = 4,
TimeSpan? gracePeriod = null)
{
var lifetime = new Mock<IHostApplicationLifetime>();
lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token);
var configurationManager = new Mock<IServerConfigurationManager>();
configurationManager.SetupGet(x => x.Configuration)
.Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout });
return new LimitedConcurrencyLibraryScheduler(
lifetime.Object,
NullLogger<LimitedConcurrencyLibraryScheduler>.Instance,
configurationManager.Object,
gracePeriod ?? _shortGracePeriod);
}
private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler)
=> scheduler.Enqueue(
Enumerable.Range(0, 8).ToArray(),
(_, _) => Task.CompletedTask,
new Progress<double>(),
CancellationToken.None);
private static async Task WaitForAsync(Func<bool> condition)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle");
await Task.Delay(20, TestContext.Current.CancellationToken);
}
}
}
}
|