diff --git a/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs b/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs index 338c8d0bf..fb54c3e81 100644 --- a/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs +++ b/src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs @@ -5,7 +5,7 @@ namespace Elsa.Hosting.Management.Options; /// /// /// 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). +/// change-token subscription and queue ({instanceName}-elsa-tct). /// 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 . /// /// - /// 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. /// public string? InstanceName { get; set; } @@ -39,7 +39,7 @@ public class ApplicationInstanceOptions /// /// /// The environment variable name is trimmed before lookup. The value it contains follows the same - /// transport entity-name length constraints as . + /// character rules and deterministic shortening behavior 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 index 95eeef0b2..856061318 100644 --- a/src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs +++ b/src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs @@ -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 /// 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(); - if (hasInvalidCharacters) { - errors.Add( + var errors = new List + { $"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}"; + } } diff --git a/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs b/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs index 292a086ed..de36e0d34 100644 --- a/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs +++ b/test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs @@ -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(() => 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(() => 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 {