Files
jellyfin-ha-src/tests/Jellyfin.Server.Implementations.Tests/Configuration/SharedConfigurationPropagationTests.cs
T
unkin-agent b662ffa48f
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
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.
2026-09-20 23:47:21 +10:00

148 lines
5.6 KiB
C#

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;
/// <summary>
/// Two independently constructed <see cref="ServerConfigurationManager"/> instances over one configuration
/// directory are the in-process stand-in for two replicas sharing one <c>/config</c> mount: what either of
/// them writes, the other has to pick up without being restarted.
/// </summary>
public sealed class SharedConfigurationPropagationTests : IDisposable
{
private readonly string _root;
/// <summary>
/// Initializes a new instance of the <see cref="SharedConfigurationPropagationTests"/> class.
/// </summary>
public SharedConfigurationPropagationTests()
{
_root = Path.Combine(Path.GetTempPath(), "jf-config-prop-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
/// <inheritdoc />
public void Dispose()
{
try
{
Directory.Delete(_root, true);
}
catch (IOException)
{
}
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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);
}
/// <summary>
/// The same has to hold for the named configurations, which are cached per key and never reloaded.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<RedisConfigurationInvalidationBus>.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<ServerConfigurationManager> CreateSubscribedInstanceAsync(FakeInvalidationBusFabric fabric, string originId)
{
var instance = CreateInstance(fabric, originId);
var subscriber = new ConfigurationInvalidationSubscriber(
instance.InvalidationBus,
instance,
NullLogger<ConfigurationInvalidationSubscriber>.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;
}
}