From 90e560bc731021b4b21c6f7cc8dc0360e7a02c79 Mon Sep 17 00:00:00 2001 From: gurkanguran Date: Fri, 14 Jul 2023 19:29:43 +0200 Subject: [PATCH] Redis proto cluster member store (#4241) * Implemented redis as a proto cluster member store * Added component tests for redis protocluster member store --------- Co-authored-by: Sipke Schoorstra --- Elsa.sln | 7 +++ ...otoActor.Cluster.AzureContainerApps.csproj | 1 + .../ServiceCollectionExtensions.cs | 15 ++--- .../Stores/Redis/ClusterMember.cs | 6 ++ .../Stores/Redis/RedisClusterMemberStore.cs | 62 ++++++++++++++++++ .../Redis/ServiceCollectionExtensions.cs | 24 +++++++ .../ServiceCollectionExtensions.cs | 12 +++- .../Program.cs | 7 ++- .../appsettings.json | 3 +- .../RedisClusterMemberStoreTests.cs | 63 +++++++++++++++++++ .../AzureContainerAppTests/RedisFixture.cs | 31 +++++++++ .../Elsa.ProtoCluster.ComponentTests.csproj | 31 +++++++++ .../Usings.cs | 1 + 13 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ClusterMember.cs create mode 100644 src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/RedisClusterMemberStore.cs create mode 100644 src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ServiceCollectionExtensions.cs create mode 100644 test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisClusterMemberStoreTests.cs create mode 100644 test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisFixture.cs create mode 100644 test/Elsa.ProtoCluster.ComponentTests/Elsa.ProtoCluster.ComponentTests.csproj create mode 100644 test/Elsa.ProtoCluster.ComponentTests/Usings.cs diff --git a/Elsa.sln b/Elsa.sln index f88e4d6ef..861ec293c 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -230,6 +230,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.AspNet.MongoDb EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Api.Rpc", "src\modules\Elsa.Workflows.Api.Rpc\Elsa.Workflows.Api.Rpc.csproj", "{0A94D69A-61AC-4654-83AB-B24445628A99}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.ProtoCluster.ComponentTests", "test\Elsa.ProtoCluster.ComponentTests\Elsa.ProtoCluster.ComponentTests.csproj", "{2430CB5F-7D07-4A9E-BD40-EC1B111B85FF}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.AspNet.DynamicActivityProvider", "src\samples\aspnet\Elsa.Samples.AspNet.DynamicActivityProvider\Elsa.Samples.AspNet.DynamicActivityProvider.csproj", "{85E13383-7C39-4719-AAC0-0B357C3A97C7}" EndProject Global @@ -578,6 +580,10 @@ Global {0A94D69A-61AC-4654-83AB-B24445628A99}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A94D69A-61AC-4654-83AB-B24445628A99}.Release|Any CPU.ActiveCfg = Release|Any CPU {0A94D69A-61AC-4654-83AB-B24445628A99}.Release|Any CPU.Build.0 = Release|Any CPU + {2430CB5F-7D07-4A9E-BD40-EC1B111B85FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2430CB5F-7D07-4A9E-BD40-EC1B111B85FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2430CB5F-7D07-4A9E-BD40-EC1B111B85FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2430CB5F-7D07-4A9E-BD40-EC1B111B85FF}.Release|Any CPU.Build.0 = Release|Any CPU {85E13383-7C39-4719-AAC0-0B357C3A97C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {85E13383-7C39-4719-AAC0-0B357C3A97C7}.Debug|Any CPU.Build.0 = Debug|Any CPU {85E13383-7C39-4719-AAC0-0B357C3A97C7}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -686,6 +692,7 @@ Global {3A377A6A-F735-4010-9E00-72E8BEB8F1F2} = {9B4F139F-7D26-435C-A561-89E65A67A8E5} {DAACE93A-866E-428E-B2A9-AFAC79F9C7A4} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5} {0A94D69A-61AC-4654-83AB-B24445628A99} = {B08B4E00-C2AB-48F3-8389-449F42AEF179} + {2430CB5F-7D07-4A9E-BD40-EC1B111B85FF} = {08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C} {85E13383-7C39-4719-AAC0-0B357C3A97C7} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5} EndGlobalSection EndGlobal diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj index 4dcb7dd5e..6c642973f 100644 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Elsa.ProtoActor.Cluster.AzureContainerApps.csproj @@ -17,5 +17,6 @@ + diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs index b596203ce..c3aa7bdbc 100644 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/ServiceCollectionExtensions.cs @@ -19,9 +19,10 @@ public static class ServiceCollectionExtensions /// /// The service collection to add the provider to. /// An to create instances. + /// An optional configuration for the member store. /// An optional action to configure the provider options. /// The service collection. - public static IServiceCollection AddAzureContainerAppsProvider(this IServiceCollection services, IArmClientProvider? armClientProvider = default, [AllowNull] Action configure = null) + public static IServiceCollection AddAzureContainerAppsProvider(this IServiceCollection services, IArmClientProvider? armClientProvider = default, Action? configureMemberStore = null, Action? configure = null) { var configureOptions = configure ?? (_ => { }); services.Configure(configureOptions); @@ -31,14 +32,10 @@ public static class ServiceCollectionExtensions if (armClientProvider != null) services.AddSingleton(armClientProvider); - // Register the default member store. - services.AddSingleton(sp => - { - var clientProvider = sp.GetRequiredService(); - var logger = sp.GetRequiredService>(); - var options = sp.GetRequiredService>().Value; - return new ResourceTagsClusterMemberStore(clientProvider, logger, options.ResourceGroupName, options.SubscriptionId); - }); + if (configureMemberStore != null) + configureMemberStore.Invoke(services); + else + services.AddResourceTagsMemberStore(); return services; } diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ClusterMember.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ClusterMember.cs new file mode 100644 index 000000000..ca19026f2 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ClusterMember.cs @@ -0,0 +1,6 @@ +namespace Proto.Cluster.AzureContainerApps.Stores.Redis; + +/// +/// A member with a cluster name and kind values. +/// +public record ClusterMember(string Host, int Port, string ClusterName, IEnumerable Kinds); \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/RedisClusterMemberStore.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/RedisClusterMemberStore.cs new file mode 100644 index 000000000..9325a7eac --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/RedisClusterMemberStore.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using JetBrains.Annotations; +using StackExchange.Redis; + +namespace Proto.Cluster.AzureContainerApps.Stores.Redis; + +/// +/// Stores cluster member information in Redis database. +/// +[PublicAPI] +public class RedisClusterMemberStore : IClusterMemberStore +{ + private const string ClusterKey = "proto:cluster:members"; + private readonly IDatabase _database; + + /// + /// Initializes a new instance of the class. + /// + public RedisClusterMemberStore(ConnectionMultiplexer connectionMultiplexer) => _database = connectionMultiplexer.GetDatabase(); + + /// + public async ValueTask> ListAsync(CancellationToken cancellationToken = default) + { + var entries = await _database.HashGetAllAsync(ClusterKey); + + return entries.Select(entry => + { + var clusterMember = Deserialize(entry.Value!); + return new Member + { + Id = entry.Name, + Host = clusterMember.Host, + Port = clusterMember.Port, + Kinds = { clusterMember.Kinds } + }; + }).ToList(); + } + + /// + public async ValueTask RegisterAsync(string clusterName, Member member, CancellationToken cancellationToken = default) + { + var clusterMember = new ClusterMember(member.Host, member.Port, clusterName, member.Kinds); + var serialized = Serialize(clusterMember); + + await _database.HashSetAsync(ClusterKey, member.Id, serialized); + } + + /// + public async ValueTask UnregisterAsync(string memberId, CancellationToken cancellationToken = default) + { + await _database.HashDeleteAsync(ClusterKey, memberId); + } + + /// + public async ValueTask ClearAsync(string clusterName, CancellationToken cancellationToken = default) + { + await _database.KeyDeleteAsync(ClusterKey); + } + + private static string Serialize(ClusterMember member) => JsonSerializer.Serialize(member); + private static ClusterMember Deserialize(string data) => JsonSerializer.Deserialize(data)!; +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ServiceCollectionExtensions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..ab59f6e15 --- /dev/null +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/Redis/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; + +namespace Proto.Cluster.AzureContainerApps.Stores.Redis; + +/// +/// Adds extension methods to for registering the Azure Container Apps provider +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the to the service collection. + /// + /// The service collection to add the provider to. + /// Connection string for the Redis client. + /// The service collection. + public static IServiceCollection AddRedisClusterMemberStore(this IServiceCollection services, string connectionString) + { + services.AddSingleton(sp => ConnectionMultiplexer.Connect(connectionString)); + services.AddSingleton(); + return services; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs index ad3c42fd9..1ec41e9fb 100644 --- a/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.ProtoActor.Cluster.AzureContainerApps/Stores/ResourceTags/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Proto.Cluster.AzureContainerApps.Stores.ResourceTags; @@ -20,8 +22,16 @@ public static class ServiceCollectionExtensions var configureOptions = configure ?? (_ => { }); services.Configure(configureOptions); services.ConfigureOptions(); - services.Replace(new ServiceDescriptor(typeof(IClusterMemberStore), typeof(ResourceTagsClusterMemberStore), ServiceLifetime.Singleton)); + services.AddSingleton(sp => + { + var clientProvider = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var options = sp.GetRequiredService>().Value; + return new ResourceTagsClusterMemberStore(clientProvider, logger, options.ResourceGroupName, + options.SubscriptionId); + }); + return services; } } \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs index a12d208f2..258437b24 100644 --- a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/Program.cs @@ -7,6 +7,7 @@ using Elsa.ProtoActor.Protos; using Google.Protobuf.WellKnownTypes; using Microsoft.Data.Sqlite; using Proto.Cluster.AzureContainerApps; +using Proto.Cluster.AzureContainerApps.Stores.Redis; using Proto.Persistence.Sqlite; using Proto.Remote; using Proto.Remote.GrpcNet; @@ -15,13 +16,17 @@ var builder = WebApplication.CreateBuilder(args); var services = builder.Services; var configuration = builder.Configuration; var sqliteConnectionString = configuration.GetConnectionString("Sqlite")!; +var redisConnectionString = configuration.GetConnectionString("Redis")!; var identitySection = configuration.GetSection("Identity"); var identityTokenSection = identitySection.GetSection("Tokens"); var protoActorSection = configuration.GetSection("ProtoActor"); var protoActorClusterSection = protoActorSection.GetSection("Cluster"); // Configure Proto Actor cluster provider services. -services.AddAzureContainerAppsProvider(ArmClientProviders.DefaultAzureCredential, options => protoActorClusterSection.GetSection("AzureContainerApps").Bind(options)); +services.AddAzureContainerAppsProvider( + ArmClientProviders.DefaultAzureCredential, + sc => sc.AddRedisClusterMemberStore(redisConnectionString), + options => protoActorClusterSection.GetSection("AzureContainerApps").Bind(options)); // Add Elsa services. services diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json index 4195e124b..775253ed1 100644 --- a/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json +++ b/src/samples/aspnet/Elsa.Samples.AspNet.ProtoActorRuntime.AzureContainerApps/appsettings.json @@ -7,7 +7,8 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "Sqlite": "Data Source=elsa.sqlite.db;Cache=Shared;" + "Sqlite": "Data Source=elsa.sqlite.db;Cache=Shared;", + "Redis": "localhost:6379" }, "Identity": { "Tokens": { diff --git a/test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisClusterMemberStoreTests.cs b/test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisClusterMemberStoreTests.cs new file mode 100644 index 000000000..c9c0dc2b4 --- /dev/null +++ b/test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisClusterMemberStoreTests.cs @@ -0,0 +1,63 @@ +using Proto.Cluster; +using Proto.Cluster.AzureContainerApps.Stores.Redis; +using StackExchange.Redis; + +namespace Elsa.ProtoCluster.ComponentTests.AzureContainerAppTests; + +public class RedisClusterMemberStoreTests : IClassFixture +{ + private readonly RedisClusterMemberStore _memberStore; + private readonly Member _member; + + public RedisClusterMemberStoreTests(RedisFixture redisFixture) + { + var multiplexer = ConnectionMultiplexer.Connect(redisFixture.GetConnectionString()); + _memberStore = new RedisClusterMemberStore(multiplexer); + + _member = new Member + { + Id = "id1", + Host = "localhost", + Port = 8000, + Kinds = { "kind1", "kind2" } + }; + } + + [Fact(DisplayName = "Invoking RegisterAsync should register member")] + public async Task RegisterAsync_ShouldRegisterMember() + { + await _memberStore.RegisterAsync("cluster", _member); + + var members = await _memberStore.ListAsync(); + Assert.Contains(members, m => m.Id == _member.Id); + } + + [Fact(DisplayName = "Invoking UnregisterAsync should unregister member")] + public async Task UnregisterAsync_ShouldUnregisterMember() + { + await _memberStore.RegisterAsync("cluster", _member); + await _memberStore.UnregisterAsync(_member.Id); + + var members = await _memberStore.ListAsync(); + Assert.DoesNotContain(members, m => m.Id == _member.Id); + } + + [Fact(DisplayName = "Invoking ClearAsync should clear all members")] + public async Task ClearAsync_ShouldClearAllMembers() + { + await _memberStore.RegisterAsync("cluster", _member); + await _memberStore.ClearAsync("cluster"); + + var members = await _memberStore.ListAsync(); + Assert.Empty(members); + } + + [Fact(DisplayName = "Invoking ListAsync should return all registered members")] + public async Task ListAsync_ShouldReturnAllRegisteredMembers() + { + await _memberStore.RegisterAsync("cluster", _member); + + var members = await _memberStore.ListAsync(); + Assert.Contains(members, m => m.Id == _member.Id); + } +} \ No newline at end of file diff --git a/test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisFixture.cs b/test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisFixture.cs new file mode 100644 index 000000000..c39057654 --- /dev/null +++ b/test/Elsa.ProtoCluster.ComponentTests/AzureContainerAppTests/RedisFixture.cs @@ -0,0 +1,31 @@ +using DotNet.Testcontainers.Builders; +using Testcontainers.Redis; + +namespace Elsa.ProtoCluster.ComponentTests.AzureContainerAppTests; + +public class RedisFixture : IAsyncLifetime +{ + private RedisContainer? _redisContainer; + + public async Task InitializeAsync() + { + _redisContainer = new RedisBuilder() + .WithPortBinding(6379, true) + .Build(); + + await _redisContainer.StartAsync().ConfigureAwait(false); + } + + public string GetConnectionString() + { + return _redisContainer != null ? + _redisContainer.GetConnectionString() : + throw new InvalidOperationException("Redis container not initialized."); + } + + public async Task DisposeAsync() + { + if(_redisContainer != null) + await _redisContainer.StopAsync(); + } +} diff --git a/test/Elsa.ProtoCluster.ComponentTests/Elsa.ProtoCluster.ComponentTests.csproj b/test/Elsa.ProtoCluster.ComponentTests/Elsa.ProtoCluster.ComponentTests.csproj new file mode 100644 index 000000000..2a0a8c8de --- /dev/null +++ b/test/Elsa.ProtoCluster.ComponentTests/Elsa.ProtoCluster.ComponentTests.csproj @@ -0,0 +1,31 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Elsa.ProtoCluster.ComponentTests/Usings.cs b/test/Elsa.ProtoCluster.ComponentTests/Usings.cs new file mode 100644 index 000000000..8c927eb74 --- /dev/null +++ b/test/Elsa.ProtoCluster.ComponentTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file