b662ffa48f
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.
148 lines
5.6 KiB
C#
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;
|
|
}
|
|
}
|