Shorten configured application instance names

Configured stable application instance names that exceed the Azure Service Bus transport entity limit are now shortened deterministically instead of failing startup. The same configured value resolves to the same shortened name across restarts, preserving stable per-instance transport identity while supporting normal Kubernetes pod names.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2026-06-20 12:51:30 +02:00
parent 0028604caf
commit c7a97c6412
3 changed files with 62 additions and 34 deletions

View file

@ -5,7 +5,7 @@ namespace Elsa.Hosting.Management.Options;
/// </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>).
/// change-token subscription and queue (<c>{instanceName}-elsa-tct</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
@ -24,10 +24,10 @@ public class ApplicationInstanceOptions
/// 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.
/// letter or number. Values that are too long for downstream transport entity names are shortened
/// deterministically so the same configured value resolves to the same application instance name
/// across restarts.
/// </remarks>
public string? InstanceName { get; set; }
@ -39,7 +39,7 @@ public class ApplicationInstanceOptions
/// </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"/>.
/// character rules and deterministic shortening behavior as <see cref="InstanceName"/>.
/// </remarks>
public string? InstanceNameEnvironmentVariable { get; set; }
}

View file

@ -2,6 +2,7 @@ using Elsa.Hosting.Management.Contracts;
using Elsa.Hosting.Management.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
namespace Elsa.Hosting.Management.Services;
@ -22,7 +23,8 @@ namespace Elsa.Hosting.Management.Services;
public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNameProvider
{
internal const int AzureServiceBusSubscriptionNameMaxLength = 50;
internal const string TriggerChangeTokenSignalEndpointNameSuffix = "-elsa-trigger-change-token-signal";
internal const string TriggerChangeTokenSignalEndpointNameSuffix = "-elsa-tct";
private const int ShortenedNameHashLength = 16;
internal static readonly int ConfiguredInstanceNameMaxLength = AzureServiceBusSubscriptionNameMaxLength - TriggerChangeTokenSignalEndpointNameSuffix.Length;
private readonly string _instanceName;
@ -39,7 +41,7 @@ public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNam
if (!string.IsNullOrWhiteSpace(value.InstanceName))
{
_instanceName = ValidateConfiguredInstanceName(value.InstanceName, $"{nameof(ApplicationInstanceOptions)}.{nameof(ApplicationInstanceOptions.InstanceName)}");
_instanceName = ResolveConfiguredInstanceName(value.InstanceName, $"{nameof(ApplicationInstanceOptions)}.{nameof(ApplicationInstanceOptions.InstanceName)}", logger);
return;
}
@ -50,7 +52,7 @@ public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNam
if (!string.IsNullOrWhiteSpace(fromEnvironment))
{
_instanceName = ValidateConfiguredInstanceName(fromEnvironment, $"environment variable '{environmentVariable}'");
_instanceName = ResolveConfiguredInstanceName(fromEnvironment, $"environment variable '{environmentVariable}'", logger);
return;
}
@ -67,33 +69,44 @@ public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNam
/// <inheritdoc />
public string GetName() => _instanceName;
private static string ValidateConfiguredInstanceName(string value, string source)
private static string ResolveConfiguredInstanceName(string value, string source, ILogger logger)
{
var instanceName = value.Trim();
var isTooLong = instanceName.Length > ConfiguredInstanceNameMaxLength;
var hasInvalidCharacters = !IsValidConfiguredInstanceName(instanceName);
if (!isTooLong && !hasInvalidCharacters)
return instanceName;
var errors = new List<string>();
if (hasInvalidCharacters)
{
errors.Add(
var errors = new List<string>
{
$"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.");
"Use only letters, numbers, periods, hyphens, or underscores, and start and end the value with a letter or number."
};
if (isTooLong)
{
errors.Add(
$"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.");
}
throw new InvalidOperationException(string.Join(" ", errors));
}
if (isTooLong)
{
errors.Add(
$"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.");
}
if (!isTooLong)
return instanceName;
throw new InvalidOperationException(string.Join(" ", errors));
var shortenedName = ShortenConfiguredInstanceName(instanceName);
logger.LogWarning(
"The configured application instance name from {Source} is {Length} characters long, exceeding the {MaxLength}-character limit required for Azure Service Bus transport entities. " +
"Using deterministic shortened instance name '{ShortenedName}' instead.",
source,
instanceName.Length,
ConfiguredInstanceNameMaxLength,
shortenedName);
return shortenedName;
}
private static bool IsValidConfiguredInstanceName(string instanceName)
@ -106,4 +119,13 @@ public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNam
private static bool IsAsciiLetterOrDigit(char value) =>
value is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9';
private static string ShortenConfiguredInstanceName(string instanceName)
{
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(instanceName))).ToLowerInvariant()[..ShortenedNameHashLength];
var prefixLength = ConfiguredInstanceNameMaxLength - hash.Length - 1;
var prefix = instanceName[..prefixLength];
return $"{prefix}-{hash}";
}
}

View file

@ -149,14 +149,17 @@ public class ConfiguredApplicationInstanceNameProviderTests
}
[Fact]
public void ExplicitInstanceName_TooLong_Throws()
public void ExplicitInstanceName_TooLong_IsShortenedDeterministically()
{
var instanceName = new string('a', ConfiguredApplicationInstanceNameProvider.ConfiguredInstanceNameMaxLength + 1);
var instanceName = "nexxbiz-executor-api-v3-1-extra-long-replica-0001";
var exception = Assert.Throws<InvalidOperationException>(() => CreateProvider(new() { InstanceName = instanceName }));
var name1 = CreateProvider(new() { InstanceName = instanceName }).GetName();
var name2 = CreateProvider(new() { InstanceName = instanceName }).GetName();
Assert.Contains($"{ConfiguredApplicationInstanceNameProvider.ConfiguredInstanceNameMaxLength} characters or fewer", exception.Message);
Assert.Contains("Azure Service Bus", exception.Message);
Assert.Equal(name1, name2);
Assert.True(name1.Length <= ConfiguredApplicationInstanceNameProvider.ConfiguredInstanceNameMaxLength);
Assert.StartsWith(instanceName[..8], name1);
Assert.NotEqual(instanceName, name1);
}
[Theory]
@ -184,17 +187,20 @@ public class ConfiguredApplicationInstanceNameProviderTests
}
[Fact]
public void EnvironmentVariableValue_TooLong_Throws()
public void EnvironmentVariableValue_TooLong_IsShortenedDeterministically()
{
var variable = NewVariableName();
Environment.SetEnvironmentVariable(variable, new string('a', ConfiguredApplicationInstanceNameProvider.ConfiguredInstanceNameMaxLength + 1));
var instanceName = "nexxbiz-executor-api-v3-1-extra-long-replica-0001";
Environment.SetEnvironmentVariable(variable, instanceName);
try
{
var exception = Assert.Throws<InvalidOperationException>(() => CreateProvider(new() { InstanceNameEnvironmentVariable = variable }));
var name1 = CreateProvider(new() { InstanceNameEnvironmentVariable = variable }).GetName();
var name2 = CreateProvider(new() { InstanceNameEnvironmentVariable = variable }).GetName();
Assert.Contains(variable, exception.Message);
Assert.Contains($"{ConfiguredApplicationInstanceNameProvider.ConfiguredInstanceNameMaxLength} characters or fewer", exception.Message);
Assert.Equal(name1, name2);
Assert.True(name1.Length <= ConfiguredApplicationInstanceNameProvider.ConfiguredInstanceNameMaxLength);
Assert.NotEqual(instanceName, name1);
}
finally
{