3b416c7fb2
Timer-driven library tasks fire on every replica, so a library refresh or a database optimise runs once per pod against the same shared library. - add IScanLeaderLease with a Redis TTL implementation and a no-op default - skip timer-driven runs of the gated tasks on instances without the lease - treat an unreachable Redis as holding the lease so tasks never stop running - leave manual and API-triggered runs ungated
190 lines
6.9 KiB
C#
190 lines
6.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Emby.Server.Implementations.ScheduledTasks;
|
|
using MediaBrowser.Common.Configuration;
|
|
using MediaBrowser.Controller.ScheduledTasks;
|
|
using MediaBrowser.Model.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
|
|
|
|
/// <summary>
|
|
/// Tests that <see cref="ScheduledTaskWorker"/> gates the periodic (timer-driven) execution of gated
|
|
/// tasks to the scan leader, while leaving non-gated tasks and manual/API-triggered runs unaffected.
|
|
/// </summary>
|
|
public class ScheduledTaskWorkerLeaderGatingTests
|
|
{
|
|
private const string GatedKey = "RefreshLibrary";
|
|
private const string NonGatedKey = "DeleteTranscodeFiles";
|
|
|
|
/// <summary>
|
|
/// A non-leader must not enqueue a gated task when its periodic trigger fires.
|
|
/// </summary>
|
|
[Fact]
|
|
[Trait("Category", "UnitTest")]
|
|
public async Task PeriodicTrigger_NonLeader_GatedTask_DoesNotQueue()
|
|
{
|
|
var taskManager = new Mock<ITaskManager>();
|
|
var lease = CreateLease(isLeader: false);
|
|
var options = CreateOptions(GatedKey);
|
|
var task = new StubScheduledTask(GatedKey);
|
|
|
|
using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), taskManager.Object, NullLogger.Instance, lease.Object, options);
|
|
|
|
await FireTriggerAsync(worker);
|
|
|
|
taskManager.Verify(t => t.QueueScheduledTask(It.IsAny<IScheduledTask>(), It.IsAny<TaskOptions>()), Times.Never);
|
|
lease.Verify(l => l.TryAcquireOrRenewAsync(It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The leader must enqueue a gated task when its periodic trigger fires.
|
|
/// </summary>
|
|
[Fact]
|
|
[Trait("Category", "UnitTest")]
|
|
public async Task PeriodicTrigger_Leader_GatedTask_Queues()
|
|
{
|
|
var taskManager = new Mock<ITaskManager>();
|
|
var lease = CreateLease(isLeader: true);
|
|
var options = CreateOptions(GatedKey);
|
|
var task = new StubScheduledTask(GatedKey);
|
|
|
|
using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), taskManager.Object, NullLogger.Instance, lease.Object, options);
|
|
|
|
await FireTriggerAsync(worker);
|
|
|
|
taskManager.Verify(t => t.QueueScheduledTask(task, It.IsAny<TaskOptions>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A non-gated task must always enqueue when its periodic trigger fires, even for a non-leader, and
|
|
/// must not consult the scan-leader lease at all.
|
|
/// </summary>
|
|
[Fact]
|
|
[Trait("Category", "UnitTest")]
|
|
public async Task PeriodicTrigger_NonLeader_NonGatedTask_Queues()
|
|
{
|
|
var taskManager = new Mock<ITaskManager>();
|
|
var lease = CreateLease(isLeader: false);
|
|
var options = CreateOptions(GatedKey);
|
|
var task = new StubScheduledTask(NonGatedKey);
|
|
|
|
using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), taskManager.Object, NullLogger.Instance, lease.Object, options);
|
|
|
|
await FireTriggerAsync(worker);
|
|
|
|
taskManager.Verify(t => t.QueueScheduledTask(task, It.IsAny<TaskOptions>()), Times.Once);
|
|
lease.Verify(l => l.TryAcquireOrRenewAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A manual/API-triggered run goes through <see cref="ScheduledTaskWorker.Execute"/>, which must run
|
|
/// the task regardless of leadership and must not consult the scan-leader lease.
|
|
/// </summary>
|
|
[Fact]
|
|
[Trait("Category", "UnitTest")]
|
|
public async Task Execute_NonLeader_GatedTask_RunsAndIgnoresLease()
|
|
{
|
|
var realTaskManager = new TaskManager(CreateAppPaths(), new Mock<ILogger<TaskManager>>().Object);
|
|
var lease = CreateLease(isLeader: false);
|
|
var options = CreateOptions(GatedKey);
|
|
var task = new StubScheduledTask(GatedKey);
|
|
|
|
using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), realTaskManager, NullLogger.Instance, lease.Object, options);
|
|
|
|
await worker.Execute(new TaskOptions());
|
|
|
|
Assert.Equal(1, task.ExecuteCount);
|
|
lease.Verify(l => l.TryAcquireOrRenewAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
private static Mock<IScanLeaderLease> CreateLease(bool isLeader)
|
|
{
|
|
var lease = new Mock<IScanLeaderLease>();
|
|
lease
|
|
.Setup(l => l.TryAcquireOrRenewAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(isLeader);
|
|
return lease;
|
|
}
|
|
|
|
private static ScanLeaderOptions CreateOptions(params string[] gatedKeys)
|
|
=> new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60, GatedTaskKeys = gatedKeys };
|
|
|
|
private static IApplicationPaths CreateAppPaths()
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "jf-scanleader-tests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var appPaths = new Mock<IApplicationPaths>();
|
|
appPaths.Setup(p => p.DataPath).Returns(dir);
|
|
appPaths.Setup(p => p.ConfigurationDirectoryPath).Returns(dir);
|
|
return appPaths.Object;
|
|
}
|
|
|
|
private static async Task FireTriggerAsync(ScheduledTaskWorker worker)
|
|
{
|
|
var method = typeof(ScheduledTaskWorker).GetMethod(
|
|
"OnTriggerTriggered",
|
|
BindingFlags.NonPublic | BindingFlags.Instance);
|
|
Assert.NotNull(method);
|
|
|
|
method!.Invoke(worker, new object[] { new RecordingTrigger(), EventArgs.Empty });
|
|
|
|
// OnTriggerTriggered is async void; the queue decision completes synchronously against the mocked
|
|
// lease, so a short delay lets any continuation settle before the assertion.
|
|
await Task.Delay(100);
|
|
}
|
|
|
|
private sealed class StubScheduledTask : IScheduledTask
|
|
{
|
|
private readonly string _key;
|
|
|
|
public StubScheduledTask(string key)
|
|
{
|
|
_key = key;
|
|
}
|
|
|
|
public int ExecuteCount { get; private set; }
|
|
|
|
public string Name => "Stub Task";
|
|
|
|
public string Key => _key;
|
|
|
|
public string Description => "Stub task for gating tests.";
|
|
|
|
public string Category => "Tests";
|
|
|
|
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
|
{
|
|
ExecuteCount++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => Array.Empty<TaskTriggerInfo>();
|
|
}
|
|
|
|
private sealed class RecordingTrigger : ITaskTrigger
|
|
{
|
|
#pragma warning disable CS0067 // Required by the interface but unused in this test double.
|
|
public event EventHandler<EventArgs>? Triggered;
|
|
#pragma warning restore CS0067
|
|
|
|
public TaskOptions TaskOptions { get; } = new TaskOptions();
|
|
|
|
public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
|
|
{
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
}
|
|
}
|
|
}
|