From b662ffa48f04de25ded8fd706d223effabefb913 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 20 Sep 2026 23:47:21 +1000 Subject: [PATCH] propagate shared-config and library-option changes between instances Add a Redis pub/sub invalidation bus, publish from the configuration and library-option write paths, and drop the matching local cache entry on the instances that did not write. No-op without a Redis connection string, and fails open when it is unreachable. --- .../AppBase/BaseConfigurationManager.cs | 31 ++++ .../ApplicationHost.cs | 2 + .../ConfigurationInvalidationSubscriber.cs | 77 +++++++++ .../RedisConfigurationInvalidationBus.cs | 96 +++++++++++ Jellyfin.Server/CoreAppHost.cs | 4 + ...InvalidationServiceCollectionExtensions.cs | 74 ++++++++ .../ConfigurationInvalidation.cs | 26 +++ .../ConfigurationInvalidationScope.cs | 29 ++++ .../IConfigurationInvalidationBus.cs | 28 +++ .../Configuration/IConfigurationManager.cs | 7 + .../NullConfigurationInvalidationBus.cs | 27 +++ .../Entities/CollectionFolder.cs | 34 +++- .../FakeInvalidationBusFabric.cs | 73 ++++++++ .../LibraryVisibilityPropagationTests.cs | 161 ++++++++++++++++++ .../RedisConfigurationInvalidationBusTests.cs | 104 +++++++++++ .../SharedConfigurationPropagationTests.cs | 147 ++++++++++++++++ 16 files changed, 919 insertions(+), 1 deletion(-) create mode 100644 Emby.Server.Implementations/Configuration/ConfigurationInvalidationSubscriber.cs create mode 100644 Emby.Server.Implementations/Configuration/RedisConfigurationInvalidationBus.cs create mode 100644 Jellyfin.Server/Extensions/ConfigurationInvalidationServiceCollectionExtensions.cs create mode 100644 MediaBrowser.Common/Configuration/ConfigurationInvalidation.cs create mode 100644 MediaBrowser.Common/Configuration/ConfigurationInvalidationScope.cs create mode 100644 MediaBrowser.Common/Configuration/IConfigurationInvalidationBus.cs create mode 100644 MediaBrowser.Common/Configuration/NullConfigurationInvalidationBus.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Configuration/FakeInvalidationBusFabric.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Configuration/LibraryVisibilityPropagationTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Configuration/RedisConfigurationInvalidationBusTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Configuration/SharedConfigurationPropagationTests.cs diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index aa19948e36..3c2342406d 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -87,6 +87,12 @@ namespace Emby.Server.Implementations.AppBase /// The application paths. public IApplicationPaths CommonApplicationPaths { get; private set; } + /// + /// Gets or sets the bus announcing configuration writes to the other instances sharing this + /// configuration directory. Defaults to a no-op, which is the single-instance behaviour. + /// + public IConfigurationInvalidationBus InvalidationBus { get; set; } = NullConfigurationInvalidationBus.Instance; + /// /// Gets or sets the system configuration. /// @@ -169,6 +175,8 @@ namespace Emby.Server.Implementations.AppBase } OnConfigurationUpdated(); + + InvalidationBus.Publish(ConfigurationInvalidationScope.SystemConfiguration, null); } /// @@ -350,6 +358,29 @@ namespace Emby.Server.Implementations.AppBase } OnNamedConfigurationUpdated(key, configuration); + + InvalidationBus.Publish(ConfigurationInvalidationScope.NamedConfiguration, key); + } + + /// + public void InvalidateCachedConfiguration(string? key) + { + if (string.IsNullOrEmpty(key)) + { + lock (_configurationSyncLock) + { + _configuration = null; + } + + // Reloads the system configuration off the shared file as a side effect of re-deriving + // the cache path from it, then tells the in-process consumers to re-read it. + OnConfigurationUpdated(); + return; + } + + _configurations.TryRemove(key, out _); + + OnNamedConfigurationUpdated(key, GetConfiguration(key)); } /// diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 1a54565863..c4db55fa71 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -706,6 +706,8 @@ namespace Emby.Server.Implementations BaseItem.UserDataManager = Resolve(); CollectionFolder.XmlSerializer = _xmlSerializer; CollectionFolder.ApplicationHost = this; + CollectionFolder.InvalidationBus = Resolve(); + ConfigurationManager.InvalidationBus = CollectionFolder.InvalidationBus; Folder.UserViewManager = Resolve(); Folder.CollectionManager = Resolve(); Folder.LimitedConcurrencyLibraryScheduler = Resolve(); diff --git a/Emby.Server.Implementations/Configuration/ConfigurationInvalidationSubscriber.cs b/Emby.Server.Implementations/Configuration/ConfigurationInvalidationSubscriber.cs new file mode 100644 index 0000000000..80fe0160b1 --- /dev/null +++ b/Emby.Server.Implementations/Configuration/ConfigurationInvalidationSubscriber.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Configuration +{ + /// + /// Applies the configuration invalidations published by the other instances sharing this + /// configuration directory, dropping the local cache entry so the next read comes off the shared file. + /// + public sealed class ConfigurationInvalidationSubscriber : IHostedService + { + private readonly IConfigurationInvalidationBus _bus; + private readonly IConfigurationManager _configurationManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The invalidation bus. + /// The configuration manager holding the cached configuration. + /// The logger. + public ConfigurationInvalidationSubscriber( + IConfigurationInvalidationBus bus, + IConfigurationManager configurationManager, + ILogger logger) + { + _bus = bus; + _configurationManager = configurationManager; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _bus.Subscribe(Apply); + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private void Apply(ConfigurationInvalidation invalidation) + { + try + { + switch (invalidation.Scope) + { + case ConfigurationInvalidationScope.SystemConfiguration: + _configurationManager.InvalidateCachedConfiguration(null); + break; + case ConfigurationInvalidationScope.NamedConfiguration when !string.IsNullOrEmpty(invalidation.Target): + _configurationManager.InvalidateCachedConfiguration(invalidation.Target); + break; + case ConfigurationInvalidationScope.LibraryOptions when !string.IsNullOrEmpty(invalidation.Target): + CollectionFolder.InvalidateLibraryOptions(invalidation.Target); + break; + case ConfigurationInvalidationScope.AllLibraryOptions: + CollectionFolder.InvalidateAllLibraryOptions(); + break; + default: + return; + } + + _logger.LogDebug("Applied {Scope} invalidation for {Target} from {OriginId}.", invalidation.Scope, invalidation.Target, invalidation.OriginId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to apply a {Scope} invalidation for {Target}.", invalidation.Scope, invalidation.Target); + } + } + } +} diff --git a/Emby.Server.Implementations/Configuration/RedisConfigurationInvalidationBus.cs b/Emby.Server.Implementations/Configuration/RedisConfigurationInvalidationBus.cs new file mode 100644 index 0000000000..7438b7eebd --- /dev/null +++ b/Emby.Server.Implementations/Configuration/RedisConfigurationInvalidationBus.cs @@ -0,0 +1,96 @@ +using System; +using System.Text.Json; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Emby.Server.Implementations.Configuration +{ + /// + /// A Redis pub/sub . Notices are broadcast on one channel + /// and every instance but the publisher applies them. + /// + public sealed class RedisConfigurationInvalidationBus : IConfigurationInvalidationBus + { + private const string ChannelName = "jellyfin:configinvalidation"; + + private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + private readonly ISubscriber _subscriber; + private readonly ILogger _logger; + private readonly string _originId; + + /// + /// Initializes a new instance of the class. + /// + /// The Redis connection multiplexer. + /// The logger. + public RedisConfigurationInvalidationBus(IConnectionMultiplexer redis, ILogger logger) + { + ArgumentNullException.ThrowIfNull(redis); + + _subscriber = redis.GetSubscriber(); + _logger = logger; + _originId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName; + } + + /// + public void Publish(ConfigurationInvalidationScope scope, string? target) + { + var invalidation = new ConfigurationInvalidation + { + Scope = scope, + Target = target, + OriginId = _originId + }; + + try + { + // Fire and forget: an admin saving configuration must never wait on, or fail because of, + // the bus. The write has already reached the shared directory by this point. + _subscriber.Publish( + RedisChannel.Literal(ChannelName), + JsonSerializer.Serialize(invalidation, _jsonOptions), + CommandFlags.FireAndForget); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to publish {Scope} invalidation for {Target}; other instances keep their cached copy until they restart.", scope, target); + } + } + + /// + public void Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + try + { + _subscriber.Subscribe(RedisChannel.Literal(ChannelName), (_, value) => Dispatch(handler, value)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to subscribe to configuration invalidations; this instance keeps its cached configuration until it restarts."); + } + } + + private void Dispatch(Action handler, RedisValue value) + { + try + { + var invalidation = JsonSerializer.Deserialize(value.ToString(), _jsonOptions); + if (invalidation is null || string.Equals(invalidation.OriginId, _originId, StringComparison.Ordinal)) + { + return; + } + + handler(invalidation); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to apply a configuration invalidation."); + } + } + } +} diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index ee6c80ba10..9ac86bb8ed 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -112,6 +112,10 @@ namespace Jellyfin.Server // instance. Active by default once a Redis connection is configured, no-op otherwise. serviceCollection.AddScanLeaderLease(_startupConfig, Logger); + // Configuration invalidation bus: propagates shared-configuration and library-option writes + // to the other instances. Redis-backed when configured, no-op otherwise. + serviceCollection.AddConfigurationInvalidationBus(_startupConfig, Logger); + foreach (var type in GetExportTypes()) { serviceCollection.AddSingleton(typeof(ILyricProvider), type); diff --git a/Jellyfin.Server/Extensions/ConfigurationInvalidationServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ConfigurationInvalidationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..e2bd67880e --- /dev/null +++ b/Jellyfin.Server/Extensions/ConfigurationInvalidationServiceCollectionExtensions.cs @@ -0,0 +1,74 @@ +using System; +using Emby.Server.Implementations.Configuration; +using MediaBrowser.Common.Configuration; +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 shared-configuration invalidation bus. +/// +public static class ConfigurationInvalidationServiceCollectionExtensions +{ + /// + /// Registers the invalidation bus, Redis-backed when a connection string is configured and no-op + /// otherwise, and the subscriber applying the notices other instances publish. + /// + /// + /// The connection string is only set for a multi-instance deployment, which is the only shape where + /// one instance can write the shared configuration directory behind another's back. + /// + /// The service collection. + /// The configuration to read the Redis connection string from. + /// The logger to report the selected bus on. + /// The updated service collection. + public static IServiceCollection AddConfigurationInvalidationBus( + this IServiceCollection serviceCollection, + IConfiguration configuration, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(logger); + + if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey])) + { + logger.LogInformation( + "Configuration invalidation bus: {Bus}. Shared-configuration and library-visibility changes stay local to the instance that made them; set {Key} to propagate them.", + nameof(NullConfigurationInvalidationBus), + TranscodeStoreOptions.RedisConnectionStringKey); + + return serviceCollection.AddSingleton(NullConfigurationInvalidationBus.Instance); + } + + logger.LogInformation( + "Configuration invalidation bus: {Bus}. Shared-configuration and library-visibility changes propagate to every instance.", + nameof(RedisConfigurationInvalidationBus)); + + serviceCollection.AddSingleton(sp => + { + try + { + return new RedisConfigurationInvalidationBus( + sp.GetRequiredService(), + sp.GetRequiredService>()); + } + catch (Exception ex) + { + // Fail open: an unreachable Redis degrades to the single-instance behaviour of every + // instance keeping its own cached configuration, rather than aborting startup. + sp.GetRequiredService>().LogError( + ex, + "Redis is configured but unavailable, so shared-configuration changes will not propagate between instances. Check {Key}.", + TranscodeStoreOptions.RedisConnectionStringKey); + + return NullConfigurationInvalidationBus.Instance; + } + }); + + return serviceCollection.AddHostedService(); + } +} diff --git a/MediaBrowser.Common/Configuration/ConfigurationInvalidation.cs b/MediaBrowser.Common/Configuration/ConfigurationInvalidation.cs new file mode 100644 index 0000000000..ba6c884a44 --- /dev/null +++ b/MediaBrowser.Common/Configuration/ConfigurationInvalidation.cs @@ -0,0 +1,26 @@ +namespace MediaBrowser.Common.Configuration +{ + /// + /// A notice that one instance has written shared configuration, so every other instance has to drop + /// its locally cached copy and read the shared file again. + /// + public sealed class ConfigurationInvalidation + { + /// + /// Gets or sets the cache this notice refers to. + /// + public ConfigurationInvalidationScope Scope { get; set; } + + /// + /// Gets or sets what was invalidated within the scope: the configuration key for + /// , the library path for + /// , and null otherwise. + /// + public string? Target { get; set; } + + /// + /// Gets or sets the identity of the instance that published the notice, so it can ignore its own. + /// + public string? OriginId { get; set; } + } +} diff --git a/MediaBrowser.Common/Configuration/ConfigurationInvalidationScope.cs b/MediaBrowser.Common/Configuration/ConfigurationInvalidationScope.cs new file mode 100644 index 0000000000..2c541a4334 --- /dev/null +++ b/MediaBrowser.Common/Configuration/ConfigurationInvalidationScope.cs @@ -0,0 +1,29 @@ +namespace MediaBrowser.Common.Configuration +{ + /// + /// Identifies which locally cached copy of the shared configuration a + /// refers to. + /// + public enum ConfigurationInvalidationScope + { + /// + /// The system configuration cached by the configuration manager. + /// + SystemConfiguration = 0, + + /// + /// A single named configuration, identified by its key. + /// + NamedConfiguration = 1, + + /// + /// The library options of a single collection folder, identified by its path. + /// + LibraryOptions = 2, + + /// + /// The library options of every collection folder, for changes to the library structure itself. + /// + AllLibraryOptions = 3 + } +} diff --git a/MediaBrowser.Common/Configuration/IConfigurationInvalidationBus.cs b/MediaBrowser.Common/Configuration/IConfigurationInvalidationBus.cs new file mode 100644 index 0000000000..e620153626 --- /dev/null +++ b/MediaBrowser.Common/Configuration/IConfigurationInvalidationBus.cs @@ -0,0 +1,28 @@ +using System; + +namespace MediaBrowser.Common.Configuration +{ + /// + /// Carries cache-invalidation notices between the instances that share one configuration directory. + /// + /// + /// The shared directory carries the content; this bus only carries the fact that it changed. Every + /// implementation is expected to fail open: a bus that cannot deliver must not throw into the write + /// path, leaving each instance on its own locally cached copy until it restarts. + /// + public interface IConfigurationInvalidationBus + { + /// + /// Announces that this instance has written shared configuration. + /// + /// The cache that was written. + /// The configuration key or library path that was written, if any. + void Publish(ConfigurationInvalidationScope scope, string? target); + + /// + /// Registers the handler invoked for notices published by other instances. + /// + /// The handler applying the invalidation locally. + void Subscribe(Action handler); + } +} diff --git a/MediaBrowser.Common/Configuration/IConfigurationManager.cs b/MediaBrowser.Common/Configuration/IConfigurationManager.cs index 18a8d3e7b7..b8e92d6f83 100644 --- a/MediaBrowser.Common/Configuration/IConfigurationManager.cs +++ b/MediaBrowser.Common/Configuration/IConfigurationManager.cs @@ -85,6 +85,13 @@ namespace MediaBrowser.Common.Configuration /// /// The factories. void AddParts(IEnumerable factories); + + /// + /// Drops the locally cached copy of configuration another instance has written to the shared + /// configuration directory, so the next read reloads it, and raises the local update event. + /// + /// The named configuration key, or null for the system configuration. + void InvalidateCachedConfiguration(string? key); } public static class ConfigurationManagerExtensions diff --git a/MediaBrowser.Common/Configuration/NullConfigurationInvalidationBus.cs b/MediaBrowser.Common/Configuration/NullConfigurationInvalidationBus.cs new file mode 100644 index 0000000000..a93b41a2cf --- /dev/null +++ b/MediaBrowser.Common/Configuration/NullConfigurationInvalidationBus.cs @@ -0,0 +1,27 @@ +using System; + +namespace MediaBrowser.Common.Configuration +{ + /// + /// A no-op used by single-instance installs and whenever + /// the shared bus is unavailable. Every instance keeps its own cached configuration, which is the + /// behaviour of an install that has only one. + /// + public sealed class NullConfigurationInvalidationBus : IConfigurationInvalidationBus + { + /// + /// Gets the shared instance. + /// + public static NullConfigurationInvalidationBus Instance { get; } = new NullConfigurationInvalidationBus(); + + /// + public void Publish(ConfigurationInvalidationScope scope, string? target) + { + } + + /// + public void Subscribe(Action handler) + { + } + } +} diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index ffdc8421da..8ebbb72020 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -70,6 +71,12 @@ namespace MediaBrowser.Controller.Entities public static IServerApplicationHost ApplicationHost { get; set; } + /// + /// Gets or sets the bus announcing library option writes to the other instances sharing this + /// configuration directory. Defaults to a no-op, which is the single-instance behaviour. + /// + public static IConfigurationInvalidationBus InvalidationBus { get; set; } = NullConfigurationInvalidationBus.Instance; + [JsonIgnore] public override bool SupportsPlayedStatus => false; @@ -188,11 +195,36 @@ namespace MediaBrowser.Controller.Entities XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options)); + + InvalidationBus.Publish(ConfigurationInvalidationScope.LibraryOptions, path); } - public static void OnCollectionFolderChange() + /// + /// Drops the cached options of one library so the next read comes off options.xml again. + /// Applied on the instances that did not write, and so does not publish. + /// + /// The library path. + public static void InvalidateLibraryOptions(string path) + { + _libraryOptions.TryRemove(path, out _); + + LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, GetLibraryOptions(path))); + } + + /// + /// Drops every cached library option set. Applied on the instances that did not write, and so does + /// not publish. + /// + public static void InvalidateAllLibraryOptions() => _libraryOptions.Clear(); + public static void OnCollectionFolderChange() + { + InvalidateAllLibraryOptions(); + + InvalidationBus.Publish(ConfigurationInvalidationScope.AllLibraryOptions, null); + } + public override bool IsSaveLocalMetadataEnabled() { return true; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Configuration/FakeInvalidationBusFabric.cs b/tests/Jellyfin.Server.Implementations.Tests/Configuration/FakeInvalidationBusFabric.cs new file mode 100644 index 0000000000..04fcceeede --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Configuration/FakeInvalidationBusFabric.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Server.Implementations.Tests.Configuration; + +/// +/// An in-process stand-in for the Redis pub/sub bus: every endpoint connected to one fabric receives +/// what the others publish, and never its own notices. +/// +internal sealed class FakeInvalidationBusFabric +{ + private readonly List _endpoints = new(); + + /// + /// Connects a new instance to the fabric. + /// + /// The identity of the connecting instance. + /// The bus of that instance. + public IConfigurationInvalidationBus Connect(string originId) + { + var endpoint = new Endpoint(this, originId); + lock (_endpoints) + { + _endpoints.Add(endpoint); + } + + return endpoint; + } + + private void Broadcast(ConfigurationInvalidation invalidation) + { + Endpoint[] endpoints; + lock (_endpoints) + { + endpoints = _endpoints.ToArray(); + } + + foreach (var endpoint in endpoints.Where(e => !string.Equals(e.OriginId, invalidation.OriginId, StringComparison.Ordinal))) + { + endpoint.Deliver(invalidation); + } + } + + private sealed class Endpoint : IConfigurationInvalidationBus + { + private readonly FakeInvalidationBusFabric _fabric; + private readonly List> _handlers = new(); + + public Endpoint(FakeInvalidationBusFabric fabric, string originId) + { + _fabric = fabric; + OriginId = originId; + } + + public string OriginId { get; } + + public void Publish(ConfigurationInvalidationScope scope, string? target) + => _fabric.Broadcast(new ConfigurationInvalidation { Scope = scope, Target = target, OriginId = OriginId }); + + public void Subscribe(Action handler) + => _handlers.Add(handler); + + public void Deliver(ConfigurationInvalidation invalidation) + { + foreach (var handler in _handlers) + { + handler(invalidation); + } + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Configuration/LibraryVisibilityPropagationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Configuration/LibraryVisibilityPropagationTests.cs new file mode 100644 index 0000000000..65694a0427 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Configuration/LibraryVisibilityPropagationTests.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Configuration; +using Emby.Server.Implementations.Serialization; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Configuration; + +/// +/// Library options are cached in a process-wide dictionary, so the replica that did not serve the admin's +/// request is the one under test here: the other replica's write reaches the shared library directory, and +/// this one has to stop answering out of its own stale copy. +/// +/// +/// Disabling a library is an access revocation that overrides every per-user check, so a stale replica +/// keeps serving content that is supposed to be hidden from everyone. +/// +public sealed class LibraryVisibilityPropagationTests : IDisposable +{ + private readonly string _libraryPath; + private readonly MyXmlSerializer _serializer = new MyXmlSerializer(); + + /// + /// Initializes a new instance of the class. + /// + public LibraryVisibilityPropagationTests() + { + _libraryPath = Path.Combine(Path.GetTempPath(), "jf-library-prop-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_libraryPath); + + var applicationHost = new Mock(); + applicationHost.Setup(host => host.ExpandVirtualPath(It.IsAny())).Returns(path => path); + applicationHost.Setup(host => host.ReverseVirtualPath(It.IsAny())).Returns(path => path); + + CollectionFolder.XmlSerializer = _serializer; + CollectionFolder.ApplicationHost = applicationHost.Object; + } + + /// + public void Dispose() + { + CollectionFolder.InvalidationBus = NullConfigurationInvalidationBus.Instance; + CollectionFolder.InvalidateAllLibraryOptions(); + + try + { + Directory.Delete(_libraryPath, true); + } + catch (IOException) + { + } + } + + /// + /// Disabling a library on one replica has to hide it on every replica. Until it does, the ones that did + /// not serve the request keep the library visible to every user. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task LibraryDisabledOnAnotherInstance_IsNotVisibleHere() + { + var fabric = new FakeInvalidationBusFabric(); + var otherInstance = fabric.Connect("pod-a"); + await SubscribeThisInstanceAsync(fabric); + + WriteSharedOptions(enabled: true); + + var user = CreateUser(); + var library = new CollectionFolder { Path = _libraryPath, Name = "Movies" }; + + // This replica answers out of its cache from here on. + Assert.True(library.IsVisible(user)); + + // The admin disables the library on the other replica: it writes the shared directory and says so. + WriteSharedOptions(enabled: false); + otherInstance.Publish(ConfigurationInvalidationScope.LibraryOptions, _libraryPath); + + Assert.False(library.IsVisible(user)); + Assert.False(CollectionFolder.GetLibraryOptions(_libraryPath).Enabled); + } + + /// + /// A path remap made on another replica has to reach this one, or it keeps resolving media against a + /// path that is no longer the library's. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task LibraryPathRemappedOnAnotherInstance_IsSeenHere() + { + var fabric = new FakeInvalidationBusFabric(); + var otherInstance = fabric.Connect("pod-a"); + await SubscribeThisInstanceAsync(fabric); + + WriteSharedOptions(enabled: true, mediaPath: "/media/old"); + Assert.Equal("/media/old", CollectionFolder.GetLibraryOptions(_libraryPath).PathInfos[0].Path); + + WriteSharedOptions(enabled: true, mediaPath: "/media/new"); + otherInstance.Publish(ConfigurationInvalidationScope.LibraryOptions, _libraryPath); + + Assert.Equal("/media/new", CollectionFolder.GetLibraryOptions(_libraryPath).PathInfos[0].Path); + } + + /// + /// Saving library options here has to tell the other replicas, which is the half of the exchange the + /// tests above take as given. + /// + [Fact] + public void SaveLibraryOptions_AnnouncesTheLibraryToTheOtherInstances() + { + var fabric = new FakeInvalidationBusFabric(); + ConfigurationInvalidation? received = null; + + var otherInstance = fabric.Connect("pod-b"); + otherInstance.Subscribe(invalidation => received = invalidation); + CollectionFolder.InvalidationBus = fabric.Connect("pod-a"); + + CollectionFolder.SaveLibraryOptions(_libraryPath, new LibraryOptions { Enabled = false }); + + Assert.NotNull(received); + Assert.Equal(ConfigurationInvalidationScope.LibraryOptions, received.Scope); + Assert.Equal(_libraryPath, received.Target); + } + + private async Task SubscribeThisInstanceAsync(FakeInvalidationBusFabric fabric) + { + var bus = fabric.Connect("pod-b"); + CollectionFolder.InvalidationBus = bus; + + var subscriber = new ConfigurationInvalidationSubscriber( + bus, + Mock.Of(), + NullLogger.Instance); + + await subscriber.StartAsync(CancellationToken.None); + } + + private void WriteSharedOptions(bool enabled, string mediaPath = "/media") + { + // Written the way the other replica writes it, straight onto the shared directory. + var options = new LibraryOptions { Enabled = enabled, PathInfos = [new MediaPathInfo(mediaPath)] }; + _serializer.SerializeToFile(options, Path.Combine(_libraryPath, "options.xml")); + } + + private static User CreateUser() + { + var user = new User("propagation", "auth", "reset"); + user.SetPermission(PermissionKind.EnableAllFolders, true); + return user; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Configuration/RedisConfigurationInvalidationBusTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Configuration/RedisConfigurationInvalidationBusTests.cs new file mode 100644 index 0000000000..95232a8f10 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Configuration/RedisConfigurationInvalidationBusTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading.Tasks; +using Emby.Server.Implementations.Configuration; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using StackExchange.Redis; +using Testcontainers.Redis; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Configuration; + +/// +/// Round-trips through a real Redis, the transport two +/// replicas actually use to tell each other that the shared configuration directory has changed. +/// +[Trait("Category", "RequiresDocker")] +public sealed class RedisConfigurationInvalidationBusTests : IAsyncLifetime +{ + private readonly RedisContainer _container; + private IConnectionMultiplexer? _redis; + + /// + /// Initializes a new instance of the class. + /// + public RedisConfigurationInvalidationBusTests() + { + _container = new RedisBuilder("redis:7-alpine").Build(); + } + + /// + public async ValueTask InitializeAsync() + { + await _container.StartAsync(); + _redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString()); + } + + /// + public async ValueTask DisposeAsync() + { + if (_redis is not null) + { + await _redis.DisposeAsync(); + } + + await _container.DisposeAsync(); + } + + /// + /// A notice published by one replica reaches the other, carrying enough to invalidate one entry. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task Publish_ReachesTheOtherInstance() + { + var received = new TaskCompletionSource(); + var instanceA = CreateBus("pod-a"); + var instanceB = CreateBus("pod-b"); + + instanceB.Subscribe(invalidation => received.TrySetResult(invalidation)); + + instanceA.Publish(ConfigurationInvalidationScope.LibraryOptions, "/media/movies"); + + var invalidation = await received.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.Equal(ConfigurationInvalidationScope.LibraryOptions, invalidation.Scope); + Assert.Equal("/media/movies", invalidation.Target); + Assert.Equal("pod-a", invalidation.OriginId); + } + + /// + /// The publishing replica has already applied the change to its own cache, so it must not act on its + /// own notice and reload what it just wrote. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task Publish_IsNotDeliveredToThePublisher() + { + var ownNotice = new TaskCompletionSource(); + var otherNotice = new TaskCompletionSource(); + var instanceA = CreateBus("pod-a"); + var instanceB = CreateBus("pod-b"); + + instanceA.Subscribe(invalidation => ownNotice.TrySetResult(invalidation)); + instanceB.Subscribe(invalidation => otherNotice.TrySetResult(invalidation)); + + instanceA.Publish(ConfigurationInvalidationScope.SystemConfiguration, null); + + // Ordering is per channel, so B having the notice means A would have had it too. + await otherNotice.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Assert.False(ownNotice.Task.IsCompleted); + } + + private RedisConfigurationInvalidationBus CreateBus(string originId) + { + Environment.SetEnvironmentVariable("JELLYFIN_INSTANCE_ID", originId); + try + { + return new RedisConfigurationInvalidationBus(_redis!, NullLogger.Instance); + } + finally + { + Environment.SetEnvironmentVariable("JELLYFIN_INSTANCE_ID", null); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Configuration/SharedConfigurationPropagationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Configuration/SharedConfigurationPropagationTests.cs new file mode 100644 index 0000000000..115efc6b06 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Configuration/SharedConfigurationPropagationTests.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations; +using Emby.Server.Implementations.Configuration; +using Emby.Server.Implementations.Serialization; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using StackExchange.Redis; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Configuration; + +/// +/// Two independently constructed instances over one configuration +/// directory are the in-process stand-in for two replicas sharing one /config mount: what either of +/// them writes, the other has to pick up without being restarted. +/// +public sealed class SharedConfigurationPropagationTests : IDisposable +{ + private readonly string _root; + + /// + /// Initializes a new instance of the class. + /// + public SharedConfigurationPropagationTests() + { + _root = Path.Combine(Path.GetTempPath(), "jf-config-prop-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + /// + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) + { + } + } + + /// + /// A system configuration setting tightened on one replica has to hold on every other replica, not + /// only on the one that served the admin's request. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SystemConfigurationSavedOnOneInstance_IsSeenByAnotherWithoutRestart() + { + var fabric = new FakeInvalidationBusFabric(); + var instanceA = CreateInstance(fabric, "pod-a"); + var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); + + // B has the pre-change configuration in hand before A writes, as a running replica would. + Assert.True(instanceB.Configuration.QuickConnectAvailable); + + instanceA.Configuration.QuickConnectAvailable = false; + instanceA.SaveConfiguration(); + + Assert.False(instanceB.Configuration.QuickConnectAvailable); + } + + /// + /// The same has to hold for the named configurations, which are cached per key and never reloaded. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task NamedConfigurationSavedOnOneInstance_IsSeenByAnotherWithoutRestart() + { + var fabric = new FakeInvalidationBusFabric(); + var instanceA = CreateInstance(fabric, "pod-a"); + var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); + + Assert.True(instanceB.GetNetworkConfiguration().EnableRemoteAccess); + + var updated = instanceA.GetNetworkConfiguration(); + updated.EnableRemoteAccess = false; + instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated); + + Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess); + } + + /// + /// A replica that cannot reach the bus keeps serving: the admin's save still lands on the shared + /// directory, and the only loss is that the other replicas are not told about it. + /// + [Fact] + public void SaveConfiguration_WithUnreachableBus_DoesNotThrow() + { + using var multiplexer = ConnectionMultiplexer.Connect("127.0.0.1:1,abortConnect=false,connectTimeout=200,connectRetry=1,syncTimeout=200"); + var bus = new RedisConfigurationInvalidationBus(multiplexer, NullLogger.Instance); + + bus.Subscribe(_ => throw new InvalidOperationException("Nothing can be delivered by an unreachable bus.")); + + var instance = CreateInstance(new FakeInvalidationBusFabric(), "pod-a"); + instance.InvalidationBus = bus; + + instance.Configuration.QuickConnectAvailable = false; + instance.SaveConfiguration(); + instance.SaveConfiguration(NetworkConfigurationStore.StoreKey, instance.GetNetworkConfiguration()); + + Assert.False(instance.Configuration.QuickConnectAvailable); + } + + private async Task CreateSubscribedInstanceAsync(FakeInvalidationBusFabric fabric, string originId) + { + var instance = CreateInstance(fabric, originId); + var subscriber = new ConfigurationInvalidationSubscriber( + instance.InvalidationBus, + instance, + NullLogger.Instance); + + await subscriber.StartAsync(CancellationToken.None); + return instance; + } + + private ServerConfigurationManager CreateInstance(FakeInvalidationBusFabric fabric, string originId) + { + // Every instance has its own paths object, all of them pointing at the one shared directory. + var paths = new ServerApplicationPaths( + Ensure("data"), + Ensure("log"), + Ensure("config"), + Ensure("cache"), + Ensure("web")); + + var manager = new ServerConfigurationManager(paths, NullLoggerFactory.Instance, new MyXmlSerializer()) + { + InvalidationBus = fabric.Connect(originId) + }; + + manager.AddParts([new NetworkConfigurationFactory()]); + return manager; + } + + private string Ensure(string name) + { + var path = Path.Combine(_root, name); + Directory.CreateDirectory(path); + return path; + } +}