Files
jellyfin-ha-src/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/ScheduledTaskWorkerLeaderGatingTests.cs
unkin-agent 3b416c7fb2 feat: gate periodic library tasks behind a scan-leader lease
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
2026-09-11 23:56:51 +10:00

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()
{
}
}
}