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:
parent
d8d7eae204
commit
fc48af062c
15
Elsa.sln
15
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}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in a new issue