fix(ha): gate library tasks on the scan leader by default
Jellyfin:ScanLeader:Enabled defaults to false and nothing sets it, so leader election never runs and every replica executes the timer-driven library tasks concurrently - the exact behaviour the lease prevents. - Enable gating by default when a Redis connection string is configured - Honour an explicit Enabled setting either way - Carry the effective decision onto the bound options the task worker reads - Log at startup whether gating is active - Warn when gating is enabled but no Redis connection string is configured
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.ScheduledTasks;
|
||||
using Jellyfin.Server.Extensions;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Tests.ScanLeader;
|
||||
|
||||
/// <summary>
|
||||
/// Without gating every replica runs the timer-driven library tasks, which is what the lease exists
|
||||
/// to prevent, and nothing in the running server reveals which way the switch went. Both the default
|
||||
/// and the startup log line are pinned here.
|
||||
/// </summary>
|
||||
public sealed class ScanLeaderRegistrationTests
|
||||
{
|
||||
private const string RedisKey = "Jellyfin:TranscodeStore:RedisConnectionString";
|
||||
|
||||
[Fact]
|
||||
public void AddScanLeaderLease_Should_Gate_By_Default_When_Redis_Is_Configured()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var logger = new LogRecorder();
|
||||
|
||||
services.AddScanLeaderLease(Configuration(redis: "valkey-cheeztv-valkey:6379,abortConnect=false"), logger);
|
||||
|
||||
Assert.Equal(typeof(RedisScanLeaderLease), LeaseImplementation(services));
|
||||
Assert.True(EffectiveOptions(services).Enabled);
|
||||
Assert.True(logger.Has(LogLevel.Information, "active"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScanLeaderLease_Should_Not_Gate_Without_Redis()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var logger = new LogRecorder();
|
||||
|
||||
services.AddScanLeaderLease(Configuration(redis: null), logger);
|
||||
|
||||
Assert.Equal(typeof(NullScanLeaderLease), LeaseImplementation(services));
|
||||
Assert.False(EffectiveOptions(services).Enabled);
|
||||
Assert.True(logger.Has(LogLevel.Information, "off"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScanLeaderLease_Should_Honour_Explicit_Opt_Out()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var logger = new LogRecorder();
|
||||
|
||||
services.AddScanLeaderLease(Configuration(redis: "valkey:6379", enabled: "false"), logger);
|
||||
|
||||
Assert.Equal(typeof(NullScanLeaderLease), LeaseImplementation(services));
|
||||
Assert.False(EffectiveOptions(services).Enabled);
|
||||
Assert.DoesNotContain(logger.Entries, entry => entry.Level >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScanLeaderLease_Should_Warn_When_Enabled_Without_Redis()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var logger = new LogRecorder();
|
||||
|
||||
services.AddScanLeaderLease(Configuration(redis: null, enabled: "true"), logger);
|
||||
|
||||
Assert.Equal(typeof(NullScanLeaderLease), LeaseImplementation(services));
|
||||
Assert.False(EffectiveOptions(services).Enabled);
|
||||
Assert.True(logger.Has(LogLevel.Warning, "cannot run"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScanLeaderLease_Should_Keep_Bound_Options()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[RedisKey] = "valkey:6379",
|
||||
["Jellyfin:ScanLeader:LeaseDurationSeconds"] = "90"
|
||||
})
|
||||
.Build();
|
||||
|
||||
services.AddScanLeaderLease(configuration, new LogRecorder());
|
||||
|
||||
var options = EffectiveOptions(services);
|
||||
|
||||
Assert.Equal(90, options.LeaseDurationSeconds);
|
||||
Assert.Contains("RefreshLibrary", options.GatedTaskKeys);
|
||||
}
|
||||
|
||||
private static IConfiguration Configuration(string? redis, string? enabled = null)
|
||||
=> new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[RedisKey] = redis,
|
||||
[ScanLeaderOptions.EnabledKey] = enabled
|
||||
})
|
||||
.Build();
|
||||
|
||||
private static Type? LeaseImplementation(IServiceCollection services)
|
||||
=> services.Single(descriptor => descriptor.ServiceType == typeof(IScanLeaderLease)).ImplementationType;
|
||||
|
||||
private static ScanLeaderOptions EffectiveOptions(IServiceCollection services)
|
||||
{
|
||||
using var provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IOptions<ScanLeaderOptions>>().Value;
|
||||
}
|
||||
|
||||
private sealed class LogRecorder : ILogger
|
||||
{
|
||||
private readonly List<(LogLevel Level, string Message)> _entries = new();
|
||||
|
||||
public IReadOnlyList<(LogLevel Level, string Message)> Entries => _entries;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
=> NoopScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formatter);
|
||||
_entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
public bool Has(LogLevel level, string substring)
|
||||
=> _entries.Any(entry => entry.Level == level && entry.Message.Contains(substring, StringComparison.Ordinal));
|
||||
|
||||
private sealed class NoopScope : IDisposable
|
||||
{
|
||||
public static readonly NoopScope Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user