diff --git a/Elsa.sln b/Elsa.sln
index 2320b54a8..56d538652 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -344,6 +344,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.SamplePackage", "src\a
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Shells.Api.Tests", "test\unit\Elsa.Shells.Api.Tests\Elsa.Shells.Api.Tests.csproj", "{42FFCACD-5A9D-462B-AC84-7E1D14CDBDF0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Hosting.Management.UnitTests", "test\unit\Elsa.Hosting.Management.UnitTests\Elsa.Hosting.Management.UnitTests.csproj", "{39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1204,6 +1206,18 @@ Global
{42FFCACD-5A9D-462B-AC84-7E1D14CDBDF0}.Release|x64.Build.0 = Release|Any CPU
{42FFCACD-5A9D-462B-AC84-7E1D14CDBDF0}.Release|x86.ActiveCfg = Release|Any CPU
{42FFCACD-5A9D-462B-AC84-7E1D14CDBDF0}.Release|x86.Build.0 = Release|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Debug|x64.Build.0 = Debug|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Debug|x86.Build.0 = Debug|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x64.ActiveCfg = Release|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x64.Build.0 = Release|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x86.ActiveCfg = Release|Any CPU
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1314,6 +1328,7 @@ Global
{23BBF2CE-D2BA-43D4-8C78-A0B7B4AD6219} = {FD31E565-A5D9-4F25-B484-2F27FAB99B17}
{85E81AC4-10EF-4A93-A7A3-930087621ACC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
{42FFCACD-5A9D-462B-AC84-7E1D14CDBDF0} = {18453B51-25EB-4317-A4B3-B10518252E92}
+ {39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
diff --git a/src/modules/Elsa.Hosting.Management/Features/ClusteringFeature.cs b/src/modules/Elsa.Hosting.Management/Features/ClusteringFeature.cs
index 4a38e9eed..ad900c3ff 100644
--- a/src/modules/Elsa.Hosting.Management/Features/ClusteringFeature.cs
+++ b/src/modules/Elsa.Hosting.Management/Features/ClusteringFeature.cs
@@ -21,9 +21,14 @@ public class ClusteringFeature : FeatureBase
///
/// A factory that instantiates an .
///
+ ///
+ /// Defaults to , which honors
+ /// for a stable instance name and falls back to a random
+ /// name when none is configured (preserving the previous default behaviour).
+ ///
public Func InstanceNameProvider { get; set; } = sp =>
{
- return ActivatorUtilities.CreateInstance(sp);
+ return ActivatorUtilities.CreateInstance(sp);
};
///
@@ -31,6 +36,12 @@ public class ClusteringFeature : FeatureBase
///
public Action HeartbeatOptions { get; set; } = _ => { };
+ ///
+ /// Configures how the application instance name is determined. Set a stable name (for example from
+ /// the pod name) to avoid accumulating orphaned per-instance transport entities across restarts.
+ ///
+ public Action ApplicationInstanceOptions { get; set; } = _ => { };
+
///
public override void ConfigureHostedServices()
{
@@ -42,6 +53,7 @@ public class ClusteringFeature : FeatureBase
public override void Apply()
{
Services.Configure(HeartbeatOptions)
+ .Configure(ApplicationInstanceOptions)
.AddSingleton(InstanceNameProvider)
.AddSingleton();
}
diff --git a/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs b/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs
new file mode 100644
index 000000000..338c8d0bf
--- /dev/null
+++ b/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs
@@ -0,0 +1,45 @@
+namespace Elsa.Hosting.Management.Options;
+
+///
+/// Options that control how the name of the current application instance is determined.
+///
+///
+/// The instance name is used to name per-instance transport entities, such as the Azure Service Bus
+/// change-token subscription and queue ({instanceName}-elsa-trigger-change-token-signal).
+/// By default a random name is generated for every process start, which means a new entity is created
+/// on every restart. Under transports with a per-topic entity limit (for example Azure Service Bus,
+/// which caps a topic at 2,000 subscriptions), these orphaned entities can accumulate across restarts
+/// until the limit is reached and new instances can no longer start. Providing a stable name
+/// that is reused across restarts of the same logical instance keeps the number of entities bounded.
+///
+/// A stable name must be both stable across restarts of the same instance and unique across instances
+/// that run at the same time. In Kubernetes a StatefulSet provides exactly this (each pod keeps its
+/// ordinal hostname across restarts); for a Deployment the pod name can be projected via the Downward
+/// API (for example metadata.name).
+///
+public class ApplicationInstanceOptions
+{
+ ///
+ /// An explicit, stable, unique-per-instance name. When set, this value is used directly and takes
+ /// precedence over .
+ ///
+ ///
+ /// Keep this value short enough for downstream transport entity names. For Azure Service Bus, the
+ /// change-token subscription name must fit in 50 characters, leaving 17 characters for this prefix.
+ /// Use only letters, numbers, periods, hyphens, or underscores, and start and end the value with a
+ /// letter or number.
+ ///
+ public string? InstanceName { get; set; }
+
+ ///
+ /// The name of an environment variable to read the instance name from when
+ /// is not set. For example, set this to HOSTNAME to use the Kubernetes pod name (stable across
+ /// restarts when running as a StatefulSet). When or empty, no environment
+ /// variable is read and a random name is generated instead.
+ ///
+ ///
+ /// The environment variable name is trimmed before lookup. The value it contains follows the same
+ /// transport entity-name length constraints as .
+ ///
+ public string? InstanceNameEnvironmentVariable { get; set; }
+}
diff --git a/src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs b/src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs
new file mode 100644
index 000000000..dd44c96d5
--- /dev/null
+++ b/src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs
@@ -0,0 +1,99 @@
+using Elsa.Hosting.Management.Contracts;
+using Elsa.Hosting.Management.Options;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Elsa.Hosting.Management.Services;
+
+///
+/// Resolves the application instance name from , allowing a
+/// stable name to be configured so that per-instance transport entities are reused across restarts
+/// instead of accumulating. Falls back to a random name when no stable name is configured, which
+/// preserves the previous default behaviour.
+///
+///
+/// Resolution order:
+///
+/// - when set.
+/// - The environment variable named by when configured and non-empty.
+/// - A randomly generated name (legacy behaviour).
+///
+///
+public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNameProvider
+{
+ private const int AzureServiceBusSubscriptionNameMaxLength = 50;
+ private const string TriggerChangeTokenSignalEndpointNameSuffix = "-elsa-trigger-change-token-signal";
+ private static readonly int ConfiguredInstanceNameMaxLength = AzureServiceBusSubscriptionNameMaxLength - TriggerChangeTokenSignalEndpointNameSuffix.Length;
+
+ private readonly string _instanceName;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ConfiguredApplicationInstanceNameProvider(
+ IOptions options,
+ RandomIntIdentityGenerator randomIdentityGenerator,
+ ILogger logger)
+ {
+ var value = options.Value;
+
+ if (!string.IsNullOrWhiteSpace(value.InstanceName))
+ {
+ _instanceName = ValidateConfiguredInstanceName(value.InstanceName, $"{nameof(ApplicationInstanceOptions)}.{nameof(ApplicationInstanceOptions.InstanceName)}");
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(value.InstanceNameEnvironmentVariable))
+ {
+ var environmentVariable = value.InstanceNameEnvironmentVariable.Trim();
+ var fromEnvironment = Environment.GetEnvironmentVariable(environmentVariable);
+
+ if (!string.IsNullOrWhiteSpace(fromEnvironment))
+ {
+ _instanceName = ValidateConfiguredInstanceName(fromEnvironment, $"environment variable '{environmentVariable}'");
+ return;
+ }
+
+ logger.LogWarning(
+ "The configured instance-name environment variable '{EnvironmentVariable}' is not set or empty. Falling back to a random instance name. " +
+ "A random name causes per-instance transport entities (such as the Azure Service Bus change-token subscription) to be recreated on every restart, " +
+ "which can accumulate until the transport's per-topic limit is reached.",
+ environmentVariable);
+ }
+
+ _instanceName = randomIdentityGenerator.GenerateId();
+ }
+
+ ///
+ public string GetName() => _instanceName;
+
+ private static string ValidateConfiguredInstanceName(string value, string source)
+ {
+ var instanceName = value.Trim();
+
+ if (instanceName.Length <= ConfiguredInstanceNameMaxLength)
+ {
+ if (IsValidConfiguredInstanceName(instanceName))
+ return instanceName;
+
+ throw new InvalidOperationException(
+ $"The configured application instance name from {source} contains invalid characters. " +
+ "Use only letters, numbers, periods, hyphens, or underscores, and start and end the value with a letter or number.");
+ }
+
+ throw new InvalidOperationException(
+ $"The configured application instance name from {source} is {instanceName.Length} characters long, but it must be {ConfiguredInstanceNameMaxLength} characters or fewer. " +
+ $"The value is used to create per-instance transport entities such as '{instanceName}{TriggerChangeTokenSignalEndpointNameSuffix}', which must fit within Azure Service Bus's {AzureServiceBusSubscriptionNameMaxLength}-character subscription name limit. " +
+ "Configure a shorter stable name that is still unique for each concurrently running instance.");
+ }
+
+ private static bool IsValidConfiguredInstanceName(string instanceName)
+ {
+ return IsAsciiLetterOrDigit(instanceName[0])
+ && IsAsciiLetterOrDigit(instanceName[^1])
+ && instanceName.All(c => IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_');
+ }
+
+ private static bool IsAsciiLetterOrDigit(char value) =>
+ value is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9';
+}
diff --git a/test/unit/Elsa.Hosting.Management.UnitTests/Elsa.Hosting.Management.UnitTests.csproj b/test/unit/Elsa.Hosting.Management.UnitTests/Elsa.Hosting.Management.UnitTests.csproj
new file mode 100644
index 000000000..46fc08991
--- /dev/null
+++ b/test/unit/Elsa.Hosting.Management.UnitTests/Elsa.Hosting.Management.UnitTests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ [Elsa.Hosting.Management]*
+ 0
+
+
+
+
+
+
+
+
diff --git a/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs b/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs
new file mode 100644
index 000000000..6d7d1819c
--- /dev/null
+++ b/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs
@@ -0,0 +1,206 @@
+using Elsa.Hosting.Management.Options;
+using Elsa.Hosting.Management.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Elsa.Hosting.Management.UnitTests.Services;
+
+public class ConfiguredApplicationInstanceNameProviderTests
+{
+ private const int AzureServiceBusSubscriptionNameMaxLength = 50;
+ private const string TriggerChangeTokenSignalEndpointNameSuffix = "-elsa-trigger-change-token-signal";
+ private static readonly int ConfiguredInstanceNameMaxLength = AzureServiceBusSubscriptionNameMaxLength - TriggerChangeTokenSignalEndpointNameSuffix.Length;
+
+ [Fact]
+ public void ExplicitInstanceName_IsUsedDirectly()
+ {
+ var provider = CreateProvider(new()
+ {
+ InstanceName = "pod-0",
+ InstanceNameEnvironmentVariable = "ELSA_TEST_INSTANCE_NAME"
+ });
+
+ Assert.Equal("pod-0", provider.GetName());
+ }
+
+ [Fact]
+ public void ExplicitInstanceName_IsTrimmed()
+ {
+ var provider = CreateProvider(new()
+ {
+ InstanceName = " pod-0 "
+ });
+
+ Assert.Equal("pod-0", provider.GetName());
+ }
+
+ [Fact]
+ public void ExplicitInstanceName_TakesPrecedenceOverEnvironmentVariable()
+ {
+ var variable = NewVariableName();
+ Environment.SetEnvironmentVariable(variable, "from-env");
+
+ try
+ {
+ var provider = CreateProvider(new()
+ {
+ InstanceName = "explicit",
+ InstanceNameEnvironmentVariable = variable
+ });
+
+ Assert.Equal("explicit", provider.GetName());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+
+ [Fact]
+ public void EnvironmentVariable_IsUsedWhenInstanceNameNotSet()
+ {
+ var variable = NewVariableName();
+ Environment.SetEnvironmentVariable(variable, "pod-7");
+
+ try
+ {
+ var provider = CreateProvider(new()
+ {
+ InstanceNameEnvironmentVariable = variable
+ });
+
+ Assert.Equal("pod-7", provider.GetName());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+
+ [Fact]
+ public void EnvironmentVariableName_IsTrimmed()
+ {
+ var variable = NewVariableName();
+ Environment.SetEnvironmentVariable(variable, "pod-7");
+
+ try
+ {
+ var provider = CreateProvider(new()
+ {
+ InstanceNameEnvironmentVariable = $" {variable} "
+ });
+
+ Assert.Equal("pod-7", provider.GetName());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+
+ [Fact]
+ public void EnvironmentVariable_ValueIsTrimmed()
+ {
+ var variable = NewVariableName();
+ Environment.SetEnvironmentVariable(variable, " pod-7 ");
+
+ try
+ {
+ var provider = CreateProvider(new()
+ {
+ InstanceNameEnvironmentVariable = variable
+ });
+
+ Assert.Equal("pod-7", provider.GetName());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+
+ [Fact]
+ public void NoConfiguration_FallsBackToRandomName()
+ {
+ var name1 = CreateProvider(new()).GetName();
+ var name2 = CreateProvider(new()).GetName();
+
+ Assert.False(string.IsNullOrWhiteSpace(name1));
+ Assert.False(string.IsNullOrWhiteSpace(name2));
+ Assert.NotEqual(name1, name2);
+ }
+
+ [Fact]
+ public void EnvironmentVariableConfiguredButEmpty_FallsBackToRandomName()
+ {
+ var variable = NewVariableName();
+ Environment.SetEnvironmentVariable(variable, null);
+
+ var name1 = CreateProvider(new() { InstanceNameEnvironmentVariable = variable }).GetName();
+ var name2 = CreateProvider(new() { InstanceNameEnvironmentVariable = variable }).GetName();
+
+ Assert.False(string.IsNullOrWhiteSpace(name1));
+ Assert.NotEqual(name1, name2);
+ }
+
+ [Fact]
+ public void ExplicitInstanceName_AtMaximumLength_IsAccepted()
+ {
+ var instanceName = new string('a', ConfiguredInstanceNameMaxLength);
+
+ var provider = CreateProvider(new() { InstanceName = instanceName });
+
+ Assert.Equal(instanceName, provider.GetName());
+ }
+
+ [Fact]
+ public void ExplicitInstanceName_TooLong_Throws()
+ {
+ var instanceName = new string('a', ConfiguredInstanceNameMaxLength + 1);
+
+ var exception = Assert.Throws(() => CreateProvider(new() { InstanceName = instanceName }));
+
+ Assert.Contains($"{ConfiguredInstanceNameMaxLength} characters or fewer", exception.Message);
+ Assert.Contains("Azure Service Bus", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("pod 0")]
+ [InlineData("pöd-0")]
+ [InlineData("-pod-0")]
+ [InlineData("pod-0-")]
+ public void ExplicitInstanceName_InvalidCharacters_Throws(string instanceName)
+ {
+ var exception = Assert.Throws(() => CreateProvider(new() { InstanceName = instanceName }));
+
+ Assert.Contains("contains invalid characters", exception.Message);
+ }
+
+ [Fact]
+ public void EnvironmentVariableValue_TooLong_Throws()
+ {
+ var variable = NewVariableName();
+ Environment.SetEnvironmentVariable(variable, new string('a', ConfiguredInstanceNameMaxLength + 1));
+
+ try
+ {
+ var exception = Assert.Throws(() => CreateProvider(new() { InstanceNameEnvironmentVariable = variable }));
+
+ Assert.Contains(variable, exception.Message);
+ Assert.Contains($"{ConfiguredInstanceNameMaxLength} characters or fewer", exception.Message);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+
+ private static ConfiguredApplicationInstanceNameProvider CreateProvider(ApplicationInstanceOptions options)
+ {
+ return new ConfiguredApplicationInstanceNameProvider(
+ Microsoft.Extensions.Options.Options.Create(options),
+ new RandomIntIdentityGenerator(),
+ NullLogger.Instance);
+ }
+
+ private static string NewVariableName() => "ELSA_TEST_INSTANCE_" + Guid.NewGuid().ToString("N");
+}