fix: add stable application instance name option

Cherry-picked-from: e1096c714772a8a776c209fef45983538b81a3a1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2026-06-19 23:33:22 +02:00
parent d8d7eae204
commit fc48af062c
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
6 changed files with 391 additions and 1 deletions

View file

@ -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}

View file

@ -21,9 +21,14 @@ public class ClusteringFeature : FeatureBase
/// <summary>
/// A factory that instantiates an <see cref="IApplicationInstanceNameProvider"/>.
/// </summary>
/// <remarks>
/// Defaults to <see cref="ConfiguredApplicationInstanceNameProvider"/>, which honors
/// <see cref="ApplicationInstanceOptions"/> for a stable instance name and falls back to a random
/// name when none is configured (preserving the previous default behaviour).
/// </remarks>
public Func<IServiceProvider, IApplicationInstanceNameProvider> InstanceNameProvider { get; set; } = sp =>
{
return ActivatorUtilities.CreateInstance<RandomApplicationInstanceNameProvider>(sp);
return ActivatorUtilities.CreateInstance<ConfiguredApplicationInstanceNameProvider>(sp);
};
/// <summary>
@ -31,6 +36,12 @@ public class ClusteringFeature : FeatureBase
/// </summary>
public Action<HeartbeatOptions> HeartbeatOptions { get; set; } = _ => { };
/// <summary>
/// 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.
/// </summary>
public Action<ApplicationInstanceOptions> ApplicationInstanceOptions { get; set; } = _ => { };
/// <inheritdoc />
public override void ConfigureHostedServices()
{
@ -42,6 +53,7 @@ public class ClusteringFeature : FeatureBase
public override void Apply()
{
Services.Configure(HeartbeatOptions)
.Configure(ApplicationInstanceOptions)
.AddSingleton(InstanceNameProvider)
.AddSingleton<RandomIntIdentityGenerator>();
}

View file

@ -0,0 +1,45 @@
namespace Elsa.Hosting.Management.Options;
/// <summary>
/// Options that control how the name of the current application instance is determined.
/// </summary>
/// <remarks>
/// The instance name is used to name per-instance transport entities, such as the Azure Service Bus
/// change-token subscription and queue (<c>{instanceName}-elsa-trigger-change-token-signal</c>).
/// 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 <i>stable</i> 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 <c>metadata.name</c>).
/// </remarks>
public class ApplicationInstanceOptions
{
/// <summary>
/// An explicit, stable, unique-per-instance name. When set, this value is used directly and takes
/// precedence over <see cref="InstanceNameEnvironmentVariable"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string? InstanceName { get; set; }
/// <summary>
/// The name of an environment variable to read the instance name from when <see cref="InstanceName"/>
/// is not set. For example, set this to <c>HOSTNAME</c> to use the Kubernetes pod name (stable across
/// restarts when running as a StatefulSet). When <see langword="null"/> or empty, no environment
/// variable is read and a random name is generated instead.
/// </summary>
/// <remarks>
/// The environment variable name is trimmed before lookup. The value it contains follows the same
/// transport entity-name length constraints as <see cref="InstanceName"/>.
/// </remarks>
public string? InstanceNameEnvironmentVariable { get; set; }
}

View file

@ -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;
/// <summary>
/// Resolves the application instance name from <see cref="ApplicationInstanceOptions"/>, 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.
/// </summary>
/// <remarks>
/// Resolution order:
/// <list type="number">
/// <item><description><see cref="ApplicationInstanceOptions.InstanceName"/> when set.</description></item>
/// <item><description>The environment variable named by <see cref="ApplicationInstanceOptions.InstanceNameEnvironmentVariable"/> when configured and non-empty.</description></item>
/// <item><description>A randomly generated name (legacy behaviour).</description></item>
/// </list>
/// </remarks>
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;
/// <summary>
/// Initializes a new instance of the <see cref="ConfiguredApplicationInstanceNameProvider"/> class.
/// </summary>
public ConfiguredApplicationInstanceNameProvider(
IOptions<ApplicationInstanceOptions> options,
RandomIntIdentityGenerator randomIdentityGenerator,
ILogger<ConfiguredApplicationInstanceNameProvider> 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();
}
/// <inheritdoc />
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';
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Hosting.Management]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Hosting.Management\Elsa.Hosting.Management.csproj" />
</ItemGroup>
</Project>

View file

@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ConfiguredApplicationInstanceNameProvider>.Instance);
}
private static string NewVariableName() => "ELSA_TEST_INSTANCE_" + Guid.NewGuid().ToString("N");
}