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