From 3a08815d5c223f2164cdd8168562889a214bbaef Mon Sep 17 00:00:00 2001 From: Colton Fussy <519596+Confusingboat@users.noreply.github.com> Date: Mon, 25 Mar 2024 20:46:11 -0500 Subject: [PATCH] Add Redis xUnit Fixtures (#15) --- .../DefaultLocalRedisInstance.cs | 2 - .../EphemeralRedisDatabasePoolFixture.cs | 16 -- .../EphemeralRedisMultiplexerFixture.cs | 23 ++ .../Ephemerally.Redis.Xunit.csproj | 2 +- ...=> PooledEphemeralRedisDatabaseFixture.cs} | 4 +- .../PooledEphemeralRedisMultiplexerFixture.cs | 23 ++ .../PublicExtensions.cs | 13 ++ src/Ephemerally.Redis.Xunit/RedisInstance.cs | 24 ++ .../RedisMultiplexerFixture.cs | 36 ++- .../RedisTestContainerFixture.cs | 24 -- .../ConnectionMultiplexerDecorator.cs | 171 +++++++++++++++ .../EphemeralConnectionMultiplexer.cs | 206 +++--------------- src/Ephemerally.Redis/FixedSizeObjectPool.cs | 42 ++-- src/Ephemerally.Redis/InternalExtensions.cs | 16 ++ .../PooledConnectionMultiplexer.cs | 75 +++++++ src/Ephemerally.Redis/PublicExtensions.cs | 23 +- src/Ephemerally/InternalExtensions.cs | 28 +-- .../Database/EphemeralDatabaseTests.cs | 6 +- .../Ephemerally.Redis.Tests.csproj | 5 +- .../FixedSizeObjectPoolTests.cs | 119 +++++++++- .../EphemeralRedisDatabaseFixtureTests.cs | 92 -------- ...edEphemeralRedisMultiplexerFixtureTests.cs | 198 +++++++++++++++++ .../EphemeralConnectionMultiplexerTests.cs | 57 +++++ tests/Ephemerally.Redis.Tests/Playground.cs | 115 ++++++++++ .../RedisFactAttribute.cs | 6 + .../RedisMultiplexerFixtures.cs | 4 +- .../RedisTestCollection.cs | 8 +- .../RedisTestContainerFixtures.cs | 6 +- tests/Ephemerally.Tests/LocalAttributes.cs | 34 +++ 29 files changed, 994 insertions(+), 384 deletions(-) delete mode 100644 src/Ephemerally.Redis.Xunit/EphemeralRedisDatabasePoolFixture.cs create mode 100644 src/Ephemerally.Redis.Xunit/EphemeralRedisMultiplexerFixture.cs rename src/Ephemerally.Redis.Xunit/{EphemeralRedisDatabaseFixture.cs => PooledEphemeralRedisDatabaseFixture.cs} (78%) create mode 100644 src/Ephemerally.Redis.Xunit/PooledEphemeralRedisMultiplexerFixture.cs create mode 100644 src/Ephemerally.Redis.Xunit/PublicExtensions.cs create mode 100644 src/Ephemerally.Redis.Xunit/RedisInstance.cs delete mode 100644 src/Ephemerally.Redis.Xunit/RedisTestContainerFixture.cs create mode 100644 src/Ephemerally.Redis/ConnectionMultiplexerDecorator.cs create mode 100644 src/Ephemerally.Redis/InternalExtensions.cs create mode 100644 src/Ephemerally.Redis/PooledConnectionMultiplexer.cs delete mode 100644 tests/Ephemerally.Redis.Tests/Fixtures/EphemeralRedisDatabaseFixtureTests.cs create mode 100644 tests/Ephemerally.Redis.Tests/Fixtures/PooledEphemeralRedisMultiplexerFixtureTests.cs create mode 100644 tests/Ephemerally.Redis.Tests/Multiplexer/EphemeralConnectionMultiplexerTests.cs create mode 100644 tests/Ephemerally.Redis.Tests/Playground.cs create mode 100644 tests/Ephemerally.Redis.Tests/RedisFactAttribute.cs create mode 100644 tests/Ephemerally.Tests/LocalAttributes.cs diff --git a/src/Ephemerally.Redis.Xunit/DefaultLocalRedisInstance.cs b/src/Ephemerally.Redis.Xunit/DefaultLocalRedisInstance.cs index 37dd46f..d71c2d9 100644 --- a/src/Ephemerally.Redis.Xunit/DefaultLocalRedisInstance.cs +++ b/src/Ephemerally.Redis.Xunit/DefaultLocalRedisInstance.cs @@ -8,6 +8,4 @@ public class DefaultLocalRedisInstance public const string ConnectionString = "localhost:6379,allowAdmin=true"; - - public static ushort Port => 6379; } \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/EphemeralRedisDatabasePoolFixture.cs b/src/Ephemerally.Redis.Xunit/EphemeralRedisDatabasePoolFixture.cs deleted file mode 100644 index 467b1fe..0000000 --- a/src/Ephemerally.Redis.Xunit/EphemeralRedisDatabasePoolFixture.cs +++ /dev/null @@ -1,16 +0,0 @@ -using StackExchange.Redis; - -namespace Ephemerally.Redis.Xunit; - -public class EphemeralRedisDatabasePoolFixture : RedisMultiplexerFixture -{ - public EphemeralRedisDatabasePoolFixture() { } - protected EphemeralRedisDatabasePoolFixture(IRedisTestContainerFixture containerFixture) - : base(containerFixture) { } - - protected override async Task CreateMultiplexerAsync() - { - var implementation = await base.CreateMultiplexerAsync(); - return new EphemeralConnectionMultiplexer(implementation); - } -} \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/EphemeralRedisMultiplexerFixture.cs b/src/Ephemerally.Redis.Xunit/EphemeralRedisMultiplexerFixture.cs new file mode 100644 index 0000000..8709952 --- /dev/null +++ b/src/Ephemerally.Redis.Xunit/EphemeralRedisMultiplexerFixture.cs @@ -0,0 +1,23 @@ +using StackExchange.Redis; + +namespace Ephemerally.Redis.Xunit; + +public class EphemeralRedisMultiplexerFixture() + : EphemeralRedisMultiplexerFixture(new TEphemeralRedisInstance()) + where TEphemeralRedisInstance : IRedisInstanceFixture, new(); + +public class EphemeralRedisMultiplexerFixture : RedisMultiplexerFixture +{ + public EphemeralRedisMultiplexerFixture() { } + protected EphemeralRedisMultiplexerFixture(IRedisInstanceFixture redisInstanceFixture) + : base(redisInstanceFixture) { } + + protected override async Task CreateMultiplexerAsync() + { + var implementation = await base.CreateMultiplexerAsync(); + return await CreateEphemeralMultiplexerAsync(implementation); + } + + protected virtual Task CreateEphemeralMultiplexerAsync(IConnectionMultiplexer implementation) => + Task.FromResult(new EphemeralConnectionMultiplexer(implementation)); +} \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/Ephemerally.Redis.Xunit.csproj b/src/Ephemerally.Redis.Xunit/Ephemerally.Redis.Xunit.csproj index a8e3aaa..886ff38 100644 --- a/src/Ephemerally.Redis.Xunit/Ephemerally.Redis.Xunit.csproj +++ b/src/Ephemerally.Redis.Xunit/Ephemerally.Redis.Xunit.csproj @@ -6,7 +6,7 @@ disable latest false - false + true diff --git a/src/Ephemerally.Redis.Xunit/EphemeralRedisDatabaseFixture.cs b/src/Ephemerally.Redis.Xunit/PooledEphemeralRedisDatabaseFixture.cs similarity index 78% rename from src/Ephemerally.Redis.Xunit/EphemeralRedisDatabaseFixture.cs rename to src/Ephemerally.Redis.Xunit/PooledEphemeralRedisDatabaseFixture.cs index 39ec279..c179a02 100644 --- a/src/Ephemerally.Redis.Xunit/EphemeralRedisDatabaseFixture.cs +++ b/src/Ephemerally.Redis.Xunit/PooledEphemeralRedisDatabaseFixture.cs @@ -4,13 +4,13 @@ namespace Ephemerally.Redis.Xunit; -internal class EphemeralRedisDatabaseFixture : EphemeralRedisDatabasePoolFixture +internal class PooledEphemeralRedisDatabaseFixture : PooledEphemeralRedisMultiplexerFixture { private readonly Lazy> _database; public IEphemeralRedisDatabase Database => _database.Value.Result; - public EphemeralRedisDatabaseFixture() + public PooledEphemeralRedisDatabaseFixture() { _database = new(CreateDatabaseAsync); } diff --git a/src/Ephemerally.Redis.Xunit/PooledEphemeralRedisMultiplexerFixture.cs b/src/Ephemerally.Redis.Xunit/PooledEphemeralRedisMultiplexerFixture.cs new file mode 100644 index 0000000..665c80f --- /dev/null +++ b/src/Ephemerally.Redis.Xunit/PooledEphemeralRedisMultiplexerFixture.cs @@ -0,0 +1,23 @@ +using StackExchange.Redis; + +namespace Ephemerally.Redis.Xunit; + +public class PooledEphemeralRedisMultiplexerFixture() + : PooledEphemeralRedisMultiplexerFixture(new TEphemeralRedisInstance()) + where TEphemeralRedisInstance : IRedisInstanceFixture, new(); + +public class PooledEphemeralRedisMultiplexerFixture : EphemeralRedisMultiplexerFixture +{ + public PooledEphemeralRedisMultiplexerFixture() { } + protected PooledEphemeralRedisMultiplexerFixture(IRedisInstanceFixture redisInstanceFixture) + : base(redisInstanceFixture) { } + + protected override async Task CreateMultiplexerAsync() + { + var implementation = await base.CreateMultiplexerAsync(); + return await CreatePooledMultiplexerAsync(implementation); + } + + protected virtual Task CreatePooledMultiplexerAsync(IConnectionMultiplexer implementation) => + Task.FromResult(new PooledConnectionMultiplexer(implementation)); +} \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/PublicExtensions.cs b/src/Ephemerally.Redis.Xunit/PublicExtensions.cs new file mode 100644 index 0000000..2d61f61 --- /dev/null +++ b/src/Ephemerally.Redis.Xunit/PublicExtensions.cs @@ -0,0 +1,13 @@ +using Ephemerally.Redis.Xunit; +using StackExchange.Redis; + +namespace Ephemerally; + +public static class PublicExtensions +{ + public static ConnectionMultiplexer GetMultiplexer(this IRedisInstanceFixture fixture) => + ConnectionMultiplexer.Connect(fixture.ConnectionString); + + public static Task GetMultiplexerAsync(this IRedisInstanceFixture fixture) => + ConnectionMultiplexer.ConnectAsync(fixture.ConnectionString); +} \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/RedisInstance.cs b/src/Ephemerally.Redis.Xunit/RedisInstance.cs new file mode 100644 index 0000000..4657211 --- /dev/null +++ b/src/Ephemerally.Redis.Xunit/RedisInstance.cs @@ -0,0 +1,24 @@ +using Xunit; + +namespace Ephemerally.Redis.Xunit; + +public interface IRedisInstance +{ + string ConnectionString { get; } +} + +public interface IRedisInstanceFixture : IRedisInstance, IAsyncLifetime { } + +public sealed class UnmanagedDefaultLocalRedisInstanceFixture : IRedisInstanceFixture +{ + private static readonly Lazy _instance = new(() => new UnmanagedDefaultLocalRedisInstanceFixture()); + + public static UnmanagedDefaultLocalRedisInstanceFixture DefaultLocalRedisInstanceFixture => _instance.Value; + + private UnmanagedDefaultLocalRedisInstanceFixture() { } + + public string ConnectionString => DefaultLocalRedisInstance.ConnectionString; + public Task InitializeAsync() => Task.CompletedTask; + + public Task DisposeAsync() => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/RedisMultiplexerFixture.cs b/src/Ephemerally.Redis.Xunit/RedisMultiplexerFixture.cs index d90caa8..917d832 100644 --- a/src/Ephemerally.Redis.Xunit/RedisMultiplexerFixture.cs +++ b/src/Ephemerally.Redis.Xunit/RedisMultiplexerFixture.cs @@ -3,42 +3,54 @@ namespace Ephemerally.Redis.Xunit; -public class RedisMultiplexerFixture() - : RedisMultiplexerFixture(new TRedisTestContainerFixture()) - where TRedisTestContainerFixture : IRedisTestContainerFixture, new(); +public class RedisMultiplexerFixture() + : RedisMultiplexerFixture(new TEphemeralRedisInstance()) + where TEphemeralRedisInstance : IRedisInstanceFixture, new(); -public class RedisMultiplexerFixture : IAsyncLifetime +public class RedisMultiplexerFixture : IAsyncLifetime, IAsyncDisposable { - private readonly IRedisTestContainerFixture _containerFixture; + private bool _disposed; + + private readonly IRedisInstanceFixture _redisInstanceFixture; private readonly Lazy> _multiplexer; public IConnectionMultiplexer Multiplexer => _multiplexer.Value.Result; protected Task GetMultiplexer() => _multiplexer.Value; - public RedisMultiplexerFixture() : this(UnmanagedTestContainerFixture.Instance) { } + public RedisMultiplexerFixture() : this(UnmanagedDefaultLocalRedisInstanceFixture.DefaultLocalRedisInstanceFixture) { } - protected RedisMultiplexerFixture(IRedisTestContainerFixture containerFixture) + protected RedisMultiplexerFixture(IRedisInstanceFixture redisInstanceFixture) { - _containerFixture = containerFixture; + _redisInstanceFixture = redisInstanceFixture; _multiplexer = new Lazy>(CreateMultiplexerAsync); } protected virtual async Task CreateMultiplexerAsync() => - await ConnectionMultiplexer.ConnectAsync(_containerFixture.ConnectionString); + await ConnectionMultiplexer.ConnectAsync(_redisInstanceFixture.ConnectionString); public virtual async Task InitializeAsync() { - await _containerFixture.InitializeAsync(); - await _multiplexer.Value; + await _redisInstanceFixture.InitializeAsync(); } public virtual async Task DisposeAsync() { + if (_disposed) return; + _disposed = true; + if (!_multiplexer.IsValueCreated) return; var multiplexer = await GetMultiplexer(); await multiplexer.DisposeAsync(); - await _containerFixture.DisposeAsync(); + await _redisInstanceFixture.DisposeAsync(); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + await DisposeAsync(); } } \ No newline at end of file diff --git a/src/Ephemerally.Redis.Xunit/RedisTestContainerFixture.cs b/src/Ephemerally.Redis.Xunit/RedisTestContainerFixture.cs deleted file mode 100644 index 37512d9..0000000 --- a/src/Ephemerally.Redis.Xunit/RedisTestContainerFixture.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Xunit; - -namespace Ephemerally.Redis.Xunit; - -public interface IRedisTestContainerFixture : IAsyncLifetime -{ - ushort PublicPort { get; } - string ConnectionString { get; } -} - -public sealed class UnmanagedTestContainerFixture : IRedisTestContainerFixture -{ - private static readonly Lazy _instance = new(() => new UnmanagedTestContainerFixture()); - - public static UnmanagedTestContainerFixture Instance => _instance.Value; - - private UnmanagedTestContainerFixture() { } - - public ushort PublicPort => DefaultLocalRedisInstance.Port; - public string ConnectionString => DefaultLocalRedisInstance.ConnectionString; - public Task InitializeAsync() => Task.CompletedTask; - - public Task DisposeAsync() => Task.CompletedTask; -} \ No newline at end of file diff --git a/src/Ephemerally.Redis/ConnectionMultiplexerDecorator.cs b/src/Ephemerally.Redis/ConnectionMultiplexerDecorator.cs new file mode 100644 index 0000000..1110624 --- /dev/null +++ b/src/Ephemerally.Redis/ConnectionMultiplexerDecorator.cs @@ -0,0 +1,171 @@ +using System.Net; +using StackExchange.Redis; +using StackExchange.Redis.Maintenance; +using StackExchange.Redis.Profiling; + +namespace Ephemerally.Redis; + +public abstract class ConnectionMultiplexerDecorator(IConnectionMultiplexer underlyingMultiplexer) + : IConnectionMultiplexer +{ + private bool _disposed; + + public IConnectionMultiplexer UnderlyingMultiplexer { get; } = underlyingMultiplexer; + + public virtual void Dispose() + { + if (_disposed) return; + + _disposed = true; + + UnderlyingMultiplexer.Dispose(); + } + + public virtual ValueTask DisposeAsync() + { + if (_disposed) return new(); + + _disposed = true; + + return UnderlyingMultiplexer.DisposeAsync(); + } + + + #region Decorated Members + + public virtual IDatabase GetDatabase(int db = -1, object asyncState = null) => UnderlyingMultiplexer.GetDatabase(db, asyncState); + + #endregion + + #region Delegated IConnectionMultiplexer Members + + public void RegisterProfiler(Func profilingSessionProvider) => UnderlyingMultiplexer.RegisterProfiler(profilingSessionProvider); + + public ServerCounters GetCounters() => UnderlyingMultiplexer.GetCounters(); + + public EndPoint[] GetEndPoints(bool configuredOnly = false) => UnderlyingMultiplexer.GetEndPoints(configuredOnly); + + public void Wait(Task task) => UnderlyingMultiplexer.Wait(task); + + public T Wait(Task task) => UnderlyingMultiplexer.Wait(task); + + public void WaitAll(params Task[] tasks) => UnderlyingMultiplexer.WaitAll(tasks); + + public int HashSlot(RedisKey key) => UnderlyingMultiplexer.HashSlot(key); + + public ISubscriber GetSubscriber(object asyncState = null) => UnderlyingMultiplexer.GetSubscriber(asyncState); + + public IServer GetServer(string host, int port, object asyncState = null) => UnderlyingMultiplexer.GetServer(host, port, asyncState); + + public IServer GetServer(string hostAndPort, object asyncState = null) => UnderlyingMultiplexer.GetServer(hostAndPort, asyncState); + + public IServer GetServer(IPAddress host, int port) => UnderlyingMultiplexer.GetServer(host, port); + + public IServer GetServer(EndPoint endpoint, object asyncState = null) => UnderlyingMultiplexer.GetServer(endpoint, asyncState); + + public IServer[] GetServers() => UnderlyingMultiplexer.GetServers(); + + public Task ConfigureAsync(TextWriter log = null) => UnderlyingMultiplexer.ConfigureAsync(log); + + public bool Configure(TextWriter log = null) => UnderlyingMultiplexer.Configure(log); + + public string GetStatus() => UnderlyingMultiplexer.GetStatus(); + + public void GetStatus(TextWriter log) => UnderlyingMultiplexer.GetStatus(log); + + public void Close(bool allowCommandsToComplete = true) => UnderlyingMultiplexer.Close(allowCommandsToComplete); + + public Task CloseAsync(bool allowCommandsToComplete = true) => UnderlyingMultiplexer.CloseAsync(allowCommandsToComplete); + + public string GetStormLog() => UnderlyingMultiplexer.GetStormLog(); + + public void ResetStormLog() => UnderlyingMultiplexer.ResetStormLog(); + + public long PublishReconfigure(CommandFlags flags = CommandFlags.None) => UnderlyingMultiplexer.PublishReconfigure(flags); + + public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) => UnderlyingMultiplexer.PublishReconfigureAsync(flags); + + public int GetHashSlot(RedisKey key) => UnderlyingMultiplexer.GetHashSlot(key); + + public void ExportConfiguration(Stream destination, ExportOptions options = ExportOptions.All) => UnderlyingMultiplexer.ExportConfiguration(destination, options); + + public string ClientName => UnderlyingMultiplexer.ClientName; + + public string Configuration => UnderlyingMultiplexer.Configuration; + + public int TimeoutMilliseconds => UnderlyingMultiplexer.TimeoutMilliseconds; + + public long OperationCount => UnderlyingMultiplexer.OperationCount; + + public bool PreserveAsyncOrder + { + get => UnderlyingMultiplexer.PreserveAsyncOrder; + set => UnderlyingMultiplexer.PreserveAsyncOrder = value; + } + + public bool IsConnected => UnderlyingMultiplexer.IsConnected; + + public bool IsConnecting => UnderlyingMultiplexer.IsConnecting; + + public bool IncludeDetailInExceptions + { + get => UnderlyingMultiplexer.IncludeDetailInExceptions; + set => UnderlyingMultiplexer.IncludeDetailInExceptions = value; + } + + public int StormLogThreshold + { + get => UnderlyingMultiplexer.StormLogThreshold; + set => UnderlyingMultiplexer.StormLogThreshold = value; + } + + public event EventHandler ErrorMessage + { + add => UnderlyingMultiplexer.ErrorMessage += value; + remove => UnderlyingMultiplexer.ErrorMessage -= value; + } + + public event EventHandler ConnectionFailed + { + add => UnderlyingMultiplexer.ConnectionFailed += value; + remove => UnderlyingMultiplexer.ConnectionFailed -= value; + } + + public event EventHandler InternalError + { + add => UnderlyingMultiplexer.InternalError += value; + remove => UnderlyingMultiplexer.InternalError -= value; + } + + public event EventHandler ConnectionRestored + { + add => UnderlyingMultiplexer.ConnectionRestored += value; + remove => UnderlyingMultiplexer.ConnectionRestored -= value; + } + + public event EventHandler ConfigurationChanged + { + add => UnderlyingMultiplexer.ConfigurationChanged += value; + remove => UnderlyingMultiplexer.ConfigurationChanged -= value; + } + + public event EventHandler ConfigurationChangedBroadcast + { + add => UnderlyingMultiplexer.ConfigurationChangedBroadcast += value; + remove => UnderlyingMultiplexer.ConfigurationChangedBroadcast -= value; + } + + public event EventHandler ServerMaintenanceEvent + { + add => UnderlyingMultiplexer.ServerMaintenanceEvent += value; + remove => UnderlyingMultiplexer.ServerMaintenanceEvent -= value; + } + + public event EventHandler HashSlotMoved + { + add => UnderlyingMultiplexer.HashSlotMoved += value; + remove => UnderlyingMultiplexer.HashSlotMoved -= value; + } + + #endregion +} \ No newline at end of file diff --git a/src/Ephemerally.Redis/EphemeralConnectionMultiplexer.cs b/src/Ephemerally.Redis/EphemeralConnectionMultiplexer.cs index f0c6075..8d6092c 100644 --- a/src/Ephemerally.Redis/EphemeralConnectionMultiplexer.cs +++ b/src/Ephemerally.Redis/EphemeralConnectionMultiplexer.cs @@ -1,207 +1,63 @@ -using System.Net; -using StackExchange.Redis; -using StackExchange.Redis.Maintenance; -using StackExchange.Redis.Profiling; +using StackExchange.Redis; namespace Ephemerally.Redis; -public class EphemeralConnectionMultiplexer : IConnectionMultiplexer +public class EphemeralConnectionMultiplexer(IConnectionMultiplexer underlyingMultiplexer) + : ConnectionMultiplexerDecorator(underlyingMultiplexer) { private bool _disposed; + private readonly HashSet _databases = new(); - private readonly IConnectionMultiplexer _multiplexer; - - private readonly FixedSizeObjectPool _databases; - - public EphemeralConnectionMultiplexer(IConnectionMultiplexer multiplexer) - : this(multiplexer, Enumerable.Range(0, 16).ToArray()) { } - - public EphemeralConnectionMultiplexer( - IConnectionMultiplexer multiplexer, - int[] databases) + public override IDatabase GetDatabase(int db = -1, object asyncState = null) { - _multiplexer = multiplexer; - _databases = new( - databases - .Select(db => (IDatabase)multiplexer.GetDatabase(db).ToEphemeral()) - .ToList()); + var ephemeralDb = UnderlyingMultiplexer.GetDatabase(db, asyncState).AsEphemeral(); + _databases.Add(db); + return ephemeralDb; } - public void Dispose() + public override void Dispose() { + if (_disposed) return; + _disposed = true; + try { - if (_disposed) return; - - foreach (var db in _databases.Objects) + foreach (var db in _databases) { - db.TryDispose(); + UnderlyingMultiplexer + .GetRootMultiplexer() + .GetDatabase(db) + .AsEphemeral() + .TryDispose(); } } finally { - _disposed = true; - - _multiplexer.Dispose(); + base.Dispose(); } } - public async ValueTask DisposeAsync() + public override async ValueTask DisposeAsync() { + if (_disposed) return; + _disposed = true; + try { - if (_disposed) return; - await Task.WhenAll( _databases - .Objects - .Select(db => db + .Select(db => UnderlyingMultiplexer + .GetRootMultiplexer() + .GetDatabase(db) + .AsEphemeral() .TryDisposeAsync() - .AsTask())); + .AsTask() + ) + ); } finally { - _disposed = true; - - await _multiplexer.DisposeAsync(); + await base.DisposeAsync(); } } - - #region Decorated Members - - public IDatabase GetDatabase(int db = -1, object asyncState = null) => new PooledRedisDatabase(_databases, _databases.Get()); - - #endregion - - #region Delegated IConnectionMultiplexer Members - - public void RegisterProfiler(Func profilingSessionProvider) => _multiplexer.RegisterProfiler(profilingSessionProvider); - - public ServerCounters GetCounters() => _multiplexer.GetCounters(); - - public EndPoint[] GetEndPoints(bool configuredOnly = false) => _multiplexer.GetEndPoints(configuredOnly); - - public void Wait(Task task) => _multiplexer.Wait(task); - - public T Wait(Task task) => _multiplexer.Wait(task); - - public void WaitAll(params Task[] tasks) => _multiplexer.WaitAll(tasks); - - public int HashSlot(RedisKey key) => _multiplexer.HashSlot(key); - - public ISubscriber GetSubscriber(object asyncState = null) => _multiplexer.GetSubscriber(asyncState); - - public IServer GetServer(string host, int port, object asyncState = null) => _multiplexer.GetServer(host, port, asyncState); - - public IServer GetServer(string hostAndPort, object asyncState = null) => _multiplexer.GetServer(hostAndPort, asyncState); - - public IServer GetServer(IPAddress host, int port) => _multiplexer.GetServer(host, port); - - public IServer GetServer(EndPoint endpoint, object asyncState = null) => _multiplexer.GetServer(endpoint, asyncState); - - public IServer[] GetServers() => _multiplexer.GetServers(); - - public Task ConfigureAsync(TextWriter log = null) => _multiplexer.ConfigureAsync(log); - - public bool Configure(TextWriter log = null) => _multiplexer.Configure(log); - - public string GetStatus() => _multiplexer.GetStatus(); - - public void GetStatus(TextWriter log) => _multiplexer.GetStatus(log); - - public void Close(bool allowCommandsToComplete = true) => _multiplexer.Close(allowCommandsToComplete); - - public Task CloseAsync(bool allowCommandsToComplete = true) => _multiplexer.CloseAsync(allowCommandsToComplete); - - public string GetStormLog() => _multiplexer.GetStormLog(); - - public void ResetStormLog() => _multiplexer.ResetStormLog(); - - public long PublishReconfigure(CommandFlags flags = CommandFlags.None) => _multiplexer.PublishReconfigure(flags); - - public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) => _multiplexer.PublishReconfigureAsync(flags); - - public int GetHashSlot(RedisKey key) => _multiplexer.GetHashSlot(key); - - public void ExportConfiguration(Stream destination, ExportOptions options = ExportOptions.All) => _multiplexer.ExportConfiguration(destination, options); - - public string ClientName => _multiplexer.ClientName; - - public string Configuration => _multiplexer.Configuration; - - public int TimeoutMilliseconds => _multiplexer.TimeoutMilliseconds; - - public long OperationCount => _multiplexer.OperationCount; - - public bool PreserveAsyncOrder - { - get => _multiplexer.PreserveAsyncOrder; - set => _multiplexer.PreserveAsyncOrder = value; - } - - public bool IsConnected => _multiplexer.IsConnected; - - public bool IsConnecting => _multiplexer.IsConnecting; - - public bool IncludeDetailInExceptions - { - get => _multiplexer.IncludeDetailInExceptions; - set => _multiplexer.IncludeDetailInExceptions = value; - } - - public int StormLogThreshold - { - get => _multiplexer.StormLogThreshold; - set => _multiplexer.StormLogThreshold = value; - } - - public event EventHandler ErrorMessage - { - add => _multiplexer.ErrorMessage += value; - remove => _multiplexer.ErrorMessage -= value; - } - - public event EventHandler ConnectionFailed - { - add => _multiplexer.ConnectionFailed += value; - remove => _multiplexer.ConnectionFailed -= value; - } - - public event EventHandler InternalError - { - add => _multiplexer.InternalError += value; - remove => _multiplexer.InternalError -= value; - } - - public event EventHandler ConnectionRestored - { - add => _multiplexer.ConnectionRestored += value; - remove => _multiplexer.ConnectionRestored -= value; - } - - public event EventHandler ConfigurationChanged - { - add => _multiplexer.ConfigurationChanged += value; - remove => _multiplexer.ConfigurationChanged -= value; - } - - public event EventHandler ConfigurationChangedBroadcast - { - add => _multiplexer.ConfigurationChangedBroadcast += value; - remove => _multiplexer.ConfigurationChangedBroadcast -= value; - } - - public event EventHandler ServerMaintenanceEvent - { - add => _multiplexer.ServerMaintenanceEvent += value; - remove => _multiplexer.ServerMaintenanceEvent -= value; - } - - public event EventHandler HashSlotMoved - { - add => _multiplexer.HashSlotMoved += value; - remove => _multiplexer.HashSlotMoved -= value; - } - - #endregion } \ No newline at end of file diff --git a/src/Ephemerally.Redis/FixedSizeObjectPool.cs b/src/Ephemerally.Redis/FixedSizeObjectPool.cs index d4085d0..3267ab6 100644 --- a/src/Ephemerally.Redis/FixedSizeObjectPool.cs +++ b/src/Ephemerally.Redis/FixedSizeObjectPool.cs @@ -1,4 +1,5 @@ -using System.Collections.Concurrent; +using System.Collections; +using System.Collections.Concurrent; using System.Collections.Frozen; namespace Ephemerally.Redis; @@ -6,27 +7,44 @@ namespace Ephemerally.Redis; public class FixedSizeObjectPool { private readonly FrozenDictionary _objects; - private readonly BlockingCollection _availableObjects; public IEnumerable Objects => _objects.Keys; public FixedSizeObjectPool(ICollection objects) { - _objects = objects.ToFrozenDictionary(x => x, _ => new SemaphoreSlim(1, 1)); - _availableObjects = new BlockingCollection(objects.Count); - foreach (var obj in objects) - { - _availableObjects.Add(obj); - } + _objects = objects + .GroupBy(x => x) + .Select(x => (x.Key, Count: x.Count())) + .ToFrozenDictionary(x => x.Key, x => new SemaphoreSlim(x.Count, x.Count)); } - public T Get() + public T Get() => GetWhere(x => true); + + public T GetWhere(Func predicate) + { + var candidates = _objects + .Where(kv => predicate(kv.Key)) + .Select(kv => (Obj: kv.Key, WaitHandle: kv.Value.AvailableWaitHandle)) + .ToList(); + + if (candidates.Count == 0) + throw new ObjectNotFromPoolException(); + + var candidateIndex = WaitHandle.WaitAny( + candidates + .Select(x => x.WaitHandle) + .ToArray()); + + return GetInternal(candidates[candidateIndex].Obj); + } + + private T GetInternal(T obj) { - var obj = _availableObjects.Take(); if (!_objects.TryGetValue(obj, out var semaphore)) throw new ObjectNotFromPoolException(); semaphore.Wait(); + return obj; } @@ -43,10 +61,6 @@ public void Return(T obj) { throw new ObjectAlreadyReturnedException(); } - - - if (!_availableObjects.TryAdd(obj)) - throw new ObjectAlreadyReturnedException(); } } diff --git a/src/Ephemerally.Redis/InternalExtensions.cs b/src/Ephemerally.Redis/InternalExtensions.cs new file mode 100644 index 0000000..3a916d3 --- /dev/null +++ b/src/Ephemerally.Redis/InternalExtensions.cs @@ -0,0 +1,16 @@ +using StackExchange.Redis; + +namespace Ephemerally.Redis; + +internal static class InternalExtensions +{ + public static IConnectionMultiplexer GetRootMultiplexer(this IConnectionMultiplexer multiplexer) + { + while (true) + { + if (multiplexer is not ConnectionMultiplexerDecorator decorator) return multiplexer; + + multiplexer = decorator.UnderlyingMultiplexer; + } + } +} \ No newline at end of file diff --git a/src/Ephemerally.Redis/PooledConnectionMultiplexer.cs b/src/Ephemerally.Redis/PooledConnectionMultiplexer.cs new file mode 100644 index 0000000..cc39e02 --- /dev/null +++ b/src/Ephemerally.Redis/PooledConnectionMultiplexer.cs @@ -0,0 +1,75 @@ +using StackExchange.Redis; + +namespace Ephemerally.Redis; + +public class PooledConnectionMultiplexer : ConnectionMultiplexerDecorator +{ + private bool _disposed; + + private readonly FixedSizeObjectPool _databases; + + public PooledConnectionMultiplexer(IConnectionMultiplexer underlyingMultiplexer) + : this(underlyingMultiplexer, Enumerable.Range(0, 16).ToArray()) { } + + public PooledConnectionMultiplexer( + IConnectionMultiplexer underlyingMultiplexer, + int[] databases) : base(underlyingMultiplexer) + { + _databases = new( + databases + .Select(db => underlyingMultiplexer.GetDatabase(db)) + .ToList()); + } + + public override void Dispose() + { + try + { + if (_disposed) return; + + _disposed = true; + + foreach (var db in _databases.Objects) + { + db.TryDispose(); + } + } + finally + { + base.Dispose(); + } + } + + public override async ValueTask DisposeAsync() + { + try + { + if (_disposed) return; + + _disposed = true; + + await Task.WhenAll( + _databases + .Objects + .Select(db => db + .TryDisposeAsync() + .AsTask())); + } + finally + { + await base.DisposeAsync(); + } + } + + #region Decorated Members + + public override IDatabase GetDatabase(int db = -1, object asyncState = null) => + new PooledRedisDatabase( + _databases, + db == -1 + ? _databases.Get() + : _databases.GetWhere(x => x.Database == db) + ); + + #endregion +} \ No newline at end of file diff --git a/src/Ephemerally.Redis/PublicExtensions.cs b/src/Ephemerally.Redis/PublicExtensions.cs index 324a9b2..2c2c596 100644 --- a/src/Ephemerally.Redis/PublicExtensions.cs +++ b/src/Ephemerally.Redis/PublicExtensions.cs @@ -19,19 +19,24 @@ public static EphemeralRedisDatabase GetEphemeralDatabase( int db = -1) => multiplexer.GetDatabase(db).AsEphemeral(); + #region EphemeralConnectionMultiplexer + public static EphemeralConnectionMultiplexer AsEphemeralMultiplexer(this IConnectionMultiplexer multiplexer) => - multiplexer is EphemeralConnectionMultiplexer connectionMultiplexer - ? connectionMultiplexer - : multiplexer.ToEphemeralMultiplexer(); + multiplexer as EphemeralConnectionMultiplexer ?? multiplexer.ToEphemeralMultiplexer(); public static EphemeralConnectionMultiplexer ToEphemeralMultiplexer(this IConnectionMultiplexer multiplexer) => new(multiplexer); - public static async Task AsEphemeralMultiplexer(this Task creatingMultiplexer) - where T : IConnectionMultiplexer => - (await creatingMultiplexer.ConfigureAwait(false)).AsEphemeralMultiplexer(); + #endregion + + #region PooledConnectionMultiplexer + + public static PooledConnectionMultiplexer AsPooledMultiplexer(this IConnectionMultiplexer multiplexer) => + multiplexer as PooledConnectionMultiplexer ?? multiplexer.ToPooledMultiplexer(); + + public static PooledConnectionMultiplexer ToPooledMultiplexer(this IConnectionMultiplexer multiplexer) => + new(multiplexer); + - public static async Task ToEphemeralMultiplexer(this Task creatingMultiplexer) - where T : IConnectionMultiplexer => - (await creatingMultiplexer.ConfigureAwait(false)).ToEphemeralMultiplexer(); + #endregion } \ No newline at end of file diff --git a/src/Ephemerally/InternalExtensions.cs b/src/Ephemerally/InternalExtensions.cs index 225696b..d6c0b98 100644 --- a/src/Ephemerally/InternalExtensions.cs +++ b/src/Ephemerally/InternalExtensions.cs @@ -1,23 +1,25 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Ephemerally; +namespace Ephemerally; internal static class InternalExtensions { internal static T OrDefault(this T options) where T : EphemeralOptions, new() => options ?? new T(); - public static ValueTask TryDisposeAsync(this T self) where T : class => - self is IAsyncDisposable disposable - ? disposable.DisposeAsync() - : new(); + public static async ValueTask TryDisposeAsync(this T self) where T : class + { + if (self is not IAsyncDisposable disposable) + return false; - public static void TryDispose(this T self) where T : class + await disposable.DisposeAsync().ConfigureAwait(false); + return true; + } + + public static bool TryDispose(this T self) where T : class { - (self as IDisposable)?.Dispose(); + if (self is not IDisposable disposable) + return false; + + disposable.Dispose(); + return true; } } \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/Database/EphemeralDatabaseTests.cs b/tests/Ephemerally.Redis.Tests/Database/EphemeralDatabaseTests.cs index d679f48..caa50d4 100644 --- a/tests/Ephemerally.Redis.Tests/Database/EphemeralDatabaseTests.cs +++ b/tests/Ephemerally.Redis.Tests/Database/EphemeralDatabaseTests.cs @@ -19,7 +19,7 @@ public abstract class EphemeralDatabaseTests(RedisMultiplexerFixture fixture) { private readonly IConnectionMultiplexer _multiplexer = fixture.Multiplexer; - [Fact] + [RedisFact] public async Task Should_return_a_database_and_flush_it() { // Arrange @@ -37,7 +37,7 @@ public async Task Should_return_a_database_and_flush_it() actual.HasValue.ShouldBeFalse(); } - [Fact] + [RedisFact] public async Task User_supplied_database_should_be_flushed() { // Arrange @@ -55,7 +55,7 @@ public async Task User_supplied_database_should_be_flushed() actual.HasValue.ShouldBeFalse(); } - [Fact] + [RedisFact] public async Task Should_not_flush_separate_database() { // Arrange diff --git a/tests/Ephemerally.Redis.Tests/Ephemerally.Redis.Tests.csproj b/tests/Ephemerally.Redis.Tests/Ephemerally.Redis.Tests.csproj index 7f87228..7511918 100644 --- a/tests/Ephemerally.Redis.Tests/Ephemerally.Redis.Tests.csproj +++ b/tests/Ephemerally.Redis.Tests/Ephemerally.Redis.Tests.csproj @@ -22,14 +22,11 @@ + - - - - diff --git a/tests/Ephemerally.Redis.Tests/FixedSizeObjectPoolTests.cs b/tests/Ephemerally.Redis.Tests/FixedSizeObjectPoolTests.cs index 92ab0a4..e729b02 100644 --- a/tests/Ephemerally.Redis.Tests/FixedSizeObjectPoolTests.cs +++ b/tests/Ephemerally.Redis.Tests/FixedSizeObjectPoolTests.cs @@ -1,9 +1,12 @@ -using Shouldly; +using System.Diagnostics.CodeAnalysis; +using Shouldly; namespace Ephemerally.Redis.Tests; public class FixedSizeObjectPoolTests { + private const int TestTimeout = 2; + [Fact] public void Get_returns_object_from_pool() { @@ -18,7 +21,7 @@ public void Get_returns_object_from_pool() items.ShouldContain(obj); } - [Fact(Timeout = 2)] + [Fact(Timeout = TestTimeout)] public void Return_throws_if_object_not_from_pool() { // Arrange @@ -28,7 +31,7 @@ public void Return_throws_if_object_not_from_pool() Should.Throw(() => pool.Return(4)); } - [Fact(Timeout = 2)] + [Fact(Timeout = TestTimeout)] public void Return_throws_if_object_already_returned() { // Arrange @@ -40,17 +43,123 @@ public void Return_throws_if_object_already_returned() Should.Throw(() => pool.Return(obj)); } - [Fact(Timeout = 2)] + [Fact(Timeout = TestTimeout)] public void Get_can_be_called_once_for_each_item_in_pool() { // Arrange var pool = new FixedSizeObjectPool([1, 2, 3]); // Act + var one = pool.Get(); + var two = pool.Get(); + var three = pool.Get(); + + // Assert + one.ShouldBe(1); + two.ShouldBe(2); + three.ShouldBe(3); + } + + [Fact(Timeout = TestTimeout)] + public void Get_can_be_called_once_for_each_duplicate_instance() + { + // Arrange + var pool = new FixedSizeObjectPool([1, 1, 1]); + + // Act + var one = pool.Get(); + var two = pool.Get(); + var three = pool.Get(); + + // Assert + one.ShouldBe(1); + two.ShouldBe(1); + three.ShouldBe(1); + } + + [Fact(Timeout = TestTimeout)] + public void Return_can_be_called_once_for_each_duplicate_instance() + { + // Arrange + var pool = new FixedSizeObjectPool([1, 1, 1]); + pool.Get(); pool.Get(); pool.Get(); + + // Act & Assert + Should.NotThrow(() => + { + pool.Return(1); + pool.Return(1); + pool.Return(1); + }); + } + + [Fact(Timeout = TestTimeout)] + public void GetWhere_should_return_first_matching_object() + { + // Arrange + var pool = new FixedSizeObjectPool([1, 2, 3]); + + // Act + var obj = pool.GetWhere(i => i == 2); + + // Assert + obj.ShouldBe(2); + } + + [Fact(Timeout = TestTimeout)] + public void GetWhere_should_throw_when_no_matching_objects_are_in_pool() + { + // Arrange + var pool = new FixedSizeObjectPool([1, 2, 3]); + + // Act & Assert + Should.Throw(() => pool.GetWhere(i => i == 4)); + } + + [Fact(Timeout = TestTimeout)] + public void GetWhere_should_return_second_matching_object() + { + // Arrange + var pool = new FixedSizeObjectPool([1, 2, 3, 4]); + var two = pool.GetWhere(i => i == 2); + + // Act + var obj = pool.GetWhere(i => i % 2 == 0); + + // Assert + obj.ShouldBe(4); + } + + [Fact(Timeout = TestTimeout * 10), SuppressMessage("ReSharper", "MethodSupportsCancellation")] + public async Task GetWhere_should_wait_for_first_available_matching_object() + { + // Arrange + var pool = new FixedSizeObjectPool([1, 2, 3, 4]); pool.Get(); + pool.GetWhere(i => i == 2); + using var cts = new CancellationTokenSource(); + + var task1 = Task.Run(async () => + { + try + { + await Task.Delay(Timeout.Infinite, cts.Token); + } + catch (TaskCanceledException) { /* Expected */ } + pool.Return(2); + }); + pool.GetWhere(i => i == 4); - // If test does not time out, it passes + // Act + var task2 = Task.Run(() => pool.GetWhere(i => i == 2)); + await Task.Delay(1); + await cts.CancelAsync(); + + // Assert + var actual = await task2; + actual.ShouldBe(2); + await task1; } } \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/Fixtures/EphemeralRedisDatabaseFixtureTests.cs b/tests/Ephemerally.Redis.Tests/Fixtures/EphemeralRedisDatabaseFixtureTests.cs deleted file mode 100644 index 1ff3a39..0000000 --- a/tests/Ephemerally.Redis.Tests/Fixtures/EphemeralRedisDatabaseFixtureTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Ephemerally.Redis.Xunit; -using Shouldly; -using StackExchange.Redis; - -namespace Ephemerally.Redis.Tests.Fixtures; - -// ReSharper disable once InconsistentNaming -public class EphemeralRedisDatabaseFixtureTests_6( - BigEphemeralRedisDatabasePoolFixture bigFixture, - SmallEphemeralRedisDatabasePoolFixture smallFixture) - : EphemeralRedisDatabaseFixtureTests(bigFixture, smallFixture), - IClassFixture>, - IClassFixture>; - -// ReSharper disable once InconsistentNaming -public class EphemeralRedisDatabaseFixtureTests_7( - BigEphemeralRedisDatabasePoolFixture bigFixture, - SmallEphemeralRedisDatabasePoolFixture smallFixture) - : EphemeralRedisDatabaseFixtureTests(bigFixture, smallFixture), - IClassFixture>, - IClassFixture>; - -[Collection(RedisTestCollection.Name)] -public abstract class EphemeralRedisDatabaseFixtureTests( - EphemeralRedisDatabasePoolFixture bigFixture, - EphemeralRedisDatabasePoolFixture smallFixture) -{ - private readonly EphemeralRedisDatabasePoolFixture - _bigFixture = bigFixture, - _smallFixture = smallFixture; - - [Fact] - public async Task Create_database_gets_a_new_database_every_time() - { - // Arrange - // Act - await using var db1 = _bigFixture.Multiplexer.GetEphemeralDatabase(); - await using var db2 = _bigFixture.Multiplexer.GetEphemeralDatabase(); - await using var db3 = _bigFixture.Multiplexer.GetEphemeralDatabase(); - await using var db4 = _bigFixture.Multiplexer.GetEphemeralDatabase(); - - // Assert - new[] - { - db1.Database, - db2.Database, - db3.Database, - db4.Database - } - .Distinct() - .Count() - .ShouldBe(4); - } - - [Fact] - public async Task Create_database_should_return_previously_used_database_after_disposal() - { - // Arrange - var db0 = _smallFixture.Multiplexer.GetEphemeralDatabase(); - var db0Id = db0.Database; - await using var db1 = _smallFixture.Multiplexer.GetEphemeralDatabase(); - - // Act - await db0.DisposeAsync(); - await using var db2 = _smallFixture.Multiplexer.GetEphemeralDatabase(); - - // Assert - db2.Database.ShouldBe(db0Id); - } -} - -public class BigEphemeralRedisDatabasePoolFixture() - : EphemeralRedisDatabasePoolFixture(new TRedisTestContainerFixture()) - where TRedisTestContainerFixture : IRedisTestContainerFixture, new() -{ - protected override async Task CreateMultiplexerAsync() - { - var multiplexer = await base.CreateMultiplexerAsync(); - return new EphemeralConnectionMultiplexer(multiplexer, [0, 1, 2, 3]); - } -} - -public class SmallEphemeralRedisDatabasePoolFixture() - : EphemeralRedisDatabasePoolFixture(new TRedisTestContainerFixture()) - where TRedisTestContainerFixture : IRedisTestContainerFixture, new() -{ - protected override async Task CreateMultiplexerAsync() - { - var multiplexer = await base.CreateMultiplexerAsync(); - return new EphemeralConnectionMultiplexer(multiplexer, [0, 1]); - } -} \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/Fixtures/PooledEphemeralRedisMultiplexerFixtureTests.cs b/tests/Ephemerally.Redis.Tests/Fixtures/PooledEphemeralRedisMultiplexerFixtureTests.cs new file mode 100644 index 0000000..fe2cacd --- /dev/null +++ b/tests/Ephemerally.Redis.Tests/Fixtures/PooledEphemeralRedisMultiplexerFixtureTests.cs @@ -0,0 +1,198 @@ +using Ephemerally.Redis.Xunit; +using Shouldly; +using StackExchange.Redis; + +namespace Ephemerally.Redis.Tests.Fixtures; + +// ReSharper disable once InconsistentNaming +public class PooledEphemeralRedisMultiplexerFixtureTests6 : PooledEphemeralRedisMultiplexerFixtureTests; + +// ReSharper disable once InconsistentNaming +public class PooledEphemeralRedisMultiplexerFixtureTests7 : PooledEphemeralRedisMultiplexerFixtureTests; + +[Collection(RedisTestCollection.Name)] +public abstract class PooledEphemeralRedisMultiplexerFixtureTests : IAsyncLifetime + where TRedisFixture : class, IRedisInstanceFixture, new() +{ + private readonly PooledEphemeralRedisMultiplexerFixture + _bigFixture = new BigPooledEphemeralRedisMultiplexerFixture(), + _smallFixture = new SmallPooledEphemeralRedisMultiplexerFixture(); + + [RedisFact] + public async Task Create_database_gets_a_new_database_every_time() + { + // Arrange + // Act + await using var db1 = _bigFixture.Multiplexer.GetEphemeralDatabase(); + await using var db2 = _bigFixture.Multiplexer.GetEphemeralDatabase(); + await using var db3 = _bigFixture.Multiplexer.GetEphemeralDatabase(); + await using var db4 = _bigFixture.Multiplexer.GetEphemeralDatabase(); + + // Assert + new[] + { + db1.Database, + db2.Database, + db3.Database, + db4.Database + } + .Distinct() + .Count() + .ShouldBe(4); + } + + [RedisFact] + public async Task Create_database_should_return_previously_used_database_after_disposal() + { + // Arrange + var db0 = _smallFixture.Multiplexer.GetEphemeralDatabase(); + var db0Id = db0.Database; + await using var db1 = _smallFixture.Multiplexer.GetEphemeralDatabase(); + + // Act + await db0.DisposeAsync(); + await using var db2 = _smallFixture.Multiplexer.GetEphemeralDatabase(); + + // Assert + db2.Database.ShouldBe(db0Id); + } + + [RedisFact] + public async Task Dispose_should_flush_all_included_databases() + { + // Arrange + int[] dbs = [0, 1, 2]; + const string key = nameof(Dispose_should_flush_all_included_databases); + var values = dbs.Select(_ => Guid.NewGuid().ToString()).ToArray(); + + await using var redis = new TRedisFixture().ToDisposable(); + await using var sut = new InTestRedisFixture(redis, dbs); + await sut.InitializeAsync(); + for (var i = 0; i < dbs.Length; i++) + { + var db = sut.Multiplexer.GetEphemeralDatabase(dbs[i]); + await db.StringSetAsync(key, values[i]); + } + + // Act + await sut.DisposeAsync(); + + // Assert + await using var multiplexer = await ConnectionMultiplexer.ConnectAsync(redis.Value.ConnectionString); + foreach (var db in dbs) + { + var actual = await multiplexer.GetDatabase(db).StringGetAsync(key); + actual.HasValue.ShouldBeFalse(); + } + } + + [RedisFact] + public async Task Dispose_should_not_flush_excluded_databases() + { + // Arrange + int[] dbs = [0, 1, 2]; + int[] includedDbs = [1]; + const string key = nameof(Dispose_should_not_flush_excluded_databases); + var values = dbs.Select(_ => Guid.NewGuid().ToString()).ToArray(); + + await using var redis = new TRedisFixture().ToDisposable(); + await using var sut = new InTestRedisFixture(redis, includedDbs); + await sut.InitializeAsync(); + + await using var multiplexer1 = await ConnectionMultiplexer.ConnectAsync(redis.Value.ConnectionString); + + for (var i = 0; i < dbs.Length; i++) + { + var db = multiplexer1.GetDatabase(dbs[i]); + await db.StringSetAsync(key, values[i]); + } + + await multiplexer1.DisposeAsync(); + + foreach (var db in includedDbs) + { + sut.Multiplexer.GetDatabase(db).StringGet(key).HasValue.ShouldBeTrue(); + } + + // Act + await sut.DisposeAsync(); + + // Assert + await using var multiplexer2 = await ConnectionMultiplexer.ConnectAsync(redis.Value.ConnectionString); + foreach (var db in dbs) + { + var actual = await multiplexer2.GetDatabase(db).StringGetAsync(key); + if (includedDbs.Contains(db)) + { + actual.HasValue.ShouldBeFalse(); + } + else + { + actual.HasValue.ShouldBeTrue(); + } + } + } + + private class InTestRedisFixture(TRedisFixture redis, int[] databases) : PooledEphemeralRedisMultiplexerFixture(redis) + { + protected override Task CreatePooledMultiplexerAsync(IConnectionMultiplexer implementation) + { + return Task.FromResult(new PooledConnectionMultiplexer(implementation, databases)); + } + } + + #region IAsyncLifetime Members + + public async Task InitializeAsync() + { + await _bigFixture.InitializeAsync(); + await _smallFixture.InitializeAsync(); + } + + public async Task DisposeAsync() + { + await _bigFixture.DisposeAsync(); + await _smallFixture.DisposeAsync(); + } + + #endregion +} + +public class BigPooledEphemeralRedisMultiplexerFixture() + : PooledEphemeralRedisMultiplexerFixture(new TRedisFixture()) + where TRedisFixture : IRedisInstanceFixture, new() +{ + protected override Task CreatePooledMultiplexerAsync(IConnectionMultiplexer implementation) + { + return Task.FromResult(new PooledConnectionMultiplexer(implementation, [0, 1, 2, 3])); + } +} + +public class SmallPooledEphemeralRedisMultiplexerFixture() + : PooledEphemeralRedisMultiplexerFixture(new TRedisFixture()) + where TRedisFixture : IRedisInstanceFixture, new() +{ + protected override Task CreatePooledMultiplexerAsync(IConnectionMultiplexer implementation) + { + return Task.FromResult(new PooledConnectionMultiplexer(implementation, [0, 1])); + } +} + +file static class AsyncDisposableExtensions +{ + public static AsyncDisposable ToDisposable(this T asyncLifetime) + where T : class, IAsyncLifetime => new(asyncLifetime); +} + +internal class AsyncDisposable(T value) : IAsyncDisposable + where T : class, IAsyncLifetime +{ + public T Value { get; } = value; + public async ValueTask DisposeAsync() + { + if (!await Value.TryDisposeAsync()) + await Value.DisposeAsync(); + } + + public static implicit operator T(AsyncDisposable asyncDisposable) => asyncDisposable.Value; +} \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/Multiplexer/EphemeralConnectionMultiplexerTests.cs b/tests/Ephemerally.Redis.Tests/Multiplexer/EphemeralConnectionMultiplexerTests.cs new file mode 100644 index 0000000..6aaa9be --- /dev/null +++ b/tests/Ephemerally.Redis.Tests/Multiplexer/EphemeralConnectionMultiplexerTests.cs @@ -0,0 +1,57 @@ +using Ephemerally.Redis.Xunit; +using Shouldly; +using StackExchange.Redis; + +namespace Ephemerally.Redis.Tests.Multiplexer; + +public class EphemeralConnectionMultiplexerTests6(RedisInstanceFixture6 fixture) : EphemeralConnectionMultiplexerTests(fixture); + +public class EphemeralConnectionMultiplexerTests7(RedisInstanceFixture7 fixture) : EphemeralConnectionMultiplexerTests(fixture); + +public abstract class EphemeralConnectionMultiplexerTests(TRedisInstanceFixture fixture) + : IClassFixture + where TRedisInstanceFixture : class, IRedisInstanceFixture, new() +{ + private readonly TRedisInstanceFixture _fixture = fixture; + + [Fact] + public async Task Dispose_should_flush_any_databases_retrieved_by_GetDatabase() + { + // Arrange + int[] dbs = [0, 1, 2]; + int[] includedDbs = [1]; + const string key = nameof(Dispose_should_flush_any_databases_retrieved_by_GetDatabase); + var value = Guid.NewGuid().ToString(); + + await using var rootMultiplexer = await _fixture.GetMultiplexerAsync(); + var multiplexer = rootMultiplexer.AsEphemeralMultiplexer(); + + foreach (var db in dbs) + { + IConnectionMultiplexer m = includedDbs.Contains(db) + ? multiplexer + : rootMultiplexer; + + var database = m.GetDatabase(db); + await database.StringSetAsync(key, value); + } + + // Act + await multiplexer.DisposeAsync(); + + // Assert + await using var newMultiplexer = await _fixture.GetMultiplexerAsync(); + foreach (var db in dbs) + { + var actual = await newMultiplexer.GetDatabase(db).StringGetAsync(key); + if (includedDbs.Contains(db)) + { + actual.HasValue.ShouldBeFalse(); + } + else + { + actual.HasValue.ShouldBeTrue(); + } + } + } +} \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/Playground.cs b/tests/Ephemerally.Redis.Tests/Playground.cs new file mode 100644 index 0000000..af67252 --- /dev/null +++ b/tests/Ephemerally.Redis.Tests/Playground.cs @@ -0,0 +1,115 @@ +using Ephemerally.Redis.Xunit; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Ephemerally.Tests; + +namespace Ephemerally.Redis.Tests; + +/// +/// This example uses a default, pre-existing Redis instance on localhost with default port 6379. +/// +public class ExamplesUsingDefaultUnmanagedRedisInstance( + RedisMultiplexerFixture basicFixture, + EphemeralRedisMultiplexerFixture ephemeralFixture, + PooledEphemeralRedisMultiplexerFixture pooledEphemeralFixture) : + IClassFixture, + IClassFixture, + IClassFixture +{ + private readonly RedisMultiplexerFixture + _basicFixture = basicFixture, + _ephemeralFixture = ephemeralFixture, + _pooledEphemeralFixture = pooledEphemeralFixture; + + [LocalFact(Skip = "Example only")] + public async Task BasicExample() + { + await using var multiplexer = _basicFixture + .Multiplexer // Start with a basic multiplexer + .AsEphemeralMultiplexer() // Enable automatic cleanup of databases for this instance + .AsPooledMultiplexer(); // Provide concurrency safety for this instance + } + + [LocalFact(Skip = "Example only")] + public async Task EphemeralExample() + { + await using var multiplexer = _ephemeralFixture + .Multiplexer // Instance will automatically clean up any databases accessed + .AsPooledMultiplexer(); // Provide concurrency safety for this instance + } + + [LocalFact(Skip = "Example only")] + public async Task PooledEphemeralExample() + { + await using var multiplexer = _pooledEphemeralFixture + .Multiplexer; // Instance will automatically clean up any databases accessed and provide concurrency safety + } +} + +/// +/// This example uses a custom Redis instance with testcontainers. +/// By using an abstract base class, we can create a matrix of tests that run on multiple Redis instances. +/// +/// +public class CustomRedisInstance : IRedisInstanceFixture +{ + private readonly Lazy _container = new(() => + new ContainerBuilder() + .WithImage("redis:6-alpine") + .WithPortBinding(6379, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379)) + .Build()); + + protected IContainer Container => _container.Value; + + public ushort PublicPort => Container.GetMappedPublicPort(6379); + public string ConnectionString => $"localhost:{PublicPort},allowAdmin=true"; + + public Task InitializeAsync() => _container.Value.StartAsync(); + public Task DisposeAsync() => _container.TryDisposeAsync().AsTask(); +} + +public class ExamplesUsingCustomRedisInstance( + RedisMultiplexerFixture basicFixture, + EphemeralRedisMultiplexerFixture ephemeralFixture, + PooledEphemeralRedisMultiplexerFixture pooledEphemeralFixture) + : ExamplesUsingCustomRedisInstance(basicFixture, ephemeralFixture, pooledEphemeralFixture); + +public abstract class ExamplesUsingCustomRedisInstance( + RedisMultiplexerFixture basicFixture, + EphemeralRedisMultiplexerFixture ephemeralFixture, + PooledEphemeralRedisMultiplexerFixture pooledEphemeralFixture) : + IClassFixture>, + IClassFixture>, + IClassFixture> + where TRedisInstance : IRedisInstanceFixture, new() +{ + private readonly RedisMultiplexerFixture + _basicFixture = basicFixture, + _ephemeralFixture = ephemeralFixture, + _pooledEphemeralFixture = pooledEphemeralFixture; + + [LocalFact(Skip = "Example only")] + public async Task CustomExample() + { + await using var multiplexer = _basicFixture + .Multiplexer // Start with a custom multiplexer + .AsEphemeralMultiplexer() // Enable automatic cleanup of databases for this instance + .AsPooledMultiplexer(); // Provide concurrency safety for this instance + } + + [LocalFact(Skip = "Example only")] + public async Task EphemeralExample() + { + await using var multiplexer = _ephemeralFixture + .Multiplexer // Instance will automatically clean up any databases accessed + .AsPooledMultiplexer(); // Provide concurrency safety for this instance + } + + [LocalFact(Skip = "Example only")] + public async Task PooledEphemeralExample() + { + await using var multiplexer = _pooledEphemeralFixture + .Multiplexer; // Instance will automatically clean up any databases accessed and provide concurrency safety + } +} \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/RedisFactAttribute.cs b/tests/Ephemerally.Redis.Tests/RedisFactAttribute.cs new file mode 100644 index 0000000..3d0b9a0 --- /dev/null +++ b/tests/Ephemerally.Redis.Tests/RedisFactAttribute.cs @@ -0,0 +1,6 @@ +namespace Ephemerally.Redis.Tests; + +public class RedisFactAttribute : FactAttribute +{ + public override int Timeout => 2_000; +} \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/RedisMultiplexerFixtures.cs b/tests/Ephemerally.Redis.Tests/RedisMultiplexerFixtures.cs index 19a13d6..0f51e8b 100644 --- a/tests/Ephemerally.Redis.Tests/RedisMultiplexerFixtures.cs +++ b/tests/Ephemerally.Redis.Tests/RedisMultiplexerFixtures.cs @@ -3,6 +3,6 @@ namespace Ephemerally.Redis.Tests; // ReSharper disable InconsistentNaming -public class RedisMultiplexerFixture_6 : RedisMultiplexerFixture; +public class RedisMultiplexerFixture_6 : RedisMultiplexerFixture; -public class RedisMultiplexerFixture_7 : RedisMultiplexerFixture; \ No newline at end of file +public class RedisMultiplexerFixture_7 : RedisMultiplexerFixture; \ No newline at end of file diff --git a/tests/Ephemerally.Redis.Tests/RedisTestCollection.cs b/tests/Ephemerally.Redis.Tests/RedisTestCollection.cs index d6cfb39..cdd1316 100644 --- a/tests/Ephemerally.Redis.Tests/RedisTestCollection.cs +++ b/tests/Ephemerally.Redis.Tests/RedisTestCollection.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Ephemerally.Redis.Tests; +namespace Ephemerally.Redis.Tests; [CollectionDefinition(Name, DisableParallelization = true)] public class RedisTestCollection diff --git a/tests/Ephemerally.Redis.Tests/RedisTestContainerFixtures.cs b/tests/Ephemerally.Redis.Tests/RedisTestContainerFixtures.cs index 3438c09..3bc3748 100644 --- a/tests/Ephemerally.Redis.Tests/RedisTestContainerFixtures.cs +++ b/tests/Ephemerally.Redis.Tests/RedisTestContainerFixtures.cs @@ -4,7 +4,7 @@ namespace Ephemerally.Redis.Tests; -public abstract class RedisTestContainerFixture : TestContainerFixture, IRedisTestContainerFixture +public abstract class RedisInstanceFixture : TestContainerFixture, IRedisInstanceFixture { public ushort PublicPort => Container.GetMappedPublicPort(6379); @@ -12,7 +12,7 @@ public abstract class RedisTestContainerFixture : TestContainerFixture, IRedisTe } // ReSharper disable once InconsistentNaming -public class RedisTestContainerFixture_6 : RedisTestContainerFixture +public class RedisInstanceFixture6 : RedisInstanceFixture { protected override IContainer CreateContainer() => new ContainerBuilder() @@ -23,7 +23,7 @@ protected override IContainer CreateContainer() => } // ReSharper disable once InconsistentNaming -public class RedisTestContainerFixture_7 : RedisTestContainerFixture +public class RedisInstanceFixture7 : RedisInstanceFixture { protected override IContainer CreateContainer() => new ContainerBuilder() diff --git a/tests/Ephemerally.Tests/LocalAttributes.cs b/tests/Ephemerally.Tests/LocalAttributes.cs new file mode 100644 index 0000000..0f4a682 --- /dev/null +++ b/tests/Ephemerally.Tests/LocalAttributes.cs @@ -0,0 +1,34 @@ +using Xunit; +using static Ephemerally.Tests.LocalExtensions; +using TheoryAttribute = Xunit.TheoryAttribute; + +// ReSharper disable InconsistentNaming, VirtualMemberCallInConstructor + +namespace Ephemerally.Tests; + +public class LocalFactAttribute : FactAttribute +{ + public LocalFactAttribute() + { + if (IsCI()) + { + Skip = "Local only"; + } + } +} + +public class LocalTheoryAttribute : TheoryAttribute +{ + public LocalTheoryAttribute() + { + if (IsCI()) + { + Skip = "Local only"; + } + } +} + +file static class LocalExtensions +{ + public static bool IsCI() => Environment.GetEnvironmentVariable("CI") is not null; +} \ No newline at end of file