From 95220e2ed64109a9953cecd3a2be11745b71425a Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 13 Sep 2026 13:10:52 +1000 Subject: [PATCH] fix(ha): read transcode store config from the variable form deployments set The startup configuration only reads JELLYFIN_ prefixed environment variables, so the bare Jellyfin__TranscodeStore__* form used by the chart, the manifests and the README is dropped and the Redis store is never registered. Nothing logs the selected store, so the fallback is invisible. - Read bare Jellyfin__* variables into the Jellyfin:* configuration root - Keep an explicit JELLYFIN_ variable winning over the bare form - Log the selected transcode session store at startup - Ping Redis once at startup and log an unreachable store at Error - Log endpoints only, never the connection string --- .../TranscodeStoreConnectivityProbe.cs | 56 ++++++++++ Jellyfin.Server/CoreAppHost.cs | 28 +---- .../ConfigurationBuilderExtensions.cs | 51 +++++++++ ...anscodeStoreServiceCollectionExtensions.cs | 87 +++++++++++++++ Jellyfin.Server/Program.cs | 2 + .../MediaEncoding/TranscodeStoreOptions.cs | 10 ++ README.md | 11 +- docs/FORK-DIFF.md | 3 + .../TranscodeStoreWiringTests.cs | 103 +++++++++++++++++ .../JellyfinSectionConfigurationTests.cs | 95 ++++++++++++++++ .../HighAvailability/RecordingLogger.cs | 42 +++++++ .../TranscodeStoreConnectivityProbeTests.cs | 75 +++++++++++++ .../TranscodeStoreRegistrationTests.cs | 104 ++++++++++++++++++ 13 files changed, 640 insertions(+), 27 deletions(-) create mode 100644 Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs create mode 100644 Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs create mode 100644 Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/TranscodeStoreWiringTests.cs create mode 100644 tests/Jellyfin.Server.Tests/HighAvailability/JellyfinSectionConfigurationTests.cs create mode 100644 tests/Jellyfin.Server.Tests/HighAvailability/RecordingLogger.cs create mode 100644 tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreConnectivityProbeTests.cs create mode 100644 tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreRegistrationTests.cs diff --git a/Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs b/Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs new file mode 100644 index 0000000000..2c41875ced --- /dev/null +++ b/Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Emby.Server.Implementations.MediaEncoding; + +/// +/// Pings the configured Redis transcode session store once at startup so an unreachable store is +/// reported there instead of being discovered as a silent loss of cross-pod takeover. +/// +public sealed class TranscodeStoreConnectivityProbe : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve the Redis connection. + /// The logger. + public TranscodeStoreConnectivityProbe(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + try + { + // Resolved here rather than injected: connecting must not be able to abort startup. + var redis = _serviceProvider.GetRequiredService(); + var roundTrip = await redis.GetDatabase().PingAsync().ConfigureAwait(false); + + _logger.LogInformation( + "Redis transcode session store is reachable ({RoundTripMs}ms round trip). HA transcode takeover is active.", + (long)roundTrip.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Redis transcode session store is configured but UNREACHABLE. HA transcode takeover is not working: sessions stay local to this instance and are lost when it restarts. Check {Key}.", + TranscodeStoreOptions.RedisConnectionStringKey); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 0077b52bf1..1dffd75401 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Reflection; using Emby.Server.Implementations; -using Emby.Server.Implementations.MediaEncoding; using Emby.Server.Implementations.ScheduledTasks; using Emby.Server.Implementations.Session; using Jellyfin.Api.WebSocketListeners; @@ -10,6 +9,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Drawing; using Jellyfin.Drawing.Skia; using Jellyfin.LiveTv; +using Jellyfin.Server.Extensions; using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; @@ -35,7 +35,6 @@ using MediaBrowser.Providers.Lyric; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using StackExchange.Redis; namespace Jellyfin.Server { @@ -107,29 +106,8 @@ namespace Jellyfin.Server serviceCollection.AddScoped(); // Transcode session store: Redis-backed when configured, no-op otherwise. - serviceCollection.Configure(_startupConfig.GetSection("Jellyfin:TranscodeStore")); - var redisConnectionString = _startupConfig["Jellyfin:TranscodeStore:RedisConnectionString"]; - if (!string.IsNullOrEmpty(redisConnectionString)) - { - serviceCollection.AddSingleton(sp => - { - try - { - return ConnectionMultiplexer.Connect(redisConnectionString); - } - catch (Exception ex) - { - sp.GetRequiredService>() - .LogError(ex, "Failed to connect to Redis. Check the Jellyfin:TranscodeStore:RedisConnectionString configuration."); - throw; - } - }); - serviceCollection.AddSingleton(); - } - else - { - serviceCollection.AddSingleton(); - } + var redisConnectionString = _startupConfig[TranscodeStoreOptions.RedisConnectionStringKey]; + serviceCollection.AddTranscodeSessionStore(_startupConfig, Logger); // Scan-leader lease: gates periodic library-mutating scheduled tasks to a single leader // instance. Redis-backed when enabled and a Redis connection is configured, no-op otherwise. diff --git a/Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000000..90a5035aa4 --- /dev/null +++ b/Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; + +namespace Jellyfin.Server.Extensions; + +/// +/// Extensions for building the application configuration. +/// +public static class ConfigurationBuilderExtensions +{ + /// + /// The environment variable prefix that maps onto this fork's own Jellyfin:* configuration + /// keys, for example Jellyfin__TranscodeStore__RedisConnectionString. + /// + public const string JellyfinSectionEnvironmentPrefix = "Jellyfin__"; + + /// + /// The configuration section root this fork keeps its own settings under. + /// + public const string JellyfinSectionRoot = "Jellyfin"; + + /// + /// Adds environment variables named Jellyfin__Section__Key as the configuration keys + /// Jellyfin:Section:Key. + /// + /// + /// The base configuration only reads JELLYFIN_ prefixed environment variables, so the + /// unprefixed form every manifest, chart and document uses would otherwise be dropped and the + /// feature it configures would stay off with no error. + /// + /// The configuration builder. + /// The updated configuration builder. + public static IConfigurationBuilder AddJellyfinSectionEnvironmentVariables(this IConfigurationBuilder builder) + { + // Read through the framework provider so "__" to ":" normalisation and case handling match the + // prefixed form exactly; the prefix it strips is then put back as the section root. + var scoped = new ConfigurationBuilder() + .AddEnvironmentVariables(JellyfinSectionEnvironmentPrefix) + .Build(); + + var entries = scoped.AsEnumerable() + .Where(entry => entry.Value is not null) + .Select(entry => new KeyValuePair( + ConfigurationPath.Combine(JellyfinSectionRoot, entry.Key), + entry.Value)) + .ToList(); + + return builder.AddInMemoryCollection(entries); + } +} diff --git a/Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs new file mode 100644 index 0000000000..60ca77817d --- /dev/null +++ b/Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.MediaEncoding; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Jellyfin.Server.Extensions; + +/// +/// Extensions for registering the transcode session store. +/// +public static class TranscodeStoreServiceCollectionExtensions +{ + /// + /// Registers the transcode session store, Redis-backed when a connection string is configured and + /// no-op otherwise, and reports the selected store at . + /// + /// The service collection. + /// The configuration to read Jellyfin:TranscodeStore from. + /// The logger to report the selected store on. + /// The updated service collection. + public static IServiceCollection AddTranscodeSessionStore( + this IServiceCollection serviceCollection, + IConfiguration configuration, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(logger); + + serviceCollection.Configure(configuration.GetSection(TranscodeStoreOptions.ConfigurationSection)); + + var redisConnectionString = configuration[TranscodeStoreOptions.RedisConnectionStringKey]; + if (string.IsNullOrEmpty(redisConnectionString)) + { + logger.LogInformation( + "Transcode session store: {Store}. Cross-pod transcode takeover is off; set {Key} to enable it.", + nameof(NullTranscodeSessionStore), + TranscodeStoreOptions.RedisConnectionStringKey); + + return serviceCollection.AddSingleton(); + } + + logger.LogInformation( + "Transcode session store: {Store} on {Endpoints}.", + nameof(RedisTranscodeSessionStore), + DescribeEndpoints(redisConnectionString)); + + serviceCollection.AddSingleton(sp => + { + try + { + return ConnectionMultiplexer.Connect(redisConnectionString); + } + catch (Exception ex) + { + sp.GetRequiredService>() + .LogError(ex, "Failed to connect to Redis. Check the {Key} configuration.", TranscodeStoreOptions.RedisConnectionStringKey); + throw; + } + }); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + + return serviceCollection; + } + + /// + /// Renders the endpoints of a connection string for logging. The connection string itself is never + /// logged because it can carry a password. + /// + private static string DescribeEndpoints(string redisConnectionString) + { + try + { + return string.Join( + ',', + ConfigurationOptions.Parse(redisConnectionString).EndPoints.Select(endpoint => endpoint.ToString())); + } + catch (ArgumentException) + { + return "(unparsable connection string)"; + } + } +} diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 8390c91313..5530cfae93 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -391,6 +391,8 @@ namespace Jellyfin.Server .AddInMemoryCollection(inMemoryDefaultConfig) .AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true) .AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true) + // Added before the prefixed source so an explicit JELLYFIN_ variable still wins. + .AddJellyfinSectionEnvironmentVariables() .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); } diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs b/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs index 65a7b31348..ea527b80cf 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs @@ -5,6 +5,16 @@ namespace MediaBrowser.Controller.MediaEncoding; /// public sealed class TranscodeStoreOptions { + /// + /// The configuration section these options bind from. + /// + public const string ConfigurationSection = "Jellyfin:TranscodeStore"; + + /// + /// The configuration key holding the Redis connection string. + /// + public const string RedisConnectionStringKey = ConfigurationSection + ":RedisConnectionString"; + /// /// Gets or sets the Redis connection string. /// A null or empty value indicates single-instance mode, where diff --git a/README.md b/README.md index 816aba126b..7ac2845e7d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \ ### HA mode with Redis -Set the `Jellyfin:TranscodeStore:RedisConnectionString` configuration key. You can pass it as an environment variable, a `DOTNET_` prefixed env var, or in a JSON config file. +Set the `Jellyfin:TranscodeStore:RedisConnectionString` configuration key. You can pass it as a `Jellyfin__TranscodeStore__RedisConnectionString` environment variable, as the equivalent `JELLYFIN_` prefixed variable, or in a JSON config file. **Environment variable:** @@ -98,7 +98,14 @@ dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \ } ``` -When `RedisConnectionString` is set, `RedisTranscodeSessionStore` is registered in DI. If the Redis connection fails at startup, the server throws and refuses to start — this is intentional so you don't silently fall back to broken HA behavior. +The selected store is logged at startup, so HA transcoding is never on or off without a signal: + +``` +Transcode session store: RedisTranscodeSessionStore on valkey:6379. +Redis transcode session store is reachable (2ms round trip). HA transcode takeover is active. +``` + +Without a connection string the line reads `Transcode session store: NullTranscodeSessionStore`. A configured but unreachable store is logged at `Error`; the server keeps serving with per-instance sessions rather than refusing to start. --- diff --git a/docs/FORK-DIFF.md b/docs/FORK-DIFF.md index 0483ed27ff..b7656f4093 100644 --- a/docs/FORK-DIFF.md +++ b/docs/FORK-DIFF.md @@ -37,6 +37,9 @@ auth or plugin logic is rewritten. | `MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs` | `RedisConnectionString`, `LeaseDurationSeconds` and `SessionRetentionSeconds` | | `MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs` | No-op store used when no Redis connection is configured | | `Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs` | Redis store; sessions under `jellyfin:transcode:{playSessionId}`, key TTL is the retention window so an orphaned session outlives its lease | +| `Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs` | Startup ping; an unreachable configured store is logged at `Error` instead of failing open silently | +| `Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs` | Store selection, logged at `Information` so the active store is visible at startup | +| `Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs` | Reads bare `Jellyfin__*` environment variables into the `Jellyfin:*` configuration root | Lease takeover and renewal each run as a single Lua script, so concurrent pods cannot both claim an expired lease and a renewal cannot revert a takeover. The expiry is stored as unix diff --git a/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/TranscodeStoreWiringTests.cs b/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/TranscodeStoreWiringTests.cs new file mode 100644 index 0000000000..61df768846 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/TranscodeStoreWiringTests.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Emby.Server.Implementations.MediaEncoding; +using Jellyfin.Server; +using Jellyfin.Server.Extensions; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StackExchange.Redis; +using Testcontainers.Redis; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.MediaEncoding; + +/// +/// Drives the whole configuration path a deployment uses: a bare Jellyfin__TranscodeStore__* +/// environment variable, the server's own configuration builder, the store registration, and a +/// session round-trip against a real Valkey server. +/// +[Trait("Category", "RequiresDocker")] +public sealed class TranscodeStoreWiringTests : IAsyncLifetime +{ + private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString"; + + private readonly RedisContainer _container; + private string _configDirectory = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + public TranscodeStoreWiringTests() + { + _container = new RedisBuilder("valkey/valkey:8-alpine").Build(); + } + + /// + /// Starts Valkey and lays out the configuration directory the server reads at startup. + /// + /// A representing the asynchronous operation. + public async ValueTask InitializeAsync() + { + await _container.StartAsync().ConfigureAwait(false); + _configDirectory = Directory.CreateTempSubdirectory("jellyfin-wiring-test").FullName; + await File.WriteAllTextAsync(Path.Combine(_configDirectory, "logging.default.json"), "{}").ConfigureAwait(false); + } + + /// + /// Removes the environment variable, configuration directory and container. + /// + /// A representing the asynchronous operation. + public async ValueTask DisposeAsync() + { + Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null); + + if (_configDirectory.Length > 0) + { + Directory.Delete(_configDirectory, true); + } + + await _container.DisposeAsync().ConfigureAwait(false); + } + + /// + /// The variable form deployments set selects the Redis store and that store really talks to Valkey. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task ManifestStyleEnvironmentVariable_Should_Reach_Valkey() + { + Environment.SetEnvironmentVariable( + RedisConnectionStringVariable, + _container.GetConnectionString() + ",abortConnect=false"); + + var appPaths = new Mock(); + appPaths.Setup(paths => paths.ConfigurationDirectoryPath).Returns(_configDirectory); + var configuration = Program.CreateAppConfiguration(new StartupOptions(), appPaths.Object); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTranscodeSessionStore(configuration, NullLogger.Instance); + + await using var provider = services.BuildServiceProvider(); + + var store = provider.GetRequiredService(); + Assert.IsType(store); + + var playSessionId = Guid.NewGuid().ToString("N"); + await store.SetAsync( + TranscodeSession.CreateForPlaylist(playSessionId, "media-1", "pod-a", "/transcodes/abc.m3u8", TimeSpan.FromSeconds(30)), + TestContext.Current.CancellationToken); + + var stored = await store.TryGetAsync(playSessionId, TestContext.Current.CancellationToken); + + Assert.NotNull(stored); + Assert.Equal("pod-a", stored.OwnerPod); + + var redis = provider.GetRequiredService(); + Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:transcode:" + playSessionId)); + } +} diff --git a/tests/Jellyfin.Server.Tests/HighAvailability/JellyfinSectionConfigurationTests.cs b/tests/Jellyfin.Server.Tests/HighAvailability/JellyfinSectionConfigurationTests.cs new file mode 100644 index 0000000000..4cfc9518ed --- /dev/null +++ b/tests/Jellyfin.Server.Tests/HighAvailability/JellyfinSectionConfigurationTests.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.HighAvailability; + +/// +/// The fork's own settings live under the Jellyfin:* configuration root and every manifest, +/// chart and document sets them as bare Jellyfin__Section__Key environment variables. The +/// startup configuration the host reads them from must therefore accept that form; when it does not, +/// a correctly set variable is dropped and the feature it configures stays off without any error. +/// +public sealed class JellyfinSectionConfigurationTests : IDisposable +{ + private const string RedisKey = "Jellyfin:TranscodeStore:RedisConnectionString"; + private const string UnprefixedVariable = "Jellyfin__TranscodeStore__RedisConnectionString"; + private const string PrefixedVariable = "JELLYFIN_Jellyfin__TranscodeStore__RedisConnectionString"; + private const string LeaseUnprefixedVariable = "Jellyfin__TranscodeStore__LeaseDurationSeconds"; + + private readonly string _configDirectory; + + public JellyfinSectionConfigurationTests() + { + _configDirectory = Directory.CreateTempSubdirectory("jellyfin-config-test").FullName; + File.WriteAllText(Path.Combine(_configDirectory, "logging.default.json"), "{}"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(UnprefixedVariable, null); + Environment.SetEnvironmentVariable(PrefixedVariable, null); + Environment.SetEnvironmentVariable(LeaseUnprefixedVariable, null); + Directory.Delete(_configDirectory, true); + } + + [Fact] + public void CreateAppConfiguration_Should_Read_UnprefixedVariable() + { + Environment.SetEnvironmentVariable(UnprefixedVariable, "valkey-cheeztv-valkey:6379,abortConnect=false"); + + var config = CreateConfiguration(); + + Assert.Equal("valkey-cheeztv-valkey:6379,abortConnect=false", config[RedisKey]); + } + + [Fact] + public void CreateAppConfiguration_Should_Read_UnprefixedNonStringValue() + { + Environment.SetEnvironmentVariable(LeaseUnprefixedVariable, "45"); + + var config = CreateConfiguration(); + + Assert.Equal("45", config["Jellyfin:TranscodeStore:LeaseDurationSeconds"]); + } + + [Fact] + public void CreateAppConfiguration_Should_Read_PrefixedVariable() + { + Environment.SetEnvironmentVariable(PrefixedVariable, "redis:6379"); + + var config = CreateConfiguration(); + + Assert.Equal("redis:6379", config[RedisKey]); + } + + [Fact] + public void CreateAppConfiguration_Should_Prefer_PrefixedVariable() + { + Environment.SetEnvironmentVariable(UnprefixedVariable, "unprefixed:6379"); + Environment.SetEnvironmentVariable(PrefixedVariable, "prefixed:6379"); + + var config = CreateConfiguration(); + + Assert.Equal("prefixed:6379", config[RedisKey]); + } + + [Fact] + public void CreateAppConfiguration_Should_Leave_Key_Unset_Without_Variables() + { + var config = CreateConfiguration(); + + Assert.Null(config[RedisKey]); + } + + private IConfiguration CreateConfiguration() + { + var appPaths = new Mock(); + appPaths.Setup(paths => paths.ConfigurationDirectoryPath).Returns(_configDirectory); + + return Program.CreateAppConfiguration(new StartupOptions(), appPaths.Object); + } +} diff --git a/tests/Jellyfin.Server.Tests/HighAvailability/RecordingLogger.cs b/tests/Jellyfin.Server.Tests/HighAvailability/RecordingLogger.cs new file mode 100644 index 0000000000..3ebd00b256 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/HighAvailability/RecordingLogger.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Tests.HighAvailability; + +/// +/// An that keeps every formatted entry so tests can assert on the startup +/// signals operators rely on. +/// +/// The category type. +internal sealed class RecordingLogger : ILogger +{ + private readonly List<(LogLevel Level, string Message, Exception? Exception)> _entries = new(); + + public IReadOnlyList<(LogLevel Level, string Message, Exception? Exception)> Entries => _entries; + + public IDisposable BeginScope(TState state) + where TState : notnull + => NoopScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + _entries.Add((logLevel, formatter(state, exception), exception)); + } + + public bool HasEntry(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() + { + } + } +} diff --git a/tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreConnectivityProbeTests.cs b/tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreConnectivityProbeTests.cs new file mode 100644 index 0000000000..3f952a6001 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreConnectivityProbeTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.MediaEncoding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using StackExchange.Redis; +using Xunit; + +namespace Jellyfin.Server.Tests.HighAvailability; + +/// +/// A Redis store that cannot be reached degrades silently: the client is configured not to abort the +/// connection and every call site swallows failures. The probe is the only startup signal, so both of +/// its outcomes are pinned here. +/// +public sealed class TranscodeStoreConnectivityProbeTests +{ + [Fact] + public async Task StartAsync_Should_Log_Information_When_Reachable() + { + var database = new Mock(); + database.Setup(db => db.PingAsync(It.IsAny())).ReturnsAsync(TimeSpan.FromMilliseconds(3)); + + var (logger, probe) = CreateProbe(database.Object); + + await probe.StartAsync(CancellationToken.None); + + Assert.True(logger.HasEntry(LogLevel.Information, "reachable")); + Assert.DoesNotContain(logger.Entries, entry => entry.Level >= LogLevel.Warning); + } + + [Fact] + public async Task StartAsync_Should_Log_Error_When_Unreachable() + { + var database = new Mock(); + database.Setup(db => db.PingAsync(It.IsAny())) + .ThrowsAsync(new RedisConnectionException(ConnectionFailureType.UnableToConnect, "no route to host")); + + var (logger, probe) = CreateProbe(database.Object); + + await probe.StartAsync(CancellationToken.None); + + Assert.True(logger.HasEntry(LogLevel.Error, "UNREACHABLE")); + } + + [Fact] + public async Task StartAsync_Should_Log_Error_Instead_Of_Aborting_Startup() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, "no route to host")); + using var provider = services.BuildServiceProvider(); + + var logger = new RecordingLogger(); + var probe = new TranscodeStoreConnectivityProbe(provider, logger); + + await probe.StartAsync(CancellationToken.None); + + Assert.True(logger.HasEntry(LogLevel.Error, "UNREACHABLE")); + } + + private static (RecordingLogger Logger, TranscodeStoreConnectivityProbe Probe) CreateProbe(IDatabase database) + { + var multiplexer = new Mock(); + multiplexer.Setup(redis => redis.GetDatabase(It.IsAny(), It.IsAny())).Returns(database); + + var services = new ServiceCollection(); + services.AddSingleton(multiplexer.Object); + var provider = services.BuildServiceProvider(); + + var logger = new RecordingLogger(); + return (logger, new TranscodeStoreConnectivityProbe(provider, logger)); + } +} diff --git a/tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreRegistrationTests.cs b/tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreRegistrationTests.cs new file mode 100644 index 0000000000..42f1563a4c --- /dev/null +++ b/tests/Jellyfin.Server.Tests/HighAvailability/TranscodeStoreRegistrationTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Emby.Server.Implementations.MediaEncoding; +using Jellyfin.Server.Extensions; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Jellyfin.Server.Tests.HighAvailability; + +/// +/// Which transcode session store was selected is invisible at runtime — the Redis client does not +/// abort on an unreachable server and every call site swallows failures — so the selection is +/// asserted here together with the startup log line that reports it. +/// +public sealed class TranscodeStoreRegistrationTests +{ + [Fact] + public void AddTranscodeSessionStore_Should_Select_RedisStore_From_UnprefixedEnvironmentKeyShape() + { + var services = new ServiceCollection(); + var logger = new RecordingLogger(); + + services.AddTranscodeSessionStore(BuildConfiguration("valkey-cheeztv-valkey:6379,abortConnect=false"), logger); + + Assert.Equal(typeof(RedisTranscodeSessionStore), StoreImplementation(services)); + Assert.True(logger.HasEntry(LogLevel.Information, nameof(RedisTranscodeSessionStore))); + } + + [Fact] + public void AddTranscodeSessionStore_Should_Select_NullStore_Without_ConnectionString() + { + var services = new ServiceCollection(); + var logger = new RecordingLogger(); + + services.AddTranscodeSessionStore(BuildConfiguration(null), logger); + + Assert.Equal(typeof(NullTranscodeSessionStore), StoreImplementation(services)); + Assert.True(logger.HasEntry(LogLevel.Information, nameof(NullTranscodeSessionStore))); + } + + [Fact] + public void AddTranscodeSessionStore_Should_Log_Endpoints_Without_Password() + { + var services = new ServiceCollection(); + var logger = new RecordingLogger(); + + services.AddTranscodeSessionStore(BuildConfiguration("valkey:6379,password=hunter2"), logger); + + var message = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Information).Message; + Assert.Contains("valkey:6379", message, StringComparison.Ordinal); + Assert.DoesNotContain("hunter2", message, StringComparison.Ordinal); + } + + [Fact] + public void AddTranscodeSessionStore_Should_Register_ConnectivityProbe_Only_With_Redis() + { + var withRedis = new ServiceCollection(); + withRedis.AddTranscodeSessionStore(BuildConfiguration("valkey:6379"), new RecordingLogger()); + + var withoutRedis = new ServiceCollection(); + withoutRedis.AddTranscodeSessionStore(BuildConfiguration(null), new RecordingLogger()); + + Assert.Contains(withRedis, descriptor => descriptor.ImplementationType == typeof(TranscodeStoreConnectivityProbe)); + Assert.DoesNotContain(withoutRedis, descriptor => descriptor.ServiceType == typeof(IHostedService)); + } + + [Fact] + public void AddTranscodeSessionStore_Should_Bind_Options() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [TranscodeStoreOptions.RedisConnectionStringKey] = "valkey:6379", + ["Jellyfin:TranscodeStore:LeaseDurationSeconds"] = "45" + }) + .Build(); + + services.AddTranscodeSessionStore(configuration, new RecordingLogger()); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(45, options.LeaseDurationSeconds); + Assert.Equal("valkey:6379", options.RedisConnectionString); + } + + private static IConfiguration BuildConfiguration(string? redisConnectionString) + => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [TranscodeStoreOptions.RedisConnectionStringKey] = redisConnectionString + }) + .Build(); + + private static Type? StoreImplementation(IServiceCollection services) + => services.Single(descriptor => descriptor.ServiceType == typeof(ITranscodeSessionStore)).ImplementationType; +} -- 2.47.3