From b78649a3669cba8f16bb22af97f69b35e42f235e Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 20 Jun 2026 00:03:43 +0200 Subject: [PATCH] Port ASB stable application instance name Backports the opt-in configured application instance name provider from #7734 to release/3.8.0, including 3.8 shell-feature wiring and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Elsa.sln | 15 ++ .../Features/ClusteringFeature.cs | 14 +- .../Options/ApplicationInstanceOptions.cs | 45 ++++ ...nfiguredApplicationInstanceNameProvider.cs | 99 +++++++++ .../ShellFeatures/ClusteringFeature.cs | 15 +- .../Elsa.Hosting.Management.UnitTests.csproj | 13 ++ ...redApplicationInstanceNameProviderTests.cs | 206 ++++++++++++++++++ 7 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs create mode 100644 src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs create mode 100644 test/unit/Elsa.Hosting.Management.UnitTests/Elsa.Hosting.Management.UnitTests.csproj create mode 100644 test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs diff --git a/Elsa.sln b/Elsa.sln index 3d848fb0d..3bf4c6418 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -443,6 +443,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Platform.Integration.U EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "platform", "platform", "{B284D6B0-E488-4062-ABC8-7AEB25784931}" 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 @@ -1987,6 +1989,18 @@ Global {84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|x64.Build.0 = Release|Any CPU {84E70F39-2C24-4C93-A30F-6EC1330E2F49}.Release|x86.ActiveCfg = Release|Any CPU {84E70F39-2C24-4C93-A30F-6EC1330E2F49}.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 @@ -2157,6 +2171,7 @@ Global {84E70F39-2C24-4C93-A30F-6EC1330E2F49} = {18453B51-25EB-4317-A4B3-B10518252E92} {B284D6B0-E488-4062-ABC8-7AEB25784931} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {11F7C89F-835A-4D78-A846-9FCB463DA80C} = {B284D6B0-E488-4062-ABC8-7AEB25784931} + {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/src/modules/Elsa.Hosting.Management/ShellFeatures/ClusteringFeature.cs b/src/modules/Elsa.Hosting.Management/ShellFeatures/ClusteringFeature.cs index 95fbb1bc7..ed8860aa2 100644 --- a/src/modules/Elsa.Hosting.Management/ShellFeatures/ClusteringFeature.cs +++ b/src/modules/Elsa.Hosting.Management/ShellFeatures/ClusteringFeature.cs @@ -22,9 +22,14 @@ public class ClusteringFeature : IShellFeature /// /// 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); }; /// @@ -32,13 +37,19 @@ public class ClusteringFeature : IShellFeature /// 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 void ConfigureServices(IServiceCollection services) { services.Configure(HeartbeatOptions) + .Configure(ApplicationInstanceOptions) .AddSingleton(InstanceNameProvider) .AddSingleton() .AddHostedService() .AddHostedService(); } } - 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"); +}