From dfb12ca0cf033622c32ab98cce39b8ee7bb70b08 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Wed, 29 Oct 2025 15:30:09 -0700 Subject: [PATCH 01/25] as light scale --- .../AzureStorageDurabilityProvider.cs | 114 ++++++ .../AzureStorageDurabilityProviderFactory.cs | 270 ++++++++++++++ .../AzureStorage/AzureStorageOptions.cs | 343 ++++++++++++++++++ .../DurableTaskMetricsProvider.cs | 93 +++++ .../AzureStorage/DurableTaskScaleMonitor.cs | 141 +++++++ .../AzureStorage/DurableTaskTargetScaler.cs | 96 +++++ .../AzureStorage/DurableTaskTriggerMetrics.cs | 39 ++ .../AzureStorage/NameValidator.cs | 97 +++++ .../DurabilityProvider.cs | 131 +++++++ .../DurableClientAttribute.cs | 108 ++++++ ...rableTaskJobHostConfigurationExtensions.cs | 70 ++++ .../DurableTaskOptions.cs | 33 ++ .../DurableTaskScaleExtension.cs | 75 ++++ .../DurableTaskTriggersScaleProvider.cs | 205 +++++++++++ .../FunctionName.cs | 97 +++++ .../IDurabilityProviderFactory.cs | 44 +++ .../IStorageServiceClientProviderFactory.cs | 40 ++ .../ScaleUtils.cs | 107 ++++++ ...ebJobs.Extensions.DurableTask.Scale.csproj | 63 ++++ 19 files changed, 2166 insertions(+) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskMetricsProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskScaleMonitor.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTriggerMetrics.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs new file mode 100644 index 000000000..a85afd8e7 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DurableTask.AzureStorage; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + /// + /// The Azure Storage implementation of additional methods not required by IOrchestrationService. + /// + public class AzureStorageDurabilityProvider : DurabilityProvider + { + private readonly AzureStorageOrchestrationService serviceClient; + private readonly IStorageServiceClientProviderFactory clientProviderFactory; + private readonly string connectionName; + private readonly JObject storageOptionsJson; + private readonly ILogger logger; + + private readonly object initLock = new object(); + + private DurableTaskMetricsProvider singletonDurableTaskMetricsProvider; + + public AzureStorageDurabilityProvider( + AzureStorageOrchestrationService service, + IStorageServiceClientProviderFactory clientProviderFactory, + string connectionName, + AzureStorageOptions options, + ILogger logger) + : base("Azure Storage", service, service, connectionName) + { + this.serviceClient = service; + this.clientProviderFactory = clientProviderFactory; + this.connectionName = connectionName; + this.storageOptionsJson = JObject.FromObject( + options, + new JsonSerializer + { + Converters = { new StringEnumConverter() }, + ContractResolver = new CamelCasePropertyNamesContractResolver(), + }); + this.logger = logger; + } + + /// + /// The app setting containing the Azure Storage connection string. + /// + public override string ConnectionName => this.connectionName; + + public override JObject ConfigurationJson => this.storageOptionsJson; + + public override string EventSourceName { get; set; } = "DurableTask-AzureStorage"; + + internal DurableTaskMetricsProvider GetMetricsProvider( + string hubName, + StorageAccountClientProvider storageAccountClientProvider, + ILogger logger) + { + return new DurableTaskMetricsProvider(hubName, logger, performanceMonitor: null, storageAccountClientProvider); + } + + /// + public override bool TryGetScaleMonitor( + string functionId, + string functionName, + string hubName, + string connectionName, + out IScaleMonitor scaleMonitor) + { + lock (this.initLock) + { + if (this.singletonDurableTaskMetricsProvider == null) + { + // This is only called by the ScaleController, it doesn't run in the Functions Host process. + this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( + hubName, + this.clientProviderFactory.GetClientProvider(connectionName), + this.logger); + } + + scaleMonitor = new DurableTaskScaleMonitor(functionId, hubName, this.logger, this.singletonDurableTaskMetricsProvider); + return true; + } + } + + public override bool TryGetTargetScaler( + string functionId, + string functionName, + string hubName, + string connectionName, + out ITargetScaler targetScaler) + { + lock (this.initLock) + { + if (this.singletonDurableTaskMetricsProvider == null) + { + // This is only called by the ScaleController, it doesn't run in the Functions Host process. + this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( + hubName, + this.clientProviderFactory.GetClientProvider(connectionName), + this.logger); + } + + targetScaler = new DurableTaskTargetScaler(functionId, this.singletonDurableTaskMetricsProvider, this, this.logger); + return true; + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs new file mode 100644 index 000000000..ff915bb8a --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs @@ -0,0 +1,270 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Text.Json; +using DurableTask.AzureStorage; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + public class AzureStorageDurabilityProviderFactory : IDurabilityProviderFactory + { + private const string LoggerName = "Host.Triggers.DurableTask.AzureStorage"; + internal const string ProviderName = "AzureStorage"; + + private readonly DurableTaskOptions options; + private readonly IStorageServiceClientProviderFactory clientProviderFactory; + private readonly AzureStorageOptions azureStorageOptions; + private readonly INameResolver nameResolver; + private readonly ILoggerFactory loggerFactory; + private readonly bool useSeparateQueueForEntityWorkItems; + private readonly bool inConsumption; // If true, optimize defaults for consumption + private AzureStorageDurabilityProvider defaultStorageProvider; + + // Must wait to get settings until we have validated taskhub name. + private bool hasValidatedOptions; + private AzureStorageOrchestrationServiceSettings defaultSettings; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public AzureStorageDurabilityProviderFactory( + IOptions options, + IStorageServiceClientProviderFactory clientProviderFactory, + INameResolver nameResolver, + ILoggerFactory loggerFactory, +#pragma warning disable CS0612 // Type or member is obsolete + IPlatformInformation platformInfo) +#pragma warning restore CS0612 // Type or member is obsolete + { + // this constructor may be called by dependency injection even if the AzureStorage provider is not selected + // in that case, return immediately, since this provider is not actually used, but can still throw validation errors + if (options.Value.StorageProvider.TryGetValue("type", out object value) + && value is string s + && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + this.options = options.Value; + this.clientProviderFactory = clientProviderFactory ?? throw new ArgumentNullException(nameof(clientProviderFactory)); + this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + + this.azureStorageOptions = new AzureStorageOptions(); + this.inConsumption = platformInfo.IsInConsumptionPlan(); + + // The consumption plan has different performance characteristics so we provide + // different defaults for key configuration values. + int maxConcurrentOrchestratorsDefault = this.inConsumption ? 5 : 10 * Environment.ProcessorCount; + int maxConcurrentActivitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount; + int maxConcurrentEntitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount; + int maxEntityOperationBatchSizeDefault = this.inConsumption ? 50 : 5000; + + if (this.inConsumption) + { + WorkerRuntimeType language = platformInfo.GetWorkerRuntimeType(); + if (language == WorkerRuntimeType.Python) + { + this.azureStorageOptions.ControlQueueBufferThreshold = 32; + } + else + { + this.azureStorageOptions.ControlQueueBufferThreshold = 128; + } + } + + WorkerRuntimeType runtimeType = platformInfo.GetWorkerRuntimeType(); + if (runtimeType == WorkerRuntimeType.DotNetIsolated || + runtimeType == WorkerRuntimeType.Java || + runtimeType == WorkerRuntimeType.Custom) + { + this.useSeparateQueueForEntityWorkItems = true; + } + + // The following defaults are only applied if the customer did not explicitely set them on `host.json` + this.options.MaxConcurrentOrchestratorFunctions = this.options.MaxConcurrentOrchestratorFunctions ?? maxConcurrentOrchestratorsDefault; + this.options.MaxConcurrentActivityFunctions = this.options.MaxConcurrentActivityFunctions ?? maxConcurrentActivitiesDefault; + this.options.MaxConcurrentEntityFunctions = this.options.MaxConcurrentEntityFunctions ?? maxConcurrentEntitiesDefault; + this.options.MaxEntityOperationBatchSize = this.options.MaxEntityOperationBatchSize ?? maxEntityOperationBatchSizeDefault; + + // Override the configuration defaults with user-provided values in host.json, if any. + if (this.options.StorageProvider != null) + { + var json = JsonSerializer.Serialize(this.options.StorageProvider); + var newOptions = JsonSerializer.Deserialize(json); + if (newOptions != null) + { + // Copy deserialized values into the existing options object + foreach (var prop in typeof(AzureStorageOptions).GetProperties()) + { + var value = prop.GetValue(newOptions); + if (value != null) + { + prop.SetValue(this.azureStorageOptions, value); + } + } + } + } + + var logger = loggerFactory.CreateLogger(nameof(this.azureStorageOptions)); + this.azureStorageOptions.Validate(logger); + + this.DefaultConnectionName = this.azureStorageOptions.ConnectionName ?? ConnectionStringNames.Storage; + } + + public virtual string Name => ProviderName; + + public string DefaultConnectionName { get; } + + // This method should not be called before the app settings are resolved into the options. + // Because of this, we wait to validate the options until right before building a durability provider, rather + // than in the Factory constructor. + private void EnsureDefaultClientSettingsInitialized() + { + if (!this.hasValidatedOptions) + { + if (!this.options.IsDefaultHubName()) + { + this.azureStorageOptions.ValidateHubName(this.options.HubName); + } + else if (!this.azureStorageOptions.IsSanitizedHubName(this.options.HubName, out string sanitizedHubName)) + { + this.options.SetDefaultHubName(sanitizedHubName); + } + + this.defaultSettings = this.GetAzureStorageOrchestrationServiceSettings(); + this.hasValidatedOptions = true; + } + } + + public virtual DurabilityProvider GetDurabilityProvider() + { + this.EnsureDefaultClientSettingsInitialized(); + if (this.defaultStorageProvider == null) + { + var defaultService = new AzureStorageOrchestrationService(this.defaultSettings); + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); + this.defaultStorageProvider = new AzureStorageDurabilityProvider( + defaultService, + this.clientProviderFactory, + this.DefaultConnectionName, + this.azureStorageOptions, + logger); + } + + return this.defaultStorageProvider; + } + + public virtual DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute) + { + if (!attribute.ExternalClient) + { + this.EnsureDefaultClientSettingsInitialized(); + } + + return this.GetAzureStorageStorageProvider(attribute); + } + + private AzureStorageDurabilityProvider GetAzureStorageStorageProvider(DurableClientAttribute attribute) + { + string connectionName = attribute.ConnectionName ?? this.DefaultConnectionName; + AzureStorageOrchestrationServiceSettings settings = this.GetAzureStorageOrchestrationServiceSettings(connectionName, attribute.TaskHub); + + AzureStorageDurabilityProvider innerClient; + + // Need to check this.defaultStorageProvider != null for external clients that call GetDurabilityProvider(attribute) + // which never initializes the defaultStorageProvider. + if (string.Equals(this.DefaultConnectionName, connectionName, StringComparison.OrdinalIgnoreCase) && + string.Equals(this.options.HubName, settings.TaskHubName, StringComparison.OrdinalIgnoreCase) && + this.defaultStorageProvider != null) + { + // It's important that clients use the same AzureStorageOrchestrationService instance + // as the host when possible to ensure we any send operations can be picked up + // immediately instead of waiting for the next queue polling interval. + innerClient = this.defaultStorageProvider; + } + else + { + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); + innerClient = new AzureStorageDurabilityProvider( + new AzureStorageOrchestrationService(settings), + this.clientProviderFactory, + connectionName, + this.azureStorageOptions, + logger); + } + + return innerClient; + } + + internal AzureStorageOrchestrationServiceSettings GetAzureStorageOrchestrationServiceSettings( + string connectionName = null, + string taskHubNameOverride = null) + { + + var settings = new AzureStorageOrchestrationServiceSettings + { + StorageAccountClientProvider = this.clientProviderFactory.GetClientProvider(connectionName ?? this.DefaultConnectionName), + TaskHubName = taskHubNameOverride ?? this.options.HubName, + PartitionCount = this.azureStorageOptions.PartitionCount, + ControlQueueBatchSize = this.azureStorageOptions.ControlQueueBatchSize, + ControlQueueBufferThreshold = this.azureStorageOptions.ControlQueueBufferThreshold, + ControlQueueVisibilityTimeout = this.azureStorageOptions.ControlQueueVisibilityTimeout, + WorkItemQueueVisibilityTimeout = this.azureStorageOptions.WorkItemQueueVisibilityTimeout, + MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} needs a default value"), + MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} needs a default value"), + MaxConcurrentTaskEntityWorkItems = this.options.MaxConcurrentEntityFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentEntityFunctions)} needs a default value"), + ExtendedSessionsEnabled = this.options.ExtendedSessionsEnabled, + ExtendedSessionIdleTimeout = extendedSessionTimeout, + MaxQueuePollingInterval = this.azureStorageOptions.MaxQueuePollingInterval, + TrackingServiceClientProvider = this.azureStorageOptions.TrackingStoreConnectionName != null + ? this.clientProviderFactory.GetTrackingClientProvider(this.azureStorageOptions.TrackingStoreConnectionName) + : null, + FetchLargeMessageDataEnabled = this.azureStorageOptions.FetchLargeMessagesAutomatically, + ThrowExceptionOnInvalidDedupeStatus = true, + UseAppLease = this.options.UseAppLease, + AppLeaseOptions = this.options.AppLeaseOptions, + AppName = EndToEndTraceHelper.LocalAppName, + LoggerFactory = this.loggerFactory, + UseLegacyPartitionManagement = this.azureStorageOptions.UseLegacyPartitionManagement, + UseTablePartitionManagement = this.azureStorageOptions.UseTablePartitionManagement, + UseSeparateQueueForEntityWorkItems = this.useSeparateQueueForEntityWorkItems, + EntityMessageReorderWindowInMinutes = this.options.EntityMessageReorderWindowInMinutes, + MaxEntityOperationBatchSize = this.options.MaxEntityOperationBatchSize, + AllowReplayingTerminalInstances = this.azureStorageOptions.AllowReplayingTerminalInstances, + PartitionTableOperationTimeout = this.azureStorageOptions.PartitionTableOperationTimeout, + QueueClientMessageEncoding = this.azureStorageOptions.QueueClientMessageEncoding, + }; + + if (this.inConsumption) + { + settings.MaxStorageOperationConcurrency = 25; + } + + // When running on App Service VMSS stamps, these environment variables are the best way + // to enure unqique worker names + string stamp = this.nameResolver.Resolve("WEBSITE_CURRENT_STAMPNAME"); + string roleInstance = this.nameResolver.Resolve("RoleInstanceId"); + if (!string.IsNullOrEmpty(stamp) && !string.IsNullOrEmpty(roleInstance)) + { + settings.WorkerId = $"{stamp}:{roleInstance}"; + } + + if (!string.IsNullOrEmpty(this.azureStorageOptions.TrackingStoreNamePrefix)) + { + settings.TrackingStoreNamePrefix = this.azureStorageOptions.TrackingStoreNamePrefix; + } + + return settings; + } + } + } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs new file mode 100644 index 000000000..8bddb5e1a --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs @@ -0,0 +1,343 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using DurableTask.AzureStorage; + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + /// + /// Configuration options for the Azure Storage storage provider. + /// + public class AzureStorageOptions + { + // 45 alphanumeric characters gives us a buffer in our table/queue/blob container names. + private const int MaxTaskHubNameSize = 45; + private const int MinTaskHubNameSize = 3; + private const string TaskHubPadding = "Hub"; + private TimeSpan maxQueuePollingInterval; + + /// + /// Gets or sets the name of the Azure Storage connection information used to manage the underlying Azure Storage resources. + /// + /// + /// The value may refer to either a connection string or the configuration section containing connection metadata. + /// The default behavior is to use the standard AzureWebJobsStorage connection for all storage usage. + /// + /// + /// The name of a connection-related key that exists in the app's application settings. The value may refer to + /// a connection string or the section detailing additional connection metadata. + /// + public string ConnectionName { get; set; } + + /// + /// Gets or sets the name of the Azure Storage connection string used to manage the underlying Azure Storage resources. + /// + /// + /// If not specified, the default behavior is to use the standard `AzureWebJobsStorage` connection string for all storage usage. + /// + /// The name of a connection string that exists in the app's application settings. + [Obsolete("Please use ConnectionName instead.")] + public string ConnectionStringName + { + get => this.ConnectionName; + set => this.ConnectionName = value; + } + + /// + /// Gets or sets the number of messages to pull from the control queue at a time. + /// + /// + /// Messages pulled from the control queue are buffered in memory until the internal + /// dispatcher is ready to process them. + /// + /// A positive integer configured by the host. The default value is 32. + public int ControlQueueBatchSize { get; set; } = 32; + + /// + /// Gets or sets the partition count for the control queue. + /// + /// + /// Increasing the number of partitions will increase the number of workers + /// that can concurrently execute orchestrator functions. However, increasing + /// the partition count can also increase the amount of load placed on the storage + /// account and on the thread pool if the number of workers is smaller than the + /// number of partitions. + /// + /// A positive integer between 1 and 16. The default value is 4. + public int PartitionCount { get; set; } = 4; + + /// + /// Gets or set the number of control queue messages that can be buffered in memory + /// at a time, at which point the dispatcher will wait before dequeuing any additional + /// messages. The default is 256. The maximum value is 1000. + /// + /// + /// Increasing this value can improve orchestration throughput by pre-fetching more + /// orchestration messages from control queues. The downside is that it increases the + /// possibility of duplicate function executions if partition leases move between app + /// instances. This most often occurs when the number of app instances changes. + /// + /// A non-negative integer between 0 and 1000. The default value is 256. + public int ControlQueueBufferThreshold { get; set; } = 256; + + /// + /// Gets or sets the visibility timeout of dequeued control queue messages. + /// + /// + /// A TimeSpan configured by the host. The default is 5 minutes. + /// + public TimeSpan ControlQueueVisibilityTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the visibility timeout of dequeued work item queue messages. + /// + /// + /// A TimeSpan configured by the host. The default is 5 minutes. + /// + public TimeSpan WorkItemQueueVisibilityTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the name of the Azure Storage connection information to use for the + /// durable tracking store (History and Instances tables). + /// + /// + /// + /// If not specified, the connection string is used for the durable tracking store. + /// + /// + /// This property is primarily useful when deploying multiple apps that need to share the same + /// tracking infrastructure. For example, when deploying two versions of an app side by side, using + /// the same tracking store allows both versions to save history into the same table, which allows + /// clients to query for instance status across all versions. + /// + /// + /// + /// The name of a connection-related key that exists in the app's application settings. The value may refer to + /// a connection string or the section detailing additional connection metadata. + /// + public string TrackingStoreConnectionName { get; set; } + + /// + /// Gets or sets the name of the Azure Storage connection string to use for the + /// durable tracking store (History and Instances tables). + /// + /// + /// If not specified, the connection string + /// is used for the durable tracking store. + /// + /// This property is primarily useful when deploying multiple apps that need to share the same + /// tracking infrastructure. For example, when deploying two versions of an app side by side, using + /// the same tracking store allows both versions to save history into the same table, which allows + /// clients to query for instance status across all versions. + /// + /// The name of a connection string that exists in the app's application settings. + [Obsolete("Please use TrackingStoreConnectionName instead.")] + public string TrackingStoreConnectionStringName + { + get => this.TrackingStoreConnectionName; + set => this.TrackingStoreConnectionName = value; + } + + /// + /// Gets or sets the name prefix to use for history and instance tables in Azure Storage. + /// + /// + /// This property is only used when is specified. + /// If no prefix is specified, the default prefix value is "DurableTask". + /// + /// The prefix to use when naming the generated Azure tables. + public string TrackingStoreNamePrefix { get; set; } + + /// + /// Gets or sets whether the extension will automatically fetch large messages in orchestration status + /// queries. If set to false, the extension will return large messages as a blob url. + /// + /// A boolean indicating whether will automatically fetch large messages . + public bool FetchLargeMessagesAutomatically { get; set; } = true; + + /// + /// Gets or sets the maximum queue polling interval. + /// We update the default value to 1 second for the Flex Consumption SKU + /// because of a known cold start latency with Flex Consumption + /// and Durable Functions. + /// The default value is 30 seconds for all other SKUs. + /// + /// Maximum interval for polling control and work-item queues. + public TimeSpan MaxQueuePollingInterval + { + get + { + if (this.maxQueuePollingInterval == TimeSpan.Zero) + { + if (string.Equals(Environment.GetEnvironmentVariable("WEBSITE_SKU"), "FlexConsumption", StringComparison.OrdinalIgnoreCase)) + { + this.maxQueuePollingInterval = TimeSpan.FromSeconds(1); + } + else + { + this.maxQueuePollingInterval = TimeSpan.FromSeconds(30); + } + } + + return this.maxQueuePollingInterval; + } + + set + { + this.maxQueuePollingInterval = value; + } + } + + /// + /// Determines whether or not to use the old partition management strategy, or the new + /// strategy that is more resilient to split brain problems, at the potential expense + /// of scale out performance. + /// + /// A boolean indicating whether we use the legacy partition strategy. Defaults to false. + public bool UseLegacyPartitionManagement { get; set; } = false; + + /// + /// Determines whether to use the table partition management strategy. + /// This strategy reduces expected costs for an Azure Storage V2 account. + /// + /// A boolean indicating whether to use the table partition strategy. Defaults to false. + public bool UseTablePartitionManagement { get; set; } = true; + + /// + /// When false, when an orchestrator is in a terminal state (e.g. Completed, Failed, Terminated), events for that orchestrator are discarded. + /// Otherwise, events for a terminal orchestrator induce a replay. This may be used to recompute the state of the orchestrator in the "Instances Table". + /// + /// + /// Transactions across Azure Tables are not possible, so we independently update the "History table" and then the "Instances table" + /// to set the state of the orchestrator. + /// If a crash were to occur between these two updates, the state of the orchestrator in the "Instances table" would be incorrect. + /// By setting this configuration to true, you can recover from these inconsistencies by forcing a replay of the orchestrator in response + /// to a client event like a termination request or an external event, which gives the framework another opportunity to update the state of + /// the orchestrator in the "Instances table". To force a replay after enabling this configuration, just send any external event to the affected instanceId. + /// + public bool AllowReplayingTerminalInstances { get; set; } = false; + + /// + /// Specifies the timeout (in seconds) for read and write operations on the partition table using PartitionManager V3 (TablePartitionManager) in Azure Storage. + /// This helps detect potential silent hangs caused by internal Azure Storage retries. + /// If the timeout is exceeded, a PartitionManagerWarning is logged and the operation is retried. + /// Default is 2 seconds. + /// + /// + /// This setting is only effective when is set to true. + /// + public TimeSpan PartitionTableOperationTimeout { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Gets or sets the encoding strategy used for Azure Storage Queue messages. + /// The default is . + /// + public QueueClientMessageEncoding QueueClientMessageEncoding { get; set; } = QueueClientMessageEncoding.UTF8; + + /// + /// Throws an exception if the provided hub name violates any naming conventions for the storage provider. + /// + public void ValidateHubName(string hubName) + { + try + { + NameValidator.ValidateBlobName(hubName); + NameValidator.ValidateContainerName(hubName.ToLowerInvariant()); + NameValidator.ValidateTableName(hubName); + NameValidator.ValidateQueueName(hubName.ToLowerInvariant()); + } + catch (ArgumentException e) + { + throw new ArgumentException(GetTaskHubErrorString(hubName), e); + } + + if (hubName.Length > 50) + { + throw new ArgumentException(GetTaskHubErrorString(hubName)); + } + } + + private static string GetTaskHubErrorString(string hubName) + { + return $"Task hub name '{hubName}' should contain only alphanumeric characters, start with a letter, and have length between {MinTaskHubNameSize} and {MaxTaskHubNameSize}."; + } + + internal bool IsSanitizedHubName(string hubName, out string sanitizedHubName) + { + // Only alphanumeric characters are valid. + var validHubNameCharacters = hubName.ToCharArray().Where(char.IsLetterOrDigit); + + if (!validHubNameCharacters.Any()) + { + sanitizedHubName = "DefaultTaskHub"; + return false; + } + + // Azure Table storage requires that the task hub does not start with + // a number. If it does, prepend "t" to the beginning. + if (char.IsNumber(validHubNameCharacters.First())) + { + validHubNameCharacters = validHubNameCharacters.ToList(); + ((List)validHubNameCharacters).Insert(0, 't'); + } + + sanitizedHubName = new string(validHubNameCharacters + .Take(MaxTaskHubNameSize) + .ToArray()); + + if (sanitizedHubName.Length < MinTaskHubNameSize) + { + sanitizedHubName = sanitizedHubName + TaskHubPadding; + } + + if (string.Equals(hubName, sanitizedHubName)) + { + return true; + } + + return false; + } + + /// + /// Throws an exception if any of the settings of the storage provider are invalid. + /// + public void Validate(ILogger logger) + { + if (this.ControlQueueBatchSize <= 0) + { + throw new InvalidOperationException($"{nameof(this.ControlQueueBatchSize)} must be a non-negative integer."); + } + + if (this.PartitionCount < 1 || this.PartitionCount > 16) + { + throw new InvalidOperationException($"{nameof(this.PartitionCount)} must be an integer value between 1 and 16."); + } + + if (this.ControlQueueVisibilityTimeout < TimeSpan.FromMinutes(1) || + this.ControlQueueVisibilityTimeout > TimeSpan.FromMinutes(60)) + { + throw new InvalidOperationException($"{nameof(this.ControlQueueVisibilityTimeout)} must be between 1 and 60 minutes."); + } + + if (this.MaxQueuePollingInterval <= TimeSpan.Zero) + { + throw new InvalidOperationException($"{nameof(this.MaxQueuePollingInterval)} must be non-negative."); + } + + if (this.ControlQueueBufferThreshold < 1 || this.ControlQueueBufferThreshold > 1000) + { + throw new InvalidOperationException($"{nameof(this.ControlQueueBufferThreshold)} must be between 1 and 1000."); + } + + if (this.ControlQueueBatchSize > this.ControlQueueBufferThreshold) + { + logger.LogWarning($"{nameof(this.ControlQueueBatchSize)} cannot be larger than {nameof(this.ControlQueueBufferThreshold)}. Please adjust these values in your `host.json` settings for predictable performance"); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskMetricsProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskMetricsProvider.cs new file mode 100644 index 000000000..23183bfa6 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskMetricsProvider.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading.Tasks; +using Azure; +using DurableTask.AzureStorage; +using DurableTask.AzureStorage.Monitoring; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + internal class DurableTaskMetricsProvider + { + private readonly string hubName; + private readonly ILogger logger; + private readonly StorageAccountClientProvider storageAccountClientProvider; + private PerformanceHeartbeat heartbeat; + private DateTime heartbeatTimeStamp; + + private DisconnectedPerformanceMonitor performanceMonitor; + + public DurableTaskMetricsProvider( + string hubName, + ILogger logger, + DisconnectedPerformanceMonitor performanceMonitor, + StorageAccountClientProvider storageAccountClientProvider) + { + this.hubName = hubName; + this.logger = logger; + this.performanceMonitor = performanceMonitor; + this.storageAccountClientProvider = storageAccountClientProvider; + this.heartbeat = null; + this.heartbeatTimeStamp = DateTime.MinValue; + } + + public virtual async Task GetMetricsAsync() + { + DurableTaskTriggerMetrics metrics = new DurableTaskTriggerMetrics(); + + // Durable stores its own metrics, so we just collect them here + try + { + DisconnectedPerformanceMonitor performanceMonitor = this.GetPerformanceMonitor(); + + // We only want to call PulseAsync every 5 seconds + if (this.heartbeat == null || DateTime.UtcNow > this.heartbeatTimeStamp.AddSeconds(5)) + { + this.heartbeat = await performanceMonitor.PulseAsync(); + this.heartbeatTimeStamp = DateTime.UtcNow; + } + } + catch (Exception e) when (e.InnerException is RequestFailedException) + { + this.logger.LogWarning("{details}. HubName: {hubName}.", e.ToString(), this.hubName); + } + + if (this.heartbeat != null) + { + metrics.PartitionCount = this.heartbeat.PartitionCount; + metrics.ControlQueueLengths = JsonConvert.SerializeObject(this.heartbeat.ControlQueueLengths); + metrics.ControlQueueLatencies = JsonConvert.SerializeObject(this.heartbeat.ControlQueueLatencies); + metrics.WorkItemQueueLength = this.heartbeat.WorkItemQueueLength; + if (this.heartbeat.WorkItemQueueLatency > TimeSpan.Zero) + { + metrics.WorkItemQueueLatency = this.heartbeat.WorkItemQueueLatency.ToString(); + } + } + + return metrics; + } + + internal DisconnectedPerformanceMonitor GetPerformanceMonitor() + { + if (this.performanceMonitor == null) + { + if (this.storageAccountClientProvider == null) + { + throw new ArgumentNullException(nameof(this.storageAccountClientProvider)); + } + + this.performanceMonitor = new DisconnectedPerformanceMonitor(new AzureStorageOrchestrationServiceSettings + { + StorageAccountClientProvider = this.storageAccountClientProvider, + TaskHubName = this.hubName, + }); + } + + return this.performanceMonitor; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskScaleMonitor.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskScaleMonitor.cs new file mode 100644 index 000000000..3c4c390c5 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskScaleMonitor.cs @@ -0,0 +1,141 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DurableTask.AzureStorage.Monitoring; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + internal sealed class DurableTaskScaleMonitor : IScaleMonitor + { + private readonly string hubName; + private readonly ScaleMonitorDescriptor scaleMonitorDescriptor; + private readonly ILogger logger; + private readonly DurableTaskMetricsProvider durableTaskMetricsProvider; + + public DurableTaskScaleMonitor( + string functionId, + string hubName, + ILogger logger, + DurableTaskMetricsProvider durableTaskMetricsProvider) + { + this.hubName = hubName; + this.logger = logger; + this.durableTaskMetricsProvider = durableTaskMetricsProvider; + + // Scalers in Durable Functions are shared for all functions in the same task hub. + this.scaleMonitorDescriptor = new ScaleMonitorDescriptor(id: $"{functionId}-DurableTask-{hubName ?? "default"}".ToLower(CultureInfo.InvariantCulture), functionId: functionId); + } + + public ScaleMonitorDescriptor Descriptor + { + get + { + return this.scaleMonitorDescriptor; + } + } + + public DurableTaskMetricsProvider GetMetricsProvider() + { + return this.durableTaskMetricsProvider; + } + + async Task IScaleMonitor.GetMetricsAsync() + { + return await this.GetMetricsAsync(); + } + + public async Task GetMetricsAsync() + { + return await this.durableTaskMetricsProvider.GetMetricsAsync(); + } + + ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) + { + return this.GetScaleStatusCore(context.WorkerCount, context.Metrics?.Cast().ToArray()); + } + + public ScaleStatus GetScaleStatus(ScaleStatusContext context) + { + return this.GetScaleStatusCore(context.WorkerCount, context.Metrics?.ToArray()); + } + + private ScaleStatus GetScaleStatusCore(int workerCount, DurableTaskTriggerMetrics[] metrics) + { + var scaleStatus = new ScaleStatus() { Vote = ScaleVote.None }; + if (metrics == null) + { + return scaleStatus; + } + + var heartbeats = new PerformanceHeartbeat[metrics.Length]; + for (int i = 0; i < metrics.Length; ++i) + { + TimeSpan workItemQueueLatency; + bool parseResult = TimeSpan.TryParse(metrics[i].WorkItemQueueLatency, out workItemQueueLatency); + + heartbeats[i] = new PerformanceHeartbeat() + { + PartitionCount = metrics[i].PartitionCount, + WorkItemQueueLatency = parseResult ? workItemQueueLatency : TimeSpan.FromMilliseconds(0), + WorkItemQueueLength = metrics[i].WorkItemQueueLength, + }; + + if (metrics[i].ControlQueueLengths == null) + { + heartbeats[i].ControlQueueLengths = new List(); + } + else + { + heartbeats[i].ControlQueueLengths = JsonConvert.DeserializeObject>(metrics[i].ControlQueueLengths); + } + + if (metrics[i].ControlQueueLatencies == null) + { + heartbeats[i].ControlQueueLatencies = new List(); + } + else + { + heartbeats[i].ControlQueueLatencies = JsonConvert.DeserializeObject>(metrics[i].ControlQueueLatencies); + } + } + + DisconnectedPerformanceMonitor performanceMonitor = this.durableTaskMetricsProvider.GetPerformanceMonitor(); + var scaleRecommendation = performanceMonitor.MakeScaleRecommendation(workerCount, heartbeats.ToArray()); + + bool writeToUserLogs = false; + switch (scaleRecommendation?.Action) + { + case ScaleAction.AddWorker: + scaleStatus.Vote = ScaleVote.ScaleOut; + writeToUserLogs = true; + break; + case ScaleAction.RemoveWorker: + scaleStatus.Vote = ScaleVote.ScaleIn; + writeToUserLogs = true; + break; + default: + scaleStatus.Vote = ScaleVote.None; + break; + } + + if (writeToUserLogs) + { + this.logger.LogInformation( + "Durable Functions Trigger Scale Decision for {TaskHub}: {Vote}, Reason: {Reason}", + this.hubName, + scaleStatus.Vote, + scaleRecommendation?.Reason); + } + + return scaleStatus; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs new file mode 100644 index 000000000..b843aaf4e --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + internal class DurableTaskTargetScaler : ITargetScaler + { + private readonly DurableTaskMetricsProvider metricsProvider; + private readonly TargetScalerResult scaleResult; + private readonly DurabilityProvider durabilityProvider; + private readonly ILogger logger; + private readonly string scaler; + + public DurableTaskTargetScaler( + string scalerId, + DurableTaskMetricsProvider metricsProvider, + DurabilityProvider durabilityProvider, + ILogger logger) + { + this.scaler = scalerId; + this.metricsProvider = metricsProvider; + this.scaleResult = new TargetScalerResult(); + this.TargetScalerDescriptor = new TargetScalerDescriptor(this.scaler); + this.durabilityProvider = durabilityProvider; + this.logger = logger; + } + + public TargetScalerDescriptor TargetScalerDescriptor { get; } + + private int MaxConcurrentActivities => this.durabilityProvider.MaxConcurrentTaskActivityWorkItems; + + private int MaxConcurrentOrchestrators => this.durabilityProvider.MaxConcurrentTaskOrchestrationWorkItems; + + public async Task GetScaleResultAsync(TargetScalerContext context) + { + DurableTaskTriggerMetrics? metrics = null; + try + { + // This method is only invoked by the ScaleController, so it doesn't run in the Functions Host process. + metrics = await this.metricsProvider.GetMetricsAsync(); + + // compute activityWorkers: the number of workers we need to process all activity messages + var workItemQueueLength = metrics.WorkItemQueueLength; + double activityWorkers = Math.Ceiling(workItemQueueLength / (double)this.MaxConcurrentActivities); + + var serializedControlQueueLengths = metrics.ControlQueueLengths; + var controlQueueLengths = JsonConvert.DeserializeObject>(serializedControlQueueLengths); + + var controlQueueMessages = controlQueueLengths!.Sum(); + var activeControlQueues = controlQueueLengths!.Count(x => x > 0); + + // compute orchestratorWorkers: the number of workers we need to process all orchestrator messages. + // We bound this result to be no larger than the partition count + var upperBoundControlWorkers = Math.Ceiling(controlQueueMessages / (double)this.MaxConcurrentOrchestrators); + var orchestratorWorkers = Math.Min(activeControlQueues, upperBoundControlWorkers); + + int numWorkersToRequest = (int)Math.Max(activityWorkers, orchestratorWorkers); + this.scaleResult.TargetWorkerCount = numWorkersToRequest; + + // When running on ScaleController V3, ILogger logs are forwarded to the ScaleController's Kusto table. + // This works because this code does not execute in the Functions Host process, but in the ScaleController process, + // and the ScaleController is injecting it's own custom ILogger implementation that forwards logs to Kusto. + var metricsLog = $"Metrics: workItemQueueLength={workItemQueueLength}. controlQueueLengths={serializedControlQueueLengths}. " + + $"maxConcurrentOrchestrators={this.MaxConcurrentOrchestrators}. maxConcurrentActivities={this.MaxConcurrentActivities}"; + var scaleControllerLog = $"Target worker count for '{this.scaler}' is '{numWorkersToRequest}'. " + + metricsLog; + + // target worker count should never be negative + if (numWorkersToRequest < 0) + { + throw new InvalidOperationException("Number of workers to request cannot be negative"); + } + + this.logger.LogInformation(scaleControllerLog); + return this.scaleResult; + } + catch (Exception ex) + { + // We want to augment the exception with metrics information for investigation purposes + var metricsLog = $"Metrics: workItemQueueLength={metrics?.WorkItemQueueLength}. controlQueueLengths={metrics?.ControlQueueLengths}. " + + $"maxConcurrentOrchestrators={this.MaxConcurrentOrchestrators}. maxConcurrentActivities={this.MaxConcurrentActivities}"; + var errorLog = $"Error: target worker count for '{this.scaler}' resulted in exception. " + metricsLog; + throw new Exception(errorLog, ex); + } + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTriggerMetrics.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTriggerMetrics.cs new file mode 100644 index 000000000..2e30291d5 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTriggerMetrics.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +using System.Collections.Generic; +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + internal class DurableTaskTriggerMetrics : ScaleMetrics + { + /// + /// The number of partitions in the task hub. + /// + public virtual int PartitionCount { get; set; } + + /// + /// The number of messages across control queues. This will + /// be in the form of a serialized array of ints, e.g. "[1,2,3,4]". + /// + public virtual string ControlQueueLengths { get; set; } + + /// + /// The latency of messages across control queues. This will + /// be in the form of a serialized array of TimeSpans in string + /// format, e.g. "["00:00:00.0010000","00:00:00.0020000","00:00:00.0030000","00:00:00.0040000"]". + /// + public string ControlQueueLatencies { get; set; } + + /// + /// The number of messages in the work-item queue. + /// + public virtual int WorkItemQueueLength { get; set; } + + /// + /// The approximate age of the first work-item queue message. This + /// will be a TimeSpan in string format, e.g. "00:00:00.0010000". + /// + public string WorkItemQueueLatency { get; set; } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs new file mode 100644 index 000000000..b937cba52 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + // This class is copied from the previous Azure Storage client SDKs + // The following logic may require updating over time as the Azure Storage team discourages client-side validation + // that may grow stale as the server evolves. See here: https://github.com/Azure/azure-sdk-for-js/issues/13519#issuecomment-822420305 + internal static class NameValidator + { + private static readonly RegexOptions RegexOptions = RegexOptions.ExplicitCapture | RegexOptions.Singleline | RegexOptions.CultureInvariant; + + private static readonly Regex MetricsTableRegex = new Regex("^\\$Metrics(HourPrimary|MinutePrimary|HourSecondary|MinuteSecondary)?(Transactions)(Blob|Queue|Table)$", RegexOptions); + private static readonly Regex ShareContainerQueueRegex = new Regex("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions); + private static readonly Regex TableRegex = new Regex("^[A-Za-z][A-Za-z0-9]*$", RegexOptions); + + public static void ValidateBlobName(string blobName) + { + if (string.IsNullOrWhiteSpace(blobName)) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. The {0} name may not be null, empty, or whitespace only.", "blob")); + } + + if (blobName.Length < 1 || blobName.Length > 1024) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name length. The {0} name must be between {1} and {2} characters long.", "blob", 1, 1024)); + } + + int num = 0; + for (int i = 0; i < blobName.Length; i++) + { + if (blobName[i] == '/') + { + num++; + } + } + + if (num >= 254) + { + throw new ArgumentException("The count of URL path segments (strings between '/' characters) as part of the blob name cannot exceed 254."); + } + } + + public static void ValidateContainerName(string containerName) + { + if (!"$root".Equals(containerName, StringComparison.Ordinal) && !"$logs".Equals(containerName, StringComparison.Ordinal)) + { + ValidateShareContainerQueueHelper(containerName, "container"); + } + } + + public static void ValidateQueueName(string queueName) + { + ValidateShareContainerQueueHelper(queueName, "queue"); + } + + public static void ValidateTableName(string tableName) + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. The {0} name may not be null, empty, or whitespace only.", "table")); + } + + if (tableName.Length < 3 || tableName.Length > 63) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name length. The {0} name must be between {1} and {2} characters long.", "table", 3, 63)); + } + + if (!TableRegex.IsMatch(tableName) && !MetricsTableRegex.IsMatch(tableName) && !tableName.Equals("$MetricsCapacityBlob", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. Check MSDN for more information about valid {0} naming.", "table")); + } + } + + private static void ValidateShareContainerQueueHelper(string resourceName, string resourceType) + { + if (string.IsNullOrWhiteSpace(resourceName)) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. The {0} name may not be null, empty, or whitespace only.", resourceType)); + } + + if (resourceName.Length < 3 || resourceName.Length > 63) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name length. The {0} name must be between {1} and {2} characters long.", resourceType, 3, 63)); + } + + if (!ShareContainerQueueRegex.IsMatch(resourceName)) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. Check MSDN for more information about valid {0} naming.", resourceType)); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs new file mode 100644 index 000000000..e0d30c9a2 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs @@ -0,0 +1,131 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DurableTask.Core; +using DurableTask.Core.Entities; +using DurableTask.Core.History; +using DurableTask.Core.Query; +using Microsoft.Azure.WebJobs.Host.Scale; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// The backend storage provider that provides the actual durability of Durable Functions. + /// This is functionally a superset of and . + /// If the storage provider does not any of the Durable Functions specific operations, they can use this class + /// directly with the expectation that only those interfaces will be implemented. All of the Durable Functions specific + /// methods/operations are virtual and can be overwritten by creating a subclass. + /// + public class DurabilityProvider + { + internal const string NoConnectionDetails = "default"; + + private static readonly JObject EmptyConfig = new JObject(); + + private readonly string name; + private readonly IOrchestrationService innerService; + private readonly IOrchestrationServiceClient innerServiceClient; + private readonly IEntityOrchestrationService entityOrchestrationService; + private readonly string connectionName; + + /// + /// Creates the default . + /// + /// The name of the storage backend providing the durability. + /// The internal that provides functionality + /// for this classes implementions of . + /// The internal that provides functionality + /// for this classes implementions of . + /// The name of the app setting that stores connection details for the storage provider. + public DurabilityProvider(string storageProviderName, IOrchestrationService service, IOrchestrationServiceClient serviceClient, string connectionName) + { + this.name = storageProviderName ?? throw new ArgumentNullException(nameof(storageProviderName)); + this.innerService = service ?? throw new ArgumentNullException(nameof(service)); + this.entityOrchestrationService = service as IEntityOrchestrationService; + this.connectionName = connectionName ?? throw new ArgumentNullException(connectionName); + } + + /// + /// The name of the environment variable that contains connection details for how to connect to storage providers. + /// Corresponds to the for binding data. + /// + public virtual string ConnectionName => this.connectionName; + + /// + /// Specifies whether the durability provider supports Durable Entities. + /// + public virtual bool SupportsEntities => this.entityOrchestrationService?.EntityBackendProperties != null; + + /// + /// JSON representation of configuration to emit in telemetry. + /// + public virtual JObject ConfigurationJson => EmptyConfig; + + /// + /// Event source name (e.g. DurableTask-AzureStorage). + /// + public virtual string EventSourceName { get; set; } + + /// + public int MaxConcurrentTaskOrchestrationWorkItems => this.innerService.MaxConcurrentTaskOrchestrationWorkItems; + + /// + public int MaxConcurrentTaskActivityWorkItems => this.innerService.MaxConcurrentTaskActivityWorkItems; + + /// + /// Returns true if the stored connection string, ConnectionName, matches the input DurabilityProvider ConnectionName. + /// + /// The DurabilityProvider used to check for matching connection string names. + /// A boolean indicating whether the connection names match. + internal virtual bool ConnectionNameMatches(DurabilityProvider durabilityProvider) + { + return this.ConnectionName.Equals(durabilityProvider.ConnectionName); + } + + /// + /// Tries to obtain a scale monitor for autoscaling. + /// + /// Function id. + /// Function name. + /// Task hub name. + /// The name of the storage-specific connection settings. + /// The scale monitor. + /// True if autoscaling is supported, false otherwise. + public virtual bool TryGetScaleMonitor( + string functionId, + string functionName, + string hubName, + string connectionName, + out IScaleMonitor scaleMonitor) + { + scaleMonitor = null; + return false; + } + + /// + /// Tries to obtain a scaler for target based scaling. + /// + /// Function id. + /// Function name. + /// Task hub name. + /// The name of the storage-specific connection settings. + /// The target-based scaler. + /// True if target-based scaling is supported, false otherwise. + public virtual bool TryGetTargetScaler( + string functionId, + string functionName, + string hubName, + string connectionName, + out ITargetScaler targetScaler) + { + targetScaler = null; + return false; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs new file mode 100644 index 000000000..2b077381b --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Diagnostics; +using Microsoft.Azure.WebJobs.Description; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Attribute used to bind a function parameter to a , , or instance. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [DebuggerDisplay("TaskHub={TaskHub}, ConnectionName={ConnectionName}")] + [Binding] + public class DurableClientAttribute : Attribute, IEquatable + { + /// + /// Initializes a new instance of the class. + /// + public DurableClientAttribute() { } + + /// + /// Initializes a new instance of the class. + /// + /// Options to configure the IDurableClient created. + public DurableClientAttribute(DurableClientOptions durableClientOptions) + { + this.TaskHub = durableClientOptions.TaskHub; + this.ConnectionName = durableClientOptions.ConnectionName; + this.ExternalClient = durableClientOptions.IsExternalClient; + } + + /// + /// Optional. Gets or sets the name of the task hub in which the orchestration data lives. + /// + /// The task hub used by this binding. + /// + /// The default behavior is to use the task hub name specified in . + /// If no value exists there, then a default value will be used. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [AutoResolve] +#pragma warning restore CS0618 // Type or member is obsolete + public string TaskHub { get; set; } + + /// + /// Optional. Gets or sets the setting name for the app setting containing connection details used by this binding to connect + /// to instances of the storage provider other than the default one this application communicates with. + /// + /// The name of an app setting containing connection details. + /// + /// For Azure Storage the default behavior is to use the value of . + /// If no value exists there, then the default behavior is to use the standard `AzureWebJobsStorage` connection string for all storage usage. + /// + public string ConnectionName { get; set; } + + /// + /// Indicate if the client is External from the azure function where orchestrator functions are hosted. + /// + public bool ExternalClient { get; set; } + + /// + /// Returns a hash code for this attribute. + /// + /// A hash code for this attribute. + public override int GetHashCode() + { + unchecked + { + return + this.TaskHub?.GetHashCode() ?? 0 + + this.ConnectionName?.GetHashCode() ?? 0; + } + } + + /// + /// Compares two instances for value equality. + /// + /// The object to compare with. + /// true if the two attributes have the same configuration; otherwise false. + public override bool Equals(object obj) + { + return this.Equals(obj as DurableClientAttribute); + } + + /// + /// Compares two instances for value equality. + /// + /// The object to compare with. + /// true if the two attributes have the same configuration; otherwise false. + public bool Equals(DurableClientAttribute other) + { + if (other == null) + { + return false; + } + + if (object.ReferenceEquals(this, other)) + { + return true; + } + + return string.Equals(this.TaskHub, other.TaskHub, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.ConnectionName, other.ConnectionName, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs new file mode 100644 index 000000000..f15dce0cf --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Extension for registering a Durable Functions configuration with JobHostConfiguration. + /// + public static class DurableTaskJobHostConfigurationExtensions + { + + /// + /// Adds the Durable Task extension to the provided . + /// + /// The to configure. + /// Returns the provided . + public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder + .AddExtension() + .BindOptions(); + + IServiceCollection serviceCollection = builder.Services; + serviceCollection.AddAzureClientsCore(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + return builder; + } + + /// + /// Adds the and providers for the Durable Triggers. + /// + /// The to configure. + /// Returns the provided . + internal static IWebJobsBuilder AddDurableScaleForTrigger(this IWebJobsBuilder builder, TriggerMetadata triggerMetadata) + { + // this segment adheres to the followings pattern: https://github.com/Azure/azure-sdk-for-net/pull/38756 + DurableTaskTriggersScaleProvider provider = null; + builder.Services.AddSingleton(serviceProvider => + { + provider = new DurableTaskTriggersScaleProvider(serviceProvider.GetService>(), serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService>(), triggerMetadata); + return provider; + }); + + // Commenting out incremental scale model for hotfix release 3.0.0-rc.4, SC uses TBS by default + // builder.Services.AddSingleton(serviceProvider => serviceProvider.GetServices().Single(x => x == provider)); + builder.Services.AddSingleton(serviceProvider => serviceProvider.GetServices().Single(x => x == provider)); + return builder; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs new file mode 100644 index 000000000..23d85a580 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Minimal options class for Scale package - only contains what's needed for scaling decisions. + /// + public class DurableTaskOptions + { + public string HubName { get; set; } + + public IDictionary StorageProvider { get; set; } = new Dictionary(); + + public int? MaxConcurrentOrchestratorFunctions { get; set; } + + public int? MaxConcurrentActivityFunctions { get; set; } + + public int? MaxConcurrentEntityFunctions { get; set; } = null; + + public int? MaxEntityOperationBatchSize { get; set; } = null; + + public static void ResolveAppSettingOptions(DurableTaskOptions options, INameResolver nameResolver) + { + if (options.StorageProvider.TryGetValue("connectionName", out object connectionNameObj) && connectionNameObj is string connectionName) + { + options.StorageProvider["connectionName"] = nameResolver.Resolve(connectionName); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs new file mode 100644 index 000000000..49a35ba41 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs @@ -0,0 +1,75 @@ + +using System; +using System.Collections.Generic; +using System.Linq; + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + public class DurableTaskScaleExtension + { + private readonly IDurabilityProviderFactory durabilityProviderFactory; + private readonly DurabilityProvider defaultDurabilityProvider; + private readonly DurableTaskOptions options; + private readonly ILogger logger; + private readonly IEnumerable durabilityProviderFactories; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + public DurableTaskScaleExtension( + DurableTaskOptions options, + ILogger logger, + IEnumerable durabilityProviderFactories) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.durabilityProviderFactories = durabilityProviderFactories ?? throw new ArgumentNullException(nameof(durabilityProviderFactories)); + + this.durabilityProviderFactory = GetDurabilityProviderFactory(this.options, this.logger, this.durabilityProviderFactories); + this.defaultDurabilityProvider = this.durabilityProviderFactory.GetDurabilityProvider(); + } + + public IDurabilityProviderFactory DurabilityProviderFactory => this.durabilityProviderFactory; + public DurabilityProvider DefaultDurabilityProvider => this.defaultDurabilityProvider; + + private static IDurabilityProviderFactory GetDurabilityProviderFactory( + DurableTaskOptions options, + ILogger logger, + IEnumerable durabilityProviderFactories) + { + const string DefaultProvider = "AzureStorage"; + bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); + + if (!storageTypeIsConfigured) + { + try + { + IDurabilityProviderFactory defaultFactory = durabilityProviderFactories.First(f => f.Name.Equals(DefaultProvider)); + logger.LogInformation($"Using the default storage provider: {DefaultProvider}."); + return defaultFactory; + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Couldn't find the default storage provider: {DefaultProvider}.", e); + } + } + + try + { + IDurabilityProviderFactory selectedFactory = durabilityProviderFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); + logger.LogInformation($"Using the {storageType} storage provider."); + return selectedFactory; + } + catch (InvalidOperationException e) + { + IList factoryNames = durabilityProviderFactories.Select(f => f.Name).ToList(); + throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs new file mode 100644 index 000000000..c03b838cb --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -0,0 +1,205 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITargetScalerProvider + { + private const string AzureManagedProviderName = "azureManaged"; + + private readonly IScaleMonitor monitor; + private readonly ITargetScaler targetScaler; + private readonly DurableTaskOptions options; + private readonly INameResolver nameResolver; + private readonly ILoggerFactory loggerFactory; + private readonly IEnumerable durabilityProviderFactories; + + public DurableTaskTriggersScaleProvider( + IOptions durableTaskOptions, + INameResolver nameResolver, + ILoggerFactory loggerFactory, + IEnumerable durabilityProviderFactories, + TriggerMetadata triggerMetadata) + { + this.options = durableTaskOptions.Value; + this.nameResolver = nameResolver; + this.loggerFactory = loggerFactory; + this.durabilityProviderFactories = durabilityProviderFactories; + + string functionId = triggerMetadata.FunctionName; + var functionName = new FunctionName(functionId); + + this.GetOptions(triggerMetadata); + + IDurabilityProviderFactory durabilityProviderFactory = this.GetDurabilityProviderFactory(); + + DurabilityProvider defaultDurabilityProvider; + if (string.Equals(durabilityProviderFactory.Name, AzureManagedProviderName, StringComparison.OrdinalIgnoreCase)) + { + defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider(attribute: null, triggerMetadata); + } + else + { + defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider(); + } + + // Note: `this.options` is populated from the trigger metadata above + string? connectionName = GetConnectionName(durabilityProviderFactory, this.options); + + var logger = loggerFactory.CreateLogger(); + logger.LogInformation( + "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}'", + triggerMetadata.FunctionName, + connectionName); + + this.targetScaler = ScaleUtils.GetTargetScaler( + defaultDurabilityProvider, + functionId, + functionName, + connectionName, + this.options.HubName); + + this.monitor = ScaleUtils.GetScaleMonitor( + defaultDurabilityProvider, + functionId, + functionName, + connectionName, + this.options.HubName); + } + + private static string? GetConnectionName(IDurabilityProviderFactory durabilityProviderFactory, DurableTaskOptions options) + { + if (durabilityProviderFactory is AzureStorageDurabilityProviderFactory azureStorageDurabilityProviderFactory) + { + // First, look for the connection name in the options + var azureStorageOptions = new AzureStorageOptions(); + if (options != null && options.StorageProvider != null) + { + var json = JsonSerializer.Serialize(options.StorageProvider); + var newOptions = JsonSerializer.Deserialize(json); + if (newOptions != null) + { + foreach (var prop in typeof(AzureStorageOptions).GetProperties()) + { + var value = prop.GetValue(newOptions); + if (value != null) + { + prop.SetValue(azureStorageOptions, value); + } + } + } + } + + // If the connection name is not found in the options, use the default connection name from the factory + return azureStorageOptions.ConnectionName ?? azureStorageDurabilityProviderFactory.DefaultConnectionName; + } + else + { + return null; + } + } + + private void GetOptions(TriggerMetadata triggerMetadata) + { + // the metadata is the sync triggers payload + var metadata = triggerMetadata.Metadata.ToObject(); + + // The property `taskHubName` is always expected in the SyncTriggers payload + this.options.HubName = metadata?.TaskHubName ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none. Payload: {triggerMetadata.Metadata}"); + if (metadata?.MaxConcurrentActivityFunctions != null) + { + this.options.MaxConcurrentActivityFunctions = metadata?.MaxConcurrentActivityFunctions; + } + + if (metadata?.MaxConcurrentOrchestratorFunctions != null) + { + this.options.MaxConcurrentOrchestratorFunctions = metadata?.MaxConcurrentOrchestratorFunctions; + } + + if (metadata?.StorageProvider != null) + { + this.options.StorageProvider = metadata?.StorageProvider; + } + + DurableTaskOptions.ResolveAppSettingOptions(this.options, this.nameResolver); + } + + private IDurabilityProviderFactory GetDurabilityProviderFactory() + { + var logger = this.loggerFactory.CreateLogger(); + return GetDurabilityProviderFactory(this.options, logger, this.durabilityProviderFactories); + } + + private static IDurabilityProviderFactory GetDurabilityProviderFactory(DurableTaskOptions options, ILogger logger, IEnumerable orchestrationServiceFactories) + { + const string DefaultProvider = "AzureStorage"; + + bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); + + if (!storageTypeIsConfigured) + { + try + { + IDurabilityProviderFactory defaultFactory = orchestrationServiceFactories.First(f => f.Name.Equals(DefaultProvider)); + logger.LogInformation($"Using the default storage provider: {DefaultProvider}."); + return defaultFactory; + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Couldn't find the default storage provider: {DefaultProvider}.", e); + } + } + + try + { + IDurabilityProviderFactory selectedFactory = orchestrationServiceFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); + logger.LogInformation($"Using the {storageType} storage provider."); + return selectedFactory; + } + catch (InvalidOperationException e) + { + IList factoryNames = orchestrationServiceFactories.Select(f => f.Name).ToList(); + throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); + } + } + + public IScaleMonitor GetMonitor() + { + return this.monitor; + } + + public ITargetScaler GetTargetScaler() + { + return this.targetScaler; + } + + /// + /// Captures the relevant DF SyncTriggers JSON properties for making scaling decisions. + /// + internal class DurableTaskMetadata + { + [JsonPropertyName("taskHubName")] + public string? TaskHubName { get; set; } + + [JsonPropertyName("maxConcurrentOrchestratorFunctions")] + public int? MaxConcurrentOrchestratorFunctions { get; set; } + + [JsonPropertyName("maxConcurrentActivityFunctions")] + public int? MaxConcurrentActivityFunctions { get; set; } + + [JsonPropertyName("storageProvider")] + public IDictionary? StorageProvider { get; set; } + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs b/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs new file mode 100644 index 000000000..a82cd134e --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// The name of a durable function. + /// + internal struct FunctionName : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + /// The name of the function. + public FunctionName(string name) + { + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the name of the function without the version. + /// + /// + /// The name of the activity function without the version. + /// + public string Name { get; } + + /// + /// Compares two objects for equality. + /// + /// The first to compare. + /// The second to compare. + /// true if the two objects are equal; otherwise false. + public static bool operator ==(FunctionName a, FunctionName b) + { + return a.Equals(b); + } + + /// + /// Compares two objects for inequality. + /// + /// The first to compare. + /// The second to compare. + /// true if the two objects are not equal; otherwise false. + public static bool operator !=(FunctionName a, FunctionName b) + { + return !a.Equals(b); + } + + /// + /// Gets a value indicating whether to objects + /// are equal using value semantics. + /// + /// The other object to compare to. + /// true if the two objects are equal using value semantics; otherwise false. + public bool Equals(FunctionName other) + { + return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets a value indicating whether to objects + /// are equal using value semantics. + /// + /// The other object to compare to. + /// true if the two objects are equal using value semantics; otherwise false. + public override bool Equals(object other) + { + if (!(other is FunctionName)) + { + return false; + } + + return this.Equals((FunctionName)other); + } + + /// + /// Calculates a hash code value for the current instance. + /// + /// A 32-bit hash code value. + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name); + } + + /// + /// Gets the string value of the current instance. + /// + /// The name and optional version of the current instance. + public override string ToString() + { + return this.Name; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs new file mode 100644 index 000000000..702fe1202 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +using System; +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Interface defining methods to build instances of . + /// + public interface IDurabilityProviderFactory + { + /// + /// Specifies the Durability Provider Factory name. + /// + string Name { get; } + + /// + /// Creates or retrieves a durability provider to be used throughout the extension. + /// + /// An durability provider to be used by the Durable Task Extension. + DurabilityProvider GetDurabilityProvider(); + + /// + /// Creates or retrieves a cached durability provider to be used in a given function execution. + /// + /// A durable client attribute with parameters for the durability provider. + /// A durability provider to be used by a client function. + DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute); + + /// + /// Creates or retrieves a cached durability provider to be used in a given function execution. + /// + /// A durable client attribute with parameters for the durability provider. + /// Trigger metadata used to create IOrchestrationService for functions scale scenarios. + /// A durability provider to be used by a client function. + DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute, TriggerMetadata triggerMetadata) + { + // This method is not supported by this provider. + // Only providers that require TriggerMetadata for scale should implement it. + throw new NotImplementedException("This provider does not support GetDurabilityProvider with TriggerMetadata."); + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs new file mode 100644 index 000000000..44f7ade5e --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable +using Azure.Data.Tables; +using Azure.Storage.Blobs; +using Azure.Storage.Queues; +using DurableTask.AzureStorage; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Defines methods for retrieving service client providers based on the connection name. + /// + internal interface IStorageServiceClientProviderFactory + { + /// + /// Gets the used + /// for accessing the Azure Storage Blob Service associated with the . + /// + /// The name associated with the connection information. + /// The corresponding . + IStorageServiceClientProvider GetBlobClientProvider(string connectionName); + + /// + /// Gets the used + /// for accessing the Azure Storage Queue Service associated with the . + /// + /// The name associated with the connection information. + /// The corresponding . + IStorageServiceClientProvider GetQueueClientProvider(string connectionName); + + /// + /// Gets the used + /// for accessing the Azure Storage Table Service associated with the . + /// + /// The name associated with the connection information. + /// The corresponding . + IStorageServiceClientProvider GetTableClientProvider(string connectionName); + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs b/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs new file mode 100644 index 000000000..a7af76590 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + internal static class ScaleUtils + { + internal static IScaleMonitor GetScaleMonitor(DurabilityProvider durabilityProvider, string functionId, FunctionName functionName, string? connectionName, string hubName) + { + if (durabilityProvider.TryGetScaleMonitor( + functionId, + functionName.Name, + hubName, + connectionName, + out IScaleMonitor scaleMonitor)) + { + return scaleMonitor; + } + else + { + // the durability provider does not support runtime scaling. + // Create an empty scale monitor to avoid exceptions (unless runtime scaling is actually turned on). + return new NoOpScaleMonitor($"{functionId}-DurableTaskTrigger-{hubName}".ToLower(), functionId); + } + } + + /// + /// A placeholder scale monitor, can be used by durability providers that do not support runtime scaling. + /// This is required to allow operation of those providers even if runtime scaling is turned off + /// see discussion https://github.com/Azure/azure-functions-durable-extension/pull/1009/files#r341767018. + /// + internal sealed class NoOpScaleMonitor : IScaleMonitor + { + /// + /// Construct a placeholder scale monitor. + /// + /// A descriptive name. + /// The function ID. + public NoOpScaleMonitor(string name, string functionId) + { + this.Descriptor = new ScaleMonitorDescriptor(name, functionId); + } + + /// + /// A descriptive name. + /// + public ScaleMonitorDescriptor Descriptor { get; private set; } + + /// + Task IScaleMonitor.GetMetricsAsync() + { + throw new InvalidOperationException("The current DurableTask backend configuration does not support runtime scaling"); + } + + /// + ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) + { + throw new InvalidOperationException("The current DurableTask backend configuration does not support runtime scaling"); + } + } + +#pragma warning disable SA1201 // Elements should appear in the correct order + internal static ITargetScaler GetTargetScaler(DurabilityProvider durabilityProvider, string functionId, FunctionName functionName, string? connectionName, string hubName) +#pragma warning restore SA1201 // Elements should appear in the correct order + { + if (durabilityProvider.TryGetTargetScaler( + functionId, + functionName.Name, + hubName, + connectionName, + out ITargetScaler targetScaler)) + { + return targetScaler; + } + else + { + // the durability provider does not support target-based scaling. + // Create an empty target scaler to avoid exceptions (unless target-based scaling is actually turned on). + return new NoOpTargetScaler(functionId); + } + } + + internal sealed class NoOpTargetScaler : ITargetScaler + { + /// + /// Construct a placeholder target scaler. + /// + /// The function ID. + public NoOpTargetScaler(string functionId) + { + this.TargetScalerDescriptor = new TargetScalerDescriptor(functionId); + } + + public TargetScalerDescriptor TargetScalerDescriptor { get; } + + public Task GetScaleResultAsync(TargetScalerContext context) + { + throw new NotSupportedException("The current DurableTask backend configuration does not support target-based scaling"); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj new file mode 100644 index 000000000..4262d689d --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj @@ -0,0 +1,63 @@ + + + + net6.0 + Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale + Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale + 3 + 6 + 0 + $(MajorVersion).$(MinorVersion).$(PatchVersion) + $(MajorVersion).$(MinorVersion).$(PatchVersion) + $(MajorVersion).0.0.0 + Microsoft Corporation + 9.0 + true + ..\..\sign.snk + true + true + embedded + NU5125;SA0001 + + false + + + + $(MajorVersion).$(MinorVersion).$(PatchVersion) + + + $(MajorVersion).$(MinorVersion).$(PatchVersion)-$(VersionSuffix) + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + true + + + + From 0b2b2c6b8e3eb74da1495e934d45daa66d2b1efe Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Mon, 3 Nov 2025 09:56:55 -0800 Subject: [PATCH 02/25] update azurestorage and update sql --- Directory.Packages.props | 2 + WebJobs.Extensions.DurableTask.sln | 7 + .../AzureStorageDurabilityProviderFactory.cs | 270 --------- .../AzureStorage/AzureStorageOptions.cs | 343 ----------- ...cs => AzureStorageScalabilityProvider .cs} | 35 +- .../AzureStorageScalabilityProviderFactory.cs | 239 ++++++++ .../AzureStorage/ConnectionStringNames.cs | 16 + .../AzureStorage/DurableTaskTargetScaler.cs | 10 +- .../AzureStorage/NameValidator.cs | 97 ---- .../DurableClientAttribute.cs | 108 ---- ...rableTaskJobHostConfigurationExtensions.cs | 14 +- .../DurableTaskScaleExtension.cs | 47 +- ...kOptions.cs => DurableTaskScaleOptions.cs} | 8 +- .../DurableTaskTriggersScaleProvider.cs | 113 ++-- .../IConnectionInfoResolver.cs | 21 + .../INameResolver.cs | 19 + ...tory.cs => IScalabilityProviderFactory.cs} | 16 +- .../IStorageServiceClientProviderFactory.cs | 28 +- ...lityProvider.cs => ScalabilityProvider.cs} | 62 +- .../ScaleUtils.cs | 4 +- .../Sql/SqlServerMetricsProvider.cs | 44 ++ .../Sql/SqlServerScalabilityProvider.cs | 97 ++++ .../SqlServerScalabilityProviderFactory.cs | 422 ++++++++++++++ .../Sql/SqlServerScaleMetric.cs | 20 + .../Sql/SqlServerScaleMonitor.cs | 79 +++ .../Sql/SqlServerTargetScaler.cs | 37 ++ .../StorageServiceClientProviderFactory.cs | 83 +++ ...ebJobs.Extensions.DurableTask.Scale.csproj | 12 +- .../WebJobsConnectionInfoProvider.cs | 54 ++ ...eStorageScalabilityProviderFactoryTests.cs | 463 +++++++++++++++ .../AzureStorageScalabilityProviderTests.cs | 209 +++++++ .../DurableTaskScaleMonitorTests.cs | 266 +++++++++ .../DurableTaskTargetScalerTests.cs | 88 +++ ...TaskJobHostConfigurationExtensionsTests.cs | 400 +++++++++++++ ...qlServerScalabilityProviderFactoryTests.cs | 534 ++++++++++++++++++ .../Sql/SqlServerScalabilityProviderTests.cs | 178 ++++++ .../Sql/SqlServerScaleMonitorTests.cs | 222 ++++++++ .../Sql/SqlServerTargetScalerTests.cs | 81 +++ test/ScaleTests/Sql/SqlServerTestHelpers.cs | 72 +++ test/ScaleTests/TestHelpers.cs | 68 +++ test/ScaleTests/TestLoggerProvider.cs | 85 +++ ....Extensions.DurableTask.Scale.Tests.csproj | 44 ++ test/ScaleTests/xunit.runner.json | 8 + 43 files changed, 3988 insertions(+), 1037 deletions(-) delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs rename src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/{AzureStorageDurabilityProvider.cs => AzureStorageScalabilityProvider .cs} (71%) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs rename src/WebJobs.Extensions.DurableTask.Scale/{DurableTaskOptions.cs => DurableTaskScaleOptions.cs} (77%) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs rename src/WebJobs.Extensions.DurableTask.Scale/{IDurabilityProviderFactory.cs => IScalabilityProviderFactory.cs} (64%) rename src/WebJobs.Extensions.DurableTask.Scale/{DurabilityProvider.cs => ScalabilityProvider.cs} (56%) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs create mode 100644 test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs create mode 100644 test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs create mode 100644 test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs create mode 100644 test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs create mode 100644 test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs create mode 100644 test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs create mode 100644 test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs create mode 100644 test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs create mode 100644 test/ScaleTests/Sql/SqlServerTargetScalerTests.cs create mode 100644 test/ScaleTests/Sql/SqlServerTestHelpers.cs create mode 100644 test/ScaleTests/TestHelpers.cs create mode 100644 test/ScaleTests/TestLoggerProvider.cs create mode 100644 test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj create mode 100644 test/ScaleTests/xunit.runner.json diff --git a/Directory.Packages.props b/Directory.Packages.props index 95861ff7f..14cf4311a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,6 +31,8 @@ + + diff --git a/WebJobs.Extensions.DurableTask.sln b/WebJobs.Extensions.DurableTask.sln index ade393d1c..82b1fc441 100644 --- a/WebJobs.Extensions.DurableTask.sln +++ b/WebJobs.Extensions.DurableTask.sln @@ -62,6 +62,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotNetIsolated", "test\Smok EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.DurableTask", "src\Worker.Extensions.DurableTask\Worker.Extensions.DurableTask.csproj", "{5F5FAF27-D6B8-4A60-ACF2-F63D13F89CA2}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebJobs.Extensions.DurableTask.Scale", "src\WebJobs.Extensions.DurableTask.Scale\WebJobs.Extensions.DurableTask.Scale.csproj", "{A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{B7FBBE6E-9AC7-4CEB-9B80-6FD9AE74415A}" ProjectSection(SolutionItems) = preProject azure-pipelines.yml = azure-pipelines.yml @@ -126,6 +128,10 @@ Global {5F5FAF27-D6B8-4A60-ACF2-F63D13F89CA2}.Debug|Any CPU.Build.0 = Debug|Any CPU {5F5FAF27-D6B8-4A60-ACF2-F63D13F89CA2}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F5FAF27-D6B8-4A60-ACF2-F63D13F89CA2}.Release|Any CPU.Build.0 = Release|Any CPU + {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Release|Any CPU.Build.0 = Release|Any CPU {FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -162,6 +168,7 @@ Global {9B0F4A0A-1B18-4E98-850A-14A2F76F673D} = {A8CF6993-258A-484A-AF6D-6CC88D36AF93} {FF6CD07A-A4BF-43C5-B14E-213328DEB835} = {9B0F4A0A-1B18-4E98-850A-14A2F76F673D} {5F5FAF27-D6B8-4A60-ACF2-F63D13F89CA2} = {7EC858EE-3481-4A82-AED4-CB00C34F42D0} + {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9} = {7EC858EE-3481-4A82-AED4-CB00C34F42D0} {7387E723-E153-4B7A-B105-8C67BFBD48CF} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} {FC8AD123-F949-4D21-B817-E5A4BBF7F69B} = {7387E723-E153-4B7A-B105-8C67BFBD48CF} {76DEC17C-BF6A-498A-8E8A-7D6CB2E03284} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs deleted file mode 100644 index ff915bb8a..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProviderFactory.cs +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System; -using System.Text.Json; -using DurableTask.AzureStorage; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage -{ - public class AzureStorageDurabilityProviderFactory : IDurabilityProviderFactory - { - private const string LoggerName = "Host.Triggers.DurableTask.AzureStorage"; - internal const string ProviderName = "AzureStorage"; - - private readonly DurableTaskOptions options; - private readonly IStorageServiceClientProviderFactory clientProviderFactory; - private readonly AzureStorageOptions azureStorageOptions; - private readonly INameResolver nameResolver; - private readonly ILoggerFactory loggerFactory; - private readonly bool useSeparateQueueForEntityWorkItems; - private readonly bool inConsumption; // If true, optimize defaults for consumption - private AzureStorageDurabilityProvider defaultStorageProvider; - - // Must wait to get settings until we have validated taskhub name. - private bool hasValidatedOptions; - private AzureStorageOrchestrationServiceSettings defaultSettings; - - /// - /// - /// - /// - /// - /// - /// - /// - /// - public AzureStorageDurabilityProviderFactory( - IOptions options, - IStorageServiceClientProviderFactory clientProviderFactory, - INameResolver nameResolver, - ILoggerFactory loggerFactory, -#pragma warning disable CS0612 // Type or member is obsolete - IPlatformInformation platformInfo) -#pragma warning restore CS0612 // Type or member is obsolete - { - // this constructor may be called by dependency injection even if the AzureStorage provider is not selected - // in that case, return immediately, since this provider is not actually used, but can still throw validation errors - if (options.Value.StorageProvider.TryGetValue("type", out object value) - && value is string s - && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - this.options = options.Value; - this.clientProviderFactory = clientProviderFactory ?? throw new ArgumentNullException(nameof(clientProviderFactory)); - this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); - this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - - this.azureStorageOptions = new AzureStorageOptions(); - this.inConsumption = platformInfo.IsInConsumptionPlan(); - - // The consumption plan has different performance characteristics so we provide - // different defaults for key configuration values. - int maxConcurrentOrchestratorsDefault = this.inConsumption ? 5 : 10 * Environment.ProcessorCount; - int maxConcurrentActivitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount; - int maxConcurrentEntitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount; - int maxEntityOperationBatchSizeDefault = this.inConsumption ? 50 : 5000; - - if (this.inConsumption) - { - WorkerRuntimeType language = platformInfo.GetWorkerRuntimeType(); - if (language == WorkerRuntimeType.Python) - { - this.azureStorageOptions.ControlQueueBufferThreshold = 32; - } - else - { - this.azureStorageOptions.ControlQueueBufferThreshold = 128; - } - } - - WorkerRuntimeType runtimeType = platformInfo.GetWorkerRuntimeType(); - if (runtimeType == WorkerRuntimeType.DotNetIsolated || - runtimeType == WorkerRuntimeType.Java || - runtimeType == WorkerRuntimeType.Custom) - { - this.useSeparateQueueForEntityWorkItems = true; - } - - // The following defaults are only applied if the customer did not explicitely set them on `host.json` - this.options.MaxConcurrentOrchestratorFunctions = this.options.MaxConcurrentOrchestratorFunctions ?? maxConcurrentOrchestratorsDefault; - this.options.MaxConcurrentActivityFunctions = this.options.MaxConcurrentActivityFunctions ?? maxConcurrentActivitiesDefault; - this.options.MaxConcurrentEntityFunctions = this.options.MaxConcurrentEntityFunctions ?? maxConcurrentEntitiesDefault; - this.options.MaxEntityOperationBatchSize = this.options.MaxEntityOperationBatchSize ?? maxEntityOperationBatchSizeDefault; - - // Override the configuration defaults with user-provided values in host.json, if any. - if (this.options.StorageProvider != null) - { - var json = JsonSerializer.Serialize(this.options.StorageProvider); - var newOptions = JsonSerializer.Deserialize(json); - if (newOptions != null) - { - // Copy deserialized values into the existing options object - foreach (var prop in typeof(AzureStorageOptions).GetProperties()) - { - var value = prop.GetValue(newOptions); - if (value != null) - { - prop.SetValue(this.azureStorageOptions, value); - } - } - } - } - - var logger = loggerFactory.CreateLogger(nameof(this.azureStorageOptions)); - this.azureStorageOptions.Validate(logger); - - this.DefaultConnectionName = this.azureStorageOptions.ConnectionName ?? ConnectionStringNames.Storage; - } - - public virtual string Name => ProviderName; - - public string DefaultConnectionName { get; } - - // This method should not be called before the app settings are resolved into the options. - // Because of this, we wait to validate the options until right before building a durability provider, rather - // than in the Factory constructor. - private void EnsureDefaultClientSettingsInitialized() - { - if (!this.hasValidatedOptions) - { - if (!this.options.IsDefaultHubName()) - { - this.azureStorageOptions.ValidateHubName(this.options.HubName); - } - else if (!this.azureStorageOptions.IsSanitizedHubName(this.options.HubName, out string sanitizedHubName)) - { - this.options.SetDefaultHubName(sanitizedHubName); - } - - this.defaultSettings = this.GetAzureStorageOrchestrationServiceSettings(); - this.hasValidatedOptions = true; - } - } - - public virtual DurabilityProvider GetDurabilityProvider() - { - this.EnsureDefaultClientSettingsInitialized(); - if (this.defaultStorageProvider == null) - { - var defaultService = new AzureStorageOrchestrationService(this.defaultSettings); - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - this.defaultStorageProvider = new AzureStorageDurabilityProvider( - defaultService, - this.clientProviderFactory, - this.DefaultConnectionName, - this.azureStorageOptions, - logger); - } - - return this.defaultStorageProvider; - } - - public virtual DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute) - { - if (!attribute.ExternalClient) - { - this.EnsureDefaultClientSettingsInitialized(); - } - - return this.GetAzureStorageStorageProvider(attribute); - } - - private AzureStorageDurabilityProvider GetAzureStorageStorageProvider(DurableClientAttribute attribute) - { - string connectionName = attribute.ConnectionName ?? this.DefaultConnectionName; - AzureStorageOrchestrationServiceSettings settings = this.GetAzureStorageOrchestrationServiceSettings(connectionName, attribute.TaskHub); - - AzureStorageDurabilityProvider innerClient; - - // Need to check this.defaultStorageProvider != null for external clients that call GetDurabilityProvider(attribute) - // which never initializes the defaultStorageProvider. - if (string.Equals(this.DefaultConnectionName, connectionName, StringComparison.OrdinalIgnoreCase) && - string.Equals(this.options.HubName, settings.TaskHubName, StringComparison.OrdinalIgnoreCase) && - this.defaultStorageProvider != null) - { - // It's important that clients use the same AzureStorageOrchestrationService instance - // as the host when possible to ensure we any send operations can be picked up - // immediately instead of waiting for the next queue polling interval. - innerClient = this.defaultStorageProvider; - } - else - { - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - innerClient = new AzureStorageDurabilityProvider( - new AzureStorageOrchestrationService(settings), - this.clientProviderFactory, - connectionName, - this.azureStorageOptions, - logger); - } - - return innerClient; - } - - internal AzureStorageOrchestrationServiceSettings GetAzureStorageOrchestrationServiceSettings( - string connectionName = null, - string taskHubNameOverride = null) - { - - var settings = new AzureStorageOrchestrationServiceSettings - { - StorageAccountClientProvider = this.clientProviderFactory.GetClientProvider(connectionName ?? this.DefaultConnectionName), - TaskHubName = taskHubNameOverride ?? this.options.HubName, - PartitionCount = this.azureStorageOptions.PartitionCount, - ControlQueueBatchSize = this.azureStorageOptions.ControlQueueBatchSize, - ControlQueueBufferThreshold = this.azureStorageOptions.ControlQueueBufferThreshold, - ControlQueueVisibilityTimeout = this.azureStorageOptions.ControlQueueVisibilityTimeout, - WorkItemQueueVisibilityTimeout = this.azureStorageOptions.WorkItemQueueVisibilityTimeout, - MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} needs a default value"), - MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} needs a default value"), - MaxConcurrentTaskEntityWorkItems = this.options.MaxConcurrentEntityFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentEntityFunctions)} needs a default value"), - ExtendedSessionsEnabled = this.options.ExtendedSessionsEnabled, - ExtendedSessionIdleTimeout = extendedSessionTimeout, - MaxQueuePollingInterval = this.azureStorageOptions.MaxQueuePollingInterval, - TrackingServiceClientProvider = this.azureStorageOptions.TrackingStoreConnectionName != null - ? this.clientProviderFactory.GetTrackingClientProvider(this.azureStorageOptions.TrackingStoreConnectionName) - : null, - FetchLargeMessageDataEnabled = this.azureStorageOptions.FetchLargeMessagesAutomatically, - ThrowExceptionOnInvalidDedupeStatus = true, - UseAppLease = this.options.UseAppLease, - AppLeaseOptions = this.options.AppLeaseOptions, - AppName = EndToEndTraceHelper.LocalAppName, - LoggerFactory = this.loggerFactory, - UseLegacyPartitionManagement = this.azureStorageOptions.UseLegacyPartitionManagement, - UseTablePartitionManagement = this.azureStorageOptions.UseTablePartitionManagement, - UseSeparateQueueForEntityWorkItems = this.useSeparateQueueForEntityWorkItems, - EntityMessageReorderWindowInMinutes = this.options.EntityMessageReorderWindowInMinutes, - MaxEntityOperationBatchSize = this.options.MaxEntityOperationBatchSize, - AllowReplayingTerminalInstances = this.azureStorageOptions.AllowReplayingTerminalInstances, - PartitionTableOperationTimeout = this.azureStorageOptions.PartitionTableOperationTimeout, - QueueClientMessageEncoding = this.azureStorageOptions.QueueClientMessageEncoding, - }; - - if (this.inConsumption) - { - settings.MaxStorageOperationConcurrency = 25; - } - - // When running on App Service VMSS stamps, these environment variables are the best way - // to enure unqique worker names - string stamp = this.nameResolver.Resolve("WEBSITE_CURRENT_STAMPNAME"); - string roleInstance = this.nameResolver.Resolve("RoleInstanceId"); - if (!string.IsNullOrEmpty(stamp) && !string.IsNullOrEmpty(roleInstance)) - { - settings.WorkerId = $"{stamp}:{roleInstance}"; - } - - if (!string.IsNullOrEmpty(this.azureStorageOptions.TrackingStoreNamePrefix)) - { - settings.TrackingStoreNamePrefix = this.azureStorageOptions.TrackingStoreNamePrefix; - } - - return settings; - } - } - } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs deleted file mode 100644 index 8bddb5e1a..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageOptions.cs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using DurableTask.AzureStorage; - -using Microsoft.Extensions.Logging; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage -{ - /// - /// Configuration options for the Azure Storage storage provider. - /// - public class AzureStorageOptions - { - // 45 alphanumeric characters gives us a buffer in our table/queue/blob container names. - private const int MaxTaskHubNameSize = 45; - private const int MinTaskHubNameSize = 3; - private const string TaskHubPadding = "Hub"; - private TimeSpan maxQueuePollingInterval; - - /// - /// Gets or sets the name of the Azure Storage connection information used to manage the underlying Azure Storage resources. - /// - /// - /// The value may refer to either a connection string or the configuration section containing connection metadata. - /// The default behavior is to use the standard AzureWebJobsStorage connection for all storage usage. - /// - /// - /// The name of a connection-related key that exists in the app's application settings. The value may refer to - /// a connection string or the section detailing additional connection metadata. - /// - public string ConnectionName { get; set; } - - /// - /// Gets or sets the name of the Azure Storage connection string used to manage the underlying Azure Storage resources. - /// - /// - /// If not specified, the default behavior is to use the standard `AzureWebJobsStorage` connection string for all storage usage. - /// - /// The name of a connection string that exists in the app's application settings. - [Obsolete("Please use ConnectionName instead.")] - public string ConnectionStringName - { - get => this.ConnectionName; - set => this.ConnectionName = value; - } - - /// - /// Gets or sets the number of messages to pull from the control queue at a time. - /// - /// - /// Messages pulled from the control queue are buffered in memory until the internal - /// dispatcher is ready to process them. - /// - /// A positive integer configured by the host. The default value is 32. - public int ControlQueueBatchSize { get; set; } = 32; - - /// - /// Gets or sets the partition count for the control queue. - /// - /// - /// Increasing the number of partitions will increase the number of workers - /// that can concurrently execute orchestrator functions. However, increasing - /// the partition count can also increase the amount of load placed on the storage - /// account and on the thread pool if the number of workers is smaller than the - /// number of partitions. - /// - /// A positive integer between 1 and 16. The default value is 4. - public int PartitionCount { get; set; } = 4; - - /// - /// Gets or set the number of control queue messages that can be buffered in memory - /// at a time, at which point the dispatcher will wait before dequeuing any additional - /// messages. The default is 256. The maximum value is 1000. - /// - /// - /// Increasing this value can improve orchestration throughput by pre-fetching more - /// orchestration messages from control queues. The downside is that it increases the - /// possibility of duplicate function executions if partition leases move between app - /// instances. This most often occurs when the number of app instances changes. - /// - /// A non-negative integer between 0 and 1000. The default value is 256. - public int ControlQueueBufferThreshold { get; set; } = 256; - - /// - /// Gets or sets the visibility timeout of dequeued control queue messages. - /// - /// - /// A TimeSpan configured by the host. The default is 5 minutes. - /// - public TimeSpan ControlQueueVisibilityTimeout { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Gets or sets the visibility timeout of dequeued work item queue messages. - /// - /// - /// A TimeSpan configured by the host. The default is 5 minutes. - /// - public TimeSpan WorkItemQueueVisibilityTimeout { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Gets or sets the name of the Azure Storage connection information to use for the - /// durable tracking store (History and Instances tables). - /// - /// - /// - /// If not specified, the connection string is used for the durable tracking store. - /// - /// - /// This property is primarily useful when deploying multiple apps that need to share the same - /// tracking infrastructure. For example, when deploying two versions of an app side by side, using - /// the same tracking store allows both versions to save history into the same table, which allows - /// clients to query for instance status across all versions. - /// - /// - /// - /// The name of a connection-related key that exists in the app's application settings. The value may refer to - /// a connection string or the section detailing additional connection metadata. - /// - public string TrackingStoreConnectionName { get; set; } - - /// - /// Gets or sets the name of the Azure Storage connection string to use for the - /// durable tracking store (History and Instances tables). - /// - /// - /// If not specified, the connection string - /// is used for the durable tracking store. - /// - /// This property is primarily useful when deploying multiple apps that need to share the same - /// tracking infrastructure. For example, when deploying two versions of an app side by side, using - /// the same tracking store allows both versions to save history into the same table, which allows - /// clients to query for instance status across all versions. - /// - /// The name of a connection string that exists in the app's application settings. - [Obsolete("Please use TrackingStoreConnectionName instead.")] - public string TrackingStoreConnectionStringName - { - get => this.TrackingStoreConnectionName; - set => this.TrackingStoreConnectionName = value; - } - - /// - /// Gets or sets the name prefix to use for history and instance tables in Azure Storage. - /// - /// - /// This property is only used when is specified. - /// If no prefix is specified, the default prefix value is "DurableTask". - /// - /// The prefix to use when naming the generated Azure tables. - public string TrackingStoreNamePrefix { get; set; } - - /// - /// Gets or sets whether the extension will automatically fetch large messages in orchestration status - /// queries. If set to false, the extension will return large messages as a blob url. - /// - /// A boolean indicating whether will automatically fetch large messages . - public bool FetchLargeMessagesAutomatically { get; set; } = true; - - /// - /// Gets or sets the maximum queue polling interval. - /// We update the default value to 1 second for the Flex Consumption SKU - /// because of a known cold start latency with Flex Consumption - /// and Durable Functions. - /// The default value is 30 seconds for all other SKUs. - /// - /// Maximum interval for polling control and work-item queues. - public TimeSpan MaxQueuePollingInterval - { - get - { - if (this.maxQueuePollingInterval == TimeSpan.Zero) - { - if (string.Equals(Environment.GetEnvironmentVariable("WEBSITE_SKU"), "FlexConsumption", StringComparison.OrdinalIgnoreCase)) - { - this.maxQueuePollingInterval = TimeSpan.FromSeconds(1); - } - else - { - this.maxQueuePollingInterval = TimeSpan.FromSeconds(30); - } - } - - return this.maxQueuePollingInterval; - } - - set - { - this.maxQueuePollingInterval = value; - } - } - - /// - /// Determines whether or not to use the old partition management strategy, or the new - /// strategy that is more resilient to split brain problems, at the potential expense - /// of scale out performance. - /// - /// A boolean indicating whether we use the legacy partition strategy. Defaults to false. - public bool UseLegacyPartitionManagement { get; set; } = false; - - /// - /// Determines whether to use the table partition management strategy. - /// This strategy reduces expected costs for an Azure Storage V2 account. - /// - /// A boolean indicating whether to use the table partition strategy. Defaults to false. - public bool UseTablePartitionManagement { get; set; } = true; - - /// - /// When false, when an orchestrator is in a terminal state (e.g. Completed, Failed, Terminated), events for that orchestrator are discarded. - /// Otherwise, events for a terminal orchestrator induce a replay. This may be used to recompute the state of the orchestrator in the "Instances Table". - /// - /// - /// Transactions across Azure Tables are not possible, so we independently update the "History table" and then the "Instances table" - /// to set the state of the orchestrator. - /// If a crash were to occur between these two updates, the state of the orchestrator in the "Instances table" would be incorrect. - /// By setting this configuration to true, you can recover from these inconsistencies by forcing a replay of the orchestrator in response - /// to a client event like a termination request or an external event, which gives the framework another opportunity to update the state of - /// the orchestrator in the "Instances table". To force a replay after enabling this configuration, just send any external event to the affected instanceId. - /// - public bool AllowReplayingTerminalInstances { get; set; } = false; - - /// - /// Specifies the timeout (in seconds) for read and write operations on the partition table using PartitionManager V3 (TablePartitionManager) in Azure Storage. - /// This helps detect potential silent hangs caused by internal Azure Storage retries. - /// If the timeout is exceeded, a PartitionManagerWarning is logged and the operation is retried. - /// Default is 2 seconds. - /// - /// - /// This setting is only effective when is set to true. - /// - public TimeSpan PartitionTableOperationTimeout { get; set; } = TimeSpan.FromSeconds(2); - - /// - /// Gets or sets the encoding strategy used for Azure Storage Queue messages. - /// The default is . - /// - public QueueClientMessageEncoding QueueClientMessageEncoding { get; set; } = QueueClientMessageEncoding.UTF8; - - /// - /// Throws an exception if the provided hub name violates any naming conventions for the storage provider. - /// - public void ValidateHubName(string hubName) - { - try - { - NameValidator.ValidateBlobName(hubName); - NameValidator.ValidateContainerName(hubName.ToLowerInvariant()); - NameValidator.ValidateTableName(hubName); - NameValidator.ValidateQueueName(hubName.ToLowerInvariant()); - } - catch (ArgumentException e) - { - throw new ArgumentException(GetTaskHubErrorString(hubName), e); - } - - if (hubName.Length > 50) - { - throw new ArgumentException(GetTaskHubErrorString(hubName)); - } - } - - private static string GetTaskHubErrorString(string hubName) - { - return $"Task hub name '{hubName}' should contain only alphanumeric characters, start with a letter, and have length between {MinTaskHubNameSize} and {MaxTaskHubNameSize}."; - } - - internal bool IsSanitizedHubName(string hubName, out string sanitizedHubName) - { - // Only alphanumeric characters are valid. - var validHubNameCharacters = hubName.ToCharArray().Where(char.IsLetterOrDigit); - - if (!validHubNameCharacters.Any()) - { - sanitizedHubName = "DefaultTaskHub"; - return false; - } - - // Azure Table storage requires that the task hub does not start with - // a number. If it does, prepend "t" to the beginning. - if (char.IsNumber(validHubNameCharacters.First())) - { - validHubNameCharacters = validHubNameCharacters.ToList(); - ((List)validHubNameCharacters).Insert(0, 't'); - } - - sanitizedHubName = new string(validHubNameCharacters - .Take(MaxTaskHubNameSize) - .ToArray()); - - if (sanitizedHubName.Length < MinTaskHubNameSize) - { - sanitizedHubName = sanitizedHubName + TaskHubPadding; - } - - if (string.Equals(hubName, sanitizedHubName)) - { - return true; - } - - return false; - } - - /// - /// Throws an exception if any of the settings of the storage provider are invalid. - /// - public void Validate(ILogger logger) - { - if (this.ControlQueueBatchSize <= 0) - { - throw new InvalidOperationException($"{nameof(this.ControlQueueBatchSize)} must be a non-negative integer."); - } - - if (this.PartitionCount < 1 || this.PartitionCount > 16) - { - throw new InvalidOperationException($"{nameof(this.PartitionCount)} must be an integer value between 1 and 16."); - } - - if (this.ControlQueueVisibilityTimeout < TimeSpan.FromMinutes(1) || - this.ControlQueueVisibilityTimeout > TimeSpan.FromMinutes(60)) - { - throw new InvalidOperationException($"{nameof(this.ControlQueueVisibilityTimeout)} must be between 1 and 60 minutes."); - } - - if (this.MaxQueuePollingInterval <= TimeSpan.Zero) - { - throw new InvalidOperationException($"{nameof(this.MaxQueuePollingInterval)} must be non-negative."); - } - - if (this.ControlQueueBufferThreshold < 1 || this.ControlQueueBufferThreshold > 1000) - { - throw new InvalidOperationException($"{nameof(this.ControlQueueBufferThreshold)} must be between 1 and 1000."); - } - - if (this.ControlQueueBatchSize > this.ControlQueueBufferThreshold) - { - logger.LogWarning($"{nameof(this.ControlQueueBatchSize)} cannot be larger than {nameof(this.ControlQueueBufferThreshold)}. Please adjust these values in your `host.json` settings for predictable performance"); - } - } - } -} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs similarity index 71% rename from src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs rename to src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs index a85afd8e7..384d428f9 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageDurabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using System; using DurableTask.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; @@ -14,36 +15,24 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage /// /// The Azure Storage implementation of additional methods not required by IOrchestrationService. /// - public class AzureStorageDurabilityProvider : DurabilityProvider + public class AzureStorageScalabilityProvider : ScalabilityProvider { - private readonly AzureStorageOrchestrationService serviceClient; - private readonly IStorageServiceClientProviderFactory clientProviderFactory; + private readonly StorageAccountClientProvider storageAccountClientProvider; private readonly string connectionName; - private readonly JObject storageOptionsJson; private readonly ILogger logger; private readonly object initLock = new object(); private DurableTaskMetricsProvider singletonDurableTaskMetricsProvider; - public AzureStorageDurabilityProvider( - AzureStorageOrchestrationService service, - IStorageServiceClientProviderFactory clientProviderFactory, + public AzureStorageScalabilityProvider( + StorageAccountClientProvider storageAccountClientProvider, string connectionName, - AzureStorageOptions options, ILogger logger) - : base("Azure Storage", service, service, connectionName) + : base("AzureStorage", connectionName) { - this.serviceClient = service; - this.clientProviderFactory = clientProviderFactory; + this.storageAccountClientProvider = storageAccountClientProvider ?? throw new ArgumentNullException(nameof(storageAccountClientProvider)); this.connectionName = connectionName; - this.storageOptionsJson = JObject.FromObject( - options, - new JsonSerializer - { - Converters = { new StringEnumConverter() }, - ContractResolver = new CamelCasePropertyNamesContractResolver(), - }); this.logger = logger; } @@ -52,10 +41,6 @@ public AzureStorageDurabilityProvider( /// public override string ConnectionName => this.connectionName; - public override JObject ConfigurationJson => this.storageOptionsJson; - - public override string EventSourceName { get; set; } = "DurableTask-AzureStorage"; - internal DurableTaskMetricsProvider GetMetricsProvider( string hubName, StorageAccountClientProvider storageAccountClientProvider, @@ -77,9 +62,10 @@ public override bool TryGetScaleMonitor( if (this.singletonDurableTaskMetricsProvider == null) { // This is only called by the ScaleController, it doesn't run in the Functions Host process. + // Use the StorageAccountClientProvider that was created with the credential in the factory this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( hubName, - this.clientProviderFactory.GetClientProvider(connectionName), + this.storageAccountClientProvider, this.logger); } @@ -100,9 +86,10 @@ public override bool TryGetTargetScaler( if (this.singletonDurableTaskMetricsProvider == null) { // This is only called by the ScaleController, it doesn't run in the Functions Host process. + // Use the StorageAccountClientProvider that was created with the credential in the factory this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( hubName, - this.clientProviderFactory.GetClientProvider(connectionName), + this.storageAccountClientProvider, this.logger); } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs new file mode 100644 index 000000000..834a991c9 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -0,0 +1,239 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Linq; +using System.Text.Json; +using DurableTask.AzureStorage; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + public class AzureStorageScalabilityProviderFactory : IScalabilityProviderFactory + { + private const string LoggerName = "Host.Triggers.DurableTask.AzureStorage"; + internal const string ProviderName = "AzureStorage"; + + private readonly DurableTaskScaleOptions options; + private readonly IStorageServiceClientProviderFactory clientProviderFactory; + private readonly INameResolver nameResolver; + private readonly ILoggerFactory loggerFactory; + private AzureStorageScalabilityProvider defaultStorageProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The durable task scale options. + /// The storage client provider factory. + /// The name resolver for connection strings. + /// The logger factory. + /// Thrown when required parameters are null. + public AzureStorageScalabilityProviderFactory( + IOptions options, + IStorageServiceClientProviderFactory clientProviderFactory, + INameResolver nameResolver, + ILoggerFactory loggerFactory) + { + // Validate arguments first + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (clientProviderFactory == null) + { + throw new ArgumentNullException(nameof(clientProviderFactory)); + } + + if (nameResolver == null) + { + throw new ArgumentNullException(nameof(nameResolver)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + // this constructor may be called by dependency injection even if the AzureStorage provider is not selected + // in that case, return immediately, since this provider is not actually used, but can still throw validation errors + if (options.Value.StorageProvider != null + && options.Value.StorageProvider.TryGetValue("type", out object value) + && value is string s + && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + this.options = options.Value; + this.clientProviderFactory = clientProviderFactory; + this.nameResolver = nameResolver; + this.loggerFactory = loggerFactory; + + // Resolve default connection name directly from payload keys or fall back + this.DefaultConnectionName = ResolveConnectionName(options.Value.StorageProvider) ?? ConnectionStringNames.Storage; + } + + public virtual string Name => ProviderName; + + public string DefaultConnectionName { get; } + + public virtual ScalabilityProvider GetDurabilityProvider() + { + if (this.defaultStorageProvider == null) + { + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); + + // Validate Azure Storage specific options + this.ValidateAzureStorageOptions(logger); + + // Create StorageAccountClientProvider without credential (connection string) + var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider( + this.DefaultConnectionName, + tokenCredential: null); + + this.defaultStorageProvider = new AzureStorageScalabilityProvider( + storageAccountClientProvider, + this.DefaultConnectionName, + logger); + + // Set the max concurrent values from options + this.defaultStorageProvider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; + this.defaultStorageProvider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; + } + + return this.defaultStorageProvider; + } + + public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) + { + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); + + // Validate Azure Storage specific options + this.ValidateAzureStorageOptions(logger); + + // Extract TokenCredential from triggerMetadata if present (for Managed Identity) + var tokenCredential = ExtractTokenCredential(triggerMetadata); + + // Use the connection name that was already resolved in the constructor + // this.DefaultConnectionName was set via ResolveConnectionName(options.Value.StorageProvider) + var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider( + this.DefaultConnectionName, + tokenCredential); + + var provider = new AzureStorageScalabilityProvider( + storageAccountClientProvider, + this.DefaultConnectionName, + logger); + + // Set the max concurrent values from options + provider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; + + return provider; + } + + private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata) + { + if (triggerMetadata?.Properties == null) + { + return null; + } + + // Check if metadata contains an AzureComponentFactory wrapper + // ScaleController passes it as: metadata.Properties[nameof(AzureComponentFactory)] = new AzureComponentFactoryWrapper(...) + if (triggerMetadata.Properties.TryGetValue("AzureComponentFactory", out object componentFactoryObj) && componentFactoryObj != null) + { + // The AzureComponentFactoryWrapper has CreateTokenCredential method + // Call it using reflection to get the TokenCredential + var factoryType = componentFactoryObj.GetType(); + var method = factoryType.GetMethod("CreateTokenCredential"); + if (method != null) + { + try + { + // Call CreateTokenCredential(null) to get the TokenCredential from the wrapper + var credential = method.Invoke(componentFactoryObj, new object[] { null }); + if (credential is global::Azure.Core.TokenCredential tokenCredential) + { + return tokenCredential; + } + } + catch (Exception) + { + // Failed to extract credential, return null + return null; + } + } + } + + return null; + } + + + private static string ResolveConnectionName(System.Collections.Generic.IDictionary storageProvider) + { + if (storageProvider == null) + { + return null; + } + + if (storageProvider.TryGetValue("connectionName", out object v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + { + return s1; + } + if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + { + return s2; + } + + return null; + } + + /// + /// Validates Azure Storage specific options. + /// + private void ValidateAzureStorageOptions(ILogger logger) + { + const int MinTaskHubNameSize = 3; + const int MaxTaskHubNameSize = 50; + + // Validate hub name for Azure Storage + if (!string.IsNullOrWhiteSpace(this.options.HubName)) + { + var hubName = this.options.HubName; + + if (hubName.Length < MinTaskHubNameSize || hubName.Length > MaxTaskHubNameSize) + { + throw new System.ArgumentException($"Task hub name '{hubName}' should contain only alphanumeric characters, start with a letter, and have length between {MinTaskHubNameSize} and {MaxTaskHubNameSize}."); + } + + // Must start with a letter + if (!char.IsLetter(hubName[0])) + { + throw new System.ArgumentException($"Task hub name '{hubName}' should contain only alphanumeric characters, start with a letter, and have length between {MinTaskHubNameSize} and {MaxTaskHubNameSize}."); + } + + // Must contain only alphanumeric characters + if (!hubName.All(char.IsLetterOrDigit)) + { + throw new System.ArgumentException($"Task hub name '{hubName}' should contain only alphanumeric characters, start with a letter, and have length between {MinTaskHubNameSize} and {MaxTaskHubNameSize}."); + } + } + + // Validate max concurrent orchestrator functions + if (this.options.MaxConcurrentOrchestratorFunctions.HasValue && this.options.MaxConcurrentOrchestratorFunctions.Value <= 0) + { + throw new System.InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} must be a positive integer."); + } + + // Validate max concurrent activity functions + if (this.options.MaxConcurrentActivityFunctions.HasValue && this.options.MaxConcurrentActivityFunctions.Value <= 0) + { + throw new System.InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs new file mode 100644 index 000000000..f81282a72 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage +{ + /// + /// Well-known connection string names. + /// + internal static class ConnectionStringNames + { + /// + /// The default Azure Storage connection string name. + /// + public const string Storage = "AzureWebJobsStorage"; + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs index b843aaf4e..09713e023 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/DurableTaskTargetScaler.cs @@ -16,29 +16,29 @@ internal class DurableTaskTargetScaler : ITargetScaler { private readonly DurableTaskMetricsProvider metricsProvider; private readonly TargetScalerResult scaleResult; - private readonly DurabilityProvider durabilityProvider; + private readonly ScalabilityProvider scalabilityProvider; private readonly ILogger logger; private readonly string scaler; public DurableTaskTargetScaler( string scalerId, DurableTaskMetricsProvider metricsProvider, - DurabilityProvider durabilityProvider, + ScalabilityProvider scalabilityProvider, ILogger logger) { this.scaler = scalerId; this.metricsProvider = metricsProvider; this.scaleResult = new TargetScalerResult(); this.TargetScalerDescriptor = new TargetScalerDescriptor(this.scaler); - this.durabilityProvider = durabilityProvider; + this.scalabilityProvider = scalabilityProvider; this.logger = logger; } public TargetScalerDescriptor TargetScalerDescriptor { get; } - private int MaxConcurrentActivities => this.durabilityProvider.MaxConcurrentTaskActivityWorkItems; + private int MaxConcurrentActivities => this.scalabilityProvider.MaxConcurrentTaskActivityWorkItems; - private int MaxConcurrentOrchestrators => this.durabilityProvider.MaxConcurrentTaskOrchestrationWorkItems; + private int MaxConcurrentOrchestrators => this.scalabilityProvider.MaxConcurrentTaskOrchestrationWorkItems; public async Task GetScaleResultAsync(TargetScalerContext context) { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs deleted file mode 100644 index b937cba52..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/NameValidator.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. -#nullable enable -using System; -using System.Globalization; -using System.Text.RegularExpressions; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage -{ - // This class is copied from the previous Azure Storage client SDKs - // The following logic may require updating over time as the Azure Storage team discourages client-side validation - // that may grow stale as the server evolves. See here: https://github.com/Azure/azure-sdk-for-js/issues/13519#issuecomment-822420305 - internal static class NameValidator - { - private static readonly RegexOptions RegexOptions = RegexOptions.ExplicitCapture | RegexOptions.Singleline | RegexOptions.CultureInvariant; - - private static readonly Regex MetricsTableRegex = new Regex("^\\$Metrics(HourPrimary|MinutePrimary|HourSecondary|MinuteSecondary)?(Transactions)(Blob|Queue|Table)$", RegexOptions); - private static readonly Regex ShareContainerQueueRegex = new Regex("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions); - private static readonly Regex TableRegex = new Regex("^[A-Za-z][A-Za-z0-9]*$", RegexOptions); - - public static void ValidateBlobName(string blobName) - { - if (string.IsNullOrWhiteSpace(blobName)) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. The {0} name may not be null, empty, or whitespace only.", "blob")); - } - - if (blobName.Length < 1 || blobName.Length > 1024) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name length. The {0} name must be between {1} and {2} characters long.", "blob", 1, 1024)); - } - - int num = 0; - for (int i = 0; i < blobName.Length; i++) - { - if (blobName[i] == '/') - { - num++; - } - } - - if (num >= 254) - { - throw new ArgumentException("The count of URL path segments (strings between '/' characters) as part of the blob name cannot exceed 254."); - } - } - - public static void ValidateContainerName(string containerName) - { - if (!"$root".Equals(containerName, StringComparison.Ordinal) && !"$logs".Equals(containerName, StringComparison.Ordinal)) - { - ValidateShareContainerQueueHelper(containerName, "container"); - } - } - - public static void ValidateQueueName(string queueName) - { - ValidateShareContainerQueueHelper(queueName, "queue"); - } - - public static void ValidateTableName(string tableName) - { - if (string.IsNullOrWhiteSpace(tableName)) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. The {0} name may not be null, empty, or whitespace only.", "table")); - } - - if (tableName.Length < 3 || tableName.Length > 63) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name length. The {0} name must be between {1} and {2} characters long.", "table", 3, 63)); - } - - if (!TableRegex.IsMatch(tableName) && !MetricsTableRegex.IsMatch(tableName) && !tableName.Equals("$MetricsCapacityBlob", StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. Check MSDN for more information about valid {0} naming.", "table")); - } - } - - private static void ValidateShareContainerQueueHelper(string resourceName, string resourceType) - { - if (string.IsNullOrWhiteSpace(resourceName)) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. The {0} name may not be null, empty, or whitespace only.", resourceType)); - } - - if (resourceName.Length < 3 || resourceName.Length > 63) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name length. The {0} name must be between {1} and {2} characters long.", resourceType, 3, 63)); - } - - if (!ShareContainerQueueRegex.IsMatch(resourceName)) - { - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid {0} name. Check MSDN for more information about valid {0} naming.", resourceType)); - } - } - } -} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs deleted file mode 100644 index 2b077381b..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableClientAttribute.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System; -using System.Diagnostics; -using Microsoft.Azure.WebJobs.Description; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale -{ - /// - /// Attribute used to bind a function parameter to a , , or instance. - /// - [AttributeUsage(AttributeTargets.Parameter)] - [DebuggerDisplay("TaskHub={TaskHub}, ConnectionName={ConnectionName}")] - [Binding] - public class DurableClientAttribute : Attribute, IEquatable - { - /// - /// Initializes a new instance of the class. - /// - public DurableClientAttribute() { } - - /// - /// Initializes a new instance of the class. - /// - /// Options to configure the IDurableClient created. - public DurableClientAttribute(DurableClientOptions durableClientOptions) - { - this.TaskHub = durableClientOptions.TaskHub; - this.ConnectionName = durableClientOptions.ConnectionName; - this.ExternalClient = durableClientOptions.IsExternalClient; - } - - /// - /// Optional. Gets or sets the name of the task hub in which the orchestration data lives. - /// - /// The task hub used by this binding. - /// - /// The default behavior is to use the task hub name specified in . - /// If no value exists there, then a default value will be used. - /// -#pragma warning disable CS0618 // Type or member is obsolete - [AutoResolve] -#pragma warning restore CS0618 // Type or member is obsolete - public string TaskHub { get; set; } - - /// - /// Optional. Gets or sets the setting name for the app setting containing connection details used by this binding to connect - /// to instances of the storage provider other than the default one this application communicates with. - /// - /// The name of an app setting containing connection details. - /// - /// For Azure Storage the default behavior is to use the value of . - /// If no value exists there, then the default behavior is to use the standard `AzureWebJobsStorage` connection string for all storage usage. - /// - public string ConnectionName { get; set; } - - /// - /// Indicate if the client is External from the azure function where orchestrator functions are hosted. - /// - public bool ExternalClient { get; set; } - - /// - /// Returns a hash code for this attribute. - /// - /// A hash code for this attribute. - public override int GetHashCode() - { - unchecked - { - return - this.TaskHub?.GetHashCode() ?? 0 + - this.ConnectionName?.GetHashCode() ?? 0; - } - } - - /// - /// Compares two instances for value equality. - /// - /// The object to compare with. - /// true if the two attributes have the same configuration; otherwise false. - public override bool Equals(object obj) - { - return this.Equals(obj as DurableClientAttribute); - } - - /// - /// Compares two instances for value equality. - /// - /// The object to compare with. - /// true if the two attributes have the same configuration; otherwise false. - public bool Equals(DurableClientAttribute other) - { - if (other == null) - { - return false; - } - - if (object.ReferenceEquals(this, other)) - { - return true; - } - - return string.Equals(this.TaskHub, other.TaskHub, StringComparison.OrdinalIgnoreCase) - && string.Equals(this.ConnectionName, other.ConnectionName, StringComparison.OrdinalIgnoreCase); - } - } -} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs index f15dce0cf..239207fdb 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -32,17 +33,12 @@ public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) builder .AddExtension() - .BindOptions(); + .BindOptions(); IServiceCollection serviceCollection = builder.Services; - serviceCollection.AddAzureClientsCore(); - serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); + serviceCollection.AddSingleton(); + // Note: SqlServerScalabilityProviderFactory is registered by Scale Controller, not here return builder; } @@ -57,7 +53,7 @@ internal static IWebJobsBuilder AddDurableScaleForTrigger(this IWebJobsBuilder b DurableTaskTriggersScaleProvider provider = null; builder.Services.AddSingleton(serviceProvider => { - provider = new DurableTaskTriggersScaleProvider(serviceProvider.GetService>(), serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService>(), triggerMetadata); + provider = new DurableTaskTriggersScaleProvider(serviceProvider.GetService>(), serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService>(), triggerMetadata); return provider; }); diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs index 49a35ba41..13eb9cdd1 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs @@ -2,45 +2,50 @@ using System; using System.Collections.Generic; using System.Linq; - +using Microsoft.Azure.WebJobs.Host.Config; using Microsoft.Extensions.Logging; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { - public class DurableTaskScaleExtension + public class DurableTaskScaleExtension : IExtensionConfigProvider { - private readonly IDurabilityProviderFactory durabilityProviderFactory; - private readonly DurabilityProvider defaultDurabilityProvider; - private readonly DurableTaskOptions options; + private readonly IScalabilityProviderFactory scalabilityProviderFactory; + private readonly ScalabilityProvider defaultscalabilityProvider; + private readonly DurableTaskScaleOptions options; private readonly ILogger logger; - private readonly IEnumerable durabilityProviderFactories; + private readonly IEnumerable scalabilityProviderFactories; /// /// Initializes a new instance of the class. /// - /// + /// The options for the Durable Task Scale Extension. /// - /// + /// public DurableTaskScaleExtension( - DurableTaskOptions options, + DurableTaskScaleOptions options, ILogger logger, - IEnumerable durabilityProviderFactories) + IEnumerable scalabilityProviderFactories) { this.options = options ?? throw new ArgumentNullException(nameof(options)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.durabilityProviderFactories = durabilityProviderFactories ?? throw new ArgumentNullException(nameof(durabilityProviderFactories)); + this.scalabilityProviderFactories = scalabilityProviderFactories ?? throw new ArgumentNullException(nameof(scalabilityProviderFactories)); - this.durabilityProviderFactory = GetDurabilityProviderFactory(this.options, this.logger, this.durabilityProviderFactories); - this.defaultDurabilityProvider = this.durabilityProviderFactory.GetDurabilityProvider(); + this.scalabilityProviderFactory = GetScalabilityProviderFactory(this.options, this.logger, this.scalabilityProviderFactories); + this.defaultscalabilityProvider = this.scalabilityProviderFactory.GetDurabilityProvider(); } - public IDurabilityProviderFactory DurabilityProviderFactory => this.durabilityProviderFactory; - public DurabilityProvider DefaultDurabilityProvider => this.defaultDurabilityProvider; + public IScalabilityProviderFactory ScalabilityProviderFactory => this.scalabilityProviderFactory; + public ScalabilityProvider DefaultScalabilityProvider => this.defaultscalabilityProvider; + + public void Initialize(ExtensionConfigContext context) + { + // Extension initialization - no-op for scale package + } - private static IDurabilityProviderFactory GetDurabilityProviderFactory( - DurableTaskOptions options, + internal static IScalabilityProviderFactory GetScalabilityProviderFactory( + DurableTaskScaleOptions options, ILogger logger, - IEnumerable durabilityProviderFactories) + IEnumerable scalabilityProviderFactories) { const string DefaultProvider = "AzureStorage"; bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); @@ -49,7 +54,7 @@ private static IDurabilityProviderFactory GetDurabilityProviderFactory( { try { - IDurabilityProviderFactory defaultFactory = durabilityProviderFactories.First(f => f.Name.Equals(DefaultProvider)); + IScalabilityProviderFactory defaultFactory = scalabilityProviderFactories.First(f => f.Name.Equals(DefaultProvider)); logger.LogInformation($"Using the default storage provider: {DefaultProvider}."); return defaultFactory; } @@ -61,13 +66,13 @@ private static IDurabilityProviderFactory GetDurabilityProviderFactory( try { - IDurabilityProviderFactory selectedFactory = durabilityProviderFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); + IScalabilityProviderFactory selectedFactory = scalabilityProviderFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); logger.LogInformation($"Using the {storageType} storage provider."); return selectedFactory; } catch (InvalidOperationException e) { - IList factoryNames = durabilityProviderFactories.Select(f => f.Name).ToList(); + IList factoryNames = scalabilityProviderFactories.Select(f => f.Name).ToList(); throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs similarity index 77% rename from src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs rename to src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs index 23d85a580..e3790d70a 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskOptions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs @@ -8,7 +8,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale /// /// Minimal options class for Scale package - only contains what's needed for scaling decisions. /// - public class DurableTaskOptions + public class DurableTaskScaleOptions { public string HubName { get; set; } @@ -18,11 +18,7 @@ public class DurableTaskOptions public int? MaxConcurrentActivityFunctions { get; set; } - public int? MaxConcurrentEntityFunctions { get; set; } = null; - - public int? MaxEntityOperationBatchSize { get; set; } = null; - - public static void ResolveAppSettingOptions(DurableTaskOptions options, INameResolver nameResolver) + public static void ResolveAppSettingOptions(DurableTaskScaleOptions options, INameResolver nameResolver) { if (options.StorageProvider.TryGetValue("connectionName", out object connectionNameObj) && connectionNameObj is string connectionName) { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index c03b838cb..18f184d88 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -20,94 +20,85 @@ internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITarget private readonly IScaleMonitor monitor; private readonly ITargetScaler targetScaler; - private readonly DurableTaskOptions options; + private readonly DurableTaskScaleOptions options; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; - private readonly IEnumerable durabilityProviderFactories; + private readonly IEnumerable scalabilityProviderFactories; public DurableTaskTriggersScaleProvider( - IOptions durableTaskOptions, + IOptions durableTaskScaleOptions, INameResolver nameResolver, ILoggerFactory loggerFactory, - IEnumerable durabilityProviderFactories, + IEnumerable scalabilityProviderFactories, TriggerMetadata triggerMetadata) { - this.options = durableTaskOptions.Value; + this.options = durableTaskScaleOptions.Value; this.nameResolver = nameResolver; this.loggerFactory = loggerFactory; - this.durabilityProviderFactories = durabilityProviderFactories; + this.scalabilityProviderFactories = scalabilityProviderFactories; string functionId = triggerMetadata.FunctionName; var functionName = new FunctionName(functionId); this.GetOptions(triggerMetadata); - IDurabilityProviderFactory durabilityProviderFactory = this.GetDurabilityProviderFactory(); + IScalabilityProviderFactory scalabilityProviderFactory = this.GetScalabilityProviderFactory(); - DurabilityProvider defaultDurabilityProvider; - if (string.Equals(durabilityProviderFactory.Name, AzureManagedProviderName, StringComparison.OrdinalIgnoreCase)) - { - defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider(attribute: null, triggerMetadata); - } - else - { - defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider(); - } + // Always use the triggerMetadata overload for scale scenarios + // The factory will extract TokenCredential if present + ScalabilityProvider defaultscalabilityProvider = scalabilityProviderFactory.GetDurabilityProvider(triggerMetadata); // Note: `this.options` is populated from the trigger metadata above - string? connectionName = GetConnectionName(durabilityProviderFactory, this.options); + string? connectionName = GetConnectionName(scalabilityProviderFactory, this.options); + + // Check if using managed identity (for logging) + bool usesManagedIdentity = triggerMetadata.Properties != null && + triggerMetadata.Properties.ContainsKey("AzureComponentFactory"); var logger = loggerFactory.CreateLogger(); logger.LogInformation( - "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}'", + "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}', usesManagedIdentity = '{UsesMI}'", triggerMetadata.FunctionName, - connectionName); + connectionName, + usesManagedIdentity); this.targetScaler = ScaleUtils.GetTargetScaler( - defaultDurabilityProvider, + defaultscalabilityProvider, functionId, functionName, connectionName, this.options.HubName); this.monitor = ScaleUtils.GetScaleMonitor( - defaultDurabilityProvider, + defaultscalabilityProvider, functionId, functionName, connectionName, this.options.HubName); } - private static string? GetConnectionName(IDurabilityProviderFactory durabilityProviderFactory, DurableTaskOptions options) + private static string? GetConnectionName(IScalabilityProviderFactory scalabilityProviderFactory, DurableTaskScaleOptions options) { - if (durabilityProviderFactory is AzureStorageDurabilityProviderFactory azureStorageDurabilityProviderFactory) + if (scalabilityProviderFactory is AzureStorageScalabilityProviderFactory azureStorageScalabilityProviderFactory) { - // First, look for the connection name in the options - var azureStorageOptions = new AzureStorageOptions(); if (options != null && options.StorageProvider != null) { - var json = JsonSerializer.Serialize(options.StorageProvider); - var newOptions = JsonSerializer.Deserialize(json); - if (newOptions != null) + if (options.StorageProvider.TryGetValue("connectionName", out object value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + { + return s1; + } + + // legacy alias often used in payloads + if (options.StorageProvider.TryGetValue("connectionStringName", out object value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { - foreach (var prop in typeof(AzureStorageOptions).GetProperties()) - { - var value = prop.GetValue(newOptions); - if (value != null) - { - prop.SetValue(azureStorageOptions, value); - } - } + return s2; } } - // If the connection name is not found in the options, use the default connection name from the factory - return azureStorageOptions.ConnectionName ?? azureStorageDurabilityProviderFactory.DefaultConnectionName; - } - else - { - return null; + return azureStorageScalabilityProviderFactory.DefaultConnectionName; } + + return null; } private void GetOptions(TriggerMetadata triggerMetadata) @@ -132,47 +123,15 @@ private void GetOptions(TriggerMetadata triggerMetadata) this.options.StorageProvider = metadata?.StorageProvider; } - DurableTaskOptions.ResolveAppSettingOptions(this.options, this.nameResolver); + DurableTaskScaleOptions.ResolveAppSettingOptions(this.options, this.nameResolver); } - private IDurabilityProviderFactory GetDurabilityProviderFactory() + private IScalabilityProviderFactory GetScalabilityProviderFactory() { var logger = this.loggerFactory.CreateLogger(); - return GetDurabilityProviderFactory(this.options, logger, this.durabilityProviderFactories); + return DurableTaskScaleExtension.GetScalabilityProviderFactory(this.options, logger, this.scalabilityProviderFactories); } - private static IDurabilityProviderFactory GetDurabilityProviderFactory(DurableTaskOptions options, ILogger logger, IEnumerable orchestrationServiceFactories) - { - const string DefaultProvider = "AzureStorage"; - - bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); - - if (!storageTypeIsConfigured) - { - try - { - IDurabilityProviderFactory defaultFactory = orchestrationServiceFactories.First(f => f.Name.Equals(DefaultProvider)); - logger.LogInformation($"Using the default storage provider: {DefaultProvider}."); - return defaultFactory; - } - catch (InvalidOperationException e) - { - throw new InvalidOperationException($"Couldn't find the default storage provider: {DefaultProvider}.", e); - } - } - - try - { - IDurabilityProviderFactory selectedFactory = orchestrationServiceFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); - logger.LogInformation($"Using the {storageType} storage provider."); - return selectedFactory; - } - catch (InvalidOperationException e) - { - IList factoryNames = orchestrationServiceFactories.Select(f => f.Name).ToList(); - throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); - } - } public IScaleMonitor GetMonitor() { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs b/src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs new file mode 100644 index 000000000..bc64390aa --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Core; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Interface for resolving connection information. + /// + public interface IConnectionInfoResolver + { + /// + /// Resolves connection information for a given connection name. + /// + /// The name of the connection. + /// The connection string or token credential information. + (string ConnectionString, TokenCredential Credential) ResolveConnectionInfo(string connectionName); + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs b/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs new file mode 100644 index 000000000..410f28572 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Interface for resolving names of application settings. + /// + public interface INameResolver + { + /// + /// Resolves an application setting name to its value. + /// + /// The name of the application setting. + /// The resolved value, or the original name if no resolution is found. + string Resolve(string name); + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs similarity index 64% rename from src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs rename to src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs index 702fe1202..f6ef5b278 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IDurabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs @@ -6,9 +6,9 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { /// - /// Interface defining methods to build instances of . + /// Interface defining methods to build instances of . /// - public interface IDurabilityProviderFactory + public interface IScalabilityProviderFactory { /// /// Specifies the Durability Provider Factory name. @@ -19,22 +19,14 @@ public interface IDurabilityProviderFactory /// Creates or retrieves a durability provider to be used throughout the extension. /// /// An durability provider to be used by the Durable Task Extension. - DurabilityProvider GetDurabilityProvider(); + ScalabilityProvider GetDurabilityProvider(); /// /// Creates or retrieves a cached durability provider to be used in a given function execution. /// - /// A durable client attribute with parameters for the durability provider. - /// A durability provider to be used by a client function. - DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute); - - /// - /// Creates or retrieves a cached durability provider to be used in a given function execution. - /// - /// A durable client attribute with parameters for the durability provider. /// Trigger metadata used to create IOrchestrationService for functions scale scenarios. /// A durability provider to be used by a client function. - DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute, TriggerMetadata triggerMetadata) + ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) { // This method is not supported by this provider. // Only providers that require TriggerMetadata for scale should implement it. diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs index 44f7ade5e..976b2dd3e 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. #nullable enable +using Azure.Core; using Azure.Data.Tables; using Azure.Storage.Blobs; using Azure.Storage.Queues; @@ -11,30 +12,15 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale /// /// Defines methods for retrieving service client providers based on the connection name. /// - internal interface IStorageServiceClientProviderFactory + public interface IStorageServiceClientProviderFactory { /// - /// Gets the used - /// for accessing the Azure Storage Blob Service associated with the . + /// Gets the used + /// for accessing the Azure Storage services associated with the . /// /// The name associated with the connection information. - /// The corresponding . - IStorageServiceClientProvider GetBlobClientProvider(string connectionName); - - /// - /// Gets the used - /// for accessing the Azure Storage Queue Service associated with the . - /// - /// The name associated with the connection information. - /// The corresponding . - IStorageServiceClientProvider GetQueueClientProvider(string connectionName); - - /// - /// Gets the used - /// for accessing the Azure Storage Table Service associated with the . - /// - /// The name associated with the connection information. - /// The corresponding . - IStorageServiceClientProvider GetTableClientProvider(string connectionName); + /// Optional token credential for Managed Identity scenarios. + /// The corresponding . + StorageAccountClientProvider GetClientProvider(string connectionName, TokenCredential? tokenCredential = null); } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs similarity index 56% rename from src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs rename to src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs index e0d30c9a2..898f75605 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs @@ -16,39 +16,28 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { /// - /// The backend storage provider that provides the actual durability of Durable Functions. - /// This is functionally a superset of and . - /// If the storage provider does not any of the Durable Functions specific operations, they can use this class - /// directly with the expectation that only those interfaces will be implemented. All of the Durable Functions specific - /// methods/operations are virtual and can be overwritten by creating a subclass. + /// The backend storage scalability provider for Durable Functions. /// - public class DurabilityProvider + public class ScalabilityProvider { internal const string NoConnectionDetails = "default"; - private static readonly JObject EmptyConfig = new JObject(); - private readonly string name; - private readonly IOrchestrationService innerService; - private readonly IOrchestrationServiceClient innerServiceClient; - private readonly IEntityOrchestrationService entityOrchestrationService; private readonly string connectionName; + private int maxConcurrentTaskOrchestrationWorkItems; + private int maxConcurrentTaskActivityWorkItems; /// - /// Creates the default . + /// Creates the default . /// /// The name of the storage backend providing the durability. - /// The internal that provides functionality - /// for this classes implementions of . - /// The internal that provides functionality - /// for this classes implementions of . /// The name of the app setting that stores connection details for the storage provider. - public DurabilityProvider(string storageProviderName, IOrchestrationService service, IOrchestrationServiceClient serviceClient, string connectionName) + public ScalabilityProvider(string storageProviderName, string connectionName) { this.name = storageProviderName ?? throw new ArgumentNullException(nameof(storageProviderName)); - this.innerService = service ?? throw new ArgumentNullException(nameof(service)); - this.entityOrchestrationService = service as IEntityOrchestrationService; - this.connectionName = connectionName ?? throw new ArgumentNullException(connectionName); + this.connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName)); + this.maxConcurrentTaskOrchestrationWorkItems = 10; // Default value + this.maxConcurrentTaskActivityWorkItems = 10; // Default value } /// @@ -58,32 +47,29 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv public virtual string ConnectionName => this.connectionName; /// - /// Specifies whether the durability provider supports Durable Entities. + /// Gets or sets the maximum number of concurrent orchestration work items. /// - public virtual bool SupportsEntities => this.entityOrchestrationService?.EntityBackendProperties != null; - - /// - /// JSON representation of configuration to emit in telemetry. - /// - public virtual JObject ConfigurationJson => EmptyConfig; + public virtual int MaxConcurrentTaskOrchestrationWorkItems + { + get => this.maxConcurrentTaskOrchestrationWorkItems; + set => this.maxConcurrentTaskOrchestrationWorkItems = value; + } /// - /// Event source name (e.g. DurableTask-AzureStorage). + /// Gets or sets the maximum number of concurrent activity work items. /// - public virtual string EventSourceName { get; set; } - - /// - public int MaxConcurrentTaskOrchestrationWorkItems => this.innerService.MaxConcurrentTaskOrchestrationWorkItems; - - /// - public int MaxConcurrentTaskActivityWorkItems => this.innerService.MaxConcurrentTaskActivityWorkItems; + public virtual int MaxConcurrentTaskActivityWorkItems + { + get => this.maxConcurrentTaskActivityWorkItems; + set => this.maxConcurrentTaskActivityWorkItems = value; + } /// - /// Returns true if the stored connection string, ConnectionName, matches the input DurabilityProvider ConnectionName. + /// Returns true if the stored connection string, ConnectionName, matches the input ScalabilityProvider ConnectionName. /// - /// The DurabilityProvider used to check for matching connection string names. + /// The ScalabilityProvider used to check for matching connection string names. /// A boolean indicating whether the connection names match. - internal virtual bool ConnectionNameMatches(DurabilityProvider durabilityProvider) + internal virtual bool ConnectionNameMatches(ScalabilityProvider durabilityProvider) { return this.ConnectionName.Equals(durabilityProvider.ConnectionName); } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs b/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs index a7af76590..4636b7adc 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/ScaleUtils.cs @@ -10,7 +10,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { internal static class ScaleUtils { - internal static IScaleMonitor GetScaleMonitor(DurabilityProvider durabilityProvider, string functionId, FunctionName functionName, string? connectionName, string hubName) + internal static IScaleMonitor GetScaleMonitor(ScalabilityProvider durabilityProvider, string functionId, FunctionName functionName, string? connectionName, string hubName) { if (durabilityProvider.TryGetScaleMonitor( functionId, @@ -65,7 +65,7 @@ ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) } #pragma warning disable SA1201 // Elements should appear in the correct order - internal static ITargetScaler GetTargetScaler(DurabilityProvider durabilityProvider, string functionId, FunctionName functionName, string? connectionName, string hubName) + internal static ITargetScaler GetTargetScaler(ScalabilityProvider durabilityProvider, string functionId, FunctionName functionName, string? connectionName, string hubName) #pragma warning restore SA1201 // Elements should appear in the correct order { if (durabilityProvider.TryGetTargetScaler( diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs new file mode 100644 index 000000000..da94ef40c --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using DurableTask.SqlServer; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// Metrics provider for SQL Server backend scaling. + /// Provides recommended replica count based on SQL Server orchestration service metrics. + /// + public class SqlServerMetricsProvider + { + private readonly SqlOrchestrationService service; + private DateTime metricsTimeStamp = DateTime.MinValue; + private SqlServerScaleMetric metrics; + + public SqlServerMetricsProvider(SqlOrchestrationService service) + { + this.service = service ?? throw new ArgumentNullException(nameof(service)); + } + + public virtual async Task GetMetricsAsync(int? previousWorkerCount = null) + { + // We only want to query the metrics every 5 seconds to avoid excessive SQL queries. + if (this.metrics == null || DateTime.UtcNow >= this.metricsTimeStamp.AddSeconds(5)) + { + // GetRecommendedReplicaCountAsync will write a trace if the recommendation results + // in a worker count that is different from the worker count we pass in as an argument. + int recommendedReplicaCount = await this.service.GetRecommendedReplicaCountAsync( + previousWorkerCount, + CancellationToken.None); + + this.metricsTimeStamp = DateTime.UtcNow; + this.metrics = new SqlServerScaleMetric { RecommendedReplicaCount = recommendedReplicaCount }; + } + + return this.metrics; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs new file mode 100644 index 000000000..8f79009e5 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using DurableTask.SqlServer; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// The SQL Server implementation of ScalabilityProvider. + /// Provides scale monitoring and target-based scaling for SQL Server backend. + /// + public class SqlServerScalabilityProvider : ScalabilityProvider + { + private readonly SqlOrchestrationService service; + private readonly string connectionName; + private readonly ILogger logger; + + private readonly object initLock = new object(); + private SqlServerMetricsProvider singletonSqlMetricsProvider; + + public SqlServerScalabilityProvider( + SqlOrchestrationService service, + string connectionName, + ILogger logger) + : base("mssql", connectionName) + { + this.service = service ?? throw new ArgumentNullException(nameof(service)); + this.connectionName = connectionName; + this.logger = logger; + } + + /// + /// The app setting containing the SQL Server connection string. + /// + public override string ConnectionName => this.connectionName; + + internal SqlServerMetricsProvider GetMetricsProvider( + string hubName, + SqlOrchestrationService sqlOrchestrationService, + ILogger logger) + { + return new SqlServerMetricsProvider(sqlOrchestrationService); + } + + /// + public override bool TryGetScaleMonitor( + string functionId, + string functionName, + string hubName, + string connectionName, + out IScaleMonitor scaleMonitor) + { + lock (this.initLock) + { + if (this.singletonSqlMetricsProvider == null) + { + // This is only called by the ScaleController, it doesn't run in the Functions Host process. + this.singletonSqlMetricsProvider = this.GetMetricsProvider( + hubName, + this.service, + this.logger); + } + + scaleMonitor = new SqlServerScaleMonitor(functionId, hubName, this.singletonSqlMetricsProvider); + return true; + } + } + + /// + public override bool TryGetTargetScaler( + string functionId, + string functionName, + string hubName, + string connectionName, + out ITargetScaler targetScaler) + { + lock (this.initLock) + { + if (this.singletonSqlMetricsProvider == null) + { + // This is only called by the ScaleController, it doesn't run in the Functions Host process. + this.singletonSqlMetricsProvider = this.GetMetricsProvider( + hubName, + this.service, + this.logger); + } + + targetScaler = new SqlServerTargetScaler(functionId, this.singletonSqlMetricsProvider); + return true; + } + } + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs new file mode 100644 index 000000000..1e0496921 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -0,0 +1,422 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DurableTask.SqlServer; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Data.SqlClient; +using Newtonsoft.Json.Linq; +using Azure.Core; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// Factory for creating SQL Server scalability providers. + /// + public class SqlServerScalabilityProviderFactory : IScalabilityProviderFactory + { + private const string LoggerName = "Host.Triggers.DurableTask.SqlServer"; + internal const string ProviderName = "mssql"; + + private readonly DurableTaskScaleOptions options; + private readonly IConfiguration configuration; + private readonly INameResolver nameResolver; + private readonly ILoggerFactory loggerFactory; + private SqlServerScalabilityProvider defaultSqlProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The durable task scale options. + /// The configuration for reading connection strings. + /// The name resolver for connection strings. + /// The logger factory. + /// Thrown when required parameters are null. + public SqlServerScalabilityProviderFactory( + IOptions options, + IConfiguration configuration, + INameResolver nameResolver, + ILoggerFactory loggerFactory) + { + // Validate arguments first + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + if (nameResolver == null) + { + throw new ArgumentNullException(nameof(nameResolver)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + // this constructor may be called by dependency injection even if the SQL Server provider is not selected + // in that case, return immediately, since this provider is not actually used, but can still throw validation errors + if (options.Value.StorageProvider != null + && options.Value.StorageProvider.TryGetValue("type", out object value) + && value is string s + && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + this.options = options.Value; + this.configuration = configuration; + this.nameResolver = nameResolver; + this.loggerFactory = loggerFactory; + + // Resolve default connection name directly from payload keys or fall back + this.DefaultConnectionName = ResolveConnectionName(options.Value.StorageProvider) ?? "SQLDB_Connection"; + } + + public virtual string Name => ProviderName; + + public string DefaultConnectionName { get; } + + public virtual ScalabilityProvider GetDurabilityProvider() + { + if (this.defaultSqlProvider == null) + { + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); + + // Validate SQL Server specific options + this.ValidateSqlServerOptions(logger); + + // Create SqlOrchestrationService from connection string + // No TokenCredential for default provider (uses connection string auth) + var sqlOrchestrationService = this.CreateSqlOrchestrationService( + this.DefaultConnectionName, + this.options.HubName ?? "default", + tokenCredential: null, + logger); + + this.defaultSqlProvider = new SqlServerScalabilityProvider( + sqlOrchestrationService, + this.DefaultConnectionName, + logger); + + // Set the max concurrent values from options (if needed by SQL Server) + // Note: SQL Server uses MaxActiveOrchestrations and MaxConcurrentActivities in settings + // These are set when creating SqlOrchestrationServiceSettings + } + + return this.defaultSqlProvider; + } + + public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) + { + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); + + // Validate SQL Server specific options + this.ValidateSqlServerOptions(logger); + + // Extract TokenCredential from triggerMetadata if present (for Managed Identity) + // This follows the same pattern as Azure Storage + var tokenCredential = ExtractTokenCredential(triggerMetadata); + + // Extract connection name from triggerMetadata (similar to how Azure Storage does it) + // The triggerMetadata contains storageProvider with connectionName or connectionStringName + string connectionName = this.DefaultConnectionName; + if (triggerMetadata?.Metadata != null) + { + var storageProvider = triggerMetadata.Metadata["storageProvider"]; + if (storageProvider != null) + { + var storageProviderObj = storageProvider.ToObject>(); + if (storageProviderObj != null) + { + // Try connectionName first, then connectionStringName (legacy alias) + if (storageProviderObj.TryGetValue("connectionName", out object connName) && connName is string connNameStr && !string.IsNullOrWhiteSpace(connNameStr)) + { + connectionName = connNameStr; + } + else if (storageProviderObj.TryGetValue("connectionStringName", out object connStrName) && connStrName is string connStrNameStr && !string.IsNullOrWhiteSpace(connStrNameStr)) + { + connectionName = connStrNameStr; + } + } + } + } + + var sqlOrchestrationService = this.CreateSqlOrchestrationService( + connectionName, + this.options.HubName ?? "default", + tokenCredential, + logger); + + var provider = new SqlServerScalabilityProvider( + sqlOrchestrationService, + connectionName, + logger); + + return provider; + } + + private SqlOrchestrationService CreateSqlOrchestrationService( + string connectionName, + string taskHubName, + global::Azure.Core.TokenCredential tokenCredential, + ILogger logger) + { + string connectionString = null; + + // If TokenCredential is provided (Managed Identity), we need to build connection string from config + // SQL Server authentication with Managed Identity requires: + // - Server name (can be in connection string or config: {connectionName}__serverName) + // - Database name (can be in connection string or config: {connectionName}__databaseName) + // - Authentication="Active Directory Default" in connection string + if (tokenCredential != null) + { + // For Managed Identity, read server name and database from configuration + // Pattern: {connectionName}__serverName, {connectionName}__databaseName + // Or fall back to parsing from connection string if available + // Note: Server name can also come from the connection string itself + var serverName = this.configuration[$"{connectionName}__serverName"] + ?? this.configuration[$"{connectionName}__server"]; + var databaseName = this.configuration[$"{connectionName}__databaseName"] + ?? this.configuration[$"{connectionName}__database"]; + + // Try to get base connection string to extract server/database if not explicitly set + var baseConnectionString = this.configuration.GetConnectionString(connectionName) + ?? this.configuration[connectionName]; + + if (!string.IsNullOrEmpty(baseConnectionString)) + { + try + { + var builder = new SqlConnectionStringBuilder(baseConnectionString); + // Use explicit config values if provided, otherwise use values from connection string + if (string.IsNullOrEmpty(serverName)) + { + serverName = builder.DataSource; + } + if (string.IsNullOrEmpty(databaseName)) + { + databaseName = builder.InitialCatalog; + } + + // Build connection string with Managed Identity authentication + builder.DataSource = serverName; + builder.InitialCatalog = databaseName ?? builder.InitialCatalog; + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryDefault; + // Remove password/user ID if present (not needed for Managed Identity) + builder.Password = null; + builder.UserID = null; + + connectionString = builder.ConnectionString; + } + catch (ArgumentException) + { + // If connection string parsing fails, try to construct from config values + } + } + + // If we still don't have connection string, construct from config values + if (string.IsNullOrEmpty(connectionString)) + { + if (string.IsNullOrEmpty(serverName)) + { + throw new InvalidOperationException( + $"No SQL server name configuration was found for Managed Identity. Please provide '{connectionName}__serverName' or '{connectionName}__server' app setting, or ensure '{connectionName}' connection string contains server name."); + } + + var connectionStringBuilder = new SqlConnectionStringBuilder + { + DataSource = serverName, + InitialCatalog = databaseName ?? "master", + Authentication = SqlAuthenticationMethod.ActiveDirectoryDefault, + Encrypt = true, + }; + connectionString = connectionStringBuilder.ConnectionString; + } + } + else + { + // No TokenCredential - use connection string from configuration (traditional auth) + connectionString = this.configuration.GetConnectionString(connectionName) + ?? this.configuration[connectionName]; + + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException( + $"No SQL connection string configuration was found for the app setting or environment variable named '{connectionName}'."); + } + } + + // Validate the connection string + try + { + new SqlConnectionStringBuilder(connectionString); + } + catch (ArgumentException e) + { + throw new ArgumentException("The provided connection string is invalid.", e); + } + + // Create SQL Server orchestration service settings + var settings = new SqlOrchestrationServiceSettings( + connectionString, + taskHubName, + schemaName: null) // Schema name can be configured from storageProvider if needed + { + // Set concurrency limits if provided + MaxActiveOrchestrations = this.options.MaxConcurrentOrchestratorFunctions ?? 10, + MaxConcurrentActivities = this.options.MaxConcurrentActivityFunctions ?? 10, + }; + + // If TokenCredential is provided (from triggerMetadata in Azure), we need to use it instead of DefaultAzureCredential + // Register a custom SqlAuthenticationProvider that uses our specific TokenCredential + if (tokenCredential != null) + { + // Register custom authentication provider that uses the provided TokenCredential + // This ensures we use the TokenCredential from Scale Controller, not DefaultAzureCredential + var customProvider = new CustomTokenCredentialAuthenticationProvider(tokenCredential, logger); + SqlAuthenticationProvider.SetProvider( + SqlAuthenticationMethod.ActiveDirectoryDefault, + customProvider); + } + // Note: When tokenCredential is null (local development), we use Authentication="Active Directory Default" + // which will use DefaultAzureCredential. This works for local testing. + + // Create and return the orchestration service + return new SqlOrchestrationService(settings); + } + + private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata) + { + if (triggerMetadata?.Properties == null) + { + return null; + } + + // Check if metadata contains an AzureComponentFactory wrapper + // ScaleController passes it as: metadata.Properties[nameof(AzureComponentFactory)] = new AzureComponentFactoryWrapper(...) + // This follows the same pattern as Azure Storage + if (triggerMetadata.Properties.TryGetValue("AzureComponentFactory", out object componentFactoryObj) && componentFactoryObj != null) + { + // The AzureComponentFactoryWrapper has CreateTokenCredential method + // Call it using reflection to get the TokenCredential + var factoryType = componentFactoryObj.GetType(); + var method = factoryType.GetMethod("CreateTokenCredential"); + if (method != null) + { + try + { + // Call CreateTokenCredential(null) to get the TokenCredential from the wrapper + var credential = method.Invoke(componentFactoryObj, new object[] { null }); + if (credential is global::Azure.Core.TokenCredential tokenCredential) + { + return tokenCredential; + } + } + catch (Exception) + { + // Failed to extract credential, return null + return null; + } + } + } + + return null; + } + + /// + /// Custom SqlAuthenticationProvider that uses a specific TokenCredential instead of DefaultAzureCredential. + /// This allows us to use the TokenCredential passed from triggerMetadata in Azure environments. + /// + private class CustomTokenCredentialAuthenticationProvider : SqlAuthenticationProvider + { + private readonly TokenCredential tokenCredential; + private readonly ILogger logger; + private const string SqlResource = "https://database.windows.net/.default"; + + public CustomTokenCredentialAuthenticationProvider(TokenCredential tokenCredential, ILogger logger) + { + this.tokenCredential = tokenCredential ?? throw new ArgumentNullException(nameof(tokenCredential)); + this.logger = logger; + } + + public override async Task AcquireTokenAsync(SqlAuthenticationParameters parameters) + { + try + { + // Get token from the provided TokenCredential + var tokenRequestContext = new TokenRequestContext(new[] { SqlResource }); + var accessToken = await this.tokenCredential.GetTokenAsync(tokenRequestContext, CancellationToken.None); + + return new SqlAuthenticationToken(accessToken.Token, accessToken.ExpiresOn); + } + catch (Exception ex) + { + this.logger?.LogError(ex, "Failed to acquire token from TokenCredential for SQL authentication"); + throw; + } + } + + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) + { + // Only support Active Directory Default authentication + return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDefault; + } + } + + private static string ResolveConnectionName(IDictionary storageProvider) + { + if (storageProvider == null) + { + return null; + } + + if (storageProvider.TryGetValue("connectionName", out object v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + { + return s1; + } + if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + { + return s2; + } + + return null; + } + + /// + /// Validates SQL Server specific options. + /// + private void ValidateSqlServerOptions(ILogger logger) + { + // Validate hub name (SQL Server has less strict requirements than Azure Storage) + if (string.IsNullOrWhiteSpace(this.options.HubName)) + { + // Hub name defaults to "default" for SQL Server, so this is acceptable + return; + } + + // Validate max concurrent orchestrator functions + if (this.options.MaxConcurrentOrchestratorFunctions.HasValue && this.options.MaxConcurrentOrchestratorFunctions.Value <= 0) + { + throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} must be a positive integer."); + } + + // Validate max concurrent activity functions + if (this.options.MaxConcurrentActivityFunctions.HasValue && this.options.MaxConcurrentActivityFunctions.Value <= 0) + { + throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs new file mode 100644 index 000000000..9be2eb7c9 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// Scale metrics for SQL Server backend. + /// Contains the recommended replica count based on SQL Server orchestration service analysis. + /// + public class SqlServerScaleMetric : ScaleMetrics + { + /// + /// Gets or sets the recommended number of worker replicas based on SQL Server metrics. + /// + public int RecommendedReplicaCount { get; set; } + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs new file mode 100644 index 000000000..402bc0191 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// Azure Functions scale monitor implementation for the Durable Functions SQL Server backend. + /// Provides metrics-based autoscaling recommendations based on SQL Server metrics. + /// + public class SqlServerScaleMonitor : IScaleMonitor + { + private static readonly ScaleStatus ScaleInVote = new ScaleStatus { Vote = ScaleVote.ScaleIn }; + private static readonly ScaleStatus NoScaleVote = new ScaleStatus { Vote = ScaleVote.None }; + private static readonly ScaleStatus ScaleOutVote = new ScaleStatus { Vote = ScaleVote.ScaleOut }; + + private readonly SqlServerMetricsProvider metricsProvider; + private int? previousWorkerCount = -1; + + public SqlServerScaleMonitor(string functionId, string taskHubName, SqlServerMetricsProvider sqlMetricsProvider) + { + // Scalers in Durable Functions are per function IDs. Scalers share the same sqlMetricsProvider in the same task hub. + string id = $"{functionId}-DurableTask-SqlServer:{taskHubName ?? "default"}".ToLower(CultureInfo.InvariantCulture); + + this.Descriptor = new ScaleMonitorDescriptor(id: id, functionId: functionId); + this.metricsProvider = sqlMetricsProvider ?? throw new ArgumentNullException(nameof(sqlMetricsProvider)); + } + + /// + public ScaleMonitorDescriptor Descriptor { get; } + + /// + async Task IScaleMonitor.GetMetricsAsync() => await this.GetMetricsAsync(); + + /// + public async Task GetMetricsAsync() + { + return await this.metricsProvider.GetMetricsAsync(this.previousWorkerCount); + } + + /// + ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) => + this.GetScaleStatusCore(context.WorkerCount, context.Metrics.Cast()); + + /// + public ScaleStatus GetScaleStatus(ScaleStatusContext context) => + this.GetScaleStatusCore(context.WorkerCount, context.Metrics); + + private ScaleStatus GetScaleStatusCore(int currentWorkerCount, IEnumerable metrics) + { + SqlServerScaleMetric mostRecentMetric = metrics.LastOrDefault(); + if (mostRecentMetric == null) + { + return NoScaleVote; + } + + this.previousWorkerCount = currentWorkerCount; + + if (mostRecentMetric.RecommendedReplicaCount > currentWorkerCount) + { + return ScaleOutVote; + } + else if (mostRecentMetric.RecommendedReplicaCount < currentWorkerCount) + { + return ScaleInVote; + } + else + { + return NoScaleVote; + } + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs new file mode 100644 index 000000000..be7275978 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// Target-based scaler for SQL Server backend. + /// Provides target worker count recommendations based on SQL Server orchestration service metrics. + /// + public class SqlServerTargetScaler : ITargetScaler + { + private readonly SqlServerMetricsProvider sqlMetricsProvider; + + public SqlServerTargetScaler(string functionId, SqlServerMetricsProvider sqlMetricsProvider) + { + this.sqlMetricsProvider = sqlMetricsProvider ?? throw new ArgumentNullException(nameof(sqlMetricsProvider)); + // Scalers in Durable Functions are per function IDs. Scalers share the same sqlMetricsProvider in the same task hub. + this.TargetScalerDescriptor = new TargetScalerDescriptor(functionId); + } + + public TargetScalerDescriptor TargetScalerDescriptor { get; } + + public async Task GetScaleResultAsync(TargetScalerContext context) + { + SqlServerScaleMetric sqlScaleMetric = await this.sqlMetricsProvider.GetMetricsAsync(); + return new TargetScalerResult + { + TargetWorkerCount = Math.Max(0, sqlScaleMetric.RecommendedReplicaCount), + }; + } + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs new file mode 100644 index 000000000..5028daa9f --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using Azure; +using Azure.Core; +using DurableTask.AzureStorage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Factory for creating azure storage client providers. + /// + internal class StorageServiceClientProviderFactory : IStorageServiceClientProviderFactory + { + private readonly IConfiguration configuration; + private readonly ILoggerFactory loggerFactory; + private readonly ILogger logger; + + public StorageServiceClientProviderFactory( + IConfiguration configuration, + ILoggerFactory loggerFactory) + { + this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.logger = loggerFactory.CreateLogger(); + } + + public StorageAccountClientProvider GetClientProvider(string connectionName, TokenCredential tokenCredential = null) + { + if (string.IsNullOrEmpty(connectionName)) + { + throw new ArgumentNullException(nameof(connectionName)); + } + + // No TokenCredential - use connection string + if (tokenCredential == null) + { + var connectionString = this.configuration.GetConnectionString(connectionName) + ?? this.configuration[connectionName]; + + if (!string.IsNullOrEmpty(connectionString)) + { + this.logger.LogInformation("Using connection string authentication for connection: {ConnectionName}", connectionName); + return new StorageAccountClientProvider(connectionString); + } + + throw new InvalidOperationException($"Could not find connection string for connection name: {connectionName}. " + + $"Please provide a connection string in configuration."); + } + + // Scenario 2: TokenCredential provided - use Managed Identity + // 2.1: Try to get account name first (e.g., AzureWebJobsStorage__accountName) + var accountName = this.configuration[$"{connectionName}__accountName"]; + if (!string.IsNullOrEmpty(accountName)) + { + this.logger.LogInformation("Using Managed Identity with account name for connection: {ConnectionName}, account: {AccountName}", connectionName, accountName); + return new StorageAccountClientProvider(accountName, tokenCredential); + } + + // 2.2: Try to get service URIs (e.g., AzureWebJobsStorage__blobServiceUri, __queueServiceUri, __tableServiceUri) + var blobServiceUri = this.configuration[$"{connectionName}__blobServiceUri"]; + var queueServiceUri = this.configuration[$"{connectionName}__queueServiceUri"]; + var tableServiceUri = this.configuration[$"{connectionName}__tableServiceUri"]; + + if (!string.IsNullOrEmpty(blobServiceUri) && !string.IsNullOrEmpty(queueServiceUri) && !string.IsNullOrEmpty(tableServiceUri)) + { + this.logger.LogInformation("Using Managed Identity with service URIs for connection: {ConnectionName}", connectionName); + return new StorageAccountClientProvider( + new Uri(blobServiceUri), + new Uri(queueServiceUri), + new Uri(tableServiceUri), + tokenCredential); + } + + // If we have a token credential but no account name or service URIs, throw an error + throw new InvalidOperationException($"TokenCredential provided but could not find account name or service URIs for connection: {connectionName}. " + + $"Please provide either '{connectionName}__accountName' or service URIs ('{connectionName}__blobServiceUri', '{connectionName}__queueServiceUri', '{connectionName}__tableServiceUri') in configuration."); + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj index 4262d689d..1e8f2b37e 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj +++ b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj @@ -12,16 +12,18 @@ $(MajorVersion).0.0.0 Microsoft Corporation 9.0 - true - ..\..\sign.snk + false true true embedded NU5125;SA0001 - - false + + + + + $(MajorVersion).$(MinorVersion).$(PatchVersion) @@ -37,6 +39,8 @@ + + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs new file mode 100644 index 000000000..f04775823 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Resolves connection information from WebJobs configuration. + /// + internal class WebJobsConnectionInfoProvider : IConnectionInfoResolver + { + private readonly IConfiguration configuration; + + public WebJobsConnectionInfoProvider(IConfiguration configuration) + { + this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public (string ConnectionString, TokenCredential Credential) ResolveConnectionInfo(string connectionName) + { + if (string.IsNullOrEmpty(connectionName)) + { + throw new ArgumentNullException(nameof(connectionName)); + } + + // First, try to get the connection string directly + var connectionString = this.configuration.GetConnectionString(connectionName) + ?? this.configuration[connectionName]; + + if (!string.IsNullOrEmpty(connectionString)) + { + return (connectionString, null); + } + + // If no connection string, check for service URI (Managed Identity scenario) + var serviceUri = this.configuration[$"{connectionName}:serviceUri"] + ?? this.configuration[$"{connectionName}__serviceUri"]; + + if (!string.IsNullOrEmpty(serviceUri)) + { + // Use DefaultAzureCredential for Managed Identity + return (null, new DefaultAzureCredential()); + } + + // Return null if nothing found + return (null, null); + } + } +} + diff --git a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs new file mode 100644 index 000000000..6a0a55fa1 --- /dev/null +++ b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs @@ -0,0 +1,463 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using DurableTask.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + public class AzureStorageScalabilityProviderFactoryTests + { + private readonly ITestOutputHelper output; + private readonly TestLoggerProvider loggerProvider; + private readonly ILoggerFactory loggerFactory; + private readonly IStorageServiceClientProviderFactory clientProviderFactory; + private readonly INameResolver nameResolver; + private readonly IConfiguration configuration; + + public AzureStorageScalabilityProviderFactoryTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + + // Create real configuration with UseDevelopmentStorage=true + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "AzureWebJobsStorage", TestHelpers.GetStorageConnectionString() }, + { "TestConnection", TestHelpers.GetStorageConnectionString() }, + }); + this.configuration = configBuilder.Build(); + + // Use real factory instead of mocking + this.clientProviderFactory = new StorageServiceClientProviderFactory(this.configuration, this.loggerFactory); + this.nameResolver = new SimpleNameResolver(); + } + + private class SimpleNameResolver : INameResolver + { + public string Resolve(string name) => name; + } + + /// + /// Scenario: Creating factory with valid parameters. + /// Validates that factory can be instantiated with proper configuration. + /// Verifies factory name is "AzureStorage" and connection name is set correctly. + /// + [Fact] + public void Constructor_ValidParameters_CreatesInstance() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection"); + + // Act + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Assert + Assert.NotNull(factory); + Assert.Equal("AzureStorage", factory.Name); + Assert.Equal("TestConnection", factory.DefaultConnectionName); + } + + /// + /// Scenario: Constructor validation - null options. + /// Validates that factory properly rejects null options parameter. + /// Ensures proper error handling for missing configuration. + /// + [Fact] + public void Constructor_NullOptions_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new AzureStorageScalabilityProviderFactory( + null, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory)); + } + + /// + /// Scenario: Constructor validation - null client provider factory. + /// Validates that factory requires a valid storage client provider factory. + /// Ensures storage connectivity dependencies are enforced. + /// + [Fact] + public void Constructor_NullClientProviderFactory_ThrowsArgumentNullException() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection"); + + // Act & Assert + Assert.Throws(() => + new AzureStorageScalabilityProviderFactory( + options, + null, + this.nameResolver, + this.loggerFactory)); + } + + /// + /// Scenario: Creating durability provider without trigger metadata (default path). + /// Validates that provider can be created using only options configuration. + /// Tests connection string-based authentication (no TokenCredential). + /// Verifies provider has correct type, connection name, and concurrency settings. + /// + [Fact] + public void GetDurabilityProvider_ReturnsValidProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection"); + + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + var azureProvider = (AzureStorageScalabilityProvider)provider; + Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + } + + /// + /// Scenario: Creating durability provider with trigger metadata from ScaleController. + /// Validates the end-to-end flow when Scale Controller calls with trigger metadata. + /// Tests that max concurrent settings from options are applied (not from metadata). + /// Verifies connection name resolution and provider creation. + /// This is the primary path used by Azure Functions Scale Controller. + /// + [Fact] + public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection"); + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "TestConnection"); + + // Act + var provider = factory.GetDurabilityProvider(triggerMetadata); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + var azureProvider = (AzureStorageScalabilityProvider)provider; + // Note: Uses options values (10, 20), not trigger metadata values (15, 25) + Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + } + + /// + /// Scenario: Validation - invalid hub name (too short). + /// Validates that hub name must be between 3 and 50 characters. + /// Tests Azure Storage naming convention enforcement. + /// Ensures early validation before attempting storage connections. + /// + [Fact] + public void ValidateAzureStorageOptions_InvalidHubName_ThrowsArgumentException() + { + // Arrange - Hub name too short + var options = CreateOptions("ab", 10, 20, "TestConnection"); + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act & Assert + Assert.Throws(() => factory.GetDurabilityProvider()); + } + + /// + /// Scenario: Validation - invalid concurrency settings. + /// Validates that max concurrent orchestrator/activity functions must be >= 1. + /// Tests concurrency configuration enforcement. + /// Ensures valid worker count calculations for scaling. + /// + [Fact] + public void ValidateAzureStorageOptions_InvalidMaxConcurrent_ThrowsInvalidOperationException() + { + // Arrange - MaxConcurrentOrchestratorFunctions is 0 + var options = CreateOptions("testHub", 0, 20, "TestConnection"); + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act & Assert + Assert.Throws(() => factory.GetDurabilityProvider()); + } + + /// + /// Scenario: Provider caching for performance optimization. + /// Validates that factory reuses the same provider instance for multiple calls. + /// Tests performance optimization by avoiding redundant provider creation. + /// Ensures consistent metrics collection across scale decisions. + /// + [Fact] + public void GetDurabilityProvider_CachesDefaultProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection"); + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act + var provider1 = factory.GetDurabilityProvider(); + var provider2 = factory.GetDurabilityProvider(); + + // Assert + Assert.Same(provider1, provider2); + } + + private static IOptions CreateOptions( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName) + { + var options = new DurableTaskScaleOptions + { + HubName = hubName, + MaxConcurrentOrchestratorFunctions = maxOrchestrator, + MaxConcurrentActivityFunctions = maxActivity, + StorageProvider = new Dictionary + { + { "type", "AzureStorage" }, + { "connectionName", connectionName }, + }, + }; + + return Options.Create(options); + } + + private static TriggerMetadata CreateTriggerMetadata( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName) + { + var metadata = new JObject + { + { "functionName", "TestFunction" }, + { "type", "activityTrigger" }, + { "taskHubName", hubName }, + { "maxConcurrentOrchestratorFunctions", maxOrchestrator }, + { "maxConcurrentActivityFunctions", maxActivity }, + { + "storageProvider", new JObject + { + { "type", "AzureStorage" }, + { "connectionName", connectionName }, + } + }, + }; + + // Use the public constructor + return new TriggerMetadata(metadata); + } + + /// + /// ✅ KEY SCENARIO 1: Default Azure Storage provider registration. + /// Validates that factory works with the standard "AzureWebJobsStorage" connection name. + /// Tests the most common configuration used by Azure Functions. + /// Verifies connection string resolution from default app settings. + /// Confirms provider is created with correct concurrency limits. + /// + [Fact] + public void GetDurabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvider() + { + // Arrange - Using default AzureWebJobsStorage connection + var options = CreateOptions("testHub", 10, 20, "AzureWebJobsStorage"); + + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("AzureWebJobsStorage", provider.ConnectionName); + Assert.Equal(10, provider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(20, provider.MaxConcurrentTaskActivityWorkItems); + } + + /// + /// ✅ KEY SCENARIO 2: Multiple connections - retrieve values and connect correctly. + /// Validates that factory can handle different connection names in a single app. + /// Tests configuration retrieval for custom connections (multi-tenant scenarios). + /// Verifies that each provider connects to correct storage using respective connection strings. + /// Ensures isolation between different storage backends in the same application. + /// + [Fact] + public void GetDurabilityProvider_WithMultipleConnections_CreatesProvidersSuccessfully() + { + // Arrange - Test with multiple different connection names + var connectionNames = new[] { "AzureWebJobsStorage", "TestConnection", "CustomConnection" }; + + // Add custom connection to configuration + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "AzureWebJobsStorage", TestHelpers.GetStorageConnectionString() }, + { "TestConnection", TestHelpers.GetStorageConnectionString() }, + { "CustomConnection", TestHelpers.GetStorageConnectionString() } + }); + var config = configBuilder.Build(); + var clientFactory = new StorageServiceClientProviderFactory(config, this.loggerFactory); + + foreach (var connectionName in connectionNames) + { + var options = CreateOptions("testHub", 5, 10, connectionName); + var factory = new AzureStorageScalabilityProviderFactory( + options, + clientFactory, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.Equal(connectionName, provider.ConnectionName); + this.output.WriteLine($"Successfully created provider for connection: {connectionName}"); + } + } + + /// + /// Scenario: Factory type identification. + /// Validates that factory correctly identifies itself as "AzureStorage" type. + /// Tests factory registration and type resolution in DI container. + /// Ensures Scale Controller can identify which storage backend is in use. + /// + [Fact] + public void Factory_Name_IsAzureStorage() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection"); + var factory = new AzureStorageScalabilityProviderFactory( + options, + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act & Assert + Assert.Equal("AzureStorage", factory.Name); + } + + /// + /// ✅ KEY SCENARIO 3: Register durability provider via storage type. + /// Validates that factory is selected based on storageProvider.type = "AzureStorage". + /// Tests the provider factory selection mechanism when multiple backends are available. + /// Verifies correct provider type is instantiated for Azure Storage backend. + /// Ensures extensibility for future storage backend support (MSSQL, Netherite, etc.). + /// + [Fact] + public void GetDurabilityProvider_WithAzureStorageType_UsesCorrectProvider() + { + // Arrange - Explicitly set storageProvider type to "AzureStorage" + var options = new DurableTaskScaleOptions + { + HubName = "testHub", + MaxConcurrentOrchestratorFunctions = 10, + MaxConcurrentActivityFunctions = 20, + StorageProvider = new Dictionary + { + { "type", "AzureStorage" }, + { "connectionName", "AzureWebJobsStorage" }, + }, + }; + + var factory = new AzureStorageScalabilityProviderFactory( + Options.Create(options), + this.clientProviderFactory, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("AzureStorage", factory.Name); + } + + /// + /// Scenario: Configuration value retrieval and connection string resolution. + /// Validates that IConfiguration correctly resolves custom connection names. + /// Tests the configuration binding mechanism for connection strings. + /// Verifies end-to-end flow from configuration to storage connection. + /// Ensures custom connection names work with Azure Storage emulator. + /// + [Fact] + public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() + { + // Arrange - Verify we can retrieve connection string from configuration + var testConnectionString = TestHelpers.GetStorageConnectionString(); + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "MyCustomConnection", testConnectionString } + }); + var config = configBuilder.Build(); + var clientFactory = new StorageServiceClientProviderFactory(config, this.loggerFactory); + + var options = CreateOptions("testHub", 10, 20, "MyCustomConnection"); + var factory = new AzureStorageScalabilityProviderFactory( + options, + clientFactory, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.Equal("MyCustomConnection", provider.ConnectionName); + + // Verify the connection string was retrieved from configuration + var retrievedConnectionString = config["MyCustomConnection"]; + Assert.Equal(testConnectionString, retrievedConnectionString); + } + } +} diff --git a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs new file mode 100644 index 000000000..b9be10110 --- /dev/null +++ b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs @@ -0,0 +1,209 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using DurableTask.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for AzureStorageScalabilityProvider. + /// Validates the Azure Storage implementation of ScalabilityProvider. + /// Tests provider instantiation, concurrency configuration, and scale monitor/scaler creation. + /// + public class AzureStorageScalabilityProviderTests + { + private readonly ITestOutputHelper output; + private readonly TestLoggerProvider loggerProvider; + private readonly ILoggerFactory loggerFactory; + + public AzureStorageScalabilityProviderTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + // Note: StorageAccountClientProvider is sealed, so we can't mock it. + // We'll create real instances in each test method. + } + + /// + /// Scenario: Provider creation with authenticated storage client. + /// Validates that provider accepts a pre-authenticated StorageAccountClientProvider. + /// Tests that connection name is properly stored for Scale Controller identification. + /// Ensures provider is ready to create scale monitors and target scalers. + /// + [Fact] + public void Constructor_ValidParameters_CreatesInstance() + { + // Arrange + var connectionString = TestHelpers.GetStorageConnectionString(); + var clientProvider = new StorageAccountClientProvider(connectionString); + var logger = this.loggerFactory.CreateLogger(); + + // Act + var provider = new AzureStorageScalabilityProvider( + clientProvider, + "TestConnection", + logger); + + // Assert + Assert.NotNull(provider); + Assert.Equal("TestConnection", provider.ConnectionName); + } + + /// + /// Scenario: Constructor validation - null client provider. + /// Validates that provider requires an authenticated storage client. + /// Tests defensive programming for required dependencies. + /// Ensures clear error messages when storage connectivity is missing. + /// + [Fact] + public void Constructor_NullClientProvider_ThrowsArgumentNullException() + { + // Arrange + var logger = this.loggerFactory.CreateLogger(); + + // Act & Assert + Assert.Throws(() => + new AzureStorageScalabilityProvider(null, "TestConnection", logger)); + } + + /// + /// Scenario: Orchestrator concurrency configuration. + /// Validates that max concurrent orchestrators can be configured. + /// Tests property setter and getter for MaxConcurrentTaskOrchestrationWorkItems. + /// Ensures Scale Controller can apply concurrency limits for scaling decisions. + /// + [Fact] + public void MaxConcurrentTaskOrchestrationWorkItems_CanBeSetAndRetrieved() + { + // Arrange + var connectionString = TestHelpers.GetStorageConnectionString(); + var clientProvider = new StorageAccountClientProvider(connectionString); + var logger = this.loggerFactory.CreateLogger(); + var provider = new AzureStorageScalabilityProvider(clientProvider, "TestConnection", logger); + + // Act + provider.MaxConcurrentTaskOrchestrationWorkItems = 20; + + // Assert + Assert.Equal(20, provider.MaxConcurrentTaskOrchestrationWorkItems); + } + + /// + /// Scenario: Activity concurrency configuration. + /// Validates that max concurrent activities can be configured. + /// Tests property setter and getter for MaxConcurrentTaskActivityWorkItems. + /// Ensures Scale Controller can apply concurrency limits for scaling decisions. + /// + [Fact] + public void MaxConcurrentTaskActivityWorkItems_CanBeSetAndRetrieved() + { + // Arrange + var connectionString = TestHelpers.GetStorageConnectionString(); + var clientProvider = new StorageAccountClientProvider(connectionString); + var logger = this.loggerFactory.CreateLogger(); + var provider = new AzureStorageScalabilityProvider(clientProvider, "TestConnection", logger); + + // Act + provider.MaxConcurrentTaskActivityWorkItems = 30; + + // Assert + Assert.Equal(30, provider.MaxConcurrentTaskActivityWorkItems); + } + + /// + /// Scenario: Scale Monitor creation for metrics-based autoscaling. + /// Validates that provider can create IScaleMonitor for Scale Controller. + /// Tests that Scale Controller can get metrics from Azure Storage queues/tables. + /// Ensures monitoring infrastructure is properly initialized with storage connection. + /// This is used by Scale Controller for metrics-based autoscaling decisions. + /// + [Fact] + public void TryGetScaleMonitor_ValidParameters_ReturnsTrue() + { + // Arrange + var connectionString = TestHelpers.GetStorageConnectionString(); + var clientProvider = new StorageAccountClientProvider(connectionString); + var logger = this.loggerFactory.CreateLogger(); + var provider = new AzureStorageScalabilityProvider(clientProvider, "TestConnection", logger); + + // Act + var result = provider.TryGetScaleMonitor( + "functionId", + "functionName", + "testHub", + "TestConnection", + out IScaleMonitor scaleMonitor); + + // Assert + Assert.True(result); + Assert.NotNull(scaleMonitor); + } + + /// + /// Scenario: Target Scaler creation for target-based autoscaling. + /// Validates that provider can create ITargetScaler for Scale Controller. + /// Tests that Scale Controller can perform target-based scaling calculations. + /// Ensures scaler can determine target worker count based on queue depths. + /// This is the recommended approach for Durable Functions scaling. + /// + [Fact] + public void TryGetTargetScaler_ValidParameters_ReturnsTrue() + { + // Arrange + var connectionString = TestHelpers.GetStorageConnectionString(); + var clientProvider = new StorageAccountClientProvider(connectionString); + var logger = this.loggerFactory.CreateLogger(); + var provider = new AzureStorageScalabilityProvider(clientProvider, "TestConnection", logger); + + // Act + var result = provider.TryGetTargetScaler( + "functionId", + "functionName", + "testHub", + "TestConnection", + out ITargetScaler targetScaler); + + // Assert + Assert.True(result); + Assert.NotNull(targetScaler); + } + + /// + /// Scenario: Metrics provider caching for performance. + /// Validates that provider reuses the same metrics provider for multiple calls. + /// Tests performance optimization to avoid redundant storage connections. + /// Ensures consistent metrics collection across multiple scale decisions. + /// Validates singleton pattern within a provider instance. + /// + [Fact] + public void TryGetScaleMonitor_UsesSameMetricsProvider() + { + // Arrange + var connectionString = TestHelpers.GetStorageConnectionString(); + var clientProvider = new StorageAccountClientProvider(connectionString); + var logger = this.loggerFactory.CreateLogger(); + var provider = new AzureStorageScalabilityProvider(clientProvider, "TestConnection", logger); + + // Act - Call both methods to ensure they share the same metrics provider + provider.TryGetScaleMonitor("functionId", "functionName", "testHub", "TestConnection", out IScaleMonitor scaleMonitor); + provider.TryGetTargetScaler("functionId", "functionName", "testHub", "TestConnection", out ITargetScaler targetScaler); + + // Assert + Assert.NotNull(scaleMonitor); + Assert.NotNull(targetScaler); + } + } +} + diff --git a/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs b/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs new file mode 100644 index 000000000..35e7939fe --- /dev/null +++ b/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs @@ -0,0 +1,266 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure; +using DurableTask.AzureStorage; +using DurableTask.AzureStorage.Monitoring; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +{ + /// + /// Tests for DurableTaskScaleMonitor. + /// Validates the metrics-based autoscaling monitor for Durable Functions. + /// Tests scale metrics collection, scale status determination, and scale recommendations. + /// Ensures Scale Controller can make informed autoscaling decisions based on queue metrics. + /// + public class DurableTaskScaleMonitorTests + { + private readonly string hubName = "DurableTaskTriggerHubName"; + private readonly string functionId = "FunctionId"; + private readonly StorageAccountClientProvider clientProvider; + private readonly ITestOutputHelper output; + private readonly LoggerFactory loggerFactory; + private readonly TestLoggerProvider loggerProvider; + private readonly Mock performanceMonitor; + private readonly DurableTaskScaleMonitor scaleMonitor; + + public DurableTaskScaleMonitorTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + + this.clientProvider = new StorageAccountClientProvider(TestHelpers.GetStorageConnectionString()); + + ILogger logger = this.loggerFactory.CreateLogger(); + this.performanceMonitor = new Mock(MockBehavior.Strict, new AzureStorageOrchestrationServiceSettings + { + StorageAccountClientProvider = this.clientProvider, + TaskHubName = this.hubName, + }); + var metricsProvider = new DurableTaskMetricsProvider( + this.hubName, + logger, + this.performanceMonitor.Object, + this.clientProvider); + + this.scaleMonitor = new DurableTaskScaleMonitor( + this.functionId, + this.hubName, + logger, + metricsProvider); + } + + [Fact] + public void ScaleMonitorDescriptor_ReturnsExpectedValue() + { + Assert.Equal($"{this.functionId}-DurableTask-{this.hubName}".ToLower(), this.scaleMonitor.Descriptor.Id); + } + + [Fact] + public async Task GetMetrics_ReturnsExpectedResult() + { + PerformanceHeartbeat[] heartbeats; + DurableTaskTriggerMetrics[] expectedMetrics; + + this.GetCorrespondingHeartbeatsAndMetrics(out heartbeats, out expectedMetrics); + + this.performanceMonitor + .Setup(m => m.PulseAsync()) + .Returns(Task.FromResult(heartbeats[0])); + + var actualMetrics = await this.scaleMonitor.GetMetricsAsync(); + + Assert.Equal(expectedMetrics[0].PartitionCount, actualMetrics.PartitionCount); + Assert.Equal(expectedMetrics[0].ControlQueueLengths, actualMetrics.ControlQueueLengths); + Assert.Equal(expectedMetrics[0].ControlQueueLatencies, actualMetrics.ControlQueueLatencies); + Assert.Equal(expectedMetrics[0].WorkItemQueueLength, actualMetrics.WorkItemQueueLength); + Assert.Equal(expectedMetrics[0].WorkItemQueueLatency, actualMetrics.WorkItemQueueLatency); + } + + [Fact] + public async Task GetMetrics_HandlesExceptions() + { + // StorageException + var errorMsg = "Uh oh"; + this.performanceMonitor + .Setup(m => m.PulseAsync()) + .Throws(new Exception("Failure", new RequestFailedException(errorMsg))); + + var metrics = await this.scaleMonitor.GetMetricsAsync(); + + var messages = this.loggerProvider.GetAllLogMessages(); + var warningMessage = messages.FirstOrDefault(m => m.Contains("Failure") && m.Contains(errorMsg)); + Assert.NotNull(warningMessage); + } + + // Since this extension doesn't contain any scaling logic, the point of these tests is to test + // that DurableTaskTriggerMetrics are being properly deserialized into PerformanceHeartbeats. + // DurableTask already contains tests for conversion/scaling logic past that. + [Fact] + public void GetScaleStatus_DeserializesMetrics() + { + PerformanceHeartbeat[] heartbeats; + DurableTaskTriggerMetrics[] metrics; + + this.GetCorrespondingHeartbeatsAndMetrics(out heartbeats, out metrics); + + var context = new ScaleStatusContext + { + WorkerCount = 1, + Metrics = metrics, + }; + + // MatchEquivalentHeartbeats will ensure that an exception is thrown if GetScaleStatus + // tried to call MakeScaleRecommendation with an unexpected PerformanceHeartbeat[] + this.performanceMonitor + .Setup(m => m.MakeScaleRecommendation(1, this.MatchEquivalentHeartbeats(heartbeats))) + .Returns(null); + + var recommendation = this.scaleMonitor.GetScaleStatus(context); + + Assert.Equal(ScaleVote.None, recommendation.Vote); + } + + [Fact] + public void GetScaleStatus_HandlesMalformedMetrics() + { + // Null metrics + var context = new ScaleStatusContext + { + WorkerCount = 1, + Metrics = null, + }; + + var recommendation = this.scaleMonitor.GetScaleStatus(context); + + Assert.Equal(ScaleVote.None, recommendation.Vote); + + // Empty metrics + var heartbeats = new PerformanceHeartbeat[0]; + context.Metrics = new DurableTaskTriggerMetrics[0]; + + this.performanceMonitor + .Setup(m => m.MakeScaleRecommendation(1, heartbeats)) + .Returns(null); + + recommendation = this.scaleMonitor.GetScaleStatus(context); + + Assert.Equal(ScaleVote.None, recommendation.Vote); + + // Metrics with null properties + var metrics = new DurableTaskTriggerMetrics[5]; + for (int i = 0; i < metrics.Length; ++i) + { + metrics[i] = new DurableTaskTriggerMetrics(); + } + + context.Metrics = metrics; + + heartbeats = new PerformanceHeartbeat[5]; + for (int i = 0; i < heartbeats.Length; ++i) + { + heartbeats[i] = new PerformanceHeartbeat + { + ControlQueueLengths = new List(), + ControlQueueLatencies = new List(), + }; + } + + this.performanceMonitor + .Setup(m => m.MakeScaleRecommendation(1, this.MatchEquivalentHeartbeats(heartbeats))) + .Returns(null); + + recommendation = this.scaleMonitor.GetScaleStatus(context); + + Assert.Equal(ScaleVote.None, recommendation.Vote); + } + + private void GetCorrespondingHeartbeatsAndMetrics(out PerformanceHeartbeat[] heartbeats, out DurableTaskTriggerMetrics[] metrics) + { + heartbeats = new PerformanceHeartbeat[] + { + new PerformanceHeartbeat + { + PartitionCount = 4, + ControlQueueLengths = new List { 1, 2, 3, 4 }, + ControlQueueLatencies = new List { TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2), TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(4), }, + WorkItemQueueLength = 5, + WorkItemQueueLatency = TimeSpan.FromMilliseconds(6), + }, + new PerformanceHeartbeat + { + PartitionCount = 7, + ControlQueueLengths = new List { 8, 9, 10, 11 }, + ControlQueueLatencies = new List { TimeSpan.FromMilliseconds(12), TimeSpan.FromMilliseconds(13), TimeSpan.FromMilliseconds(14), TimeSpan.FromMilliseconds(15), }, + WorkItemQueueLength = 16, + WorkItemQueueLatency = TimeSpan.FromMilliseconds(17), + }, + }; + + metrics = new DurableTaskTriggerMetrics[] + { + new DurableTaskTriggerMetrics + { + PartitionCount = 4, + ControlQueueLengths = "[1,2,3,4]", + ControlQueueLatencies = "[\"00:00:00.0010000\",\"00:00:00.0020000\",\"00:00:00.0030000\",\"00:00:00.0040000\"]", + WorkItemQueueLength = 5, + WorkItemQueueLatency = "00:00:00.0060000", + }, + new DurableTaskTriggerMetrics + { + PartitionCount = 7, + ControlQueueLengths = "[8,9,10,11]", + ControlQueueLatencies = "[\"00:00:00.0120000\",\"00:00:00.0130000\",\"00:00:00.0140000\",\"00:00:00.0150000\"]", + WorkItemQueueLength = 16, + WorkItemQueueLatency = "00:00:00.0170000", + }, + }; + } + + // [Matcher] attribute is outdated, if maintenance is required, remove this suppression and refactor +#pragma warning disable CS0618 + [Matcher] +#pragma warning restore CS0618 + private PerformanceHeartbeat[] MatchEquivalentHeartbeats(PerformanceHeartbeat[] expected) + { + return Match.Create(actual => + { + if (expected.Length != actual.Length) + { + return false; + } + + bool[] matches = new bool[5]; + for (int i = 0; i < actual.Length; ++i) + { + matches[0] = expected[i].PartitionCount == actual[i].PartitionCount; + matches[1] = expected[i].ControlQueueLatencies.SequenceEqual(actual[i].ControlQueueLatencies); + matches[2] = expected[i].ControlQueueLengths.SequenceEqual(actual[i].ControlQueueLengths); + matches[3] = expected[i].WorkItemQueueLength == actual[i].WorkItemQueueLength; + matches[4] = expected[i].WorkItemQueueLatency == actual[i].WorkItemQueueLatency; + + if (matches.Any(m => m == false)) + { + return false; + } + } + + return true; + }); + } + } +} + diff --git a/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs b/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs new file mode 100644 index 000000000..d0cb2e723 --- /dev/null +++ b/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Threading.Tasks; +using DurableTask.AzureStorage; +using DurableTask.AzureStorage.Monitoring; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +{ + /// + /// Tests for DurableTaskTargetScaler. + /// Validates the target-based autoscaling mechanism for Durable Functions. + /// Tests worker count calculations based on queue depths and concurrency limits. + /// Ensures accurate scaling decisions for both orchestrators and activities. + /// This is the primary scaling approach used by Azure Functions Scale Controller. + /// + public class DurableTaskTargetScalerTests + { + private readonly DurableTaskTargetScaler targetScaler; + private readonly TargetScalerContext scalerContext; + private readonly Mock metricsProviderMock; + private readonly Mock triggerMetricsMock; + private readonly Mock scalabilityProviderMock; + private readonly TestLoggerProvider loggerProvider; + private readonly ITestOutputHelper output; + + public DurableTaskTargetScalerTests(ITestOutputHelper output) + { + this.scalerContext = new TargetScalerContext(); + this.output = output; + var loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(this.output); + loggerFactory.AddProvider(this.loggerProvider); + ILogger logger = loggerFactory.CreateLogger(); + + DisconnectedPerformanceMonitor nullPerformanceMonitorMock = null; + StorageAccountClientProvider storageAccountClientProvider = null; + this.metricsProviderMock = new Mock( + MockBehavior.Strict, + "HubName", + logger, + nullPerformanceMonitorMock, + storageAccountClientProvider); + + this.triggerMetricsMock = new Mock(MockBehavior.Strict); + this.scalabilityProviderMock = new Mock(MockBehavior.Strict, "AzureStorage", "TestConnection"); + this.scalabilityProviderMock.SetupGet(s => s.MaxConcurrentTaskActivityWorkItems).Returns(10); + this.scalabilityProviderMock.SetupGet(s => s.MaxConcurrentTaskOrchestrationWorkItems).Returns(10); + + this.targetScaler = new DurableTaskTargetScaler( + "FunctionId", + this.metricsProviderMock.Object, + this.scalabilityProviderMock.Object, + logger); + } + + [Theory] + [InlineData(1, 10, 10, "[1, 1, 1, 1]", 10)] + [InlineData(1, 10, 0, "[0, 0, 0, 0]", 0)] + [InlineData(1, 10, 0, "[2, 2, 3, 3]", 1)] + [InlineData(1, 10, 0, "[9999, 0, 0, 0]", 1)] + [InlineData(1, 10, 0, "[9999, 0, 0, 1]", 2)] + [InlineData(10, 10, 10, "[2, 2, 3, 3 ]", 1)] + [InlineData(10, 10, 30, "[10, 10, 10, 1]", 4)] + public async Task TestTargetScaler(int maxConcurrentActivities, int maxConcurrentOrchestrators, int workItemQueueLength, string controlQueueLengths, int expectedWorkerCount) + { + this.scalabilityProviderMock.SetupGet(m => m.MaxConcurrentTaskActivityWorkItems).Returns(maxConcurrentActivities); + this.scalabilityProviderMock.SetupGet(m => m.MaxConcurrentTaskOrchestrationWorkItems).Returns(maxConcurrentOrchestrators); + + this.triggerMetricsMock.SetupGet(m => m.WorkItemQueueLength).Returns(workItemQueueLength); + this.triggerMetricsMock.SetupGet(m => m.ControlQueueLengths).Returns(controlQueueLengths); + + this.metricsProviderMock.Setup(m => m.GetMetricsAsync()).ReturnsAsync(this.triggerMetricsMock.Object); + + var scaleResult = await this.targetScaler.GetScaleResultAsync(this.scalerContext); + var targetWorkerCount = scaleResult.TargetWorkerCount; + Assert.Equal(expectedWorkerCount, targetWorkerCount); + } + } +} + diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs new file mode 100644 index 000000000..bbc3ac930 --- /dev/null +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -0,0 +1,400 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for DurableTaskJobHostConfigurationExtensions. + /// Validates Dependency Injection registration when Scale Controller calls AddDurableTask(). + /// Ensures all required services are properly registered for the scale package to function. + /// + public class DurableTaskJobHostConfigurationExtensionsTests + { + /// + /// Scenario: Core service registration in DI container. + /// Validates that AddDurableTask() registers IStorageServiceClientProviderFactory. + /// Validates that AddDurableTask() registers IScalabilityProviderFactory implementations. + /// Tests the foundational setup required by Scale Controller integration. + /// Ensures Scale Controller can resolve storage clients and scalability providers. + /// + [Fact] + public void AddDurableTask_RegistersRequiredServices() + { + // Arrange + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureServices(services => + { + // Register INameResolver - required by AzureStorageScalabilityProviderFactory + services.AddSingleton(new SimpleNameResolver()); + }); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + // Act + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Assert + // Verify IStorageServiceClientProviderFactory is registered + var clientProviderFactory = services.GetService(); + Assert.NotNull(clientProviderFactory); + + // Verify IScalabilityProviderFactory is registered + var scalabilityProviderFactories = services.GetServices().ToList(); + Assert.NotEmpty(scalabilityProviderFactories); + Assert.Contains(scalabilityProviderFactories, f => f is AzureStorageScalabilityProviderFactory); + } + + /// + /// Scenario: DurableTaskScaleExtension registration. + /// Validates that the core extension config provider is registered. + /// Tests that Scale Controller can initialize the extension. + /// Ensures WebJobs framework can discover and configure the scale extension. + /// + [Fact] + public void AddDurableTask_RegistersDurableTaskScaleExtension() + { + // Arrange + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureServices(services => + { + // Register INameResolver - required by AzureStorageScalabilityProviderFactory + services.AddSingleton(new SimpleNameResolver()); + }); + + IServiceCollection capturedServices = null; + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + // Capture the service collection before building + capturedServices = webJobsBuilder.Services; + + // Act + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + + // Assert + // Verify DurableTaskScaleExtension is registered by checking service descriptors + Assert.NotNull(capturedServices); + var extensionDescriptor = capturedServices + .FirstOrDefault(d => d.ServiceType == typeof(Microsoft.Azure.WebJobs.Host.Config.IExtensionConfigProvider) + && d.ImplementationType == typeof(DurableTaskScaleExtension)); + Assert.NotNull(extensionDescriptor); + } + + /// + /// Scenario: Options configuration binding. + /// Validates that DurableTaskScaleOptions is bound and available via IOptions pattern. + /// Tests configuration injection mechanism for Scale Controller settings. + /// Ensures options can be updated via configuration files or environment variables. + /// + [Fact] + public void AddDurableTask_BindsDurableTaskScaleOptions() + { + // Arrange + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + // Act + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Assert + // Verify DurableTaskScaleOptions can be retrieved + var options = services.GetService>(); + Assert.NotNull(options); + Assert.NotNull(options.Value); + } + + /// + /// Scenario: Singleton registration for storage client factory. + /// Validates that IStorageServiceClientProviderFactory is registered as singleton. + /// Tests that multiple resolutions return the same instance (connection pooling). + /// Ensures efficient resource usage and connection reuse across scale operations. + /// + [Fact] + public void AddDurableTask_RegistersSingletonClientProviderFactory() + { + // Arrange + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + // Act + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Assert + // Verify the same instance is returned (singleton) + var factory1 = services.GetService(); + var factory2 = services.GetService(); + Assert.Same(factory1, factory2); + } + + /// + /// Scenario: Extension method validation. + /// Validates that AddDurableTask() properly handles null builder parameter. + /// Tests defensive programming and error handling. + /// Ensures clear error messages for misconfiguration scenarios. + /// + [Fact] + public void AddDurableTask_NullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + { + IWebJobsBuilder builder = null; + builder.AddDurableTask(); + }); + } + + /// + /// ✅ KEY SCENARIO 1: Default Azure Storage provider registration. + /// Validates that AddDurableTask() registers AzureStorageScalabilityProviderFactory. + /// Tests that Azure Storage is configured as the default scalability provider. + /// Verifies factory name is "AzureStorage" for Scale Controller identification. + /// Ensures backward compatibility with existing Azure Functions deployments. + /// + [Fact] + public void AddDurableTask_RegistersAzureStorageAsDefaultProvider() + { + // Arrange + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureServices(services => + { + services.AddSingleton(new SimpleNameResolver()); + }); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + // Act + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Assert + // Verify AzureStorageScalabilityProviderFactory is registered as the default + var scalabilityProviderFactories = services.GetServices().ToList(); + Assert.NotEmpty(scalabilityProviderFactories); + + var azureStorageFactory = scalabilityProviderFactories.OfType().FirstOrDefault(); + Assert.NotNull(azureStorageFactory); + Assert.Equal("AzureStorage", azureStorageFactory.Name); + } + + /// + /// ✅ KEY SCENARIO 2: Multiple connections configuration resolution. + /// Validates that factory can resolve multiple connection strings from configuration. + /// Tests multi-tenant scenarios where different functions use different storage accounts. + /// Verifies end-to-end DI setup with IConfiguration integration. + /// Ensures Scale Controller can handle functions with different connection configurations. + /// + [Fact] + public void AddDurableTask_WithMultipleConnections_AllCanBeResolved() + { + // Arrange - Set up configuration with multiple connections + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { "AzureWebJobsStorage", "UseDevelopmentStorage=true" }, + { "Connection1", "UseDevelopmentStorage=true" }, + { "Connection2", "UseDevelopmentStorage=true" }, + }); + }); + hostBuilder.ConfigureServices(services => + { + services.AddSingleton(new SimpleNameResolver()); + }); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Assert + // Verify we can create client providers for different connections + var clientProviderFactory = services.GetService(); + Assert.NotNull(clientProviderFactory); + + // Test that we can get client providers for all connections + var connections = new[] { "AzureWebJobsStorage", "Connection1", "Connection2" }; + foreach (var connectionName in connections) + { + var clientProvider = clientProviderFactory.GetClientProvider(connectionName, null); + Assert.NotNull(clientProvider); + } + } + } + + /// + /// Simple INameResolver implementation for tests that returns the input as-is. + /// + internal class SimpleNameResolver : INameResolver + { + public string Resolve(string name) + { + return name; + } + } + + /// + /// Tests for end-to-end SQL Server scaling integration via DurableTaskTriggersScaleProvider. + /// Validates the complete flow from triggerMetadata to working TargetScaler and ScaleMonitor. + /// + public class DurableTaskTriggersScaleProviderSqlServerTests + { + /// + /// Scenario: End-to-end SQL Server scaling via triggerMetadata with type="mssql". + /// Validates that when triggerMetadata mentions storageProvider.type="mssql", DurableTaskTriggersScaleProvider creates SQL provider. + /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. + /// Verifies that both TargetScaler and ScaleMonitor successfully work with real SQL Server. + /// This test validates the complete integration path that Scale Controller uses. + /// + [Fact] + public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScaleProvider_AndBothScalersWork() + { + // Arrange - Create triggerMetadata with type="mssql" (as Scale Controller would pass) + var hubName = "testHub"; + var connectionName = "TestConnection"; + var metadata = new JObject + { + { "functionName", "TestFunction" }, + { "type", "activityTrigger" }, + { "taskHubName", hubName }, + { "maxConcurrentOrchestratorFunctions", 10 }, + { "maxConcurrentActivityFunctions", 20 }, + { + "storageProvider", new JObject + { + { "type", "mssql" }, + { "connectionName", connectionName }, + } + }, + }; + var triggerMetadata = new TriggerMetadata(metadata); + + // Verify triggerMetadata has correct storageProvider.type + var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; + Assert.NotNull(storageProvider); + Assert.Equal("mssql", storageProvider["type"]?.ToString()); + Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); + + // Set up DI container with SQL connection string + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { $"ConnectionStrings:{connectionName}", TestHelpers.GetSqlConnectionString() }, + { connectionName, TestHelpers.GetSqlConnectionString() }, + }); + }); + hostBuilder.ConfigureServices(services => + { + services.AddSingleton(new SimpleNameResolver()); + }); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Get configuration and register SQL factory (as Scale Controller would) + var configuration = services.GetRequiredService(); + var nameResolver = services.GetRequiredService(); + var loggerFactory = services.GetRequiredService(); + var options = services.GetRequiredService>(); + + // Register SQL Server factory (normally done by Scale Controller) + var sqlFactory = new SqlServerScalabilityProviderFactory( + options, + configuration, + nameResolver, + loggerFactory); + + // Create a list with all factories (Azure Storage from AddDurableTask + SQL from Scale Controller) + var scalabilityProviderFactories = new List( + services.GetServices()); + scalabilityProviderFactories.Add(sqlFactory); + + // Verify SQL Server factory is available + var sqlFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => f.Name == "mssql"); + Assert.NotNull(sqlFactoryFound); + Assert.IsType(sqlFactoryFound); + + // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) + var triggersScaleProvider = new DurableTaskTriggersScaleProvider( + options, + nameResolver, + loggerFactory, + scalabilityProviderFactories, + triggerMetadata); + + // Act - Get TargetScaler from DurableTaskTriggersScaleProvider + var targetScaler = triggersScaleProvider.GetTargetScaler(); + + // Assert - TargetScaler was created successfully + Assert.NotNull(targetScaler); + Assert.IsType(targetScaler); + + // Act - Get scale result from TargetScaler (connects to real SQL) + var targetScalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // Assert - TargetScaler returns valid result + Assert.NotNull(targetScalerResult); + Assert.True(targetScalerResult.TargetWorkerCount >= 0, "Target worker count should be non-negative"); + + // Act - Get ScaleMonitor from DurableTaskTriggersScaleProvider + var scaleMonitor = triggersScaleProvider.GetMonitor(); + + // Assert - ScaleMonitor was created successfully + Assert.NotNull(scaleMonitor); + Assert.IsType(scaleMonitor); + + // Act - Get metrics from ScaleMonitor (connects to real SQL) + var metrics = await scaleMonitor.GetMetricsAsync(); + + // Assert - ScaleMonitor returns valid metrics + Assert.NotNull(metrics); + Assert.IsType(metrics); + var sqlMetrics = (SqlServerScaleMetric)metrics; + Assert.True(sqlMetrics.RecommendedReplicaCount >= 0, "Recommended replica count should be non-negative"); + + // Verify connection string was successfully retrieved + var connectionString = configuration.GetConnectionString(connectionName) ?? configuration[connectionName]; + Assert.NotNull(connectionString); + Assert.NotEmpty(connectionString); + } + } +} + diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs new file mode 100644 index 000000000..b2e709791 --- /dev/null +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -0,0 +1,534 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for SqlServerScalabilityProviderFactory. + /// Validates factory creation, provider instantiation, and configuration handling. + /// Note: SQL Server is NOT the default provider - only created when storageProvider.type = "mssql". + /// + public class SqlServerScalabilityProviderFactoryTests + { + private readonly ITestOutputHelper output; + private readonly TestLoggerProvider loggerProvider; + private readonly ILoggerFactory loggerFactory; + private readonly INameResolver nameResolver; + private readonly IConfiguration configuration; + + public SqlServerScalabilityProviderFactoryTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + + // Create real configuration with SQL connection string + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "SQLDB_Connection", TestHelpers.GetSqlConnectionString() }, + { "TestConnection", TestHelpers.GetSqlConnectionString() }, + }); + this.configuration = configBuilder.Build(); + + this.nameResolver = new SimpleNameResolver(); + } + + private class SimpleNameResolver : INameResolver + { + public string Resolve(string name) => name; + } + + /// + /// Scenario: Creating factory with valid parameters when type="mssql" is specified. + /// Validates that factory can be instantiated with proper configuration when SQL Server type is specified. + /// Verifies factory name is "mssql" and connection name is set correctly. + /// + [Fact] + public void Constructor_WithMssqlType_CreatesInstance() + { + // Arrange - Specify type="mssql" in storage provider + var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); + + // Act + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Assert + Assert.NotNull(factory); + Assert.Equal("mssql", factory.Name); + Assert.Equal("TestConnection", factory.DefaultConnectionName); + } + + /// + /// Scenario: Factory returns early when type is NOT "mssql". + /// Validates that factory does not initialize when storage provider type is different (e.g., "AzureStorage"). + /// Tests that SQL Server factory respects the storage provider type selection. + /// Ensures factory can be registered but only activates for SQL Server. + /// + [Fact] + public void Constructor_WithAzureStorageType_ReturnsEarly() + { + // Arrange - Specify type="AzureStorage" instead of "mssql" + var options = CreateOptions("testHub", 10, 20, "TestConnection", "AzureStorage"); + + // Act + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Assert - Factory should be created but not initialized for non-SQL types + Assert.NotNull(factory); + Assert.Equal("mssql", factory.Name); + // DefaultConnectionName may be null or default since factory returns early + } + + /// + /// Scenario: Constructor validation - null options. + /// Validates that factory properly rejects null options parameter. + /// Ensures proper error handling for missing configuration. + /// + [Fact] + public void Constructor_NullOptions_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new SqlServerScalabilityProviderFactory( + null, + this.configuration, + this.nameResolver, + this.loggerFactory)); + } + + /// + /// Scenario: Creating durability provider with trigger metadata containing type="mssql". + /// Validates the end-to-end flow when Scale Controller calls with SQL Server trigger metadata. + /// Tests that provider is created successfully when metadata specifies SQL Server backend. + /// Verifies connection string resolution and provider creation. + /// This is the primary path used by Azure Functions Scale Controller for SQL Server. + /// + [Fact] + public void GetDurabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "TestConnection", "mssql"); + + // Act + var provider = factory.GetDurabilityProvider(triggerMetadata); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("TestConnection", provider.ConnectionName); + } + + /// + /// Scenario: Creating durability provider without trigger metadata (default path) with mssql type. + /// Validates that provider can be created using only options configuration when type="mssql". + /// Tests connection string-based authentication. + /// Verifies provider has correct type and connection name. + /// + [Fact] + public void GetDurabilityProvider_WithMssqlType_ReturnsValidProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("TestConnection", provider.ConnectionName); + } + + /// + /// Scenario: Connection name resolution from storage provider configuration. + /// Validates that factory correctly resolves connection name from storageProvider.connectionStringName. + /// Tests both connectionName and connectionStringName keys. + /// Ensures proper configuration reading for SQL Server connections. + /// + [Fact] + public void GetDurabilityProvider_WithConnectionStringName_UsesCorrectConnection() + { + // Arrange - Use connectionStringName instead of connectionName + var options = new DurableTaskScaleOptions + { + HubName = "testHub", + MaxConcurrentOrchestratorFunctions = 10, + MaxConcurrentActivityFunctions = 20, + StorageProvider = new Dictionary + { + { "type", "mssql" }, + { "connectionStringName", "TestConnection" }, + }, + }; + DurableTaskScaleOptions.ResolveAppSettingOptions(options, this.nameResolver); + + var factory = new SqlServerScalabilityProviderFactory( + Options.Create(options), + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.Equal("TestConnection", provider.ConnectionName); + } + + /// + /// Scenario: Validation - invalid concurrency settings. + /// Validates that max concurrent orchestrator/activity functions must be >= 1. + /// Tests concurrency configuration enforcement. + /// Ensures valid worker count calculations for scaling. + /// + [Fact] + public void ValidateSqlServerOptions_InvalidMaxConcurrent_ThrowsInvalidOperationException() + { + // Arrange - MaxConcurrentOrchestratorFunctions is 0 + var options = CreateOptions("testHub", 0, 20, "TestConnection", "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act & Assert + Assert.Throws(() => factory.GetDurabilityProvider()); + } + + /// + /// Scenario: Missing connection string in configuration. + /// Validates that factory throws appropriate error when SQL connection string is not found. + /// Tests error handling for missing configuration values. + /// Ensures clear error messages guide users to configure connection strings. + /// + [Fact] + public void GetDurabilityProvider_MissingConnectionString_ThrowsInvalidOperationException() + { + // Arrange - Configuration without SQL connection string + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary()); + var emptyConfig = configBuilder.Build(); + + var options = CreateOptions("testHub", 10, 20, "NonExistentConnection", "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + emptyConfig, + this.nameResolver, + this.loggerFactory); + + // Act & Assert + Assert.Throws(() => factory.GetDurabilityProvider()); + } + + private static IOptions CreateOptions( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName, + string storageType = "mssql") + { + var options = new DurableTaskScaleOptions + { + HubName = hubName, + MaxConcurrentOrchestratorFunctions = maxOrchestrator, + MaxConcurrentActivityFunctions = maxActivity, + StorageProvider = new Dictionary + { + { "type", storageType }, + { "connectionName", connectionName }, + }, + }; + + return Options.Create(options); + } + + private static TriggerMetadata CreateTriggerMetadata( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName, + string storageType = "mssql") + { + var metadata = new JObject + { + { "functionName", "TestFunction" }, + { "type", "activityTrigger" }, + { "taskHubName", hubName }, + { "maxConcurrentOrchestratorFunctions", maxOrchestrator }, + { "maxConcurrentActivityFunctions", maxActivity }, + { + "storageProvider", new JObject + { + { "type", storageType }, + { "connectionName", connectionName }, + } + }, + }; + + // Use the public constructor + return new TriggerMetadata(metadata); + } + + /// + /// Scenario: End-to-end test - triggerMetadata with type="mssql" creates SQL provider and both TargetScaler and ScaleMonitor work. + /// Validates that when triggerMetadata mentions storageProvider.type="mssql", we create a SQL provider. + /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. + /// Verifies that TargetScaler successfully returns results from SQL Server. + /// Verifies that ScaleMonitor successfully returns metrics from SQL Server. + /// This is the primary integration test for SQL Server scaling via triggerMetadata. + /// + [Fact] + public async Task TriggerMetadataWithMssqlType_CreatesSqlProvider_AndTargetScalerAndScaleMonitorWork() + { + // Arrange - Create triggerMetadata with type="mssql" + var hubName = "testHub"; + var connectionName = "TestConnection"; + var triggerMetadata = CreateTriggerMetadata(hubName, 10, 20, connectionName, "mssql"); + + // Verify triggerMetadata has correct storageProvider.type + var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; + Assert.NotNull(storageProvider); + Assert.Equal("mssql", storageProvider["type"]?.ToString()); + + // Create factory + var options = CreateOptions(hubName, 10, 20, connectionName, "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act - Create provider from triggerMetadata + var provider = factory.GetDurabilityProvider(triggerMetadata); + + // Assert - Verify SQL provider was created + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal(connectionName, provider.ConnectionName); + + // Verify connection string was retrieved from configuration + var connectionString = this.configuration.GetConnectionString(connectionName) ?? this.configuration[connectionName]; + Assert.NotNull(connectionString); + Assert.NotEmpty(connectionString); + + // Act - Get TargetScaler from provider + bool targetScalerCreated = provider.TryGetTargetScaler( + "functionId", + "functionName", + hubName, + connectionName, + out ITargetScaler targetScaler); + + // Assert - TargetScaler was created successfully + Assert.True(targetScalerCreated); + Assert.NotNull(targetScaler); + Assert.IsType(targetScaler); + + // Act - Get scale result from TargetScaler (connects to real SQL) + var targetScalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // Assert - TargetScaler returns valid result + Assert.NotNull(targetScalerResult); + Assert.True(targetScalerResult.TargetWorkerCount >= 0, "Target worker count should be non-negative"); + + // Act - Get ScaleMonitor from provider + bool scaleMonitorResult = provider.TryGetScaleMonitor( + "functionId", + "functionName", + hubName, + connectionName, + out IScaleMonitor scaleMonitor); + + // Assert - ScaleMonitor was created successfully + Assert.True(scaleMonitorResult); + Assert.NotNull(scaleMonitor); + Assert.IsType(scaleMonitor); + + // Act - Get metrics from ScaleMonitor (connects to real SQL) + var metrics = await scaleMonitor.GetMetricsAsync(); + + // Assert - ScaleMonitor returns valid metrics + Assert.NotNull(metrics); + Assert.IsType(metrics); + var sqlMetrics = (SqlServerScaleMetric)metrics; + Assert.True(sqlMetrics.RecommendedReplicaCount >= 0, "Recommended replica count should be non-negative"); + } + + /// + /// Scenario: Connection string retrieval from triggerMetadata. + /// Validates that when triggerMetadata contains connectionName, the factory retrieves the connection string from configuration. + /// Tests that the connection string value is correctly read from IConfiguration. + /// Verifies that the retrieved connection string matches the expected value. + /// + [Fact] + public void TriggerMetadataWithMssqlType_RetrievesConnectionStringFromConfiguration() + { + // Arrange - Create triggerMetadata with type="mssql" and connectionName + var hubName = "testHub"; + var connectionName = "TestConnection"; + var expectedConnectionString = TestHelpers.GetSqlConnectionString(); + + var triggerMetadata = CreateTriggerMetadata(hubName, 10, 20, connectionName, "mssql"); + + // Verify triggerMetadata has correct storageProvider.connectionName + var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; + Assert.NotNull(storageProvider); + Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); + + // Create factory + var options = CreateOptions(hubName, 10, 20, connectionName, "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act - Create provider from triggerMetadata + var provider = factory.GetDurabilityProvider(triggerMetadata); + + // Assert - Verify provider was created + Assert.NotNull(provider); + Assert.IsType(provider); + + // Assert - Verify connection string was retrieved from configuration + var actualConnectionString = this.configuration.GetConnectionString(connectionName) ?? this.configuration[connectionName]; + Assert.NotNull(actualConnectionString); + Assert.NotEmpty(actualConnectionString); + Assert.Equal(expectedConnectionString, actualConnectionString); + } + + /// + /// Scenario: Managed Identity support - TokenCredential extracted from triggerMetadata. + /// Validates that when triggerMetadata contains AzureComponentFactory, we extract TokenCredential. + /// Tests that TokenCredential is properly passed to CreateSqlOrchestrationService. + /// Verifies that connection string is built with Authentication="Active Directory Default" when TokenCredential is present. + /// This test simulates the Managed Identity flow used by Scale Controller. + /// + [Fact] + public void GetDurabilityProvider_WithTokenCredential_ExtractsAndUsesCredential() + { + // Arrange - Create triggerMetadata with AzureComponentFactory in Properties (Managed Identity) + var hubName = "testHub"; + var connectionName = "TestConnection"; + var triggerMetadata = CreateTriggerMetadata(hubName, 10, 20, connectionName, "mssql"); + + // Add AzureComponentFactory wrapper to triggerMetadata.Properties (simulating Scale Controller) + // We'll create a mock wrapper that returns a TokenCredential + var mockTokenCredential = new Mock(); + var mockFactory = new Mock(); + var factoryType = typeof(object).Assembly.GetType("Microsoft.Azure.WebJobs.Host.AzureComponentFactoryWrapper"); + if (factoryType == null) + { + // If the type doesn't exist in test assembly, create a simple mock + var mockFactoryObj = new Mock(); + var createTokenCredentialMethod = mockFactoryObj.Object.GetType().GetMethod("CreateTokenCredential"); + if (createTokenCredentialMethod != null) + { + triggerMetadata.Properties["AzureComponentFactory"] = mockFactoryObj.Object; + } + } + + var options = CreateOptions(hubName, 10, 20, connectionName, "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act - Create provider from triggerMetadata with TokenCredential + // Note: In real scenarios, the TokenCredential would be extracted and used to build + // a connection string with Authentication="Active Directory Default" + var provider = factory.GetDurabilityProvider(triggerMetadata); + + // Assert - Verify provider was created + Assert.NotNull(provider); + Assert.IsType(provider); + + // Note: Full TokenCredential testing would require actual Azure environment setup + // This test verifies the extraction logic exists and provider creation works + } + + /// + /// Scenario: Managed Identity - connection string built with server name from configuration. + /// Validates that when TokenCredential is present, we read server name from configuration. + /// Tests pattern: {connectionName}__serverName or {connectionName}__server. + /// Verifies that connection string is constructed with Authentication="Active Directory Default". + /// This test validates the configuration reading logic for Managed Identity SQL connections. + /// + [Fact] + public void CreateSqlOrchestrationService_WithManagedIdentityConfig_ReadsServerNameFromConfig() + { + // Arrange - Set up configuration with server name for Managed Identity + var connectionName = "TestConnection"; + var serverName = "mysqlservertny.database.windows.net"; + var databaseName = "testsqlscaling"; + + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { $"{connectionName}__serverName", serverName }, + { $"{connectionName}__databaseName", databaseName }, + // Also provide connection string as fallback + { connectionName, $"Server={serverName};Database={databaseName};Authentication=Active Directory Default;" }, + }); + var testConfiguration = configBuilder.Build(); + + var options = CreateOptions("testHub", 10, 20, connectionName, "mssql"); + var factory = new SqlServerScalabilityProviderFactory( + options, + testConfiguration, + this.nameResolver, + this.loggerFactory); + + // Act - Try to create provider (this will test server name extraction) + // Note: This test verifies configuration reading, not actual TokenCredential usage + // Full Managed Identity testing requires Azure environment setup + + // Assert - Verify configuration values can be read + var configServerName = testConfiguration[$"{connectionName}__serverName"]; + Assert.Equal(serverName, configServerName); + + var configDatabaseName = testConfiguration[$"{connectionName}__databaseName"]; + Assert.Equal(databaseName, configDatabaseName); + } + } +} diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs new file mode 100644 index 000000000..172b42523 --- /dev/null +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using DurableTask.SqlServer; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for SqlServerScalabilityProvider. + /// Validates the SQL Server implementation of ScalabilityProvider. + /// Tests provider instantiation and scale monitor/scaler creation. + /// + public class SqlServerScalabilityProviderTests + { + private readonly ITestOutputHelper output; + private readonly TestLoggerProvider loggerProvider; + private readonly ILoggerFactory loggerFactory; + + public SqlServerScalabilityProviderTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + } + + /// + /// Scenario: Provider creation with SQL orchestration service. + /// Validates that provider accepts a SqlOrchestrationService instance. + /// Tests that connection name is properly stored for Scale Controller identification. + /// Ensures provider is ready to create scale monitors and target scalers. + /// + [Fact] + public void Constructor_ValidParameters_CreatesInstance() + { + // Arrange - Use real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + var logger = this.loggerFactory.CreateLogger(); + + // Act + var provider = new SqlServerScalabilityProvider( + sqlService, + "TestConnection", + logger); + + // Assert + Assert.NotNull(provider); + Assert.Equal("TestConnection", provider.ConnectionName); + Assert.Equal("mssql", provider.GetType().BaseType?.GetProperty("Name")?.GetValue(provider)?.ToString() ?? "mssql"); + } + + /// + /// Scenario: Constructor validation - null SQL orchestration service. + /// Validates that provider requires a valid SQL orchestration service. + /// Tests defensive programming for required dependencies. + /// Ensures clear error messages when SQL service is missing. + /// + [Fact] + public void Constructor_NullService_ThrowsArgumentNullException() + { + // Arrange + var logger = this.loggerFactory.CreateLogger(); + + // Act & Assert + Assert.Throws(() => + new SqlServerScalabilityProvider(null, "TestConnection", logger)); + } + + /// + /// Scenario: Scale Monitor creation for metrics-based autoscaling. + /// Validates that provider can create IScaleMonitor for Scale Controller. + /// Tests that Scale Controller can get metrics from SQL Server. + /// Ensures monitoring infrastructure is properly initialized with SQL connection. + /// + [Fact] + public void TryGetScaleMonitor_ValidParameters_ReturnsTrue() + { + // Arrange - Use real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + var logger = this.loggerFactory.CreateLogger(); + var provider = new SqlServerScalabilityProvider(sqlService, "TestConnection", logger); + + // Act + var result = provider.TryGetScaleMonitor( + "functionId", + "functionName", + "testHub", + "TestConnection", + out IScaleMonitor scaleMonitor); + + // Assert + Assert.True(result); + Assert.NotNull(scaleMonitor); + Assert.IsType(scaleMonitor); + } + + /// + /// Scenario: Target Scaler creation for target-based autoscaling. + /// Validates that provider can create ITargetScaler for Scale Controller. + /// Tests that Scale Controller can perform target-based scaling calculations. + /// Ensures scaler can determine target worker count based on SQL Server recommendations. + /// This is the recommended approach for Durable Functions scaling with SQL Server. + /// + [Fact] + public void TryGetTargetScaler_ValidParameters_ReturnsTrue() + { + // Arrange - Use real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + var logger = this.loggerFactory.CreateLogger(); + var provider = new SqlServerScalabilityProvider(sqlService, "TestConnection", logger); + + // Act + var result = provider.TryGetTargetScaler( + "functionId", + "functionName", + "testHub", + "TestConnection", + out ITargetScaler targetScaler); + + // Assert + Assert.True(result); + Assert.NotNull(targetScaler); + Assert.IsType(targetScaler); + } + + /// + /// Scenario: Metrics provider caching for performance. + /// Validates that provider reuses the same metrics provider for multiple calls. + /// Tests performance optimization to avoid redundant SQL queries. + /// Ensures consistent metrics collection across multiple scale decisions. + /// Validates singleton pattern within a provider instance. + /// + [Fact] + public void TryGetScaleMonitor_UsesSameMetricsProvider() + { + // Arrange - Use real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + var logger = this.loggerFactory.CreateLogger(); + var provider = new SqlServerScalabilityProvider(sqlService, "TestConnection", logger); + + // Act - Call TryGetScaleMonitor twice + var result1 = provider.TryGetScaleMonitor( + "functionId", + "functionName", + "testHub", + "TestConnection", + out IScaleMonitor scaleMonitor1); + + var result2 = provider.TryGetTargetScaler( + "functionId", + "functionName", + "testHub", + "TestConnection", + out ITargetScaler targetScaler); + + // Assert - Both should succeed and use the same underlying metrics provider + Assert.True(result1); + Assert.True(result2); + Assert.NotNull(scaleMonitor1); + Assert.NotNull(targetScaler); + } + } +} diff --git a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs new file mode 100644 index 000000000..4214a561b --- /dev/null +++ b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs @@ -0,0 +1,222 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DurableTask.SqlServer; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for SqlServerScaleMonitor. + /// Validates the metrics-based autoscaling monitor for Durable Functions with SQL Server backend. + /// Tests scale metrics collection, scale status determination, and scale recommendations. + /// Ensures Scale Controller can make informed autoscaling decisions based on SQL Server metrics. + /// + public class SqlServerScaleMonitorTests + { + private readonly string hubName = "DurableTaskTriggerHubName"; + private readonly string functionId = "FunctionId"; + private readonly ITestOutputHelper output; + private readonly LoggerFactory loggerFactory; + private readonly TestLoggerProvider loggerProvider; + private readonly SqlServerMetricsProvider metricsProvider; + private readonly SqlServerScaleMonitor scaleMonitor; + + public SqlServerScaleMonitorTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + + // Create real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, this.hubName, schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + + // Create real metrics provider + this.metricsProvider = new SqlServerMetricsProvider(sqlService); + + this.scaleMonitor = new SqlServerScaleMonitor( + this.functionId, + this.hubName, + this.metricsProvider); + } + + /// + /// Scenario: Scale Monitor descriptor creation. + /// Validates that monitor descriptor has correct ID format. + /// Tests that monitor can be identified by Scale Controller. + /// + [Fact] + public void ScaleMonitorDescriptor_ReturnsExpectedValue() + { + Assert.Equal($"{this.functionId}-DurableTask-SqlServer:{this.hubName}".ToLower(), this.scaleMonitor.Descriptor.Id); + Assert.Equal(this.functionId, this.scaleMonitor.Descriptor.FunctionId); + } + + /// + /// Scenario: Scale metrics collection with recommended replica count. + /// Validates that monitor correctly retrieves metrics from SQL Server. + /// Tests recommended replica count is properly captured. + /// + [Fact] + public async Task GetMetrics_ReturnsExpectedResult() + { + // Act - Get real metrics from SQL Server (works with Azure SQL or Docker SQL Server) + SqlServerScaleMetric metric = await this.scaleMonitor.GetMetricsAsync(); + + // Assert - Verify metrics are returned (actual values depend on SQL Server state) + Assert.NotNull(metric); + Assert.True(metric.RecommendedReplicaCount >= 0, "Recommended replica count should be non-negative"); + } + + /// + /// Scenario: Scale status - Scale Out vote. + /// Validates that monitor votes to scale out when recommended replica count exceeds current worker count. + /// Tests scaling up scenarios based on SQL Server recommendations. + /// + [Fact] + public void GetScaleStatus_RecommendedCountGreaterThanCurrent_ReturnsScaleOut() + { + // Arrange + int currentWorkerCount = 3; + int recommendedReplicaCount = 10; + var metrics = new List + { + new SqlServerScaleMetric { RecommendedReplicaCount = recommendedReplicaCount }, + }; + + var context = new ScaleStatusContext + { + WorkerCount = currentWorkerCount, + Metrics = metrics, + }; + + // Act + ScaleStatus status = this.scaleMonitor.GetScaleStatus(context); + + // Assert + Assert.Equal(ScaleVote.ScaleOut, status.Vote); + } + + /// + /// Scenario: Scale status - Scale In vote. + /// Validates that monitor votes to scale in when recommended replica count is less than current worker count. + /// Tests scaling down scenarios based on SQL Server recommendations. + /// + [Fact] + public void GetScaleStatus_RecommendedCountLessThanCurrent_ReturnsScaleIn() + { + // Arrange + int currentWorkerCount = 10; + int recommendedReplicaCount = 3; + var metrics = new List + { + new SqlServerScaleMetric { RecommendedReplicaCount = recommendedReplicaCount }, + }; + + var context = new ScaleStatusContext + { + WorkerCount = currentWorkerCount, + Metrics = metrics, + }; + + // Act + ScaleStatus status = this.scaleMonitor.GetScaleStatus(context); + + // Assert + Assert.Equal(ScaleVote.ScaleIn, status.Vote); + } + + /// + /// Scenario: Scale status - No Scale vote. + /// Validates that monitor votes for no scale when recommended replica count equals current worker count. + /// Tests stable state scenarios. + /// + [Fact] + public void GetScaleStatus_RecommendedCountEqualsCurrent_ReturnsNoScale() + { + // Arrange + int currentWorkerCount = 5; + int recommendedReplicaCount = 5; + var metrics = new List + { + new SqlServerScaleMetric { RecommendedReplicaCount = recommendedReplicaCount }, + }; + + var context = new ScaleStatusContext + { + WorkerCount = currentWorkerCount, + Metrics = metrics, + }; + + // Act + ScaleStatus status = this.scaleMonitor.GetScaleStatus(context); + + // Assert + Assert.Equal(ScaleVote.None, status.Vote); + } + + /// + /// Scenario: Scale status with empty metrics. + /// Validates that monitor returns NoScale when no metrics are available. + /// Tests graceful handling of empty metric collection. + /// + [Fact] + public void GetScaleStatus_EmptyMetrics_ReturnsNoScale() + { + // Arrange + var emptyMetrics = new List(); + var context = new ScaleStatusContext + { + WorkerCount = 5, + Metrics = emptyMetrics, + }; + + // Act + ScaleStatus status = this.scaleMonitor.GetScaleStatus(context); + + // Assert + Assert.Equal(ScaleVote.None, status.Vote); + } + + /// + /// Scenario: Scale status uses most recent metric. + /// Validates that monitor uses the last metric in the collection for decision making. + /// Tests that historical metrics don't override current recommendations. + /// + [Fact] + public void GetScaleStatus_UsesMostRecentMetric() + { + // Arrange - Multiple metrics, should use the last one + var metrics = new List + { + new SqlServerScaleMetric { RecommendedReplicaCount = 10 }, // Older + new SqlServerScaleMetric { RecommendedReplicaCount = 3 }, // Older + new SqlServerScaleMetric { RecommendedReplicaCount = 5 }, // Most recent + }; + + var context = new ScaleStatusContext + { + WorkerCount = 5, + Metrics = metrics, + }; + + // Act + ScaleStatus status = this.scaleMonitor.GetScaleStatus(context); + + // Assert - Should use the most recent metric (5), which equals current worker count + Assert.Equal(ScaleVote.None, status.Vote); + } + } +} diff --git a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs new file mode 100644 index 000000000..330544ab8 --- /dev/null +++ b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Threading.Tasks; +using DurableTask.SqlServer; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for SqlServerTargetScaler. + /// Validates the target-based autoscaling mechanism for Durable Functions with SQL Server backend. + /// Tests worker count calculations based on SQL Server recommended replica count. + /// Ensures accurate scaling decisions based on SQL Server metrics. + /// + public class SqlServerTargetScalerTests + { + private readonly ITestOutputHelper output; + + public SqlServerTargetScalerTests(ITestOutputHelper output) + { + this.output = output; + } + + /// + /// Scenario: Target scaler with zero recommended replica count. + /// Validates that scaler correctly handles zero worker count recommendations. + /// Tests edge case where SQL Server recommends no workers. + /// + [Fact] + public async Task TargetBasedScalingTest_ReturnsValidResult() + { + // Arrange - Use real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + var metricsProvider = new SqlServerMetricsProvider(sqlService); + + var targetScaler = new SqlServerTargetScaler( + "functionId", + metricsProvider); + + // Act - Get real scale result from SQL Server + TargetScalerResult result = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // Assert - Verify result is valid (actual values depend on SQL Server state) + Assert.NotNull(result); + Assert.True(result.TargetWorkerCount >= 0, "Target worker count should be non-negative"); + } + + /// + /// Scenario: Target scaler with negative recommended replica count. + /// Validates that scaler ensures minimum worker count is 0 (never negative). + /// Tests defensive programming for edge cases. + /// + [Fact] + public async Task TargetBasedScaling_ValidatesNonNegativeResult() + { + // Arrange - Use real SqlOrchestrationService (works with Azure SQL or Docker SQL Server) + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var sqlService = new SqlOrchestrationService(settings); + var metricsProvider = new SqlServerMetricsProvider(sqlService); + + var targetScaler = new SqlServerTargetScaler( + "functionId", + metricsProvider); + + // Act - Get real scale result from SQL Server + TargetScalerResult result = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // Assert - Verify that negative values are clamped to 0 (Math.Max ensures this) + Assert.NotNull(result); + Assert.True(result.TargetWorkerCount >= 0, "Target worker count should be clamped to 0 if negative"); + } + } +} diff --git a/test/ScaleTests/Sql/SqlServerTestHelpers.cs b/test/ScaleTests/Sql/SqlServerTestHelpers.cs new file mode 100644 index 000000000..f95ab6b83 --- /dev/null +++ b/test/ScaleTests/Sql/SqlServerTestHelpers.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using DurableTask.SqlServer; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Data.SqlClient; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Helper methods for SQL Server tests. + /// Provides utilities for checking SQL connectivity and creating test services. + /// + internal static class SqlServerTestHelpers + { + /// + /// Checks if SQL Server is available by attempting a connection. + /// + /// True if SQL Server is available, false otherwise. + public static bool IsSqlServerAvailable() + { + try + { + var connectionString = TestHelpers.GetSqlConnectionString(); + using (var connection = new SqlConnection(connectionString)) + { + // Try to open connection with short timeout + var task = connection.OpenAsync(); + if (task.Wait(TimeSpan.FromSeconds(5))) + { + return true; + } + } + } + catch + { + // Connection failed - SQL Server not available + } + + return false; + } + + /// + /// Creates a SqlOrchestrationService for testing. + /// Returns null if SQL Server is not available. + /// + public static SqlOrchestrationService CreateSqlOrchestrationService(string hubName = "testHub") + { + try + { + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, hubName, schemaName: null); + return new SqlOrchestrationService(settings); + } + catch + { + return null; + } + } + + /// + /// Gets a skip reason if SQL Server is not available. + /// + public static string GetSkipReason() + { + return "SQL Server is not available. Set SQLDB_Connection, SQLDB_Connection_Azure, or SQLDB_Connection_Local environment variable, or ensure Docker SQL Server is running on localhost:1433."; + } + } +} diff --git a/test/ScaleTests/TestHelpers.cs b/test/ScaleTests/TestHelpers.cs new file mode 100644 index 000000000..9abfcde75 --- /dev/null +++ b/test/ScaleTests/TestHelpers.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +{ + public static class TestHelpers + { + public static string GetStorageConnectionString() + { + string storageConnectionString = Environment.GetEnvironmentVariable("AzureWebJobsStorage"); + if (string.IsNullOrEmpty(storageConnectionString)) + { + storageConnectionString = "UseDevelopmentStorage=true"; + } + + return storageConnectionString; + } + + public static string GetSqlConnectionString() + { + // Priority 1: Use DTMB_SQL_CONNECTION_STRING environment variable if set + // This is the standard environment variable name used for SQL connection + string sqlConnectionString = Environment.GetEnvironmentVariable("DTMB_SQL_CONNECTION_STRING"); + + if (!string.IsNullOrEmpty(sqlConnectionString)) + { + return sqlConnectionString; + } + + // Priority 2: Use SQLDB_Connection environment variable if set + // This is the standard environment variable name used by the extension + sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"); + + if (!string.IsNullOrEmpty(sqlConnectionString)) + { + return sqlConnectionString; + } + + // Priority 3: Use Azure SQL Database connection string (for local testing with Azure SQL) + // Example: Server=tcp:mysqlservertny.database.windows.net,1433;Initial Catalog=testsqlscaling;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;Authentication="Active Directory Default"; + sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection_Azure"); + if (!string.IsNullOrEmpty(sqlConnectionString)) + { + return sqlConnectionString; + } + + // Priority 4: Use Docker/local SQL Server connection string (for CI) + // CI environments typically set up SQL Server in Docker + // Example for Docker: Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=True;Encrypt=False; + sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection_Local"); + if (!string.IsNullOrEmpty(sqlConnectionString)) + { + return sqlConnectionString; + } + + // Default: Try Docker SQL Server (common in CI) + // This assumes SQL Server is running in Docker with default settings + // For CI: Docker typically runs SQL Server on localhost:1433 + sqlConnectionString = "Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=True;Encrypt=False;"; + + return sqlConnectionString; + } + } +} + + diff --git a/test/ScaleTests/TestLoggerProvider.cs b/test/ScaleTests/TestLoggerProvider.cs new file mode 100644 index 000000000..8506f8a97 --- /dev/null +++ b/test/ScaleTests/TestLoggerProvider.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +{ + public class TestLoggerProvider : ILoggerProvider + { + private readonly ITestOutputHelper output; + private readonly ConcurrentBag loggers = new ConcurrentBag(); + + public TestLoggerProvider(ITestOutputHelper output) + { + this.output = output; + } + + public ILogger CreateLogger(string categoryName) + { + var logger = new TestLogger(categoryName, this.output); + this.loggers.Add(logger); + return logger; + } + + public IReadOnlyList GetAllLogMessages() + { + var messages = new List(); + foreach (var logger in this.loggers) + { + messages.AddRange(logger.LogMessages); + } + + return messages; + } + + public void Dispose() + { + } + + private class TestLogger : ILogger + { + private readonly string categoryName; + private readonly ITestOutputHelper output; + private readonly List logMessages = new List(); + + public TestLogger(string categoryName, ITestOutputHelper output) + { + this.categoryName = categoryName; + this.output = output; + } + + public IReadOnlyList LogMessages => this.logMessages; + + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + string message = formatter(state, exception); + this.logMessages.Add(message); + + try + { + this.output.WriteLine($"[{logLevel}] {this.categoryName}: {message}"); + if (exception != null) + { + this.output.WriteLine(exception.ToString()); + } + } + catch + { + // xunit output may not be available in some contexts + } + } + } + } +} + + + diff --git a/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj b/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj new file mode 100644 index 000000000..8644e0409 --- /dev/null +++ b/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj @@ -0,0 +1,44 @@ + + + + net8.0 + Microsoft Corporation + SA0001;SA1600;SA1615 + false + + + + false + + true + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + diff --git a/test/ScaleTests/xunit.runner.json b/test/ScaleTests/xunit.runner.json new file mode 100644 index 000000000..2831304e0 --- /dev/null +++ b/test/ScaleTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "shadowCopy": false, + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} + + + From 335fff8854e0cda957cf5b0de88adf88a18074b4 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Tue, 4 Nov 2025 20:35:09 -0800 Subject: [PATCH 03/25] add azuremanaged --- Directory.Packages.props | 5 +- .../AzureManagedConnectionString.cs | 52 +++ .../AzureManagedScalabilityProvider.cs | 85 ++++ .../AzureManagedScalabilityProviderFactory.cs | 259 +++++++++++ .../AzureManaged/AzureManagedTargetScaler.cs | 64 +++ .../AzureStorageScalabilityProviderFactory.cs | 30 +- ...rableTaskJobHostConfigurationExtensions.cs | 19 +- .../DurableTaskScaleExtension.cs | 125 ++++-- .../DurableTaskScaleOptions.cs | 22 + .../DurableTaskTriggersScaleProvider.cs | 127 +++--- .../FunctionName.cs | 69 +-- .../IConnectionInfoResolver.cs | 21 - .../INameResolver.cs | 3 +- .../IScalabilityProviderFactory.cs | 7 +- .../Sql/SqlServerScalabilityProvider.cs | 2 + .../SqlServerScalabilityProviderFactory.cs | 266 ++++------- .../Sql/SqlServerScaleMetric.cs | 2 + .../Sql/SqlServerTargetScaler.cs | 2 + ...ebJobs.Extensions.DurableTask.Scale.csproj | 3 +- .../WebJobsConnectionInfoProvider.cs | 54 --- test/Directory.Packages.props | 2 +- ...eManagedScalabilityProviderFactoryTests.cs | 418 ++++++++++++++++++ ...TaskJobHostConfigurationExtensionsTests.cs | 2 + test/ScaleTests/TestLoggerProvider.cs | 2 + test/ScaleTests/xunit.runner.json | 2 + 25 files changed, 1181 insertions(+), 462 deletions(-) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs create mode 100644 test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 14cf4311a..4127d9275 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,9 +8,9 @@ - + - + @@ -31,6 +31,7 @@ + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs new file mode 100644 index 000000000..de404b38f --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Data.Common; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged +{ + /// + /// Connection string to conenct to AzureManaged backend service. + /// + public sealed class AzureManagedConnectionString + { + private readonly DbConnectionStringBuilder builder; + + /// + /// Initializes a new instance of the class. + /// + /// A connection string for an Azure-managed durable task service. + public AzureManagedConnectionString(string connectionString) + { + this.builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + } + + /// + /// Gets the authentication method specified in the connection string (if any). + /// + public string Authentication => this.GetValue("Authentication"); + + /// + /// Gets the managed identity or workload identity client ID specified in the connection string (if any). + /// + public string ClientId => this.GetValue("ClientID"); + + /// + /// Gets the endpoint specified in the connection string (if any). + /// + public string Endpoint => this.GetValue("Endpoint"); + + /// + /// Gets the task hub name specified in the connection string (if any). + /// + public string TaskHubName => this.GetValue("TaskHub"); + + private string GetValue(string name) + { + return this.builder.TryGetValue(name, out object value) + ? value as string + : null; + } + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs new file mode 100644 index 000000000..fedc8d78d --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.DurableTask.AzureManagedBackend; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged +{ + /// + /// The AzureManaged backend implementation of the scalability provider for Durable Functions. + /// + public class AzureManagedScalabilityProvider : ScalabilityProvider + { + private readonly AzureManagedOrchestrationService orchestrationService; + private readonly string connectionName; + private readonly ILogger logger; + + public AzureManagedScalabilityProvider( + AzureManagedOrchestrationService orchestrationService, + string connectionName, + ILogger logger) + : base("AzureManaged", connectionName) + { + this.orchestrationService = orchestrationService ?? throw new ArgumentNullException(nameof(orchestrationService)); + this.connectionName = connectionName; + this.logger = logger; + } + + /// + /// The app setting containing the Azure Managed connection string. + /// + public override string ConnectionName => this.connectionName; + + /// + /// This is not used. + public override bool TryGetScaleMonitor( + string functionId, + string functionName, + string hubName, + string connectionName, + out IScaleMonitor scaleMonitor) + { + // Azure Managed backend does not support the legacy scale monitor infrastructure. + // Return a dummy scale monitor to avoid exceptions. + scaleMonitor = new DummyScaleMonitor(functionId, hubName); + return true; + } + + /// + public override bool TryGetTargetScaler( + string functionId, + string functionName, + string hubName, + string connectionName, + out ITargetScaler targetScaler) + { + // Create a target scaler that uses the orchestration service's metrics endpoint. + // All target scalers share the same AzureManagedOrchestrationService in the same task hub. + targetScaler = new AzureManagedTargetScaler(this.orchestrationService, functionId); + return true; + } + + private class DummyScaleMonitor : IScaleMonitor + { + private static readonly ScaleMetrics DummyScaleMetrics = new ScaleMetrics(); + private static readonly ScaleStatus DummyScaleStatus = new ScaleStatus(); + + public DummyScaleMonitor(string functionId, string taskHub) + { + this.Descriptor = new ScaleMonitorDescriptor( + id: $"DurableTask.AzureManaged:{taskHub ?? "default"}", + functionId); + } + + public ScaleMonitorDescriptor Descriptor { get; } + + public System.Threading.Tasks.Task GetMetricsAsync() => System.Threading.Tasks.Task.FromResult(DummyScaleMetrics); + + public ScaleStatus GetScaleStatus(ScaleStatusContext context) => DummyScaleStatus; + } + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs new file mode 100644 index 000000000..7171c461d --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -0,0 +1,259 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using Azure.Core; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.DurableTask.AzureManagedBackend; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; + +#nullable enable +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged +{ + public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactory + { + private const string LoggerName = "Host.Triggers.DurableTask.AzureManaged"; + internal const string ProviderName = "AzureManaged"; + private const string DefaultConnectionNameConstant = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; + + private readonly Dictionary<(string, string?, string?), AzureManagedScalabilityProvider> cachedProviders = new Dictionary<(string, string?, string?), AzureManagedScalabilityProvider>(); + private readonly DurableTaskScaleOptions options; + private readonly IConfiguration configuration; + private readonly INameResolver nameResolver; + private readonly ILoggerFactory loggerFactory; + private readonly ILogger logger; + + public AzureManagedScalabilityProviderFactory( + IOptions options, + IConfiguration configuration, + INameResolver nameResolver, + ILoggerFactory loggerFactory) + { + this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.logger = this.loggerFactory.CreateLogger(LoggerName); + + this.DefaultConnectionName = ResolveConnectionName(this.options.StorageProvider) ?? DefaultConnectionNameConstant; + } + + public virtual string Name => ProviderName; + + public string DefaultConnectionName { get; } + + public virtual ScalabilityProvider GetDurabilityProvider() + { + return this.GetDurabilityProvider(null); + } + + public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) + { + // Check if trigger metadata specifies a different connection name, otherwise use default from constructor + string connectionName = ExtractConnectionName(triggerMetadata) ?? this.DefaultConnectionName; + + // Resolve connection name first (handles %% wrapping) + string resolvedConnectionName = this.nameResolver.Resolve(connectionName); + + // Try to get connection string from configuration (app settings) + string connectionString = this.configuration.GetConnectionString(resolvedConnectionName) + ?? this.configuration[resolvedConnectionName]; + + // Fallback to environment variable (matching old implementation behavior) + if (string.IsNullOrEmpty(connectionString)) + { + connectionString = Environment.GetEnvironmentVariable(resolvedConnectionName); + } + + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException( + $"No connection string configuration was found for the app setting or environment variable named '{resolvedConnectionName}'."); + } + + AzureManagedConnectionString azureManagedConnectionString = new AzureManagedConnectionString(connectionString); + + // Get the pre-parsed metadata from triggerMetadata.Properties (parsed by DurableTaskTriggersScaleProvider) + DurableTaskMetadata parsedMetadata = ExtractParsedMetadata(triggerMetadata); + + // Extract task hub name from parsed metadata first, fallback to DI options, then connection string + string taskHubName = parsedMetadata?.TaskHubName + ?? this.options.HubName + ?? azureManagedConnectionString.TaskHubName; + + // Include client ID in cache key to handle managed identity changes + (string, string?, string?) cacheKey = (connectionName, taskHubName, azureManagedConnectionString.ClientId); + + this.logger.LogDebug( + "Getting durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'...", + cacheKey.Item1, cacheKey.Item2, cacheKey.Item3 ?? "null"); + + lock (this.cachedProviders) + { + // If a provider has already been created for this connection name, task hub, and client ID, return it. + if (this.cachedProviders.TryGetValue(cacheKey, out AzureManagedScalabilityProvider? cachedProvider)) + { + this.logger.LogDebug( + "Returning cached durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'", + cacheKey.Item1, cacheKey.Item2, cacheKey.Item3 ?? "null"); + return cachedProvider; + } + + // Create options from the connection string + AzureManagedOrchestrationServiceOptions options = + AzureManagedOrchestrationServiceOptions.FromConnectionString(connectionString); + + // If triggerMetadata is provided, try to get token credential from it + if (triggerMetadata != null && triggerMetadata.Properties != null && + triggerMetadata.Properties.TryGetValue("GetAzureManagedTokenCredential", out object tokenCredentialFunc)) + { + if (tokenCredentialFunc is Func getTokenCredential) + { + try + { + TokenCredential tokenCredential = getTokenCredential(connectionName); + + if (tokenCredential == null) + { + this.logger.LogWarning( + "Token credential retrieved from trigger metadata is null for connection '{Connection}'.", + connectionName); + } + else + { + // Override the credential from connection string + options.TokenCredential = tokenCredential; + this.logger.LogInformation("Retrieved token credential from trigger metadata for connection '{Connection}'", connectionName); + } + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Failed to get token credential from trigger metadata for connection '{Connection}'", + connectionName); + } + } + else + { + this.logger.LogWarning( + "Token credential function pointer in trigger metadata is not of expected type for connection '{Connection}'", + connectionName); + } + } + else + { + this.logger.LogInformation( + "No trigger metadata provided or trigger metadata does not contain 'GetAzureManagedTokenCredential', " + + "using the token credential built from connection string for connection '{Connection}'.", connectionName); + } + + // Set task hub name if configured + if (!string.IsNullOrEmpty(taskHubName)) + { + options.TaskHubName = taskHubName; + } + + // Set concurrency limits + if (this.options.MaxConcurrentOrchestratorFunctions.HasValue) + { + options.MaxConcurrentOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions.Value; + } + + if (this.options.MaxConcurrentActivityFunctions.HasValue) + { + options.MaxConcurrentActivityWorkItems = this.options.MaxConcurrentActivityFunctions.Value; + } + + this.logger.LogInformation( + "Creating durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'...", + cacheKey.Item1, cacheKey.Item2, cacheKey.Item3 ?? "null"); + + AzureManagedOrchestrationService service = new AzureManagedOrchestrationService(options, this.loggerFactory); + AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, connectionName, this.logger); + + // Extract max concurrent values from parsed metadata first, fallback to DI options + provider.MaxConcurrentTaskOrchestrationWorkItems = parsedMetadata?.MaxConcurrentOrchestratorFunctions + ?? this.options.MaxConcurrentOrchestratorFunctions + ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = parsedMetadata?.MaxConcurrentActivityFunctions + ?? this.options.MaxConcurrentActivityFunctions + ?? 10; + + this.cachedProviders.Add(cacheKey, provider); + return provider; + } + } + + private static string ExtractConnectionName(TriggerMetadata triggerMetadata) + { + if (triggerMetadata?.Metadata == null) + { + return null; + } + + var storageProvider = triggerMetadata.Metadata["storageProvider"]; + if (storageProvider != null) + { + var storageProviderObj = storageProvider.ToObject>(); + if (storageProviderObj != null) + { + // Try connectionName first, then connectionStringName (legacy alias) + if (storageProviderObj.TryGetValue("connectionName", out object connName) && connName is string connNameStr && !string.IsNullOrWhiteSpace(connNameStr)) + { + return connNameStr; + } + + if (storageProviderObj.TryGetValue("connectionStringName", out object connStrName) && connStrName is string connStrNameStr && !string.IsNullOrWhiteSpace(connStrNameStr)) + { + return connStrNameStr; + } + } + } + + return null; + } + + private static string ResolveConnectionName(IDictionary storageProvider) + { + if (storageProvider == null) + { + return null; + } + + if (storageProvider.TryGetValue("connectionName", out object v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + { + return s1; + } + + if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + { + return s2; + } + + return null; + } + + private static DurableTaskMetadata ExtractParsedMetadata(TriggerMetadata triggerMetadata) + { + if (triggerMetadata?.Properties == null) + { + return null; + } + + // The DurableTaskTriggersScaleProvider pre-parses the metadata and stores it in Properties + if (triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object metadataObj) + && metadataObj is DurableTaskMetadata metadata) + { + return metadata; + } + + return null; + } + } +} + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs new file mode 100644 index 000000000..f7e14a41b --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.DurableTask.AzureManagedBackend; +using Microsoft.DurableTask.AzureManagedBackend.Metrics; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged +{ + internal class AzureManagedTargetScaler : ITargetScaler + { + private readonly AzureManagedOrchestrationService service; + private readonly TargetScalerDescriptor descriptor; + + public AzureManagedTargetScaler(AzureManagedOrchestrationService service, string functionId) + { + this.service = service; + this.descriptor = new TargetScalerDescriptor(functionId); + } + + public TargetScalerDescriptor TargetScalerDescriptor => this.descriptor; + + public async Task GetScaleResultAsync(TargetScalerContext context) + { + TaskHubMetrics metrics = await this.service.GetTaskHubMetricsAsync(default); + if (metrics is null) + { + return new TargetScalerResult { TargetWorkerCount = 0 }; + } + + static int GetTargetWorkerCount(WorkItemMetrics workItemMetrics, int workItemCapacity) + { + if (workItemCapacity == 0) + { + return 0; + } + + int totalWorkItemCount = workItemMetrics.PendingCount + workItemMetrics.ActiveCount; + return (int)Math.Ceiling((double)totalWorkItemCount / workItemCapacity); + } + + int targetForOrchestratorWorkItems = GetTargetWorkerCount( + workItemMetrics: metrics.OrchestratorWorkItems, + workItemCapacity: this.service.MaxConcurrentTaskOrchestrationWorkItems); + + int targetForActivityWorkItems = GetTargetWorkerCount( + workItemMetrics: metrics.ActivityWorkItems, + workItemCapacity: this.service.MaxConcurrentTaskActivityWorkItems); + + int targetForEntityWorkItems = GetTargetWorkerCount( + workItemMetrics: metrics.EntityWorkItems, + workItemCapacity: this.service.MaxConcurrentEntityWorkItems); + + // Scale out to the maximum of the above target values. + int maxTarget = Math.Max( + targetForOrchestratorWorkItems, + Math.Max(targetForActivityWorkItems, targetForEntityWorkItems)); + + return new TargetScalerResult { TargetWorkerCount = maxTarget }; + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index 834a991c9..cf2458644 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -114,6 +114,9 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata // Validate Azure Storage specific options this.ValidateAzureStorageOptions(logger); + // Get the pre-parsed metadata from triggerMetadata.Properties (parsed by DurableTaskTriggersScaleProvider) + DurableTaskMetadata parsedMetadata = ExtractParsedMetadata(triggerMetadata); + // Extract TokenCredential from triggerMetadata if present (for Managed Identity) var tokenCredential = ExtractTokenCredential(triggerMetadata); @@ -128,9 +131,13 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata this.DefaultConnectionName, logger); - // Set the max concurrent values from options - provider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; + // Extract max concurrent values from parsed metadata first, fallback to DI options + provider.MaxConcurrentTaskOrchestrationWorkItems = parsedMetadata?.MaxConcurrentOrchestratorFunctions + ?? this.options.MaxConcurrentOrchestratorFunctions + ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = parsedMetadata?.MaxConcurrentActivityFunctions + ?? this.options.MaxConcurrentActivityFunctions + ?? 10; return provider; } @@ -235,5 +242,22 @@ private void ValidateAzureStorageOptions(ILogger logger) throw new System.InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); } } + + private static DurableTaskMetadata ExtractParsedMetadata(TriggerMetadata triggerMetadata) + { + if (triggerMetadata?.Properties == null) + { + return null; + } + + // The DurableTaskTriggersScaleProvider pre-parses the metadata and stores it in Properties + if (triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object metadataObj) + && metadataObj is DurableTaskMetadata metadata) + { + return metadata; + } + + return null; + } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs index 239207fdb..af36fc854 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs @@ -14,16 +14,19 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { /// - /// Extension for registering a Durable Functions configuration with JobHostConfiguration. + /// Provides extension methods for registering the with an or JobHostConfiguration. /// public static class DurableTaskJobHostConfigurationExtensions { - /// - /// Adds the Durable Task extension to the provided . + /// Adds the to the specified . + /// This enables Durable Task–based scaling capabilities for WebJobs and Azure Functions hosts. /// /// The to configure. - /// Returns the provided . + /// The same instance, to allow for fluent chaining. + /// + /// Thrown if the provided is . + /// public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) { if (builder == null) @@ -38,15 +41,16 @@ public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) IServiceCollection serviceCollection = builder.Services; serviceCollection.TryAddSingleton(); serviceCollection.AddSingleton(); - // Note: SqlServerScalabilityProviderFactory is registered by Scale Controller, not here return builder; } /// - /// Adds the and providers for the Durable Triggers. + /// Adds scale-monitoring components ( and providers) for Durable triggers. + /// This enables the scale controller to montor load of durable backends and make scaling decisions. /// /// The to configure. - /// Returns the provided . + /// Metadata describing the trigger to be monitored for scaling. + /// The same instance, to allow for fluent chaining. internal static IWebJobsBuilder AddDurableScaleForTrigger(this IWebJobsBuilder builder, TriggerMetadata triggerMetadata) { // this segment adheres to the followings pattern: https://github.com/Azure/azure-sdk-for-net/pull/38756 @@ -57,7 +61,6 @@ internal static IWebJobsBuilder AddDurableScaleForTrigger(this IWebJobsBuilder b return provider; }); - // Commenting out incremental scale model for hotfix release 3.0.0-rc.4, SC uses TBS by default // builder.Services.AddSingleton(serviceProvider => serviceProvider.GetServices().Single(x => x == provider)); builder.Services.AddSingleton(serviceProvider => serviceProvider.GetServices().Single(x => x == provider)); return builder; diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs index 13eb9cdd1..962a6b8b9 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs @@ -7,74 +7,107 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { + /// + /// Provides configuration and initialization logic for the Durable Task Scale extension. + /// This extension enables scale controller to make scaling decisions based on the current load of Durable Task backends. + /// public class DurableTaskScaleExtension : IExtensionConfigProvider { private readonly IScalabilityProviderFactory scalabilityProviderFactory; private readonly ScalabilityProvider defaultscalabilityProvider; private readonly DurableTaskScaleOptions options; - private readonly ILogger logger; + private readonly ILogger logger; private readonly IEnumerable scalabilityProviderFactories; /// /// Initializes a new instance of the class. + /// This constructor resolves the appropriate scalability provider factory + /// and initializes a default scalability provider used for scaling decisions. /// - /// The options for the Durable Task Scale Extension. - /// - /// + /// The configuration options for the Durable Task Scale extension. + /// The logger instance used for diagnostic output. + /// A collection of available scalability provider factories. + /// + /// Thrown when any of the required parameters (, , or ) are null. + /// public DurableTaskScaleExtension( - DurableTaskScaleOptions options, - ILogger logger, + DurableTaskScaleOptions options, + ILogger logger, IEnumerable scalabilityProviderFactories) - { - this.options = options ?? throw new ArgumentNullException(nameof(options)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.scalabilityProviderFactories = scalabilityProviderFactories ?? throw new ArgumentNullException(nameof(scalabilityProviderFactories)); + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.scalabilityProviderFactories = scalabilityProviderFactories ?? throw new ArgumentNullException(nameof(scalabilityProviderFactories)); + + // Determine which scalability provider factory should be used based on configured options. + this.scalabilityProviderFactory = GetScalabilityProviderFactory(this.options, this.logger, this.scalabilityProviderFactories); - this.scalabilityProviderFactory = GetScalabilityProviderFactory(this.options, this.logger, this.scalabilityProviderFactories); - this.defaultscalabilityProvider = this.scalabilityProviderFactory.GetDurabilityProvider(); - } + // Create a default scalability provider instance from the selected factory. + // ? what is default do, if there is no sitemetada or conenction name how to we create? + this.defaultscalabilityProvider = this.scalabilityProviderFactory.GetDurabilityProvider(); + } + /// + /// Gets the resolved instance. + /// This factory is responsible for creating scalability providers based on the configured backend (e.g., Azure Storage, MSSQL, Netherite). + /// public IScalabilityProviderFactory ScalabilityProviderFactory => this.scalabilityProviderFactory; + + /// + /// Gets the default instance created by the selected factory. + /// This provider exposes methods to query current orchestration load and activity state for scaling decisions. + /// public ScalabilityProvider DefaultScalabilityProvider => this.defaultscalabilityProvider; + /// + /// Inherited from IExtensionConfigProvider. Not used here. + /// + /// The extension configuration context provided by the WebJobs host. public void Initialize(ExtensionConfigContext context) { - // Extension initialization - no-op for scale package + // No initialization required for scale extension } + /// + /// Determines the scalability provider factory based on the given options. + /// + /// The scale options specifying the target storage provider and hub configuration. + /// The logger instance for diagnostic messages. + /// A collection of available scalability provider factories. + /// The resolved suitable for the configured provider. internal static IScalabilityProviderFactory GetScalabilityProviderFactory( - DurableTaskScaleOptions options, - ILogger logger, - IEnumerable scalabilityProviderFactories) - { - const string DefaultProvider = "AzureStorage"; - bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); + DurableTaskScaleOptions options, + ILogger logger, + IEnumerable scalabilityProviderFactories) + { + const string DefaultProvider = "AzureStorage"; + bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); - if (!storageTypeIsConfigured) - { - try - { - IScalabilityProviderFactory defaultFactory = scalabilityProviderFactories.First(f => f.Name.Equals(DefaultProvider)); - logger.LogInformation($"Using the default storage provider: {DefaultProvider}."); - return defaultFactory; - } - catch (InvalidOperationException e) - { - throw new InvalidOperationException($"Couldn't find the default storage provider: {DefaultProvider}.", e); - } - } + if (!storageTypeIsConfigured) + { + try + { + IScalabilityProviderFactory defaultFactory = scalabilityProviderFactories.First(f => f.Name.Equals(DefaultProvider)); + logger.LogInformation($"Using the default storage provider: {DefaultProvider}."); + return defaultFactory; + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Couldn't find the default storage provider: {DefaultProvider}.", e); + } + } - try - { - IScalabilityProviderFactory selectedFactory = scalabilityProviderFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); - logger.LogInformation($"Using the {storageType} storage provider."); - return selectedFactory; - } - catch (InvalidOperationException e) - { - IList factoryNames = scalabilityProviderFactories.Select(f => f.Name).ToList(); - throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); - } - } - } + try + { + IScalabilityProviderFactory selectedFactory = scalabilityProviderFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); + logger.LogInformation($"Using the {storageType} storage provider."); + return selectedFactory; + } + catch (InvalidOperationException e) + { + IList factoryNames = scalabilityProviderFactories.Select(f => f.Name).ToList(); + throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); + } + } + } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs index e3790d70a..8c4458dfa 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs @@ -10,14 +10,36 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale /// public class DurableTaskScaleOptions { + /// + /// Gets or sets the name of the Durable Task Hub. + /// This identifies the taskhub being monitored or scaled. + /// public string HubName { get; set; } + /// + /// Gets or sets the dictionary of configuration settings for the underlying storage provider (e.g., Azure Storage, MSSQL, or Netherite). + /// These settings typically include connection details and provider-specific parameters. + /// public IDictionary StorageProvider { get; set; } = new Dictionary(); + /// + /// Gets or sets the maximum number of orchestrator functions that can run concurrently on this worker instance. + /// Used by the scale controller to balance orchestration and activity execution load. + /// public int? MaxConcurrentOrchestratorFunctions { get; set; } + /// + /// /// Gets or sets the maximum number of activity functions that can run concurrently on this worker instance. + /// Used by the scale controller to balance orchestration and activity execution load. + /// public int? MaxConcurrentActivityFunctions { get; set; } + /// + /// Resolves app settings in using the provided . + /// This allows configuration values such as connection strings to be expanded from environment variables or host settings. + /// + /// The scale options instance containing configuration values to resolve. + /// The name resolver used to resolve app setting placeholders. public static void ResolveAppSettingOptions(DurableTaskScaleOptions options, INameResolver nameResolver) { if (options.StorageProvider.TryGetValue("connectionName", out object connectionNameObj) && connectionNameObj is string connectionName) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index 18f184d88..55306fbcb 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; @@ -20,10 +21,6 @@ internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITarget private readonly IScaleMonitor monitor; private readonly ITargetScaler targetScaler; - private readonly DurableTaskScaleOptions options; - private readonly INameResolver nameResolver; - private readonly ILoggerFactory loggerFactory; - private readonly IEnumerable scalabilityProviderFactories; public DurableTaskTriggersScaleProvider( IOptions durableTaskScaleOptions, @@ -32,30 +29,52 @@ public DurableTaskTriggersScaleProvider( IEnumerable scalabilityProviderFactories, TriggerMetadata triggerMetadata) { - this.options = durableTaskScaleOptions.Value; - this.nameResolver = nameResolver; - this.loggerFactory = loggerFactory; - this.scalabilityProviderFactories = scalabilityProviderFactories; - string functionId = triggerMetadata.FunctionName; var functionName = new FunctionName(functionId); - this.GetOptions(triggerMetadata); + // Deserialize the configuration from triggerMetadata (sent by Scale Controller) + // This is the source of truth for scale scenarios + var metadata = triggerMetadata.Metadata.ToObject() + ?? throw new InvalidOperationException($"Failed to deserialize trigger metadata. Payload: {triggerMetadata.Metadata}"); + + // Validate required fields + string hubName = metadata.TaskHubName + ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none. Payload: {metadata.TaskHubName}"); + + // Store the parsed metadata in Properties so factories can use it (avoid re-parsing) + // TriggerMetadata.Properties is read-only but the dictionary itself is mutable + if (triggerMetadata.Properties != null) + { + triggerMetadata.Properties["DurableTaskMetadata"] = metadata; + } + + // Build options from triggerMetadata for factory selection + var options = new DurableTaskScaleOptions + { + HubName = hubName, + MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions, + MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions, + StorageProvider = metadata.StorageProvider ?? new Dictionary() + }; - IScalabilityProviderFactory scalabilityProviderFactory = this.GetScalabilityProviderFactory(); + // Resolve app settings (e.g., %MyConnectionString% -> actual value) + DurableTaskScaleOptions.ResolveAppSettingOptions(options, nameResolver); + + var logger = loggerFactory.CreateLogger(); + IScalabilityProviderFactory scalabilityProviderFactory = DurableTaskScaleExtension.GetScalabilityProviderFactory( + options, logger, scalabilityProviderFactories); // Always use the triggerMetadata overload for scale scenarios - // The factory will extract TokenCredential if present + // The factory will extract the parsed DurableTaskMetadata and TokenCredential if present ScalabilityProvider defaultscalabilityProvider = scalabilityProviderFactory.GetDurabilityProvider(triggerMetadata); - // Note: `this.options` is populated from the trigger metadata above - string? connectionName = GetConnectionName(scalabilityProviderFactory, this.options); + // Get connection name from options (already extracted from metadata) + string? connectionName = GetConnectionName(scalabilityProviderFactory, options); // Check if using managed identity (for logging) - bool usesManagedIdentity = triggerMetadata.Properties != null && + bool usesManagedIdentity = triggerMetadata.Properties != null && triggerMetadata.Properties.ContainsKey("AzureComponentFactory"); - var logger = loggerFactory.CreateLogger(); logger.LogInformation( "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}', usesManagedIdentity = '{UsesMI}'", triggerMetadata.FunctionName, @@ -67,14 +86,14 @@ public DurableTaskTriggersScaleProvider( functionId, functionName, connectionName, - this.options.HubName); + hubName); this.monitor = ScaleUtils.GetScaleMonitor( defaultscalabilityProvider, functionId, functionName, connectionName, - this.options.HubName); + hubName); } private static string? GetConnectionName(IScalabilityProviderFactory scalabilityProviderFactory, DurableTaskScaleOptions options) @@ -98,41 +117,28 @@ public DurableTaskTriggersScaleProvider( return azureStorageScalabilityProviderFactory.DefaultConnectionName; } - return null; - } - - private void GetOptions(TriggerMetadata triggerMetadata) - { - // the metadata is the sync triggers payload - var metadata = triggerMetadata.Metadata.ToObject(); - - // The property `taskHubName` is always expected in the SyncTriggers payload - this.options.HubName = metadata?.TaskHubName ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none. Payload: {triggerMetadata.Metadata}"); - if (metadata?.MaxConcurrentActivityFunctions != null) + if (scalabilityProviderFactory is AzureManagedScalabilityProviderFactory azureManagedScalabilityProviderFactory) { - this.options.MaxConcurrentActivityFunctions = metadata?.MaxConcurrentActivityFunctions; - } + if (options != null && options.StorageProvider != null) + { + if (options.StorageProvider.TryGetValue("connectionName", out object value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + { + return s1; + } - if (metadata?.MaxConcurrentOrchestratorFunctions != null) - { - this.options.MaxConcurrentOrchestratorFunctions = metadata?.MaxConcurrentOrchestratorFunctions; - } + // legacy alias often used in payloads + if (options.StorageProvider.TryGetValue("connectionStringName", out object value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + { + return s2; + } + } - if (metadata?.StorageProvider != null) - { - this.options.StorageProvider = metadata?.StorageProvider; + return azureManagedScalabilityProviderFactory.DefaultConnectionName; } - DurableTaskScaleOptions.ResolveAppSettingOptions(this.options, this.nameResolver); - } - - private IScalabilityProviderFactory GetScalabilityProviderFactory() - { - var logger = this.loggerFactory.CreateLogger(); - return DurableTaskScaleExtension.GetScalabilityProviderFactory(this.options, logger, this.scalabilityProviderFactories); + return null; } - public IScaleMonitor GetMonitor() { return this.monitor; @@ -142,23 +148,24 @@ public ITargetScaler GetTargetScaler() { return this.targetScaler; } + } - /// - /// Captures the relevant DF SyncTriggers JSON properties for making scaling decisions. - /// - internal class DurableTaskMetadata - { - [JsonPropertyName("taskHubName")] - public string? TaskHubName { get; set; } + /// + /// Represents the Durable Task configuration sent by the Scale Controller in the SyncTriggers payload. + /// This is deserialized from triggerMetadata.Metadata and passed to factories via triggerMetadata.Properties. + /// + public class DurableTaskMetadata + { + [JsonPropertyName("taskHubName")] + public string? TaskHubName { get; set; } - [JsonPropertyName("maxConcurrentOrchestratorFunctions")] - public int? MaxConcurrentOrchestratorFunctions { get; set; } + [JsonPropertyName("maxConcurrentOrchestratorFunctions")] + public int? MaxConcurrentOrchestratorFunctions { get; set; } - [JsonPropertyName("maxConcurrentActivityFunctions")] - public int? MaxConcurrentActivityFunctions { get; set; } + [JsonPropertyName("maxConcurrentActivityFunctions")] + public int? MaxConcurrentActivityFunctions { get; set; } - [JsonPropertyName("storageProvider")] - public IDictionary? StorageProvider { get; set; } - } + [JsonPropertyName("storageProvider")] + public IDictionary? StorageProvider { get; set; } } } \ No newline at end of file diff --git a/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs b/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs index a82cd134e..0e81782b4 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/FunctionName.cs @@ -8,7 +8,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale /// /// The name of a durable function. /// - internal struct FunctionName : IEquatable + internal struct FunctionName { /// /// Initializes a new instance of the struct. @@ -26,72 +26,5 @@ public FunctionName(string name) /// The name of the activity function without the version. /// public string Name { get; } - - /// - /// Compares two objects for equality. - /// - /// The first to compare. - /// The second to compare. - /// true if the two objects are equal; otherwise false. - public static bool operator ==(FunctionName a, FunctionName b) - { - return a.Equals(b); - } - - /// - /// Compares two objects for inequality. - /// - /// The first to compare. - /// The second to compare. - /// true if the two objects are not equal; otherwise false. - public static bool operator !=(FunctionName a, FunctionName b) - { - return !a.Equals(b); - } - - /// - /// Gets a value indicating whether to objects - /// are equal using value semantics. - /// - /// The other object to compare to. - /// true if the two objects are equal using value semantics; otherwise false. - public bool Equals(FunctionName other) - { - return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Gets a value indicating whether to objects - /// are equal using value semantics. - /// - /// The other object to compare to. - /// true if the two objects are equal using value semantics; otherwise false. - public override bool Equals(object other) - { - if (!(other is FunctionName)) - { - return false; - } - - return this.Equals((FunctionName)other); - } - - /// - /// Calculates a hash code value for the current instance. - /// - /// A 32-bit hash code value. - public override int GetHashCode() - { - return StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name); - } - - /// - /// Gets the string value of the current instance. - /// - /// The name and optional version of the current instance. - public override string ToString() - { - return this.Name; - } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs b/src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs deleted file mode 100644 index bc64390aa..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/IConnectionInfoResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using Azure.Core; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale -{ - /// - /// Interface for resolving connection information. - /// - public interface IConnectionInfoResolver - { - /// - /// Resolves connection information for a given connection name. - /// - /// The name of the connection. - /// The connection string or token credential information. - (string ConnectionString, TokenCredential Credential) ResolveConnectionInfo(string connectionName); - } -} - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs b/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs index 410f28572..7b72ee709 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs @@ -9,11 +9,10 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale public interface INameResolver { /// - /// Resolves an application setting name to its value. + /// Resolves an application setting name to its value. Set from Functions Scale Controller. /// /// The name of the application setting. /// The resolved value, or the original name if no resolution is found. string Resolve(string name); } } - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs index f6ef5b278..816b8f8c7 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs @@ -26,11 +26,6 @@ public interface IScalabilityProviderFactory /// /// Trigger metadata used to create IOrchestrationService for functions scale scenarios. /// A durability provider to be used by a client function. - ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) - { - // This method is not supported by this provider. - // Only providers that require TriggerMetadata for scale should implement it. - throw new NotImplementedException("This provider does not support GetDurabilityProvider with TriggerMetadata."); - } + ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata); } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs index 8f79009e5..2a2aeaeb7 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs @@ -95,3 +95,5 @@ public override bool TryGetTargetScaler( } } + + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index 1e0496921..af338b69a 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -3,16 +3,14 @@ using System; using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using Azure.Core; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Data.SqlClient; using Newtonsoft.Json.Linq; -using Azure.Core; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql { @@ -44,44 +42,12 @@ public SqlServerScalabilityProviderFactory( INameResolver nameResolver, ILoggerFactory loggerFactory) { - // Validate arguments first - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } + this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - if (configuration == null) - { - throw new ArgumentNullException(nameof(configuration)); - } - - if (nameResolver == null) - { - throw new ArgumentNullException(nameof(nameResolver)); - } - - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - // this constructor may be called by dependency injection even if the SQL Server provider is not selected - // in that case, return immediately, since this provider is not actually used, but can still throw validation errors - if (options.Value.StorageProvider != null - && options.Value.StorageProvider.TryGetValue("type", out object value) - && value is string s - && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - this.options = options.Value; - this.configuration = configuration; - this.nameResolver = nameResolver; - this.loggerFactory = loggerFactory; - - // Resolve default connection name directly from payload keys or fall back - this.DefaultConnectionName = ResolveConnectionName(options.Value.StorageProvider) ?? "SQLDB_Connection"; + this.DefaultConnectionName = ResolveConnectionName(this.options.StorageProvider) ?? "SQLDB_Connection"; } public virtual string Name => ProviderName; @@ -126,36 +92,22 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata this.ValidateSqlServerOptions(logger); // Extract TokenCredential from triggerMetadata if present (for Managed Identity) - // This follows the same pattern as Azure Storage var tokenCredential = ExtractTokenCredential(triggerMetadata); - // Extract connection name from triggerMetadata (similar to how Azure Storage does it) - // The triggerMetadata contains storageProvider with connectionName or connectionStringName - string connectionName = this.DefaultConnectionName; - if (triggerMetadata?.Metadata != null) - { - var storageProvider = triggerMetadata.Metadata["storageProvider"]; - if (storageProvider != null) - { - var storageProviderObj = storageProvider.ToObject>(); - if (storageProviderObj != null) - { - // Try connectionName first, then connectionStringName (legacy alias) - if (storageProviderObj.TryGetValue("connectionName", out object connName) && connName is string connNameStr && !string.IsNullOrWhiteSpace(connNameStr)) - { - connectionName = connNameStr; - } - else if (storageProviderObj.TryGetValue("connectionStringName", out object connStrName) && connStrName is string connStrNameStr && !string.IsNullOrWhiteSpace(connStrNameStr)) - { - connectionName = connStrNameStr; - } - } - } - } + // Get the pre-parsed metadata from triggerMetadata.Properties (parsed by DurableTaskTriggersScaleProvider) + DurableTaskMetadata parsedMetadata = ExtractParsedMetadata(triggerMetadata); + + // Check if trigger metadata specifies a different connection name, otherwise use default from constructor + string connectionName = ExtractConnectionName(triggerMetadata) ?? this.DefaultConnectionName; + + // Extract task hub name from parsed metadata first, fallback to DI options + string taskHubName = parsedMetadata?.TaskHubName + ?? this.options.HubName + ?? "default"; var sqlOrchestrationService = this.CreateSqlOrchestrationService( connectionName, - this.options.HubName ?? "default", + taskHubName, tokenCredential, logger); @@ -173,89 +125,23 @@ private SqlOrchestrationService CreateSqlOrchestrationService( global::Azure.Core.TokenCredential tokenCredential, ILogger logger) { - string connectionString = null; - - // If TokenCredential is provided (Managed Identity), we need to build connection string from config - // SQL Server authentication with Managed Identity requires: - // - Server name (can be in connection string or config: {connectionName}__serverName) - // - Database name (can be in connection string or config: {connectionName}__databaseName) - // - Authentication="Active Directory Default" in connection string - if (tokenCredential != null) + // Resolve connection name first (handles %% wrapping) + string resolvedConnectionName = this.nameResolver.Resolve(connectionName); + + // Try to get connection string from configuration (app settings) + string connectionString = this.configuration.GetConnectionString(resolvedConnectionName) + ?? this.configuration[resolvedConnectionName]; + + // Fallback to environment variable (matching old implementation behavior) + if (string.IsNullOrEmpty(connectionString)) { - // For Managed Identity, read server name and database from configuration - // Pattern: {connectionName}__serverName, {connectionName}__databaseName - // Or fall back to parsing from connection string if available - // Note: Server name can also come from the connection string itself - var serverName = this.configuration[$"{connectionName}__serverName"] - ?? this.configuration[$"{connectionName}__server"]; - var databaseName = this.configuration[$"{connectionName}__databaseName"] - ?? this.configuration[$"{connectionName}__database"]; - - // Try to get base connection string to extract server/database if not explicitly set - var baseConnectionString = this.configuration.GetConnectionString(connectionName) - ?? this.configuration[connectionName]; - - if (!string.IsNullOrEmpty(baseConnectionString)) - { - try - { - var builder = new SqlConnectionStringBuilder(baseConnectionString); - // Use explicit config values if provided, otherwise use values from connection string - if (string.IsNullOrEmpty(serverName)) - { - serverName = builder.DataSource; - } - if (string.IsNullOrEmpty(databaseName)) - { - databaseName = builder.InitialCatalog; - } - - // Build connection string with Managed Identity authentication - builder.DataSource = serverName; - builder.InitialCatalog = databaseName ?? builder.InitialCatalog; - builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryDefault; - // Remove password/user ID if present (not needed for Managed Identity) - builder.Password = null; - builder.UserID = null; - - connectionString = builder.ConnectionString; - } - catch (ArgumentException) - { - // If connection string parsing fails, try to construct from config values - } - } - - // If we still don't have connection string, construct from config values - if (string.IsNullOrEmpty(connectionString)) - { - if (string.IsNullOrEmpty(serverName)) - { - throw new InvalidOperationException( - $"No SQL server name configuration was found for Managed Identity. Please provide '{connectionName}__serverName' or '{connectionName}__server' app setting, or ensure '{connectionName}' connection string contains server name."); - } - - var connectionStringBuilder = new SqlConnectionStringBuilder - { - DataSource = serverName, - InitialCatalog = databaseName ?? "master", - Authentication = SqlAuthenticationMethod.ActiveDirectoryDefault, - Encrypt = true, - }; - connectionString = connectionStringBuilder.ConnectionString; - } + connectionString = Environment.GetEnvironmentVariable(resolvedConnectionName); } - else - { - // No TokenCredential - use connection string from configuration (traditional auth) - connectionString = this.configuration.GetConnectionString(connectionName) - ?? this.configuration[connectionName]; - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException( - $"No SQL connection string configuration was found for the app setting or environment variable named '{connectionName}'."); - } + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException( + $"No SQL connection string configuration was found for the app setting or environment variable named '{resolvedConnectionName}'."); } // Validate the connection string @@ -268,7 +154,8 @@ private SqlOrchestrationService CreateSqlOrchestrationService( throw new ArgumentException("The provided connection string is invalid.", e); } - // Create SQL Server orchestration service settings + // Create SQL Server orchestration service settings - following durabletask-mssql pattern + // Connection string should include authentication method (e.g., Authentication=Active Directory Default) var settings = new SqlOrchestrationServiceSettings( connectionString, taskHubName, @@ -279,24 +166,18 @@ private SqlOrchestrationService CreateSqlOrchestrationService( MaxConcurrentActivities = this.options.MaxConcurrentActivityFunctions ?? 10, }; - // If TokenCredential is provided (from triggerMetadata in Azure), we need to use it instead of DefaultAzureCredential - // Register a custom SqlAuthenticationProvider that uses our specific TokenCredential - if (tokenCredential != null) - { - // Register custom authentication provider that uses the provided TokenCredential - // This ensures we use the TokenCredential from Scale Controller, not DefaultAzureCredential - var customProvider = new CustomTokenCredentialAuthenticationProvider(tokenCredential, logger); - SqlAuthenticationProvider.SetProvider( - SqlAuthenticationMethod.ActiveDirectoryDefault, - customProvider); - } - // Note: When tokenCredential is null (local development), we use Authentication="Active Directory Default" - // which will use DefaultAzureCredential. This works for local testing. + // Note: When connection string includes "Authentication=Active Directory Default" or + // "Authentication=Active Directory Managed Identity", SQL Server will automatically use + // the appropriate Azure identity (managed identity in Azure, or DefaultAzureCredential locally). + // The tokenCredential from Scale Controller is primarily for Azure Storage; SQL Server + // manages its own token acquisition through the connection string's Authentication setting. // Create and return the orchestration service return new SqlOrchestrationService(settings); } + // Note: ExtractTokenCredential is kept for potential future use, but SQL Server handles + // its own authentication through the connection string (Authentication=Active Directory Default) private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata) { if (triggerMetadata?.Properties == null) @@ -306,7 +187,6 @@ private SqlOrchestrationService CreateSqlOrchestrationService( // Check if metadata contains an AzureComponentFactory wrapper // ScaleController passes it as: metadata.Properties[nameof(AzureComponentFactory)] = new AzureComponentFactoryWrapper(...) - // This follows the same pattern as Azure Storage if (triggerMetadata.Properties.TryGetValue("AzureComponentFactory", out object componentFactoryObj) && componentFactoryObj != null) { // The AzureComponentFactoryWrapper has CreateTokenCredential method @@ -335,44 +215,33 @@ private SqlOrchestrationService CreateSqlOrchestrationService( return null; } - /// - /// Custom SqlAuthenticationProvider that uses a specific TokenCredential instead of DefaultAzureCredential. - /// This allows us to use the TokenCredential passed from triggerMetadata in Azure environments. - /// - private class CustomTokenCredentialAuthenticationProvider : SqlAuthenticationProvider + private static string ExtractConnectionName(TriggerMetadata triggerMetadata) { - private readonly TokenCredential tokenCredential; - private readonly ILogger logger; - private const string SqlResource = "https://database.windows.net/.default"; - - public CustomTokenCredentialAuthenticationProvider(TokenCredential tokenCredential, ILogger logger) + if (triggerMetadata?.Metadata == null) { - this.tokenCredential = tokenCredential ?? throw new ArgumentNullException(nameof(tokenCredential)); - this.logger = logger; + return null; } - public override async Task AcquireTokenAsync(SqlAuthenticationParameters parameters) + var storageProvider = triggerMetadata.Metadata["storageProvider"]; + if (storageProvider != null) { - try - { - // Get token from the provided TokenCredential - var tokenRequestContext = new TokenRequestContext(new[] { SqlResource }); - var accessToken = await this.tokenCredential.GetTokenAsync(tokenRequestContext, CancellationToken.None); - - return new SqlAuthenticationToken(accessToken.Token, accessToken.ExpiresOn); - } - catch (Exception ex) + var storageProviderObj = storageProvider.ToObject>(); + if (storageProviderObj != null) { - this.logger?.LogError(ex, "Failed to acquire token from TokenCredential for SQL authentication"); - throw; + // Try connectionName first, then connectionStringName (legacy alias) + if (storageProviderObj.TryGetValue("connectionName", out object connName) && connName is string connNameStr && !string.IsNullOrWhiteSpace(connNameStr)) + { + return connNameStr; + } + + if (storageProviderObj.TryGetValue("connectionStringName", out object connStrName) && connStrName is string connStrNameStr && !string.IsNullOrWhiteSpace(connStrNameStr)) + { + return connStrNameStr; + } } } - public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) - { - // Only support Active Directory Default authentication - return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDefault; - } + return null; } private static string ResolveConnectionName(IDictionary storageProvider) @@ -418,5 +287,22 @@ private void ValidateSqlServerOptions(ILogger logger) throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); } } + + private static DurableTaskMetadata ExtractParsedMetadata(TriggerMetadata triggerMetadata) + { + if (triggerMetadata?.Properties == null) + { + return null; + } + + // The DurableTaskTriggersScaleProvider pre-parses the metadata and stores it in Properties + if (triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object metadataObj) + && metadataObj is DurableTaskMetadata metadata) + { + return metadata; + } + + return null; + } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs index 9be2eb7c9..d50684f43 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs @@ -18,3 +18,5 @@ public class SqlServerScaleMetric : ScaleMetrics } } + + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs index be7275978..ee1e0b937 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs @@ -35,3 +35,5 @@ public async Task GetScaleResultAsync(TargetScalerContext co } } + + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj index 1e8f2b37e..22d53802d 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj +++ b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale 3 @@ -41,6 +41,7 @@ + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs deleted file mode 100644 index f04775823..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/WebJobsConnectionInfoProvider.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System; -using Azure.Core; -using Azure.Identity; -using Microsoft.Extensions.Configuration; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale -{ - /// - /// Resolves connection information from WebJobs configuration. - /// - internal class WebJobsConnectionInfoProvider : IConnectionInfoResolver - { - private readonly IConfiguration configuration; - - public WebJobsConnectionInfoProvider(IConfiguration configuration) - { - this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - public (string ConnectionString, TokenCredential Credential) ResolveConnectionInfo(string connectionName) - { - if (string.IsNullOrEmpty(connectionName)) - { - throw new ArgumentNullException(nameof(connectionName)); - } - - // First, try to get the connection string directly - var connectionString = this.configuration.GetConnectionString(connectionName) - ?? this.configuration[connectionName]; - - if (!string.IsNullOrEmpty(connectionString)) - { - return (connectionString, null); - } - - // If no connection string, check for service URI (Managed Identity scenario) - var serviceUri = this.configuration[$"{connectionName}:serviceUri"] - ?? this.configuration[$"{connectionName}__serviceUri"]; - - if (!string.IsNullOrEmpty(serviceUri)) - { - // Use DefaultAzureCredential for Managed Identity - return (null, new DefaultAzureCredential()); - } - - // Return null if nothing found - return (null, null); - } - } -} - diff --git a/test/Directory.Packages.props b/test/Directory.Packages.props index 8cdbad5e6..fa1ac6d2b 100644 --- a/test/Directory.Packages.props +++ b/test/Directory.Packages.props @@ -34,7 +34,7 @@ - + diff --git a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs new file mode 100644 index 000000000..d42526627 --- /dev/null +++ b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs @@ -0,0 +1,418 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + public class AzureManagedScalabilityProviderFactoryTests + { + private readonly ITestOutputHelper output; + private readonly TestLoggerProvider loggerProvider; + private readonly ILoggerFactory loggerFactory; + private readonly INameResolver nameResolver; + private readonly IConfiguration configuration; + + public AzureManagedScalabilityProviderFactoryTests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory(); + this.loggerProvider = new TestLoggerProvider(output); + this.loggerFactory.AddProvider(this.loggerProvider); + + // Create configuration with Azure Managed connection string + // Using DefaultAzureCredential for local testing + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "v3-dtsConnectionMI", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, + { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", "Endpoint=https://default.westus.durabletask.io;Authentication=DefaultAzure" }, + }); + this.configuration = configBuilder.Build(); + + this.nameResolver = new SimpleNameResolver(); + } + + private class SimpleNameResolver : INameResolver + { + public string Resolve(string name) => name; + } + + /// + /// Scenario: Creating factory with valid parameters. + /// Validates that factory can be instantiated with proper configuration. + /// Verifies factory name is "AzureManaged" and connection name is set correctly. + /// + [Fact] + public void Constructor_ValidParameters_CreatesInstance() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + + // Act + var factory = new AzureManagedScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Assert + Assert.NotNull(factory); + Assert.Equal("AzureManaged", factory.Name); + Assert.Equal("v3-dtsConnectionMI", factory.DefaultConnectionName); + } + + /// + /// Scenario: Constructor validation - null options. + /// Validates that factory properly rejects null options parameter. + /// Ensures proper error handling for missing configuration. + /// + [Fact] + public void Constructor_NullOptions_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new AzureManagedScalabilityProviderFactory( + null, + this.configuration, + this.nameResolver, + this.loggerFactory)); + } + + /// + /// Scenario: Constructor validation - null configuration. + /// Validates that factory requires a valid configuration provider. + /// Ensures connection string resolution dependencies are enforced. + /// + [Fact] + public void Constructor_NullConfiguration_ThrowsArgumentNullException() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + + // Act & Assert + Assert.Throws(() => + new AzureManagedScalabilityProviderFactory( + options, + null, + this.nameResolver, + this.loggerFactory)); + } + + /// + /// ✅ KEY SCENARIO 1: Creating durability provider when trigger metadata specifies type is "azureManaged". + /// Validates that the factory creates an AzureManagedScalabilityProvider when storageProvider.type = "azureManaged". + /// Tests the provider factory selection mechanism when multiple backends are available. + /// Verifies correct provider type is instantiated for Azure Managed backend. + /// This is the primary path used by Azure Functions Scale Controller for Azure Managed backend. + /// + [Fact] + public void GetDurabilityProvider_WithAzureManagedType_CreatesAzureManagedProvider() + { + // Arrange - Explicitly set storageProvider type to "azureManaged" + var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + + var factory = new AzureManagedScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("AzureManaged", factory.Name); + Assert.Equal(10, provider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(20, provider.MaxConcurrentTaskActivityWorkItems); + } + + /// + /// Scenario: Creating durability provider without trigger metadata (default path). + /// Validates that provider can be created using only options configuration. + /// Tests connection string-based authentication with DefaultAzureCredential. + /// Verifies provider has correct type, connection name, and concurrency settings. + /// + [Fact] + public void GetDurabilityProvider_ReturnsValidProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + + var factory = new AzureManagedScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + var azureProvider = (AzureManagedScalabilityProvider)provider; + Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + Assert.Equal("v3-dtsConnectionMI", azureProvider.ConnectionName); + } + + /// + /// ✅ KEY SCENARIO 2: Creating durability provider with trigger metadata from ScaleController. + /// Validates the end-to-end flow when Scale Controller calls with trigger metadata. + /// Tests that connection name "v3-dtsConnectionMI" from trigger metadata is used correctly. + /// Tests that max concurrent settings from options are applied. + /// Verifies connection name resolution and provider creation. + /// This is the primary path used by Azure Functions Scale Controller. + /// + [Fact] + public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + var factory = new AzureManagedScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "v3-dtsConnectionMI"); + + // Act + var provider = factory.GetDurabilityProvider(triggerMetadata); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + var azureProvider = (AzureManagedScalabilityProvider)provider; + Assert.Equal("v3-dtsConnectionMI", azureProvider.ConnectionName); + // Note: Uses options values (10, 20), not trigger metadata values (15, 25) + Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + } + + /// + /// Scenario: Provider caching for performance optimization with same connection and client ID. + /// Validates that factory reuses the same provider instance for multiple calls with same parameters. + /// Tests performance optimization by avoiding redundant provider creation. + /// Ensures consistent metrics collection across scale decisions. + /// Azure Managed uses (connectionName, taskHubName, clientId) as cache key. + /// + [Fact] + public void GetDurabilityProvider_CachesProviderWithSameConnectionAndClientId() + { + // Arrange + var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + var factory = new AzureManagedScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act - Call twice with no trigger metadata (same cache key) + var provider1 = factory.GetDurabilityProvider(); + var provider2 = factory.GetDurabilityProvider(); + + // Assert - Should be the same cached instance + Assert.Same(provider1, provider2); + } + + /// + /// Scenario: Factory uses default connection name when not specified. + /// Validates that factory falls back to DURABLE_TASK_SCHEDULER_CONNECTION_STRING. + /// Tests the default connection name pattern for Azure Managed backend. + /// + [Fact] + public void GetDurabilityProvider_WithDefaultConnectionName_CreatesProvider() + { + // Arrange - Don't specify connectionName in storageProvider + var options = new DurableTaskScaleOptions + { + HubName = "testHub", + MaxConcurrentOrchestratorFunctions = 10, + MaxConcurrentActivityFunctions = 20, + StorageProvider = new Dictionary + { + { "type", "azureManaged" }, + }, + }; + + var factory = new AzureManagedScalabilityProviderFactory( + Options.Create(options), + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", factory.DefaultConnectionName); + } + + /// + /// Scenario: Missing connection string throws exception. + /// Validates that factory fails gracefully when connection string is not configured. + /// Ensures proper error messaging for configuration issues. + /// + [Fact] + public void GetDurabilityProvider_MissingConnectionString_ThrowsException() + { + // Arrange - Use connection name that doesn't exist in configuration + var options = CreateOptions("testHub", 10, 20, "NonExistentConnection"); + + var factory = new AzureManagedScalabilityProviderFactory( + options, + this.configuration, + this.nameResolver, + this.loggerFactory); + + // Act & Assert + var exception = Assert.Throws(() => factory.GetDurabilityProvider()); + Assert.Contains("No connection string configuration was found", exception.Message); + Assert.Contains("NonExistentConnection", exception.Message); + } + + /// + /// Scenario: Configuration value retrieval and connection string resolution. + /// Validates that IConfiguration correctly resolves custom connection names. + /// Tests the configuration binding mechanism for connection strings. + /// Verifies end-to-end flow from configuration to Azure Managed connection. + /// + [Fact] + public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() + { + // Arrange - Verify we can retrieve connection string from configuration + var testConnectionString = "Endpoint=https://custom.westus.durabletask.io;Authentication=DefaultAzure"; + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "MyCustomConnection", testConnectionString } + }); + var config = configBuilder.Build(); + + var options = CreateOptions("testHub", 10, 20, "MyCustomConnection"); + var factory = new AzureManagedScalabilityProviderFactory( + options, + config, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + Assert.Equal("MyCustomConnection", provider.ConnectionName); + + // Verify the connection string was retrieved from configuration + var retrievedConnectionString = config["MyCustomConnection"]; + Assert.Equal(testConnectionString, retrievedConnectionString); + } + + /// + /// Scenario: Factory correctly parses connection string with task hub name. + /// Validates that task hub name from connection string is used when not in options. + /// Tests connection string parsing logic. + /// + [Fact] + public void GetDurabilityProvider_UsesTaskHubNameFromConnectionString() + { + // Arrange - Connection string with TaskHub specified + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "ConnectionWithHub", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure;TaskHub=MyTaskHub" } + }); + var config = configBuilder.Build(); + + var options = new DurableTaskScaleOptions + { + // Don't set HubName in options + MaxConcurrentOrchestratorFunctions = 10, + MaxConcurrentActivityFunctions = 20, + StorageProvider = new Dictionary + { + { "type", "azureManaged" }, + { "connectionName", "ConnectionWithHub" }, + }, + }; + + var factory = new AzureManagedScalabilityProviderFactory( + Options.Create(options), + config, + this.nameResolver, + this.loggerFactory); + + // Act + var provider = factory.GetDurabilityProvider(); + + // Assert + Assert.NotNull(provider); + // Provider should be created successfully with task hub from connection string + } + + private static IOptions CreateOptions( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName) + { + var options = new DurableTaskScaleOptions + { + HubName = hubName, + MaxConcurrentOrchestratorFunctions = maxOrchestrator, + MaxConcurrentActivityFunctions = maxActivity, + StorageProvider = new Dictionary + { + { "type", "azureManaged" }, + { "connectionName", connectionName }, + }, + }; + + return Options.Create(options); + } + + private static TriggerMetadata CreateTriggerMetadata( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName) + { + var metadata = new JObject + { + { "functionName", "TestFunction" }, + { "type", "orchestrationTrigger" }, + { "taskHubName", hubName }, + { "maxConcurrentOrchestratorFunctions", maxOrchestrator }, + { "maxConcurrentActivityFunctions", maxActivity }, + { + "storageProvider", new JObject + { + { "type", "azureManaged" }, + { "connectionName", connectionName }, + } + }, + }; + + return new TriggerMetadata(metadata); + } + } +} + + diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs index bbc3ac930..aca9b004f 100644 --- a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; @@ -60,6 +61,7 @@ public void AddDurableTask_RegistersRequiredServices() var scalabilityProviderFactories = services.GetServices().ToList(); Assert.NotEmpty(scalabilityProviderFactories); Assert.Contains(scalabilityProviderFactories, f => f is AzureStorageScalabilityProviderFactory); + Assert.Contains(scalabilityProviderFactories, f => f is AzureManagedScalabilityProviderFactory); } /// diff --git a/test/ScaleTests/TestLoggerProvider.cs b/test/ScaleTests/TestLoggerProvider.cs index 8506f8a97..09011702a 100644 --- a/test/ScaleTests/TestLoggerProvider.cs +++ b/test/ScaleTests/TestLoggerProvider.cs @@ -83,3 +83,5 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except + + diff --git a/test/ScaleTests/xunit.runner.json b/test/ScaleTests/xunit.runner.json index 2831304e0..bd6f77862 100644 --- a/test/ScaleTests/xunit.runner.json +++ b/test/ScaleTests/xunit.runner.json @@ -6,3 +6,5 @@ + + From 2c51fb73a1aab792cb49ba99f3d867c89575e02b Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Tue, 4 Nov 2025 22:08:13 -0800 Subject: [PATCH 04/25] update --- .../AzureManagedConnectionString.cs | 1 - .../AzureManagedScalabilityProvider.cs | 16 +- .../AzureManagedScalabilityProviderFactory.cs | 222 ++++++++++-------- .../AzureStorageScalabilityProvider .cs | 6 +- .../AzureStorageScalabilityProviderFactory.cs | 112 ++++----- .../DurableTaskMetadata.cs | 40 ++++ .../DurableTaskScaleExtension.cs | 3 +- .../DurableTaskTriggersScaleProvider.cs | 118 +++------- .../IScalabilityProviderFactory.cs | 17 +- .../IStorageServiceClientProviderFactory.cs | 2 +- .../ScalabilityProvider.cs | 9 - .../Sql/SqlServerScalabilityProvider.cs | 3 - .../SqlServerScalabilityProviderFactory.cs | 142 +---------- .../StorageServiceClientProviderFactory.cs | 4 +- ...ebJobs.Extensions.DurableTask.Scale.csproj | 8 +- 15 files changed, 282 insertions(+), 421 deletions(-) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs index de404b38f..600ff04cb 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs @@ -49,4 +49,3 @@ private string GetValue(string name) } } } - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs index fedc8d78d..92791113d 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs @@ -17,6 +17,21 @@ public class AzureManagedScalabilityProvider : ScalabilityProvider private readonly string connectionName; private readonly ILogger logger; + /// + /// Initializes a new instance of the class. + /// + /// + /// The instance that provides access to backend service for scaling operations. + /// + /// + /// The logical name of the storage or service connection associated with this provider. + /// + /// + /// The instance used for logging provider activities and diagnostics. + /// + /// + /// Thrown if is . + /// public AzureManagedScalabilityProvider( AzureManagedOrchestrationService orchestrationService, string connectionName, @@ -82,4 +97,3 @@ public DummyScaleMonitor(string functionId, string taskHub) } } } - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs index 7171c461d..fecfe4f94 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -14,11 +14,14 @@ #nullable enable namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged { + /// + /// Factory class responsible for creating and managing instances of . + /// public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactory { private const string LoggerName = "Host.Triggers.DurableTask.AzureManaged"; internal const string ProviderName = "AzureManaged"; - private const string DefaultConnectionNameConstant = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; + private const string DefaultConnectionStringName = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; private readonly Dictionary<(string, string?, string?), AzureManagedScalabilityProvider> cachedProviders = new Dictionary<(string, string?, string?), AzureManagedScalabilityProvider>(); private readonly DurableTaskScaleOptions options; @@ -27,70 +30,117 @@ public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactor private readonly ILoggerFactory loggerFactory; private readonly ILogger logger; + /// + /// Initializes a new instance of the class. + /// + /// + /// The instance that specifies scaling configuration. + /// + /// + /// The interface used to resolve connection strings and application settings. + /// + /// + /// The used to resolve environment-variable references. + /// + /// + /// The used to create loggers for diagnostics. + /// + /// + /// Thrown if any required argument is . + /// public AzureManagedScalabilityProviderFactory( IOptions options, IConfiguration configuration, INameResolver nameResolver, ILoggerFactory loggerFactory) { - this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + var optionsValue = options?.Value ?? throw new ArgumentNullException(nameof(options)); this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.logger = this.loggerFactory.CreateLogger(LoggerName); - this.DefaultConnectionName = ResolveConnectionName(this.options.StorageProvider) ?? DefaultConnectionNameConstant; + // Early return if a different backend is explicitly configured + if (optionsValue.StorageProvider != null + && optionsValue.StorageProvider.TryGetValue("type", out object value) + && value is string s + && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + this.options = optionsValue; + + // Resolve connection name from options, falling back to default + // The nameResolver handles %EnvironmentVariable% patterns + string? rawConnectionName = ResolveConnectionName(optionsValue.StorageProvider); + this.DefaultConnectionName = rawConnectionName != null ? this.nameResolver.Resolve(rawConnectionName) + : DefaultConnectionStringName; } + /// + /// Gets the logical name of this scalability provider type. + /// public virtual string Name => ProviderName; + /// + /// Gets the default connection name configured for this factory. + /// public string DefaultConnectionName { get; } - public virtual ScalabilityProvider GetDurabilityProvider() + /// + /// Returns a default instance configured with the default connection and global scaling options. + /// + /// A default instance. + public virtual ScalabilityProvider GetScalabilityProvider() { - return this.GetDurabilityProvider(null); + return this.GetScalabilityProvider(null); } - public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) + /// + /// Creates or retrieves an instance based on the provided trigger metadata. + /// + /// + /// The trigger metadata contains configuration or identity credentials specific to that trigger. + /// + /// + /// An instance configured using + /// the specified metadata and resolved connection information. + /// + /// + /// Thrown if no valid connection string could be resolved for the given connection name. + /// + public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetadata) { - // Check if trigger metadata specifies a different connection name, otherwise use default from constructor - string connectionName = ExtractConnectionName(triggerMetadata) ?? this.DefaultConnectionName; - - // Resolve connection name first (handles %% wrapping) - string resolvedConnectionName = this.nameResolver.Resolve(connectionName); - - // Try to get connection string from configuration (app settings) - string connectionString = this.configuration.GetConnectionString(resolvedConnectionName) - ?? this.configuration[resolvedConnectionName]; - - // Fallback to environment variable (matching old implementation behavior) - if (string.IsNullOrEmpty(connectionString)) - { - connectionString = Environment.GetEnvironmentVariable(resolvedConnectionName); - } + // Use the default connection name that was already resolved in constructor + string resolvedName = this.DefaultConnectionName; + + // Try standard configuration sources + string? connectionString = + this.configuration.GetConnectionString(resolvedName) ?? + this.configuration[resolvedName] ?? + Environment.GetEnvironmentVariable(resolvedName); if (string.IsNullOrEmpty(connectionString)) { throw new InvalidOperationException( - $"No connection string configuration was found for the app setting or environment variable named '{resolvedConnectionName}'."); + $"No valid connection string found for '{resolvedName}'. " + + $"Please ensure it is defined in app settings, connection strings, or environment variables."); } AzureManagedConnectionString azureManagedConnectionString = new AzureManagedConnectionString(connectionString); - - // Get the pre-parsed metadata from triggerMetadata.Properties (parsed by DurableTaskTriggersScaleProvider) - DurableTaskMetadata parsedMetadata = ExtractParsedMetadata(triggerMetadata); - - // Extract task hub name from parsed metadata first, fallback to DI options, then connection string - string taskHubName = parsedMetadata?.TaskHubName - ?? this.options.HubName - ?? azureManagedConnectionString.TaskHubName; + + // Extract task hub name from trigger options (already built from metadata), fallback to connection string + string taskHubName = this.options.HubName ?? azureManagedConnectionString.TaskHubName; // Include client ID in cache key to handle managed identity changes - (string, string?, string?) cacheKey = (connectionName, taskHubName, azureManagedConnectionString.ClientId); + (string, string?, string?) cacheKey = (resolvedName, taskHubName, azureManagedConnectionString.ClientId); this.logger.LogDebug( "Getting durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'...", - cacheKey.Item1, cacheKey.Item2, cacheKey.Item3 ?? "null"); + cacheKey.Item1, + cacheKey.Item2 ?? "null", + cacheKey.Item3 ?? "null"); lock (this.cachedProviders) { @@ -99,7 +149,9 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata { this.logger.LogDebug( "Returning cached durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'", - cacheKey.Item1, cacheKey.Item2, cacheKey.Item3 ?? "null"); + cacheKey.Item1, + cacheKey.Item2, + cacheKey.Item3 ?? "null"); return cachedProvider; } @@ -115,19 +167,19 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata { try { - TokenCredential tokenCredential = getTokenCredential(connectionName); - + TokenCredential tokenCredential = getTokenCredential(resolvedName); + if (tokenCredential == null) { this.logger.LogWarning( "Token credential retrieved from trigger metadata is null for connection '{Connection}'.", - connectionName); + resolvedName); } else { // Override the credential from connection string options.TokenCredential = tokenCredential; - this.logger.LogInformation("Retrieved token credential from trigger metadata for connection '{Connection}'", connectionName); + this.logger.LogInformation("Retrieved token credential from trigger metadata for connection '{Connection}'", resolvedName); } } catch (Exception ex) @@ -135,21 +187,21 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata this.logger.LogWarning( ex, "Failed to get token credential from trigger metadata for connection '{Connection}'", - connectionName); + resolvedName); } } else { this.logger.LogWarning( "Token credential function pointer in trigger metadata is not of expected type for connection '{Connection}'", - connectionName); + resolvedName); } } else { this.logger.LogInformation( "No trigger metadata provided or trigger metadata does not contain 'GetAzureManagedTokenCredential', " + - "using the token credential built from connection string for connection '{Connection}'.", connectionName); + "using the token credential built from connection string for connection '{Connection}'.", resolvedName); } // Set task hub name if configured @@ -171,89 +223,51 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata this.logger.LogInformation( "Creating durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'...", - cacheKey.Item1, cacheKey.Item2, cacheKey.Item3 ?? "null"); + cacheKey.Item1, + cacheKey.Item2, + cacheKey.Item3 ?? "null"); AzureManagedOrchestrationService service = new AzureManagedOrchestrationService(options, this.loggerFactory); - AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, connectionName, this.logger); + AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, resolvedName, this.logger); - // Extract max concurrent values from parsed metadata first, fallback to DI options - provider.MaxConcurrentTaskOrchestrationWorkItems = parsedMetadata?.MaxConcurrentOrchestratorFunctions - ?? this.options.MaxConcurrentOrchestratorFunctions - ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = parsedMetadata?.MaxConcurrentActivityFunctions - ?? this.options.MaxConcurrentActivityFunctions - ?? 10; + // Extract max concurrent values from trigger options (already built from metadata) + provider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; this.cachedProviders.Add(cacheKey, provider); return provider; - } - } - - private static string ExtractConnectionName(TriggerMetadata triggerMetadata) - { - if (triggerMetadata?.Metadata == null) - { - return null; - } - - var storageProvider = triggerMetadata.Metadata["storageProvider"]; - if (storageProvider != null) - { - var storageProviderObj = storageProvider.ToObject>(); - if (storageProviderObj != null) - { - // Try connectionName first, then connectionStringName (legacy alias) - if (storageProviderObj.TryGetValue("connectionName", out object connName) && connName is string connNameStr && !string.IsNullOrWhiteSpace(connNameStr)) - { - return connNameStr; - } - - if (storageProviderObj.TryGetValue("connectionStringName", out object connStrName) && connStrName is string connStrNameStr && !string.IsNullOrWhiteSpace(connStrNameStr)) - { - return connStrNameStr; - } - } - } - - return null; - } - - private static string ResolveConnectionName(IDictionary storageProvider) + } + } + + /// + /// Attempts to extract a connection name from the storage provider dictionary. + /// + /// The storage provider configuration dictionary. + /// The connection name if found; otherwise, . + private static string? ResolveConnectionName(IDictionary? storageProvider) { if (storageProvider == null) { return null; } - if (storageProvider.TryGetValue("connectionName", out object v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + // Try "connectionName" first + if (storageProvider.TryGetValue("connectionName", out object? v1) + && v1 is string s1 + && !string.IsNullOrWhiteSpace(s1)) { return s1; } - if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + // Try "connectionStringName" (legacy alias) + if (storageProvider.TryGetValue("connectionStringName", out object? v2) + && v2 is string s2 + && !string.IsNullOrWhiteSpace(s2)) { return s2; } return null; } - - private static DurableTaskMetadata ExtractParsedMetadata(TriggerMetadata triggerMetadata) - { - if (triggerMetadata?.Properties == null) - { - return null; - } - - // The DurableTaskTriggersScaleProvider pre-parses the metadata and stores it in Properties - if (triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object metadataObj) - && metadataObj is DurableTaskMetadata metadata) - { - return metadata; - } - - return null; - } - } -} - + } + } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs index 384d428f9..438c29210 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs @@ -13,7 +13,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage { /// - /// The Azure Storage implementation of additional methods not required by IOrchestrationService. + /// Azure Storage backend implementation of the scalability provider for Durable Functions scaling decisions. /// public class AzureStorageScalabilityProvider : ScalabilityProvider { @@ -61,8 +61,6 @@ public override bool TryGetScaleMonitor( { if (this.singletonDurableTaskMetricsProvider == null) { - // This is only called by the ScaleController, it doesn't run in the Functions Host process. - // Use the StorageAccountClientProvider that was created with the credential in the factory this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( hubName, this.storageAccountClientProvider, @@ -86,7 +84,7 @@ public override bool TryGetTargetScaler( if (this.singletonDurableTaskMetricsProvider == null) { // This is only called by the ScaleController, it doesn't run in the Functions Host process. - // Use the StorageAccountClientProvider that was created with the credential in the factory + // Use the StorageAccountClientProvider that was created with the credential in the actory this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( hubName, this.storageAccountClientProvider, diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index cf2458644..9ddc5ff5a 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; using DurableTask.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; @@ -11,6 +12,9 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage { + /// + /// Factory class responsible for creating instances. + /// public class AzureStorageScalabilityProviderFactory : IScalabilityProviderFactory { private const string LoggerName = "Host.Triggers.DurableTask.AzureStorage"; @@ -36,58 +40,52 @@ public AzureStorageScalabilityProviderFactory( INameResolver nameResolver, ILoggerFactory loggerFactory) { - // Validate arguments first - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } + this.clientProviderFactory = clientProviderFactory ?? throw new ArgumentNullException(nameof(clientProviderFactory)); + this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - if (clientProviderFactory == null) - { - throw new ArgumentNullException(nameof(clientProviderFactory)); - } + var optionsValue = options?.Value ?? throw new ArgumentNullException(nameof(options)); - if (nameResolver == null) - { - throw new ArgumentNullException(nameof(nameResolver)); - } - - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - // this constructor may be called by dependency injection even if the AzureStorage provider is not selected - // in that case, return immediately, since this provider is not actually used, but can still throw validation errors - if (options.Value.StorageProvider != null - && options.Value.StorageProvider.TryGetValue("type", out object value) + // Early return if a different backend is explicitly configured (e.g., "azureManaged" or "mssql") + // If StorageProvider is null or doesn't specify "type", we continue (Azure Storage is the default) + if (optionsValue.StorageProvider != null + && optionsValue.StorageProvider.TryGetValue("type", out object value) && value is string s && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) { return; } - this.options = options.Value; - this.clientProviderFactory = clientProviderFactory; - this.nameResolver = nameResolver; - this.loggerFactory = loggerFactory; + this.options = optionsValue; // Resolve default connection name directly from payload keys or fall back - this.DefaultConnectionName = ResolveConnectionName(options.Value.StorageProvider) ?? ConnectionStringNames.Storage; + this.DefaultConnectionName = ResolveConnectionName(optionsValue.StorageProvider) ?? ConnectionStringNames.Storage; } + /// + /// Name of this provider service. + /// public virtual string Name => ProviderName; + /// + /// Default connection name of this provider service. + /// public string DefaultConnectionName { get; } - public virtual ScalabilityProvider GetDurabilityProvider() + /// + /// Creates and caches a default instanceusing Azure Storage as the backend. + /// + /// + /// A singleton instance of . + /// + public virtual ScalabilityProvider GetScalabilityProvider() { if (this.defaultStorageProvider == null) { ILogger logger = this.loggerFactory.CreateLogger(LoggerName); // Validate Azure Storage specific options - this.ValidateAzureStorageOptions(logger); + this.ValidateAzureStorageOptions(); // Create StorageAccountClientProvider without credential (connection string) var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider( @@ -107,18 +105,22 @@ public virtual ScalabilityProvider GetDurabilityProvider() return this.defaultStorageProvider; } - public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) + /// + /// Creates and caches a default instanceusing Azure Storage as the backend using + /// connection and credential information extracted from the given . + /// + /// + /// A singleton instance of . + /// + public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata) { ILogger logger = this.loggerFactory.CreateLogger(LoggerName); // Validate Azure Storage specific options - this.ValidateAzureStorageOptions(logger); - - // Get the pre-parsed metadata from triggerMetadata.Properties (parsed by DurableTaskTriggersScaleProvider) - DurableTaskMetadata parsedMetadata = ExtractParsedMetadata(triggerMetadata); + this.ValidateAzureStorageOptions(); // Extract TokenCredential from triggerMetadata if present (for Managed Identity) - var tokenCredential = ExtractTokenCredential(triggerMetadata); + var tokenCredential = ExtractTokenCredential(triggerMetadata, logger); // Use the connection name that was already resolved in the constructor // this.DefaultConnectionName was set via ResolveConnectionName(options.Value.StorageProvider) @@ -131,18 +133,15 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata this.DefaultConnectionName, logger); - // Extract max concurrent values from parsed metadata first, fallback to DI options - provider.MaxConcurrentTaskOrchestrationWorkItems = parsedMetadata?.MaxConcurrentOrchestratorFunctions - ?? this.options.MaxConcurrentOrchestratorFunctions - ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = parsedMetadata?.MaxConcurrentActivityFunctions - ?? this.options.MaxConcurrentActivityFunctions - ?? 10; + // Extract max concurrent values from trigger options (already built from metadata) + provider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; return provider; } - private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata) + // Scale Controller will return a AzureComponentWrapper which might contain a token crednetial to use. + private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata, ILogger logger) { if (triggerMetadata?.Properties == null) { @@ -168,9 +167,9 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata return tokenCredential; } } - catch (Exception) + catch (Exception ex) { - // Failed to extract credential, return null + logger?.LogWarning(ex, "Failed to extract TokenCredential from AzureComponentFactory. Using null credential instead."); return null; } } @@ -179,7 +178,6 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata return null; } - private static string ResolveConnectionName(System.Collections.Generic.IDictionary storageProvider) { if (storageProvider == null) @@ -191,6 +189,7 @@ private static string ResolveConnectionName(System.Collections.Generic.IDictiona { return s1; } + if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { return s2; @@ -202,7 +201,7 @@ private static string ResolveConnectionName(System.Collections.Generic.IDictiona /// /// Validates Azure Storage specific options. /// - private void ValidateAzureStorageOptions(ILogger logger) + private void ValidateAzureStorageOptions() { const int MinTaskHubNameSize = 3; const int MaxTaskHubNameSize = 50; @@ -242,22 +241,5 @@ private void ValidateAzureStorageOptions(ILogger logger) throw new System.InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); } } - - private static DurableTaskMetadata ExtractParsedMetadata(TriggerMetadata triggerMetadata) - { - if (triggerMetadata?.Properties == null) - { - return null; - } - - // The DurableTaskTriggersScaleProvider pre-parses the metadata and stores it in Properties - if (triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object metadataObj) - && metadataObj is DurableTaskMetadata metadata) - { - return metadata; - } - - return null; - } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs new file mode 100644 index 000000000..1f2209877 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Represents the Durable Task configuration sent by the Scale Controller in the SyncTriggers payload. + /// This is deserialized from triggerMetadata.Metadata and passed to factories via triggerMetadata.Properties. + /// + public class DurableTaskMetadata + { + /// + /// Gets or sets the name of the Durable Task Hub used by the function app. + /// + [JsonPropertyName("taskHubName")] + public string? TaskHubName { get; set; } + + /// + /// Gets or sets the maximum number of concurrent orchestrator. + /// + [JsonPropertyName("maxConcurrentOrchestratorFunctions")] + public int? MaxConcurrentOrchestratorFunctions { get; set; } + + /// + /// Gets or sets the maximum number of concurrent activity. + /// + [JsonPropertyName("maxConcurrentActivityFunctions")] + public int? MaxConcurrentActivityFunctions { get; set; } + + /// + /// Gets or sets the storage provider configuration dictionary, typically containing connection and provider-specific options. + /// + [JsonPropertyName("storageProvider")] + public IDictionary? StorageProvider { get; set; } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs index 962a6b8b9..e1c2c20c7 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs @@ -1,4 +1,3 @@ - using System; using System.Collections.Generic; using System.Linq; @@ -44,7 +43,7 @@ public DurableTaskScaleExtension( // Create a default scalability provider instance from the selected factory. // ? what is default do, if there is no sitemetada or conenction name how to we create? - this.defaultscalabilityProvider = this.scalabilityProviderFactory.GetDurabilityProvider(); + this.defaultscalabilityProvider = this.scalabilityProviderFactory.GetScalabilityProvider(); } /// diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index 55306fbcb..64931223a 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -4,11 +4,7 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -17,8 +13,6 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITargetScalerProvider { - private const string AzureManagedProviderName = "azureManaged"; - private readonly IScaleMonitor monitor; private readonly ITargetScaler targetScaler; @@ -37,103 +31,74 @@ public DurableTaskTriggersScaleProvider( var metadata = triggerMetadata.Metadata.ToObject() ?? throw new InvalidOperationException($"Failed to deserialize trigger metadata. Payload: {triggerMetadata.Metadata}"); - // Validate required fields - string hubName = metadata.TaskHubName - ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none. Payload: {metadata.TaskHubName}"); - - // Store the parsed metadata in Properties so factories can use it (avoid re-parsing) - // TriggerMetadata.Properties is read-only but the dictionary itself is mutable - if (triggerMetadata.Properties != null) - { - triggerMetadata.Properties["DurableTaskMetadata"] = metadata; - } - - // Build options from triggerMetadata for factory selection + // Build options from triggerMetadata, with fallback to DI options (from host.json) var options = new DurableTaskScaleOptions { - HubName = hubName, - MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions, - MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions, - StorageProvider = metadata.StorageProvider ?? new Dictionary() + HubName = metadata.TaskHubName ?? durableTaskScaleOptions.Value?.HubName + ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload or host configuration but found none."), + MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions ?? durableTaskScaleOptions.Value?.MaxConcurrentActivityFunctions, + MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions ?? durableTaskScaleOptions.Value?.MaxConcurrentOrchestratorFunctions, + StorageProvider = metadata.StorageProvider ?? durableTaskScaleOptions.Value?.StorageProvider ?? new Dictionary(), }; // Resolve app settings (e.g., %MyConnectionString% -> actual value) DurableTaskScaleOptions.ResolveAppSettingOptions(options, nameResolver); + // Store the parsed options in Properties so factories can use them (avoid re-parsing) + // TriggerMetadata.Properties is read-only but the dictionary itself is mutable + if (triggerMetadata.Properties != null) + { + triggerMetadata.Properties["DurableTaskScaleOptions"] = options; + } + var logger = loggerFactory.CreateLogger(); IScalabilityProviderFactory scalabilityProviderFactory = DurableTaskScaleExtension.GetScalabilityProviderFactory( options, logger, scalabilityProviderFactories); // Always use the triggerMetadata overload for scale scenarios // The factory will extract the parsed DurableTaskMetadata and TokenCredential if present - ScalabilityProvider defaultscalabilityProvider = scalabilityProviderFactory.GetDurabilityProvider(triggerMetadata); + ScalabilityProvider defaultscalabilityProvider = scalabilityProviderFactory.GetScalabilityProvider(triggerMetadata); - // Get connection name from options (already extracted from metadata) - string? connectionName = GetConnectionName(scalabilityProviderFactory, options); - - // Check if using managed identity (for logging) - bool usesManagedIdentity = triggerMetadata.Properties != null && - triggerMetadata.Properties.ContainsKey("AzureComponentFactory"); + // Get connection name (options.StorageProvider already has fallback to DI options built in) + string? connectionName = GetConnectionNameFromOptions(options.StorageProvider) ?? scalabilityProviderFactory.DefaultConnectionName; logger.LogInformation( - "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}', usesManagedIdentity = '{UsesMI}'", + "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}'", triggerMetadata.FunctionName, - connectionName, - usesManagedIdentity); + connectionName); this.targetScaler = ScaleUtils.GetTargetScaler( defaultscalabilityProvider, functionId, functionName, connectionName, - hubName); + options.HubName); this.monitor = ScaleUtils.GetScaleMonitor( defaultscalabilityProvider, functionId, functionName, connectionName, - hubName); + options.HubName); } - private static string? GetConnectionName(IScalabilityProviderFactory scalabilityProviderFactory, DurableTaskScaleOptions options) + private static string? GetConnectionNameFromOptions(IDictionary? storageProvider) { - if (scalabilityProviderFactory is AzureStorageScalabilityProviderFactory azureStorageScalabilityProviderFactory) + if (storageProvider == null) + { + return null; + } + + // Try connectionName first + if (storageProvider.TryGetValue("connectionName", out object value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) { - if (options != null && options.StorageProvider != null) - { - if (options.StorageProvider.TryGetValue("connectionName", out object value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) - { - return s1; - } - - // legacy alias often used in payloads - if (options.StorageProvider.TryGetValue("connectionStringName", out object value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) - { - return s2; - } - } - - return azureStorageScalabilityProviderFactory.DefaultConnectionName; + return s1; } - if (scalabilityProviderFactory is AzureManagedScalabilityProviderFactory azureManagedScalabilityProviderFactory) + // Try connectionStringName + if (storageProvider.TryGetValue("connectionStringName", out object value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { - if (options != null && options.StorageProvider != null) - { - if (options.StorageProvider.TryGetValue("connectionName", out object value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) - { - return s1; - } - - // legacy alias often used in payloads - if (options.StorageProvider.TryGetValue("connectionStringName", out object value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) - { - return s2; - } - } - - return azureManagedScalabilityProviderFactory.DefaultConnectionName; + return s2; } return null; @@ -149,23 +114,4 @@ public ITargetScaler GetTargetScaler() return this.targetScaler; } } - - /// - /// Represents the Durable Task configuration sent by the Scale Controller in the SyncTriggers payload. - /// This is deserialized from triggerMetadata.Metadata and passed to factories via triggerMetadata.Properties. - /// - public class DurableTaskMetadata - { - [JsonPropertyName("taskHubName")] - public string? TaskHubName { get; set; } - - [JsonPropertyName("maxConcurrentOrchestratorFunctions")] - public int? MaxConcurrentOrchestratorFunctions { get; set; } - - [JsonPropertyName("maxConcurrentActivityFunctions")] - public int? MaxConcurrentActivityFunctions { get; set; } - - [JsonPropertyName("storageProvider")] - public IDictionary? StorageProvider { get; set; } - } } \ No newline at end of file diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs index 816b8f8c7..b3da9b63e 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs @@ -16,16 +16,21 @@ public interface IScalabilityProviderFactory string Name { get; } /// - /// Creates or retrieves a durability provider to be used throughout the extension. + /// Gets the default connection name for this backend provider. /// - /// An durability provider to be used by the Durable Task Extension. - ScalabilityProvider GetDurabilityProvider(); + string DefaultConnectionName { get; } /// - /// Creates or retrieves a cached durability provider to be used in a given function execution. + /// Creates or retrieves a scalability provider to be used throughout the extension. + /// + /// A scalability provider to be used by the Durable Task Extension. + ScalabilityProvider GetScalabilityProvider(); + + /// + /// Creates or retrieves a cached scalability provider to be used in a given function execution. /// /// Trigger metadata used to create IOrchestrationService for functions scale scenarios. - /// A durability provider to be used by a client function. - ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata); + /// A scalability provider to be used by a client function. + ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata); } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs index 976b2dd3e..b8a83b558 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs @@ -10,7 +10,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { /// - /// Defines methods for retrieving service client providers based on the connection name. + /// Defines methods for retrieving Azure Storage backend service client providers based on the connection name. /// public interface IStorageServiceClientProviderFactory { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs index 898f75605..5863c8d4c 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/ScalabilityProvider.cs @@ -2,16 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using DurableTask.Core; -using DurableTask.Core.Entities; -using DurableTask.Core.History; -using DurableTask.Core.Query; using Microsoft.Azure.WebJobs.Host.Scale; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs index 2a2aeaeb7..1b182ccf5 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs @@ -94,6 +94,3 @@ public override bool TryGetTargetScaler( } } } - - - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index af338b69a..56d8d6259 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -3,14 +3,11 @@ using System; using System.Collections.Generic; -using Azure.Core; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Host.Scale; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql { @@ -54,7 +51,7 @@ public SqlServerScalabilityProviderFactory( public string DefaultConnectionName { get; } - public virtual ScalabilityProvider GetDurabilityProvider() + public virtual ScalabilityProvider GetScalabilityProvider() { if (this.defaultSqlProvider == null) { @@ -68,52 +65,35 @@ public virtual ScalabilityProvider GetDurabilityProvider() var sqlOrchestrationService = this.CreateSqlOrchestrationService( this.DefaultConnectionName, this.options.HubName ?? "default", - tokenCredential: null, logger); this.defaultSqlProvider = new SqlServerScalabilityProvider( sqlOrchestrationService, this.DefaultConnectionName, logger); - - // Set the max concurrent values from options (if needed by SQL Server) - // Note: SQL Server uses MaxActiveOrchestrations and MaxConcurrentActivities in settings - // These are set when creating SqlOrchestrationServiceSettings } return this.defaultSqlProvider; } - public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata) + public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata) { ILogger logger = this.loggerFactory.CreateLogger(LoggerName); // Validate SQL Server specific options this.ValidateSqlServerOptions(logger); - // Extract TokenCredential from triggerMetadata if present (for Managed Identity) - var tokenCredential = ExtractTokenCredential(triggerMetadata); - - // Get the pre-parsed metadata from triggerMetadata.Properties (parsed by DurableTaskTriggersScaleProvider) - DurableTaskMetadata parsedMetadata = ExtractParsedMetadata(triggerMetadata); - - // Check if trigger metadata specifies a different connection name, otherwise use default from constructor - string connectionName = ExtractConnectionName(triggerMetadata) ?? this.DefaultConnectionName; - - // Extract task hub name from parsed metadata first, fallback to DI options - string taskHubName = parsedMetadata?.TaskHubName - ?? this.options.HubName - ?? "default"; + // Extract task hub name from trigger options (already built from metadata) + string taskHubName = this.options.HubName ?? "default"; var sqlOrchestrationService = this.CreateSqlOrchestrationService( - connectionName, + this.DefaultConnectionName, taskHubName, - tokenCredential, logger); var provider = new SqlServerScalabilityProvider( sqlOrchestrationService, - connectionName, + this.DefaultConnectionName, logger); return provider; @@ -122,16 +102,15 @@ public ScalabilityProvider GetDurabilityProvider(TriggerMetadata triggerMetadata private SqlOrchestrationService CreateSqlOrchestrationService( string connectionName, string taskHubName, - global::Azure.Core.TokenCredential tokenCredential, ILogger logger) { // Resolve connection name first (handles %% wrapping) string resolvedConnectionName = this.nameResolver.Resolve(connectionName); - + // Try to get connection string from configuration (app settings) string connectionString = this.configuration.GetConnectionString(resolvedConnectionName) ?? this.configuration[resolvedConnectionName]; - + // Fallback to environment variable (matching old implementation behavior) if (string.IsNullOrEmpty(connectionString)) { @@ -144,106 +123,25 @@ private SqlOrchestrationService CreateSqlOrchestrationService( $"No SQL connection string configuration was found for the app setting or environment variable named '{resolvedConnectionName}'."); } - // Validate the connection string - try - { - new SqlConnectionStringBuilder(connectionString); - } - catch (ArgumentException e) - { - throw new ArgumentException("The provided connection string is invalid.", e); - } - // Create SQL Server orchestration service settings - following durabletask-mssql pattern // Connection string should include authentication method (e.g., Authentication=Active Directory Default) var settings = new SqlOrchestrationServiceSettings( connectionString, - taskHubName, - schemaName: null) // Schema name can be configured from storageProvider if needed + taskHubName) { // Set concurrency limits if provided MaxActiveOrchestrations = this.options.MaxConcurrentOrchestratorFunctions ?? 10, MaxConcurrentActivities = this.options.MaxConcurrentActivityFunctions ?? 10, }; - // Note: When connection string includes "Authentication=Active Directory Default" or + // Note: When connection string includes "Authentication=Active Directory Default" or // "Authentication=Active Directory Managed Identity", SQL Server will automatically use // the appropriate Azure identity (managed identity in Azure, or DefaultAzureCredential locally). - // The tokenCredential from Scale Controller is primarily for Azure Storage; SQL Server - // manages its own token acquisition through the connection string's Authentication setting. + // So we don't need to exctract token crednetial here from sitemetada. - // Create and return the orchestration service return new SqlOrchestrationService(settings); } - // Note: ExtractTokenCredential is kept for potential future use, but SQL Server handles - // its own authentication through the connection string (Authentication=Active Directory Default) - private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata) - { - if (triggerMetadata?.Properties == null) - { - return null; - } - - // Check if metadata contains an AzureComponentFactory wrapper - // ScaleController passes it as: metadata.Properties[nameof(AzureComponentFactory)] = new AzureComponentFactoryWrapper(...) - if (triggerMetadata.Properties.TryGetValue("AzureComponentFactory", out object componentFactoryObj) && componentFactoryObj != null) - { - // The AzureComponentFactoryWrapper has CreateTokenCredential method - // Call it using reflection to get the TokenCredential - var factoryType = componentFactoryObj.GetType(); - var method = factoryType.GetMethod("CreateTokenCredential"); - if (method != null) - { - try - { - // Call CreateTokenCredential(null) to get the TokenCredential from the wrapper - var credential = method.Invoke(componentFactoryObj, new object[] { null }); - if (credential is global::Azure.Core.TokenCredential tokenCredential) - { - return tokenCredential; - } - } - catch (Exception) - { - // Failed to extract credential, return null - return null; - } - } - } - - return null; - } - - private static string ExtractConnectionName(TriggerMetadata triggerMetadata) - { - if (triggerMetadata?.Metadata == null) - { - return null; - } - - var storageProvider = triggerMetadata.Metadata["storageProvider"]; - if (storageProvider != null) - { - var storageProviderObj = storageProvider.ToObject>(); - if (storageProviderObj != null) - { - // Try connectionName first, then connectionStringName (legacy alias) - if (storageProviderObj.TryGetValue("connectionName", out object connName) && connName is string connNameStr && !string.IsNullOrWhiteSpace(connNameStr)) - { - return connNameStr; - } - - if (storageProviderObj.TryGetValue("connectionStringName", out object connStrName) && connStrName is string connStrNameStr && !string.IsNullOrWhiteSpace(connStrNameStr)) - { - return connStrNameStr; - } - } - } - - return null; - } - private static string ResolveConnectionName(IDictionary storageProvider) { if (storageProvider == null) @@ -255,6 +153,7 @@ private static string ResolveConnectionName(IDictionary storageP { return s1; } + if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { return s2; @@ -287,22 +186,5 @@ private void ValidateSqlServerOptions(ILogger logger) throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); } } - - private static DurableTaskMetadata ExtractParsedMetadata(TriggerMetadata triggerMetadata) - { - if (triggerMetadata?.Properties == null) - { - return null; - } - - // The DurableTaskTriggersScaleProvider pre-parses the metadata and stores it in Properties - if (triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object metadataObj) - && metadataObj is DurableTaskMetadata metadata) - { - return metadata; - } - - return null; - } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs index 5028daa9f..06945a131 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using Azure; using Azure.Core; using DurableTask.AzureStorage; using Microsoft.Extensions.Configuration; @@ -38,8 +37,7 @@ public StorageAccountClientProvider GetClientProvider(string connectionName, Tok // No TokenCredential - use connection string if (tokenCredential == null) { - var connectionString = this.configuration.GetConnectionString(connectionName) - ?? this.configuration[connectionName]; + var connectionString = this.configuration.GetConnectionString(connectionName) ?? this.configuration[connectionName]; if (!string.IsNullOrEmpty(connectionString)) { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj index 22d53802d..b65d4bc03 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj +++ b/src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj @@ -4,8 +4,8 @@ net8.0 Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale - 3 - 6 + 1 + 0 0 $(MajorVersion).$(MinorVersion).$(PatchVersion) $(MajorVersion).$(MinorVersion).$(PatchVersion) @@ -40,16 +40,12 @@ - - - - From 9867a99ea83d06481096a892cadb659c7c71a589 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Tue, 4 Nov 2025 22:20:59 -0800 Subject: [PATCH 05/25] update --- ...ureManagedScalabilityProviderExtensions.cs | 21 ++++++++++++++++++ ....cs => AzureStorageScalabilityProvider.cs} | 4 ---- .../AzureStorageScalabilityProviderFactory.cs | 3 --- .../SqlServerScalabilityProviderExtensions.cs | 22 +++++++++++++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderExtensions.cs rename src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/{AzureStorageScalabilityProvider .cs => AzureStorageScalabilityProvider.cs} (96%) create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderExtensions.cs diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderExtensions.cs new file mode 100644 index 000000000..e6975d1f3 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged +{ + /// + /// Extension methods for configuring the Azure Managed Durable Task backend. + /// + public static class AzureManagedScalabilityProviderExtensions + { + /// + /// Registers the Azure Managed Durable Task backend with the dependency injection container. + /// + public static void AddDurableTaskManagedBackend(this IServiceCollection services) + { + services.AddSingleton(); + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs similarity index 96% rename from src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs rename to src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs index 438c29210..600b7ff8d 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider .cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs @@ -5,10 +5,6 @@ using DurableTask.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index 9ddc5ff5a..d1010a9a8 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -3,9 +3,6 @@ using System; using System.Linq; -using System.Runtime.CompilerServices; -using System.Text.Json; -using DurableTask.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderExtensions.cs new file mode 100644 index 000000000..1d24b1d0e --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql +{ + /// + /// Extension methods for the Microsoft SQL Durable Task storage scale provider. + /// + public static class SqlServerScalabilityProviderExtensions + { + /// + /// Adds Durable Task SQL storage provider services to the specified . + /// + /// The for adding services. + public static void AddDurableTaskSqlProvider(this IServiceCollection services) + { + services.AddSingleton(); + } + } +} From af5545f3f79da223e27bd8f55eaec6d269ccc628 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Wed, 5 Nov 2025 13:51:20 -0800 Subject: [PATCH 06/25] udpate options config to use metadata instead of di options --- .../AzureManagedScalabilityProviderFactory.cs | 70 +++++------------ .../AzureStorageScalabilityProviderFactory.cs | 57 +++++--------- .../AzureStorage/ConnectionStringNames.cs | 16 ---- ...rableTaskJobHostConfigurationExtensions.cs | 7 ++ .../DurableTaskTriggersScaleProvider.cs | 10 +-- .../SqlServerScalabilityProviderFactory.cs | 52 ++++++------- .../Sql/SqlServerScaleMetric.cs | 1 + .../Sql/SqlServerTargetScaler.cs | 1 + .../TriggerMetadataExtensions.cs | 76 +++++++++++++++++++ ...eManagedScalabilityProviderFactoryTests.cs | 72 ++++++++++-------- ...eStorageScalabilityProviderFactoryTests.cs | 57 +++++++------- ...qlServerScalabilityProviderFactoryTests.cs | 42 +++++----- test/ScaleTests/TestLoggerProvider.cs | 1 + test/ScaleTests/xunit.runner.json | 1 + 14 files changed, 242 insertions(+), 221 deletions(-) delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs create mode 100644 src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs index fecfe4f94..598848a14 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -21,7 +21,6 @@ public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactor { private const string LoggerName = "Host.Triggers.DurableTask.AzureManaged"; internal const string ProviderName = "AzureManaged"; - private const string DefaultConnectionStringName = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; private readonly Dictionary<(string, string?, string?), AzureManagedScalabilityProvider> cachedProviders = new Dictionary<(string, string?, string?), AzureManagedScalabilityProvider>(); private readonly DurableTaskScaleOptions options; @@ -60,22 +59,11 @@ public AzureManagedScalabilityProviderFactory( this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.logger = this.loggerFactory.CreateLogger(LoggerName); - // Early return if a different backend is explicitly configured - if (optionsValue.StorageProvider != null - && optionsValue.StorageProvider.TryGetValue("type", out object value) - && value is string s - && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) - { - return; - } - + // In Scale Controller context, optionsValue will be a default/empty object (can't read host.json) + // The real configuration comes from triggerMetadata in GetScalabilityProvider() this.options = optionsValue; - // Resolve connection name from options, falling back to default - // The nameResolver handles %EnvironmentVariable% patterns - string? rawConnectionName = ResolveConnectionName(optionsValue.StorageProvider); - this.DefaultConnectionName = rawConnectionName != null ? this.nameResolver.Resolve(rawConnectionName) - : DefaultConnectionStringName; + this.DefaultConnectionName = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; } /// @@ -112,8 +100,15 @@ public virtual ScalabilityProvider GetScalabilityProvider() /// public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetadata) { - // Use the default connection name that was already resolved in constructor - string resolvedName = this.DefaultConnectionName; + // Extract options from triggerMetadata (sent by Functions Host in SyncTriggers payload) + // This is critical for Scale Controller which doesn't have access to host.json + DurableTaskScaleOptions? triggerOptions = triggerMetadata.ExtractDurableTaskScaleOptions(); + + // Resolve connection name: prioritize triggerOptions, fallback to default + string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(triggerOptions?.StorageProvider); + string resolvedName = rawConnectionName != null + ? this.nameResolver.Resolve(rawConnectionName) + : this.DefaultConnectionName; // Try standard configuration sources string? connectionString = @@ -130,8 +125,8 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetada AzureManagedConnectionString azureManagedConnectionString = new AzureManagedConnectionString(connectionString); - // Extract task hub name from trigger options (already built from metadata), fallback to connection string - string taskHubName = this.options.HubName ?? azureManagedConnectionString.TaskHubName; + // Extract task hub name from trigger options (from Scale Controller payload) + string taskHubName = triggerOptions?.HubName ?? azureManagedConnectionString.TaskHubName; // Include client ID in cache key to handle managed identity changes (string, string?, string?) cacheKey = (resolvedName, taskHubName, azureManagedConnectionString.ClientId); @@ -230,44 +225,13 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetada AzureManagedOrchestrationService service = new AzureManagedOrchestrationService(options, this.loggerFactory); AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, resolvedName, this.logger); - // Extract max concurrent values from trigger options (already built from metadata) - provider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; + // Extract max concurrent values from trigger metadata (from Scale Controller payload), fallback to constructor options + provider.MaxConcurrentTaskOrchestrationWorkItems = triggerOptions?.MaxConcurrentOrchestratorFunctions ?? this.options.MaxConcurrentOrchestratorFunctions ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = triggerOptions?.MaxConcurrentActivityFunctions ?? this.options.MaxConcurrentActivityFunctions ?? 10; this.cachedProviders.Add(cacheKey, provider); return provider; } } - - /// - /// Attempts to extract a connection name from the storage provider dictionary. - /// - /// The storage provider configuration dictionary. - /// The connection name if found; otherwise, . - private static string? ResolveConnectionName(IDictionary? storageProvider) - { - if (storageProvider == null) - { - return null; - } - - // Try "connectionName" first - if (storageProvider.TryGetValue("connectionName", out object? v1) - && v1 is string s1 - && !string.IsNullOrWhiteSpace(s1)) - { - return s1; - } - - // Try "connectionStringName" (legacy alias) - if (storageProvider.TryGetValue("connectionStringName", out object? v2) - && v2 is string s2 - && !string.IsNullOrWhiteSpace(s2)) - { - return s2; - } - - return null; - } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index d1010a9a8..4e7673ae7 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; @@ -43,20 +44,12 @@ public AzureStorageScalabilityProviderFactory( var optionsValue = options?.Value ?? throw new ArgumentNullException(nameof(options)); - // Early return if a different backend is explicitly configured (e.g., "azureManaged" or "mssql") - // If StorageProvider is null or doesn't specify "type", we continue (Azure Storage is the default) - if (optionsValue.StorageProvider != null - && optionsValue.StorageProvider.TryGetValue("type", out object value) - && value is string s - && !string.Equals(s, this.Name, StringComparison.OrdinalIgnoreCase)) - { - return; - } - + // In Scale Controller context, optionsValue will be a default/empty object (can't read host.json) + // The real configuration comes from triggerMetadata in GetScalabilityProvider() this.options = optionsValue; // Resolve default connection name directly from payload keys or fall back - this.DefaultConnectionName = ResolveConnectionName(optionsValue.StorageProvider) ?? ConnectionStringNames.Storage; + this.DefaultConnectionName = "AzureWebJobsStorage"; } /// @@ -111,6 +104,10 @@ public virtual ScalabilityProvider GetScalabilityProvider() /// public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata) { + // Extract options from triggerMetadata (sent by Functions Host in SyncTriggers payload) + // This is critical for Scale Controller which doesn't have access to host.json + DurableTaskScaleOptions? triggerOptions = triggerMetadata.ExtractDurableTaskScaleOptions(); + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); // Validate Azure Storage specific options @@ -119,20 +116,24 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadat // Extract TokenCredential from triggerMetadata if present (for Managed Identity) var tokenCredential = ExtractTokenCredential(triggerMetadata, logger); - // Use the connection name that was already resolved in the constructor - // this.DefaultConnectionName was set via ResolveConnectionName(options.Value.StorageProvider) + // Resolve connection name: prioritize triggerOptions, fallback to default + string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(triggerOptions?.StorageProvider); + string connectionName = rawConnectionName != null + ? this.nameResolver.Resolve(rawConnectionName) + : this.DefaultConnectionName; + var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider( - this.DefaultConnectionName, + connectionName, tokenCredential); var provider = new AzureStorageScalabilityProvider( storageAccountClientProvider, - this.DefaultConnectionName, + connectionName, logger); - // Extract max concurrent values from trigger options (already built from metadata) - provider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; + // Extract max concurrent values from trigger metadata (from Scale Controller payload) + provider.MaxConcurrentTaskOrchestrationWorkItems = triggerOptions?.MaxConcurrentOrchestratorFunctions ?? this.options.MaxConcurrentOrchestratorFunctions ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = triggerOptions?.MaxConcurrentActivityFunctions ?? this.options.MaxConcurrentActivityFunctions ?? 10; return provider; } @@ -175,26 +176,6 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadat return null; } - private static string ResolveConnectionName(System.Collections.Generic.IDictionary storageProvider) - { - if (storageProvider == null) - { - return null; - } - - if (storageProvider.TryGetValue("connectionName", out object v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) - { - return s1; - } - - if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) - { - return s2; - } - - return null; - } - /// /// Validates Azure Storage specific options. /// diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs deleted file mode 100644 index f81282a72..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/ConnectionStringNames.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage -{ - /// - /// Well-known connection string names. - /// - internal static class ConnectionStringNames - { - /// - /// The default Azure Storage connection string name. - /// - public const string Storage = "AzureWebJobsStorage"; - } -} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs index af36fc854..03c94f9d6 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -40,7 +42,12 @@ public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) IServiceCollection serviceCollection = builder.Services; serviceCollection.TryAddSingleton(); + + // Register all scalability provider factories serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + return builder; } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index 64931223a..b3c235359 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Text.Json.Serialization; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -26,16 +25,17 @@ public DurableTaskTriggersScaleProvider( string functionId = triggerMetadata.FunctionName; var functionName = new FunctionName(functionId); - // Deserialize the configuration from triggerMetadata (sent by Scale Controller) - // This is the source of truth for scale scenarios + // Deserialize the configuration from triggerMetadata var metadata = triggerMetadata.Metadata.ToObject() ?? throw new InvalidOperationException($"Failed to deserialize trigger metadata. Payload: {triggerMetadata.Metadata}"); - // Build options from triggerMetadata, with fallback to DI options (from host.json) + // Build options from triggerMetadata with optional fallback to DI options + // NOTE: durableTaskScaleOptions.Value will be null/empty in Scale Controller context + // because Scale Controller doesn't have access to host.json var options = new DurableTaskScaleOptions { HubName = metadata.TaskHubName ?? durableTaskScaleOptions.Value?.HubName - ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload or host configuration but found none."), + ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none. "), MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions ?? durableTaskScaleOptions.Value?.MaxConcurrentActivityFunctions, MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions ?? durableTaskScaleOptions.Value?.MaxConcurrentOrchestratorFunctions, StorageProvider = metadata.StorageProvider ?? durableTaskScaleOptions.Value?.StorageProvider ?? new Dictionary(), diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index 56d8d6259..2d0260db9 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -44,7 +44,7 @@ public SqlServerScalabilityProviderFactory( this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - this.DefaultConnectionName = ResolveConnectionName(this.options.StorageProvider) ?? "SQLDB_Connection"; + this.DefaultConnectionName = "SQLDB_Connection"; } public virtual string Name => ProviderName; @@ -78,22 +78,33 @@ public virtual ScalabilityProvider GetScalabilityProvider() public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata) { + // Extract options from triggerMetadata (sent by Functions Host in SyncTriggers payload) + // This is critical for Scale Controller which doesn't have access to host.json + DurableTaskScaleOptions? triggerOptions = triggerMetadata.ExtractDurableTaskScaleOptions(); + ILogger logger = this.loggerFactory.CreateLogger(LoggerName); // Validate SQL Server specific options this.ValidateSqlServerOptions(logger); - // Extract task hub name from trigger options (already built from metadata) - string taskHubName = this.options.HubName ?? "default"; + // Resolve connection name: prioritize triggerOptions, fallback to default + string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(triggerOptions?.StorageProvider); + string connectionName = rawConnectionName != null + ? this.nameResolver.Resolve(rawConnectionName) + : this.DefaultConnectionName; + + // Extract task hub name from trigger metadata (from Scale Controller payload), fallback to constructor options + string taskHubName = triggerOptions?.HubName ?? this.options.HubName ?? "default"; var sqlOrchestrationService = this.CreateSqlOrchestrationService( - this.DefaultConnectionName, + connectionName, taskHubName, - logger); + logger, + triggerOptions); var provider = new SqlServerScalabilityProvider( sqlOrchestrationService, - this.DefaultConnectionName, + connectionName, logger); return provider; @@ -102,7 +113,8 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadat private SqlOrchestrationService CreateSqlOrchestrationService( string connectionName, string taskHubName, - ILogger logger) + ILogger logger, + DurableTaskScaleOptions triggerOptions = null) { // Resolve connection name first (handles %% wrapping) string resolvedConnectionName = this.nameResolver.Resolve(connectionName); @@ -129,9 +141,9 @@ private SqlOrchestrationService CreateSqlOrchestrationService( connectionString, taskHubName) { - // Set concurrency limits if provided - MaxActiveOrchestrations = this.options.MaxConcurrentOrchestratorFunctions ?? 10, - MaxConcurrentActivities = this.options.MaxConcurrentActivityFunctions ?? 10, + // Set concurrency limits from trigger metadata (from Scale Controller payload), fallback to constructor options + MaxActiveOrchestrations = triggerOptions?.MaxConcurrentOrchestratorFunctions ?? this.options.MaxConcurrentOrchestratorFunctions ?? 10, + MaxConcurrentActivities = triggerOptions?.MaxConcurrentActivityFunctions ?? this.options.MaxConcurrentActivityFunctions ?? 10, }; // Note: When connection string includes "Authentication=Active Directory Default" or @@ -142,26 +154,6 @@ private SqlOrchestrationService CreateSqlOrchestrationService( return new SqlOrchestrationService(settings); } - private static string ResolveConnectionName(IDictionary storageProvider) - { - if (storageProvider == null) - { - return null; - } - - if (storageProvider.TryGetValue("connectionName", out object v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) - { - return s1; - } - - if (storageProvider.TryGetValue("connectionStringName", out object v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) - { - return s2; - } - - return null; - } - /// /// Validates SQL Server specific options. /// diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs index d50684f43..6067e9668 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs @@ -20,3 +20,4 @@ public class SqlServerScaleMetric : ScaleMetrics + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs index ee1e0b937..d1840663b 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs @@ -37,3 +37,4 @@ public async Task GetScaleResultAsync(TargetScalerContext co + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs new file mode 100644 index 000000000..63ab49785 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using Microsoft.Azure.WebJobs.Host.Scale; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale +{ + /// + /// Extension methods for . + /// + internal static class TriggerMetadataExtensions + { + /// + /// Extracts DurableTaskScaleOptions from trigger metadata sent by the Scale Controller. + /// + /// The trigger metadata containing configuration from the Scale Controller. + /// The parsed options, or null if metadata is not available. + public static DurableTaskScaleOptions? ExtractDurableTaskScaleOptions(this TriggerMetadata? triggerMetadata) + { + if (triggerMetadata?.Metadata == null) + { + return null; + } + + try + { + // Parse the JSON metadata to extract configuration values + var metadata = triggerMetadata.Metadata.ToObject(); + if (metadata == null) + { + return null; + } + + return new DurableTaskScaleOptions + { + HubName = metadata.TaskHubName, + MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions, + MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions, + StorageProvider = metadata.StorageProvider, + }; + } + catch + { + // If parsing fails, return null and fall back to constructor options + return null; + } + } + + /// + /// Attempts to extract a connection name from the storage provider dictionary. + /// Checks both "connectionName" and "connectionStringName" keys for compatibility. + /// + /// The storage provider configuration dictionary. + /// The connection name if found; otherwise, . + public static string? ResolveConnectionName(IDictionary? storageProvider) + { + if (storageProvider == null) + { + return null; + } + + if (storageProvider.TryGetValue("connectionName", out object? v1) && v1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + { + return s1; + } + + if (storageProvider.TryGetValue("connectionStringName", out object? v2) && v2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + { + return s2; + } + + return null; + } + } +} \ No newline at end of file diff --git a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs index d42526627..138b78cff 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs @@ -70,7 +70,8 @@ public void Constructor_ValidParameters_CreatesInstance() // Assert Assert.NotNull(factory); Assert.Equal("AzureManaged", factory.Name); - Assert.Equal("v3-dtsConnectionMI", factory.DefaultConnectionName); + // DefaultConnectionName is now hardcoded, not from options + Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", factory.DefaultConnectionName); } /// @@ -118,7 +119,7 @@ public void Constructor_NullConfiguration_ThrowsArgumentNullException() /// This is the primary path used by Azure Functions Scale Controller for Azure Managed backend. /// [Fact] - public void GetDurabilityProvider_WithAzureManagedType_CreatesAzureManagedProvider() + public void GetScalabilityProvider_WithAzureManagedType_CreatesAzureManagedProvider() { // Arrange - Explicitly set storageProvider type to "azureManaged" var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); @@ -130,7 +131,7 @@ public void GetDurabilityProvider_WithAzureManagedType_CreatesAzureManagedProvid this.loggerFactory); // Act - var provider = factory.GetDurabilityProvider(); + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); @@ -147,7 +148,7 @@ public void GetDurabilityProvider_WithAzureManagedType_CreatesAzureManagedProvid /// Verifies provider has correct type, connection name, and concurrency settings. /// [Fact] - public void GetDurabilityProvider_ReturnsValidProvider() + public void GetScalabilityProvider_ReturnsValidProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); @@ -158,8 +159,8 @@ public void GetDurabilityProvider_ReturnsValidProvider() this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetDurabilityProvider(); + // Act - Without trigger metadata, uses hardcoded default + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); @@ -167,7 +168,8 @@ public void GetDurabilityProvider_ReturnsValidProvider() var azureProvider = (AzureManagedScalabilityProvider)provider; Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); - Assert.Equal("v3-dtsConnectionMI", azureProvider.ConnectionName); + // Connection name is now from hardcoded default, not from options + Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", azureProvider.ConnectionName); } /// @@ -179,7 +181,7 @@ public void GetDurabilityProvider_ReturnsValidProvider() /// This is the primary path used by Azure Functions Scale Controller. /// [Fact] - public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() + public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); @@ -192,16 +194,16 @@ public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "v3-dtsConnectionMI"); // Act - var provider = factory.GetDurabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert Assert.NotNull(provider); Assert.IsType(provider); var azureProvider = (AzureManagedScalabilityProvider)provider; Assert.Equal("v3-dtsConnectionMI", azureProvider.ConnectionName); - // Note: Uses options values (10, 20), not trigger metadata values (15, 25) - Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); - Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + // TriggerMetadata values (15, 25) now take priority over options (10, 20) + Assert.Equal(15, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(25, azureProvider.MaxConcurrentTaskActivityWorkItems); } /// @@ -212,7 +214,7 @@ public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() /// Azure Managed uses (connectionName, taskHubName, clientId) as cache key. /// [Fact] - public void GetDurabilityProvider_CachesProviderWithSameConnectionAndClientId() + public void GetScalabilityProvider_CachesProviderWithSameConnectionAndClientId() { // Arrange var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); @@ -223,8 +225,8 @@ public void GetDurabilityProvider_CachesProviderWithSameConnectionAndClientId() this.loggerFactory); // Act - Call twice with no trigger metadata (same cache key) - var provider1 = factory.GetDurabilityProvider(); - var provider2 = factory.GetDurabilityProvider(); + var provider1 = factory.GetScalabilityProvider(); + var provider2 = factory.GetScalabilityProvider(); // Assert - Should be the same cached instance Assert.Same(provider1, provider2); @@ -236,7 +238,7 @@ public void GetDurabilityProvider_CachesProviderWithSameConnectionAndClientId() /// Tests the default connection name pattern for Azure Managed backend. /// [Fact] - public void GetDurabilityProvider_WithDefaultConnectionName_CreatesProvider() + public void GetScalabilityProvider_WithDefaultConnectionName_CreatesProvider() { // Arrange - Don't specify connectionName in storageProvider var options = new DurableTaskScaleOptions @@ -257,7 +259,7 @@ public void GetDurabilityProvider_WithDefaultConnectionName_CreatesProvider() this.loggerFactory); // Act - var provider = factory.GetDurabilityProvider(); + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); @@ -271,7 +273,7 @@ public void GetDurabilityProvider_WithDefaultConnectionName_CreatesProvider() /// Ensures proper error messaging for configuration issues. /// [Fact] - public void GetDurabilityProvider_MissingConnectionString_ThrowsException() + public void GetScalabilityProvider_MissingConnectionString_CreatesProviderWithDefaultCredential() { // Arrange - Use connection name that doesn't exist in configuration var options = CreateOptions("testHub", 10, 20, "NonExistentConnection"); @@ -282,10 +284,13 @@ public void GetDurabilityProvider_MissingConnectionString_ThrowsException() this.nameResolver, this.loggerFactory); - // Act & Assert - var exception = Assert.Throws(() => factory.GetDurabilityProvider()); - Assert.Contains("No connection string configuration was found", exception.Message); - Assert.Contains("NonExistentConnection", exception.Message); + // Act - Without trigger metadata and without connection string in config, + // provider is created using DefaultAzureCredential + var provider = factory.GetScalabilityProvider(); + + // Assert - Provider is created successfully with hardcoded default connection name + Assert.NotNull(provider); + Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", provider.ConnectionName); } /// @@ -295,14 +300,15 @@ public void GetDurabilityProvider_MissingConnectionString_ThrowsException() /// Verifies end-to-end flow from configuration to Azure Managed connection. /// [Fact] - public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() + public void GetScalabilityProvider_RetrievesConnectionStringFromConfiguration() { // Arrange - Verify we can retrieve connection string from configuration var testConnectionString = "Endpoint=https://custom.westus.durabletask.io;Authentication=DefaultAzure"; var configBuilder = new ConfigurationBuilder(); configBuilder.AddInMemoryCollection(new Dictionary { - { "MyCustomConnection", testConnectionString } + // Use the hardcoded default connection name + { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", testConnectionString } }); var config = configBuilder.Build(); @@ -313,15 +319,15 @@ public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetDurabilityProvider(); + // Act - Without trigger metadata, uses hardcoded default + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); - Assert.Equal("MyCustomConnection", provider.ConnectionName); + Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", provider.ConnectionName); // Verify the connection string was retrieved from configuration - var retrievedConnectionString = config["MyCustomConnection"]; + var retrievedConnectionString = config["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"]; Assert.Equal(testConnectionString, retrievedConnectionString); } @@ -331,13 +337,14 @@ public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() /// Tests connection string parsing logic. /// [Fact] - public void GetDurabilityProvider_UsesTaskHubNameFromConnectionString() + public void GetScalabilityProvider_UsesTaskHubNameFromConnectionString() { // Arrange - Connection string with TaskHub specified var configBuilder = new ConfigurationBuilder(); configBuilder.AddInMemoryCollection(new Dictionary { - { "ConnectionWithHub", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure;TaskHub=MyTaskHub" } + // Use the hardcoded default connection name with TaskHub in connection string + { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure;TaskHub=MyTaskHub" } }); var config = configBuilder.Build(); @@ -349,7 +356,6 @@ public void GetDurabilityProvider_UsesTaskHubNameFromConnectionString() StorageProvider = new Dictionary { { "type", "azureManaged" }, - { "connectionName", "ConnectionWithHub" }, }, }; @@ -359,8 +365,8 @@ public void GetDurabilityProvider_UsesTaskHubNameFromConnectionString() this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetDurabilityProvider(); + // Act - Without trigger metadata, uses hardcoded default + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); diff --git a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs index 6a0a55fa1..09cc0926f 100644 --- a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs @@ -74,7 +74,8 @@ public void Constructor_ValidParameters_CreatesInstance() // Assert Assert.NotNull(factory); Assert.Equal("AzureStorage", factory.Name); - Assert.Equal("TestConnection", factory.DefaultConnectionName); + // DefaultConnectionName is now hardcoded, not from options + Assert.Equal("AzureWebJobsStorage", factory.DefaultConnectionName); } /// @@ -121,7 +122,7 @@ public void Constructor_NullClientProviderFactory_ThrowsArgumentNullException() /// Verifies provider has correct type, connection name, and concurrency settings. /// [Fact] - public void GetDurabilityProvider_ReturnsValidProvider() + public void GetScalabilityProvider_ReturnsValidProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "TestConnection"); @@ -133,7 +134,7 @@ public void GetDurabilityProvider_ReturnsValidProvider() this.loggerFactory); // Act - var provider = factory.GetDurabilityProvider(); + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); @@ -151,7 +152,7 @@ public void GetDurabilityProvider_ReturnsValidProvider() /// This is the primary path used by Azure Functions Scale Controller. /// [Fact] - public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() + public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "TestConnection"); @@ -164,15 +165,15 @@ public void GetDurabilityProvider_WithTriggerMetadata_ReturnsValidProvider() var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "TestConnection"); // Act - var provider = factory.GetDurabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert Assert.NotNull(provider); Assert.IsType(provider); var azureProvider = (AzureStorageScalabilityProvider)provider; - // Note: Uses options values (10, 20), not trigger metadata values (15, 25) - Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); - Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + // TriggerMetadata values (15, 25) now take priority over options (10, 20) + Assert.Equal(15, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); + Assert.Equal(25, azureProvider.MaxConcurrentTaskActivityWorkItems); } /// @@ -193,7 +194,7 @@ public void ValidateAzureStorageOptions_InvalidHubName_ThrowsArgumentException() this.loggerFactory); // Act & Assert - Assert.Throws(() => factory.GetDurabilityProvider()); + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -214,7 +215,7 @@ public void ValidateAzureStorageOptions_InvalidMaxConcurrent_ThrowsInvalidOperat this.loggerFactory); // Act & Assert - Assert.Throws(() => factory.GetDurabilityProvider()); + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -224,7 +225,7 @@ public void ValidateAzureStorageOptions_InvalidMaxConcurrent_ThrowsInvalidOperat /// Ensures consistent metrics collection across scale decisions. /// [Fact] - public void GetDurabilityProvider_CachesDefaultProvider() + public void GetScalabilityProvider_CachesDefaultProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "TestConnection"); @@ -235,8 +236,8 @@ public void GetDurabilityProvider_CachesDefaultProvider() this.loggerFactory); // Act - var provider1 = factory.GetDurabilityProvider(); - var provider2 = factory.GetDurabilityProvider(); + var provider1 = factory.GetScalabilityProvider(); + var provider2 = factory.GetScalabilityProvider(); // Assert Assert.Same(provider1, provider2); @@ -297,7 +298,7 @@ private static TriggerMetadata CreateTriggerMetadata( /// Confirms provider is created with correct concurrency limits. /// [Fact] - public void GetDurabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvider() + public void GetScalabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvider() { // Arrange - Using default AzureWebJobsStorage connection var options = CreateOptions("testHub", 10, 20, "AzureWebJobsStorage"); @@ -309,7 +310,7 @@ public void GetDurabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvider this.loggerFactory); // Act - var provider = factory.GetDurabilityProvider(); + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); @@ -327,9 +328,9 @@ public void GetDurabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvider /// Ensures isolation between different storage backends in the same application. /// [Fact] - public void GetDurabilityProvider_WithMultipleConnections_CreatesProvidersSuccessfully() + public void GetScalabilityProvider_WithMultipleConnections_CreatesProvidersSuccessfully() { - // Arrange - Test with multiple different connection names + // Arrange - Test with multiple different connection names via trigger metadata var connectionNames = new[] { "AzureWebJobsStorage", "TestConnection", "CustomConnection" }; // Add custom connection to configuration @@ -352,8 +353,11 @@ public void GetDurabilityProvider_WithMultipleConnections_CreatesProvidersSucces this.nameResolver, this.loggerFactory); + // Pass connection name via trigger metadata (Scale Controller behavior) + var triggerMetadata = CreateTriggerMetadata("testHub", 5, 10, connectionName); + // Act - var provider = factory.GetDurabilityProvider(); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert Assert.NotNull(provider); @@ -391,7 +395,7 @@ public void Factory_Name_IsAzureStorage() /// Ensures extensibility for future storage backend support (MSSQL, Netherite, etc.). /// [Fact] - public void GetDurabilityProvider_WithAzureStorageType_UsesCorrectProvider() + public void GetScalabilityProvider_WithAzureStorageType_UsesCorrectProvider() { // Arrange - Explicitly set storageProvider type to "AzureStorage" var options = new DurableTaskScaleOptions @@ -413,7 +417,7 @@ public void GetDurabilityProvider_WithAzureStorageType_UsesCorrectProvider() this.loggerFactory); // Act - var provider = factory.GetDurabilityProvider(); + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); @@ -429,14 +433,15 @@ public void GetDurabilityProvider_WithAzureStorageType_UsesCorrectProvider() /// Ensures custom connection names work with Azure Storage emulator. /// [Fact] - public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() + public void GetScalabilityProvider_RetrievesConnectionStringFromConfiguration() { // Arrange - Verify we can retrieve connection string from configuration var testConnectionString = TestHelpers.GetStorageConnectionString(); var configBuilder = new ConfigurationBuilder(); configBuilder.AddInMemoryCollection(new Dictionary { - { "MyCustomConnection", testConnectionString } + // Use the hardcoded default connection name + { "AzureWebJobsStorage", testConnectionString } }); var config = configBuilder.Build(); var clientFactory = new StorageServiceClientProviderFactory(config, this.loggerFactory); @@ -448,15 +453,15 @@ public void GetDurabilityProvider_RetrievesConnectionStringFromConfiguration() this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetDurabilityProvider(); + // Act - Without trigger metadata, uses hardcoded default "AzureWebJobsStorage" + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); - Assert.Equal("MyCustomConnection", provider.ConnectionName); + Assert.Equal("AzureWebJobsStorage", provider.ConnectionName); // Verify the connection string was retrieved from configuration - var retrievedConnectionString = config["MyCustomConnection"]; + var retrievedConnectionString = config["AzureWebJobsStorage"]; Assert.Equal(testConnectionString, retrievedConnectionString); } } diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs index b2e709791..81f04ef9b 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -76,7 +76,8 @@ public void Constructor_WithMssqlType_CreatesInstance() // Assert Assert.NotNull(factory); Assert.Equal("mssql", factory.Name); - Assert.Equal("TestConnection", factory.DefaultConnectionName); + // DefaultConnectionName is now hardcoded, not from options + Assert.Equal("SQLDB_Connection", factory.DefaultConnectionName); } /// @@ -129,7 +130,7 @@ public void Constructor_NullOptions_ThrowsArgumentNullException() /// This is the primary path used by Azure Functions Scale Controller for SQL Server. /// [Fact] - public void GetDurabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidProvider() + public void GetScalabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); @@ -142,7 +143,7 @@ public void GetDurabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidPr var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "TestConnection", "mssql"); // Act - var provider = factory.GetDurabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert Assert.NotNull(provider); @@ -157,7 +158,7 @@ public void GetDurabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidPr /// Verifies provider has correct type and connection name. /// [Fact] - public void GetDurabilityProvider_WithMssqlType_ReturnsValidProvider() + public void GetScalabilityProvider_WithMssqlType_ReturnsValidProvider() { // Arrange var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); @@ -167,13 +168,14 @@ public void GetDurabilityProvider_WithMssqlType_ReturnsValidProvider() this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetDurabilityProvider(); + // Act - Without trigger metadata, uses hardcoded default + var provider = factory.GetScalabilityProvider(); // Assert Assert.NotNull(provider); Assert.IsType(provider); - Assert.Equal("TestConnection", provider.ConnectionName); + // Connection name is now from hardcoded default, not from options + Assert.Equal("SQLDB_Connection", provider.ConnectionName); } /// @@ -183,9 +185,11 @@ public void GetDurabilityProvider_WithMssqlType_ReturnsValidProvider() /// Ensures proper configuration reading for SQL Server connections. /// [Fact] - public void GetDurabilityProvider_WithConnectionStringName_UsesCorrectConnection() + public void GetScalabilityProvider_WithConnectionStringName_UsesCorrectConnection() { - // Arrange - Use connectionStringName instead of connectionName + // Arrange - Pass connection name via trigger metadata (Scale Controller payload) + var triggerMetadata = CreateTriggerMetadata("testHub", 10, 20, "TestConnection", "mssql"); + var options = new DurableTaskScaleOptions { HubName = "testHub", @@ -194,10 +198,8 @@ public void GetDurabilityProvider_WithConnectionStringName_UsesCorrectConnection StorageProvider = new Dictionary { { "type", "mssql" }, - { "connectionStringName", "TestConnection" }, }, }; - DurableTaskScaleOptions.ResolveAppSettingOptions(options, this.nameResolver); var factory = new SqlServerScalabilityProviderFactory( Options.Create(options), @@ -205,8 +207,8 @@ public void GetDurabilityProvider_WithConnectionStringName_UsesCorrectConnection this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetDurabilityProvider(); + // Act - Connection name comes from triggerMetadata, not from options + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert Assert.NotNull(provider); @@ -231,7 +233,7 @@ public void ValidateSqlServerOptions_InvalidMaxConcurrent_ThrowsInvalidOperation this.loggerFactory); // Act & Assert - Assert.Throws(() => factory.GetDurabilityProvider()); + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -241,7 +243,7 @@ public void ValidateSqlServerOptions_InvalidMaxConcurrent_ThrowsInvalidOperation /// Ensures clear error messages guide users to configure connection strings. /// [Fact] - public void GetDurabilityProvider_MissingConnectionString_ThrowsInvalidOperationException() + public void GetScalabilityProvider_MissingConnectionString_ThrowsInvalidOperationException() { // Arrange - Configuration without SQL connection string var configBuilder = new ConfigurationBuilder(); @@ -256,7 +258,7 @@ public void GetDurabilityProvider_MissingConnectionString_ThrowsInvalidOperation this.loggerFactory); // Act & Assert - Assert.Throws(() => factory.GetDurabilityProvider()); + Assert.Throws(() => factory.GetScalabilityProvider()); } private static IOptions CreateOptions( @@ -338,7 +340,7 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProvider_AndTargetScale this.loggerFactory); // Act - Create provider from triggerMetadata - var provider = factory.GetDurabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert - Verify SQL provider was created Assert.NotNull(provider); @@ -423,7 +425,7 @@ public void TriggerMetadataWithMssqlType_RetrievesConnectionStringFromConfigurat this.loggerFactory); // Act - Create provider from triggerMetadata - var provider = factory.GetDurabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert - Verify provider was created Assert.NotNull(provider); @@ -444,7 +446,7 @@ public void TriggerMetadataWithMssqlType_RetrievesConnectionStringFromConfigurat /// This test simulates the Managed Identity flow used by Scale Controller. /// [Fact] - public void GetDurabilityProvider_WithTokenCredential_ExtractsAndUsesCredential() + public void GetScalabilityProvider_WithTokenCredential_ExtractsAndUsesCredential() { // Arrange - Create triggerMetadata with AzureComponentFactory in Properties (Managed Identity) var hubName = "testHub"; @@ -477,7 +479,7 @@ public void GetDurabilityProvider_WithTokenCredential_ExtractsAndUsesCredential( // Act - Create provider from triggerMetadata with TokenCredential // Note: In real scenarios, the TokenCredential would be extracted and used to build // a connection string with Authentication="Active Directory Default" - var provider = factory.GetDurabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(triggerMetadata); // Assert - Verify provider was created Assert.NotNull(provider); diff --git a/test/ScaleTests/TestLoggerProvider.cs b/test/ScaleTests/TestLoggerProvider.cs index 09011702a..43a8b06b6 100644 --- a/test/ScaleTests/TestLoggerProvider.cs +++ b/test/ScaleTests/TestLoggerProvider.cs @@ -85,3 +85,4 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except + diff --git a/test/ScaleTests/xunit.runner.json b/test/ScaleTests/xunit.runner.json index bd6f77862..37cfc3b31 100644 --- a/test/ScaleTests/xunit.runner.json +++ b/test/ScaleTests/xunit.runner.json @@ -8,3 +8,4 @@ + From 25e49ce8ac9e95fc4cb772b6a2919a8751453c3d Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 9 Nov 2025 20:10:48 -0800 Subject: [PATCH 07/25] remove di options and use triggeremetada and update tests accordingly --- .../AzureManagedScalabilityProviderFactory.cs | 52 +++--- .../AzureStorageScalabilityProviderFactory.cs | 65 +++----- ...rableTaskJobHostConfigurationExtensions.cs | 10 +- .../DurableTaskMetadata.cs | 28 +++- .../DurableTaskScaleExtension.cs | 24 +-- .../DurableTaskScaleOptions.cs | 51 ------ .../DurableTaskTriggersScaleProvider.cs | 42 ++--- .../IScalabilityProviderFactory.cs | 7 +- .../IStorageServiceClientProviderFactory.cs | 3 - .../Sql/SqlServerMetricsProvider.cs | 17 ++ .../Sql/SqlServerScalabilityProvider.cs | 10 ++ .../SqlServerScalabilityProviderFactory.cs | 93 +++++------ .../Sql/SqlServerScaleMetric.cs | 4 - .../Sql/SqlServerScaleMonitor.cs | 10 ++ .../Sql/SqlServerTargetScaler.cs | 21 ++- .../TriggerMetadataExtensions.cs | 30 ++-- ...eManagedScalabilityProviderFactoryTests.cs | 154 +++--------------- ...eStorageScalabilityProviderFactoryTests.cs | 107 ++++-------- ...TaskJobHostConfigurationExtensionsTests.cs | 30 +--- ...qlServerScalabilityProviderFactoryTests.cs | 104 ++++-------- test/ScaleTests/TestLoggerProvider.cs | 1 + test/ScaleTests/xunit.runner.json | 1 + 22 files changed, 304 insertions(+), 560 deletions(-) delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs index 598848a14..f145a3217 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -23,7 +23,6 @@ public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactor internal const string ProviderName = "AzureManaged"; private readonly Dictionary<(string, string?, string?), AzureManagedScalabilityProvider> cachedProviders = new Dictionary<(string, string?, string?), AzureManagedScalabilityProvider>(); - private readonly DurableTaskScaleOptions options; private readonly IConfiguration configuration; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; @@ -32,9 +31,6 @@ public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactor /// /// Initializes a new instance of the class. /// - /// - /// The instance that specifies scaling configuration. - /// /// /// The interface used to resolve connection strings and application settings. /// @@ -48,21 +44,15 @@ public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactor /// Thrown if any required argument is . /// public AzureManagedScalabilityProviderFactory( - IOptions options, IConfiguration configuration, INameResolver nameResolver, ILoggerFactory loggerFactory) { - var optionsValue = options?.Value ?? throw new ArgumentNullException(nameof(options)); this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.logger = this.loggerFactory.CreateLogger(LoggerName); - // In Scale Controller context, optionsValue will be a default/empty object (can't read host.json) - // The real configuration comes from triggerMetadata in GetScalabilityProvider() - this.options = optionsValue; - this.DefaultConnectionName = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; } @@ -78,19 +68,20 @@ public AzureManagedScalabilityProviderFactory( /// /// Returns a default instance configured with the default connection and global scaling options. + /// This method should never be called for AzureManaged provider as metadata is always required. /// /// A default instance. + /// Always throws as this method should not be called. public virtual ScalabilityProvider GetScalabilityProvider() { - return this.GetScalabilityProvider(null); + throw new NotImplementedException("AzureManaged provider requires metadata and should not use parameterless GetScalabilityProvider()"); } /// - /// Creates or retrieves an instance based on the provided trigger metadata. + /// Creates or retrieves an instance based on the provided pre-deserialized metadata. /// - /// - /// The trigger metadata contains configuration or identity credentials specific to that trigger. - /// + /// The pre-deserialized Durable Task metadata. + /// Trigger metadata used to access Properties like token credentials. /// /// An instance configured using /// the specified metadata and resolved connection information. @@ -98,14 +89,10 @@ public virtual ScalabilityProvider GetScalabilityProvider() /// /// Thrown if no valid connection string could be resolved for the given connection name. /// - public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetadata) + public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, TriggerMetadata? triggerMetadata) { - // Extract options from triggerMetadata (sent by Functions Host in SyncTriggers payload) - // This is critical for Scale Controller which doesn't have access to host.json - DurableTaskScaleOptions? triggerOptions = triggerMetadata.ExtractDurableTaskScaleOptions(); - - // Resolve connection name: prioritize triggerOptions, fallback to default - string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(triggerOptions?.StorageProvider); + // Resolve connection name: prioritize metadata, fallback to default + string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(metadata?.StorageProvider); string resolvedName = rawConnectionName != null ? this.nameResolver.Resolve(rawConnectionName) : this.DefaultConnectionName; @@ -125,8 +112,8 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetada AzureManagedConnectionString azureManagedConnectionString = new AzureManagedConnectionString(connectionString); - // Extract task hub name from trigger options (from Scale Controller payload) - string taskHubName = triggerOptions?.HubName ?? azureManagedConnectionString.TaskHubName; + // Extract task hub name from trigger metadata (from Scale Controller payload) + string taskHubName = metadata?.TaskHubName ?? azureManagedConnectionString.TaskHubName; // Include client ID in cache key to handle managed identity changes (string, string?, string?) cacheKey = (resolvedName, taskHubName, azureManagedConnectionString.ClientId); @@ -205,15 +192,15 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetada options.TaskHubName = taskHubName; } - // Set concurrency limits - if (this.options.MaxConcurrentOrchestratorFunctions.HasValue) + // Set concurrency limits from metadata + if (metadata?.MaxConcurrentOrchestratorFunctions.HasValue == true) { - options.MaxConcurrentOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions.Value; + options.MaxConcurrentOrchestrationWorkItems = metadata.MaxConcurrentOrchestratorFunctions.Value; } - if (this.options.MaxConcurrentActivityFunctions.HasValue) + if (metadata?.MaxConcurrentActivityFunctions.HasValue == true) { - options.MaxConcurrentActivityWorkItems = this.options.MaxConcurrentActivityFunctions.Value; + options.MaxConcurrentActivityWorkItems = metadata.MaxConcurrentActivityFunctions.Value; } this.logger.LogInformation( @@ -225,9 +212,10 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata? triggerMetada AzureManagedOrchestrationService service = new AzureManagedOrchestrationService(options, this.loggerFactory); AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, resolvedName, this.logger); - // Extract max concurrent values from trigger metadata (from Scale Controller payload), fallback to constructor options - provider.MaxConcurrentTaskOrchestrationWorkItems = triggerOptions?.MaxConcurrentOrchestratorFunctions ?? this.options.MaxConcurrentOrchestratorFunctions ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = triggerOptions?.MaxConcurrentActivityFunctions ?? this.options.MaxConcurrentActivityFunctions ?? 10; + // Extract max concurrent values from trigger metadata (from Scale Controller payload) + // Default: 10 times the number of processors on the current machine + provider.MaxConcurrentTaskOrchestrationWorkItems = metadata?.MaxConcurrentOrchestratorFunctions ?? (Environment.ProcessorCount * 10); + provider.MaxConcurrentTaskActivityWorkItems = metadata?.MaxConcurrentActivityFunctions ?? (Environment.ProcessorCount * 10); this.cachedProviders.Add(cacheKey, provider); return provider; diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index 4e7673ae7..a4b396e47 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -18,7 +18,6 @@ public class AzureStorageScalabilityProviderFactory : IScalabilityProviderFactor private const string LoggerName = "Host.Triggers.DurableTask.AzureStorage"; internal const string ProviderName = "AzureStorage"; - private readonly DurableTaskScaleOptions options; private readonly IStorageServiceClientProviderFactory clientProviderFactory; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; @@ -27,13 +26,11 @@ public class AzureStorageScalabilityProviderFactory : IScalabilityProviderFactor /// /// Initializes a new instance of the class. /// - /// The durable task scale options. /// The storage client provider factory. /// The name resolver for connection strings. /// The logger factory. /// Thrown when required parameters are null. public AzureStorageScalabilityProviderFactory( - IOptions options, IStorageServiceClientProviderFactory clientProviderFactory, INameResolver nameResolver, ILoggerFactory loggerFactory) @@ -42,13 +39,7 @@ public AzureStorageScalabilityProviderFactory( this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - var optionsValue = options?.Value ?? throw new ArgumentNullException(nameof(options)); - - // In Scale Controller context, optionsValue will be a default/empty object (can't read host.json) - // The real configuration comes from triggerMetadata in GetScalabilityProvider() - this.options = optionsValue; - - // Resolve default connection name directly from payload keys or fall back + // Default connection name for Azure Storage this.DefaultConnectionName = "AzureWebJobsStorage"; } @@ -74,9 +65,6 @@ public virtual ScalabilityProvider GetScalabilityProvider() { ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - // Validate Azure Storage specific options - this.ValidateAzureStorageOptions(); - // Create StorageAccountClientProvider without credential (connection string) var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider( this.DefaultConnectionName, @@ -87,37 +75,38 @@ public virtual ScalabilityProvider GetScalabilityProvider() this.DefaultConnectionName, logger); - // Set the max concurrent values from options - this.defaultStorageProvider.MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? 10; - this.defaultStorageProvider.MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? 10; + // Set default max concurrent values + this.defaultStorageProvider.MaxConcurrentTaskOrchestrationWorkItems = 10; + this.defaultStorageProvider.MaxConcurrentTaskActivityWorkItems = 10; } return this.defaultStorageProvider; } /// - /// Creates and caches a default instanceusing Azure Storage as the backend using - /// connection and credential information extracted from the given . + /// Creates and caches a default instance using Azure Storage as the backend using + /// the provided pre-deserialized metadata and trigger metadata for accessing Properties. /// + /// The pre-deserialized Durable Task metadata. + /// Trigger metadata used to access Properties like token credentials. /// /// A singleton instance of . /// - public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata) + public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, TriggerMetadata triggerMetadata) { - // Extract options from triggerMetadata (sent by Functions Host in SyncTriggers payload) - // This is critical for Scale Controller which doesn't have access to host.json - DurableTaskScaleOptions? triggerOptions = triggerMetadata.ExtractDurableTaskScaleOptions(); - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - // Validate Azure Storage specific options - this.ValidateAzureStorageOptions(); + // Validate Azure Storage specific options if metadata is present + if (metadata != null) + { + this.ValidateAzureStorageMetadata(metadata); + } // Extract TokenCredential from triggerMetadata if present (for Managed Identity) var tokenCredential = ExtractTokenCredential(triggerMetadata, logger); - // Resolve connection name: prioritize triggerOptions, fallback to default - string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(triggerOptions?.StorageProvider); + // Resolve connection name: prioritize metadata, fallback to default + string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(metadata?.StorageProvider); string connectionName = rawConnectionName != null ? this.nameResolver.Resolve(rawConnectionName) : this.DefaultConnectionName; @@ -131,9 +120,9 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadat connectionName, logger); - // Extract max concurrent values from trigger metadata (from Scale Controller payload) - provider.MaxConcurrentTaskOrchestrationWorkItems = triggerOptions?.MaxConcurrentOrchestratorFunctions ?? this.options.MaxConcurrentOrchestratorFunctions ?? 10; - provider.MaxConcurrentTaskActivityWorkItems = triggerOptions?.MaxConcurrentActivityFunctions ?? this.options.MaxConcurrentActivityFunctions ?? 10; + // Extract max concurrent values from metadata + provider.MaxConcurrentTaskOrchestrationWorkItems = metadata?.MaxConcurrentOrchestratorFunctions ?? 10; + provider.MaxConcurrentTaskActivityWorkItems = metadata?.MaxConcurrentActivityFunctions ?? 10; return provider; } @@ -177,17 +166,17 @@ public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadat } /// - /// Validates Azure Storage specific options. + /// Validates Azure Storage specific metadata. /// - private void ValidateAzureStorageOptions() + private void ValidateAzureStorageMetadata(DurableTaskMetadata metadata) { const int MinTaskHubNameSize = 3; const int MaxTaskHubNameSize = 50; // Validate hub name for Azure Storage - if (!string.IsNullOrWhiteSpace(this.options.HubName)) + if (!string.IsNullOrWhiteSpace(metadata.TaskHubName)) { - var hubName = this.options.HubName; + var hubName = metadata.TaskHubName; if (hubName.Length < MinTaskHubNameSize || hubName.Length > MaxTaskHubNameSize) { @@ -208,15 +197,15 @@ private void ValidateAzureStorageOptions() } // Validate max concurrent orchestrator functions - if (this.options.MaxConcurrentOrchestratorFunctions.HasValue && this.options.MaxConcurrentOrchestratorFunctions.Value <= 0) + if (metadata.MaxConcurrentOrchestratorFunctions.HasValue && metadata.MaxConcurrentOrchestratorFunctions.Value <= 0) { - throw new System.InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} must be a positive integer."); + throw new System.InvalidOperationException($"{nameof(metadata.MaxConcurrentOrchestratorFunctions)} must be a positive integer."); } // Validate max concurrent activity functions - if (this.options.MaxConcurrentActivityFunctions.HasValue && this.options.MaxConcurrentActivityFunctions.Value <= 0) + if (metadata.MaxConcurrentActivityFunctions.HasValue && metadata.MaxConcurrentActivityFunctions.Value <= 0) { - throw new System.InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); + throw new System.InvalidOperationException($"{nameof(metadata.MaxConcurrentActivityFunctions)} must be a positive integer."); } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs index 03c94f9d6..abbf7d3b9 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs @@ -36,18 +36,16 @@ public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) throw new ArgumentNullException(nameof(builder)); } - builder - .AddExtension() - .BindOptions(); + builder.AddExtension(); IServiceCollection serviceCollection = builder.Services; serviceCollection.TryAddSingleton(); - + // Register all scalability provider factories serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - + return builder; } @@ -64,7 +62,7 @@ internal static IWebJobsBuilder AddDurableScaleForTrigger(this IWebJobsBuilder b DurableTaskTriggersScaleProvider provider = null; builder.Services.AddSingleton(serviceProvider => { - provider = new DurableTaskTriggersScaleProvider(serviceProvider.GetService>(), serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService>(), triggerMetadata); + provider = new DurableTaskTriggersScaleProvider(serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService>(), triggerMetadata); return provider; }); diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs index 1f2209877..cf1854f24 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Collections.Generic; using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { @@ -14,19 +10,21 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale public class DurableTaskMetadata { /// - /// Gets or sets the name of the Durable Task Hub used by the function app. + /// Gets or sets the name of the Durable Task Hub. This identifies the taskhub being monitored or scaled. /// [JsonPropertyName("taskHubName")] public string? TaskHubName { get; set; } /// - /// Gets or sets the maximum number of concurrent orchestrator. + /// Gets or sets the maximum number of orchestrator functions that can run concurrently on this worker instance. + /// Used by the scale controller to balance orchestration and activity execution load. /// [JsonPropertyName("maxConcurrentOrchestratorFunctions")] public int? MaxConcurrentOrchestratorFunctions { get; set; } /// - /// Gets or sets the maximum number of concurrent activity. + /// Gets or sets the maximum number of activity functions that can run concurrently on this worker instance. + /// Used by the scale controller to balance orchestration and activity execution load. /// [JsonPropertyName("maxConcurrentActivityFunctions")] public int? MaxConcurrentActivityFunctions { get; set; } @@ -36,5 +34,19 @@ public class DurableTaskMetadata /// [JsonPropertyName("storageProvider")] public IDictionary? StorageProvider { get; set; } + + /// + /// Resolves app settings in using the provided . + /// This allows configuration values such as connection strings to be expanded from environment variables or host settings. + /// + /// The scale options instance containing configuration values to resolve. + /// The name resolver used to resolve app setting placeholders. + public static void ResolveAppSettingOptions(DurableTaskMetadata metadata, INameResolver nameResolver) + { + if (metadata.StorageProvider.TryGetValue("connectionName", out object connectionNameObj) && connectionNameObj is string connectionName) + { + metadata.StorageProvider["connectionName"] = nameResolver.Resolve(connectionName); + } + } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs index e1c2c20c7..7f8751ff5 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs @@ -14,7 +14,7 @@ public class DurableTaskScaleExtension : IExtensionConfigProvider { private readonly IScalabilityProviderFactory scalabilityProviderFactory; private readonly ScalabilityProvider defaultscalabilityProvider; - private readonly DurableTaskScaleOptions options; + private readonly DurableTaskMetadata metadata; private readonly ILogger logger; private readonly IEnumerable scalabilityProviderFactories; @@ -23,26 +23,25 @@ public class DurableTaskScaleExtension : IExtensionConfigProvider /// This constructor resolves the appropriate scalability provider factory /// and initializes a default scalability provider used for scaling decisions. /// - /// The configuration options for the Durable Task Scale extension. + /// The metadata for the Durable Task Scale extension. /// The logger instance used for diagnostic output. /// A collection of available scalability provider factories. /// - /// Thrown when any of the required parameters (, , or ) are null. + /// Thrown when any of the required parameters (, , or ) are null. /// public DurableTaskScaleExtension( - DurableTaskScaleOptions options, + DurableTaskMetadata metadata, ILogger logger, IEnumerable scalabilityProviderFactories) { - this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.scalabilityProviderFactories = scalabilityProviderFactories ?? throw new ArgumentNullException(nameof(scalabilityProviderFactories)); - // Determine which scalability provider factory should be used based on configured options. - this.scalabilityProviderFactory = GetScalabilityProviderFactory(this.options, this.logger, this.scalabilityProviderFactories); + // Determine which scalability provider factory should be used based on configured metadata. + this.scalabilityProviderFactory = GetScalabilityProviderFactory(this.metadata, this.logger, this.scalabilityProviderFactories); // Create a default scalability provider instance from the selected factory. - // ? what is default do, if there is no sitemetada or conenction name how to we create? this.defaultscalabilityProvider = this.scalabilityProviderFactory.GetScalabilityProvider(); } @@ -68,19 +67,20 @@ public void Initialize(ExtensionConfigContext context) } /// - /// Determines the scalability provider factory based on the given options. + /// Determines the scalability provider factory based on the given metadata. /// - /// The scale options specifying the target storage provider and hub configuration. + /// The metadata specifying the target storage provider and hub configuration. /// The logger instance for diagnostic messages. /// A collection of available scalability provider factories. /// The resolved suitable for the configured provider. internal static IScalabilityProviderFactory GetScalabilityProviderFactory( - DurableTaskScaleOptions options, + DurableTaskMetadata metadata, ILogger logger, IEnumerable scalabilityProviderFactories) { const string DefaultProvider = "AzureStorage"; - bool storageTypeIsConfigured = options.StorageProvider.TryGetValue("type", out object storageType); + object storageType = null; + bool storageTypeIsConfigured = metadata.StorageProvider != null && metadata.StorageProvider.TryGetValue("type", out storageType); if (!storageTypeIsConfigured) { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs deleted file mode 100644 index 8c4458dfa..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleOptions.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System.Collections.Generic; - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale -{ - /// - /// Minimal options class for Scale package - only contains what's needed for scaling decisions. - /// - public class DurableTaskScaleOptions - { - /// - /// Gets or sets the name of the Durable Task Hub. - /// This identifies the taskhub being monitored or scaled. - /// - public string HubName { get; set; } - - /// - /// Gets or sets the dictionary of configuration settings for the underlying storage provider (e.g., Azure Storage, MSSQL, or Netherite). - /// These settings typically include connection details and provider-specific parameters. - /// - public IDictionary StorageProvider { get; set; } = new Dictionary(); - - /// - /// Gets or sets the maximum number of orchestrator functions that can run concurrently on this worker instance. - /// Used by the scale controller to balance orchestration and activity execution load. - /// - public int? MaxConcurrentOrchestratorFunctions { get; set; } - - /// - /// /// Gets or sets the maximum number of activity functions that can run concurrently on this worker instance. - /// Used by the scale controller to balance orchestration and activity execution load. - /// - public int? MaxConcurrentActivityFunctions { get; set; } - - /// - /// Resolves app settings in using the provided . - /// This allows configuration values such as connection strings to be expanded from environment variables or host settings. - /// - /// The scale options instance containing configuration values to resolve. - /// The name resolver used to resolve app setting placeholders. - public static void ResolveAppSettingOptions(DurableTaskScaleOptions options, INameResolver nameResolver) - { - if (options.StorageProvider.TryGetValue("connectionName", out object connectionNameObj) && connectionNameObj is string connectionName) - { - options.StorageProvider["connectionName"] = nameResolver.Resolve(connectionName); - } - } - } -} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index b3c235359..88e33b2da 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -16,7 +16,6 @@ internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITarget private readonly ITargetScaler targetScaler; public DurableTaskTriggersScaleProvider( - IOptions durableTaskScaleOptions, INameResolver nameResolver, ILoggerFactory loggerFactory, IEnumerable scalabilityProviderFactories, @@ -29,38 +28,29 @@ public DurableTaskTriggersScaleProvider( var metadata = triggerMetadata.Metadata.ToObject() ?? throw new InvalidOperationException($"Failed to deserialize trigger metadata. Payload: {triggerMetadata.Metadata}"); - // Build options from triggerMetadata with optional fallback to DI options - // NOTE: durableTaskScaleOptions.Value will be null/empty in Scale Controller context - // because Scale Controller doesn't have access to host.json - var options = new DurableTaskScaleOptions + // Validate required fields + if (string.IsNullOrWhiteSpace(metadata.TaskHubName)) { - HubName = metadata.TaskHubName ?? durableTaskScaleOptions.Value?.HubName - ?? throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none. "), - MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions ?? durableTaskScaleOptions.Value?.MaxConcurrentActivityFunctions, - MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions ?? durableTaskScaleOptions.Value?.MaxConcurrentOrchestratorFunctions, - StorageProvider = metadata.StorageProvider ?? durableTaskScaleOptions.Value?.StorageProvider ?? new Dictionary(), - }; + throw new InvalidOperationException($"Expected `taskHubName` property in SyncTriggers payload but found none."); + } // Resolve app settings (e.g., %MyConnectionString% -> actual value) - DurableTaskScaleOptions.ResolveAppSettingOptions(options, nameResolver); - // Store the parsed options in Properties so factories can use them (avoid re-parsing) - // TriggerMetadata.Properties is read-only but the dictionary itself is mutable - if (triggerMetadata.Properties != null) - { - triggerMetadata.Properties["DurableTaskScaleOptions"] = options; - } + DurableTaskMetadata.ResolveAppSettingOptions(metadata, nameResolver); var logger = loggerFactory.CreateLogger(); + + // Determine which scalability provider factory to use based on metadata.StorageProvider["type"] + // If StorageProvider is null or doesn't contain "type", defaults to "AzureStorage" provider IScalabilityProviderFactory scalabilityProviderFactory = DurableTaskScaleExtension.GetScalabilityProviderFactory( - options, logger, scalabilityProviderFactories); + metadata, logger, scalabilityProviderFactories); - // Always use the triggerMetadata overload for scale scenarios - // The factory will extract the parsed DurableTaskMetadata and TokenCredential if present - ScalabilityProvider defaultscalabilityProvider = scalabilityProviderFactory.GetScalabilityProvider(triggerMetadata); + // Use the new overload that accepts pre-deserialized metadata to avoid double deserialization of the metadata payload + // Still pass triggerMetadata to allow access to Properties (e.g., token credentials) + ScalabilityProvider defaultscalabilityProvider = scalabilityProviderFactory.GetScalabilityProvider(metadata, triggerMetadata); - // Get connection name (options.StorageProvider already has fallback to DI options built in) - string? connectionName = GetConnectionNameFromOptions(options.StorageProvider) ?? scalabilityProviderFactory.DefaultConnectionName; + // Get connection name from metadata.StorageProvider + string? connectionName = GetConnectionNameFromOptions(metadata.StorageProvider) ?? scalabilityProviderFactory.DefaultConnectionName; logger.LogInformation( "Creating DurableTaskTriggersScaleProvider for function {FunctionName}: connectionName = '{ConnectionName}'", @@ -72,14 +62,14 @@ public DurableTaskTriggersScaleProvider( functionId, functionName, connectionName, - options.HubName); + metadata.TaskHubName); this.monitor = ScaleUtils.GetScaleMonitor( defaultscalabilityProvider, functionId, functionName, connectionName, - options.HubName); + metadata.TaskHubName); } private static string? GetConnectionNameFromOptions(IDictionary? storageProvider) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs index b3da9b63e..8bb4902ed 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IScalabilityProviderFactory.cs @@ -28,9 +28,12 @@ public interface IScalabilityProviderFactory /// /// Creates or retrieves a cached scalability provider to be used in a given function execution. + /// This overload accepts pre-deserialized metadata to avoid double deserialization of the metadata payload. + /// The triggerMetadata is still passed to allow access to Properties (e.g., token credentials). /// - /// Trigger metadata used to create IOrchestrationService for functions scale scenarios. + /// The pre-deserialized Durable Task metadata from the trigger. + /// Trigger metadata used to access Properties like token credentials. /// A scalability provider to be used by a client function. - ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata); + ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, TriggerMetadata triggerMetadata); } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs index b8a83b558..3794ccb41 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/IStorageServiceClientProviderFactory.cs @@ -2,9 +2,6 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. #nullable enable using Azure.Core; -using Azure.Data.Tables; -using Azure.Storage.Blobs; -using Azure.Storage.Queues; using DurableTask.AzureStorage; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs index da94ef40c..699ab7e57 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerMetricsProvider.cs @@ -18,11 +18,28 @@ public class SqlServerMetricsProvider private DateTime metricsTimeStamp = DateTime.MinValue; private SqlServerScaleMetric metrics; + /// + /// Creates a new that retrieves scaling + /// metrics from the specified SQL orchestration service. + /// + /// The SQL orchestration service used to get metrics. + /// + /// Thrown if is null. + /// public SqlServerMetricsProvider(SqlOrchestrationService service) { this.service = service ?? throw new ArgumentNullException(nameof(service)); } + /// + /// Gets the latest SQL Server scaling metrics, including the recommended worker count. Results are cached for 5 seconds to reduce query load. + /// + /// + /// The previous number of workers, used to compare scaling recommendations (optional). + /// + /// + /// A containing the recommended worker count. + /// public virtual async Task GetMetricsAsync(int? previousWorkerCount = null) { // We only want to query the metrics every 5 seconds to avoid excessive SQL queries. diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs index 1b182ccf5..94ebdce35 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs @@ -21,6 +21,16 @@ public class SqlServerScalabilityProvider : ScalabilityProvider private readonly object initLock = new object(); private SqlServerMetricsProvider singletonSqlMetricsProvider; + /// + /// Creates a new for managing + /// scaling operations using a SQL Server–based orchestration service. + /// + /// The SQL orchestration service instance. + /// The name of the SQL connection. + /// The logger used for diagnostic output. + /// + /// Thrown if is null. + /// public SqlServerScalabilityProvider( SqlOrchestrationService service, string connectionName, diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index 2d0260db9..e32256723 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -2,12 +2,10 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Collections.Generic; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql { @@ -19,27 +17,22 @@ public class SqlServerScalabilityProviderFactory : IScalabilityProviderFactory private const string LoggerName = "Host.Triggers.DurableTask.SqlServer"; internal const string ProviderName = "mssql"; - private readonly DurableTaskScaleOptions options; private readonly IConfiguration configuration; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; - private SqlServerScalabilityProvider defaultSqlProvider; /// /// Initializes a new instance of the class. /// - /// The durable task scale options. /// The configuration for reading connection strings. /// The name resolver for connection strings. /// The logger factory. /// Thrown when required parameters are null. public SqlServerScalabilityProviderFactory( - IOptions options, IConfiguration configuration, INameResolver nameResolver, ILoggerFactory loggerFactory) { - this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); @@ -47,60 +40,57 @@ public SqlServerScalabilityProviderFactory( this.DefaultConnectionName = "SQLDB_Connection"; } + /// + /// Gets the name of durabletask-mssql backend provider. + /// public virtual string Name => ProviderName; + /// + /// Gets the default connection name of durabletask-mssql backend provider. + /// public string DefaultConnectionName { get; } + /// + /// Gets the default scalability provider for this factory. + /// This method should never be called for SQL provider as metadata is always required. + /// + /// A default instance. + /// Always throws as this method should not be called. public virtual ScalabilityProvider GetScalabilityProvider() { - if (this.defaultSqlProvider == null) - { - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - - // Validate SQL Server specific options - this.ValidateSqlServerOptions(logger); - - // Create SqlOrchestrationService from connection string - // No TokenCredential for default provider (uses connection string auth) - var sqlOrchestrationService = this.CreateSqlOrchestrationService( - this.DefaultConnectionName, - this.options.HubName ?? "default", - logger); - - this.defaultSqlProvider = new SqlServerScalabilityProvider( - sqlOrchestrationService, - this.DefaultConnectionName, - logger); - } - - return this.defaultSqlProvider; + throw new NotImplementedException("SQL provider requires metadata and should not use parameterless GetScalabilityProvider()"); } - public ScalabilityProvider GetScalabilityProvider(TriggerMetadata triggerMetadata) + /// + /// Creates a scalability provider using pre-deserialized metadata. + /// + /// The pre-deserialized Durable Task metadata. + /// Trigger metadata (for future extensions, e.g., token credentials). + /// A configured SQL Server scalability provider. + public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, TriggerMetadata triggerMetadata) { - // Extract options from triggerMetadata (sent by Functions Host in SyncTriggers payload) - // This is critical for Scale Controller which doesn't have access to host.json - DurableTaskScaleOptions? triggerOptions = triggerMetadata.ExtractDurableTaskScaleOptions(); - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - // Validate SQL Server specific options - this.ValidateSqlServerOptions(logger); + // Validate SQL Server specific metadata if present + if (metadata != null) + { + this.ValidateSqlServerMetadata(metadata, logger); + } - // Resolve connection name: prioritize triggerOptions, fallback to default - string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(triggerOptions?.StorageProvider); + // Resolve connection name: prioritize metadata, fallback to default + string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(metadata?.StorageProvider); string connectionName = rawConnectionName != null ? this.nameResolver.Resolve(rawConnectionName) : this.DefaultConnectionName; - // Extract task hub name from trigger metadata (from Scale Controller payload), fallback to constructor options - string taskHubName = triggerOptions?.HubName ?? this.options.HubName ?? "default"; + // Extract task hub name from trigger metadata (from Scale Controller payload) + string taskHubName = metadata?.TaskHubName ?? "default"; var sqlOrchestrationService = this.CreateSqlOrchestrationService( connectionName, taskHubName, logger, - triggerOptions); + metadata); var provider = new SqlServerScalabilityProvider( sqlOrchestrationService, @@ -114,7 +104,7 @@ private SqlOrchestrationService CreateSqlOrchestrationService( string connectionName, string taskHubName, ILogger logger, - DurableTaskScaleOptions triggerOptions = null) + DurableTaskMetadata metadata = null) { // Resolve connection name first (handles %% wrapping) string resolvedConnectionName = this.nameResolver.Resolve(connectionName); @@ -141,9 +131,10 @@ private SqlOrchestrationService CreateSqlOrchestrationService( connectionString, taskHubName) { - // Set concurrency limits from trigger metadata (from Scale Controller payload), fallback to constructor options - MaxActiveOrchestrations = triggerOptions?.MaxConcurrentOrchestratorFunctions ?? this.options.MaxConcurrentOrchestratorFunctions ?? 10, - MaxConcurrentActivities = triggerOptions?.MaxConcurrentActivityFunctions ?? this.options.MaxConcurrentActivityFunctions ?? 10, + // Set concurrency limits from trigger metadata (from Scale Controller payload) + // Default: 10 times the number of processors on the current machine + MaxActiveOrchestrations = metadata?.MaxConcurrentOrchestratorFunctions ?? (Environment.ProcessorCount * 10), + MaxConcurrentActivities = metadata?.MaxConcurrentActivityFunctions ?? (Environment.ProcessorCount * 10), }; // Note: When connection string includes "Authentication=Active Directory Default" or @@ -155,27 +146,27 @@ private SqlOrchestrationService CreateSqlOrchestrationService( } /// - /// Validates SQL Server specific options. + /// Validates SQL Server specific metadata. /// - private void ValidateSqlServerOptions(ILogger logger) + private void ValidateSqlServerMetadata(DurableTaskMetadata metadata, ILogger logger) { // Validate hub name (SQL Server has less strict requirements than Azure Storage) - if (string.IsNullOrWhiteSpace(this.options.HubName)) + if (string.IsNullOrWhiteSpace(metadata.TaskHubName)) { // Hub name defaults to "default" for SQL Server, so this is acceptable return; } // Validate max concurrent orchestrator functions - if (this.options.MaxConcurrentOrchestratorFunctions.HasValue && this.options.MaxConcurrentOrchestratorFunctions.Value <= 0) + if (metadata.MaxConcurrentOrchestratorFunctions.HasValue && metadata.MaxConcurrentOrchestratorFunctions.Value <= 0) { - throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} must be a positive integer."); + throw new InvalidOperationException($"{nameof(metadata.MaxConcurrentOrchestratorFunctions)} must be a positive integer."); } // Validate max concurrent activity functions - if (this.options.MaxConcurrentActivityFunctions.HasValue && this.options.MaxConcurrentActivityFunctions.Value <= 0) + if (metadata.MaxConcurrentActivityFunctions.HasValue && metadata.MaxConcurrentActivityFunctions.Value <= 0) { - throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentActivityFunctions)} must be a positive integer."); + throw new InvalidOperationException($"{nameof(metadata.MaxConcurrentActivityFunctions)} must be a positive integer."); } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs index 6067e9668..2d629a8d6 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMetric.cs @@ -17,7 +17,3 @@ public class SqlServerScaleMetric : ScaleMetrics public int RecommendedReplicaCount { get; set; } } } - - - - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs index 402bc0191..2f9040cad 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScaleMonitor.cs @@ -23,6 +23,16 @@ public class SqlServerScaleMonitor : IScaleMonitor private readonly SqlServerMetricsProvider metricsProvider; private int? previousWorkerCount = -1; + /// + /// Creates a new for monitoring scale metrics + /// of a Durable Functions task hub using SQL Server. + /// + /// The ID of the function to monitor. + /// The name of the task hub. Uses "default" if null. + /// The metrics provider for SQL Server. + /// + /// Thrown if is null. + /// public SqlServerScaleMonitor(string functionId, string taskHubName, SqlServerMetricsProvider sqlMetricsProvider) { // Scalers in Durable Functions are per function IDs. Scalers share the same sqlMetricsProvider in the same task hub. diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs index d1840663b..02d03d453 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs @@ -15,6 +15,15 @@ public class SqlServerTargetScaler : ITargetScaler { private readonly SqlServerMetricsProvider sqlMetricsProvider; + /// + /// Creates a new for managing target scaling + /// of a Durable Functions task hub using SQL Server. + /// + /// The ID of the function to scale. + /// The SQL Server metrics provider. + /// + /// Thrown if is null. + /// public SqlServerTargetScaler(string functionId, SqlServerMetricsProvider sqlMetricsProvider) { this.sqlMetricsProvider = sqlMetricsProvider ?? throw new ArgumentNullException(nameof(sqlMetricsProvider)); @@ -22,8 +31,16 @@ public SqlServerTargetScaler(string functionId, SqlServerMetricsProvider sqlMetr this.TargetScalerDescriptor = new TargetScalerDescriptor(functionId); } + /// + /// Gets the descriptor for this target scaler. + /// public TargetScalerDescriptor TargetScalerDescriptor { get; } + /// + /// Retrieves the current scale result based on SQL Server metrics, returning the recommended number of workers for the task hub. + /// + /// The context for scaling evaluation. + /// The calculated . public async Task GetScaleResultAsync(TargetScalerContext context) { SqlServerScaleMetric sqlScaleMetric = await this.sqlMetricsProvider.GetMetricsAsync(); @@ -34,7 +51,3 @@ public async Task GetScaleResultAsync(TargetScalerContext co } } } - - - - diff --git a/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs index 63ab49785..7c251a7a1 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs @@ -12,37 +12,33 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale internal static class TriggerMetadataExtensions { /// - /// Extracts DurableTaskScaleOptions from trigger metadata sent by the Scale Controller. + /// Extracts DurableTaskMetadata from trigger metadata sent by the Scale Controller. /// /// The trigger metadata containing configuration from the Scale Controller. - /// The parsed options, or null if metadata is not available. - public static DurableTaskScaleOptions? ExtractDurableTaskScaleOptions(this TriggerMetadata? triggerMetadata) + /// The parsed metadata, or null if metadata is not available. + public static DurableTaskMetadata? ExtractDurableTaskMetadata(this TriggerMetadata? triggerMetadata) { if (triggerMetadata?.Metadata == null) { return null; } + // Check if already parsed and stored in Properties + if (triggerMetadata.Properties != null && + triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object cachedMetadata) && + cachedMetadata is DurableTaskMetadata metadata) + { + return metadata; + } + try { // Parse the JSON metadata to extract configuration values - var metadata = triggerMetadata.Metadata.ToObject(); - if (metadata == null) - { - return null; - } - - return new DurableTaskScaleOptions - { - HubName = metadata.TaskHubName, - MaxConcurrentActivityFunctions = metadata.MaxConcurrentActivityFunctions, - MaxConcurrentOrchestratorFunctions = metadata.MaxConcurrentOrchestratorFunctions, - StorageProvider = metadata.StorageProvider, - }; + return triggerMetadata.Metadata.ToObject(); } catch { - // If parsing fails, return null and fall back to constructor options + // If parsing fails, return null return null; } } diff --git a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs index 138b78cff..df5c4c4f9 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs @@ -58,11 +58,10 @@ private class SimpleNameResolver : INameResolver public void Constructor_ValidParameters_CreatesInstance() { // Arrange - var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + // Options no longer used - removed CreateOptions call // Act var factory = new AzureManagedScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); @@ -86,7 +85,6 @@ public void Constructor_NullOptions_ThrowsArgumentNullException() Assert.Throws(() => new AzureManagedScalabilityProviderFactory( null, - this.configuration, this.nameResolver, this.loggerFactory)); } @@ -100,12 +98,11 @@ public void Constructor_NullOptions_ThrowsArgumentNullException() public void Constructor_NullConfiguration_ThrowsArgumentNullException() { // Arrange - var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + // Options no longer used - removed CreateOptions call // Act & Assert Assert.Throws(() => new AzureManagedScalabilityProviderFactory( - options, null, this.nameResolver, this.loggerFactory)); @@ -121,24 +118,14 @@ public void Constructor_NullConfiguration_ThrowsArgumentNullException() [Fact] public void GetScalabilityProvider_WithAzureManagedType_CreatesAzureManagedProvider() { - // Arrange - Explicitly set storageProvider type to "azureManaged" - var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); - + // Arrange - Azure Managed now requires metadata var factory = new AzureManagedScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetScalabilityProvider(); - - // Assert - Assert.NotNull(provider); - Assert.IsType(provider); - Assert.Equal("AzureManaged", factory.Name); - Assert.Equal(10, provider.MaxConcurrentTaskOrchestrationWorkItems); - Assert.Equal(20, provider.MaxConcurrentTaskActivityWorkItems); + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -150,26 +137,14 @@ public void GetScalabilityProvider_WithAzureManagedType_CreatesAzureManagedProvi [Fact] public void GetScalabilityProvider_ReturnsValidProvider() { - // Arrange - var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); - + // Arrange - Azure Managed now requires metadata var factory = new AzureManagedScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); - // Act - Without trigger metadata, uses hardcoded default - var provider = factory.GetScalabilityProvider(); - - // Assert - Assert.NotNull(provider); - Assert.IsType(provider); - var azureProvider = (AzureManagedScalabilityProvider)provider; - Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); - Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); - // Connection name is now from hardcoded default, not from options - Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", azureProvider.ConnectionName); + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -184,17 +159,17 @@ public void GetScalabilityProvider_ReturnsValidProvider() public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() { // Arrange - var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + // Options no longer used - removed CreateOptions call var factory = new AzureManagedScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "v3-dtsConnectionMI"); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); // Act - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert Assert.NotNull(provider); @@ -216,20 +191,14 @@ public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() [Fact] public void GetScalabilityProvider_CachesProviderWithSameConnectionAndClientId() { - // Arrange - var options = CreateOptions("testHub", 10, 20, "v3-dtsConnectionMI"); + // Arrange - Azure Managed now requires metadata var factory = new AzureManagedScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); - // Act - Call twice with no trigger metadata (same cache key) - var provider1 = factory.GetScalabilityProvider(); - var provider2 = factory.GetScalabilityProvider(); - - // Assert - Should be the same cached instance - Assert.Same(provider1, provider2); + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -240,31 +209,14 @@ public void GetScalabilityProvider_CachesProviderWithSameConnectionAndClientId() [Fact] public void GetScalabilityProvider_WithDefaultConnectionName_CreatesProvider() { - // Arrange - Don't specify connectionName in storageProvider - var options = new DurableTaskScaleOptions - { - HubName = "testHub", - MaxConcurrentOrchestratorFunctions = 10, - MaxConcurrentActivityFunctions = 20, - StorageProvider = new Dictionary - { - { "type", "azureManaged" }, - }, - }; - + // Arrange - Azure Managed now requires metadata var factory = new AzureManagedScalabilityProviderFactory( - Options.Create(options), this.configuration, this.nameResolver, this.loggerFactory); - // Act - var provider = factory.GetScalabilityProvider(); - - // Assert - Assert.NotNull(provider); - Assert.IsType(provider); - Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", factory.DefaultConnectionName); + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -275,22 +227,14 @@ public void GetScalabilityProvider_WithDefaultConnectionName_CreatesProvider() [Fact] public void GetScalabilityProvider_MissingConnectionString_CreatesProviderWithDefaultCredential() { - // Arrange - Use connection name that doesn't exist in configuration - var options = CreateOptions("testHub", 10, 20, "NonExistentConnection"); - + // Arrange - Azure Managed now requires metadata var factory = new AzureManagedScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); - // Act - Without trigger metadata and without connection string in config, - // provider is created using DefaultAzureCredential - var provider = factory.GetScalabilityProvider(); - - // Assert - Provider is created successfully with hardcoded default connection name - Assert.NotNull(provider); - Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", provider.ConnectionName); + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -312,23 +256,13 @@ public void GetScalabilityProvider_RetrievesConnectionStringFromConfiguration() }); var config = configBuilder.Build(); - var options = CreateOptions("testHub", 10, 20, "MyCustomConnection"); var factory = new AzureManagedScalabilityProviderFactory( - options, config, this.nameResolver, this.loggerFactory); - // Act - Without trigger metadata, uses hardcoded default - var provider = factory.GetScalabilityProvider(); - - // Assert - Assert.NotNull(provider); - Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", provider.ConnectionName); - - // Verify the connection string was retrieved from configuration - var retrievedConnectionString = config["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"]; - Assert.Equal(testConnectionString, retrievedConnectionString); + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -339,7 +273,7 @@ public void GetScalabilityProvider_RetrievesConnectionStringFromConfiguration() [Fact] public void GetScalabilityProvider_UsesTaskHubNameFromConnectionString() { - // Arrange - Connection string with TaskHub specified + // Arrange - Azure Managed now requires metadata var configBuilder = new ConfigurationBuilder(); configBuilder.AddInMemoryCollection(new Dictionary { @@ -348,51 +282,17 @@ public void GetScalabilityProvider_UsesTaskHubNameFromConnectionString() }); var config = configBuilder.Build(); - var options = new DurableTaskScaleOptions - { - // Don't set HubName in options - MaxConcurrentOrchestratorFunctions = 10, - MaxConcurrentActivityFunctions = 20, - StorageProvider = new Dictionary - { - { "type", "azureManaged" }, - }, - }; - var factory = new AzureManagedScalabilityProviderFactory( - Options.Create(options), config, this.nameResolver, this.loggerFactory); - // Act - Without trigger metadata, uses hardcoded default - var provider = factory.GetScalabilityProvider(); - - // Assert - Assert.NotNull(provider); - // Provider should be created successfully with task hub from connection string + // Act & Assert - Should throw NotImplementedException since Azure Managed requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } - private static IOptions CreateOptions( - string hubName, - int maxOrchestrator, - int maxActivity, - string connectionName) - { - var options = new DurableTaskScaleOptions - { - HubName = hubName, - MaxConcurrentOrchestratorFunctions = maxOrchestrator, - MaxConcurrentActivityFunctions = maxActivity, - StorageProvider = new Dictionary - { - { "type", "azureManaged" }, - { "connectionName", connectionName }, - }, - }; - - return Options.Create(options); - } + // CreateOptions helper removed - DurableTaskScaleOptions no longer exists + // Tests now rely on TriggerMetadata from Scale Controller instead of DurableTaskScaleOptions private static TriggerMetadata CreateTriggerMetadata( string hubName, diff --git a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs index 09cc0926f..a4e6e5453 100644 --- a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs @@ -61,12 +61,8 @@ private class SimpleNameResolver : INameResolver [Fact] public void Constructor_ValidParameters_CreatesInstance() { - // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection"); - // Act var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); @@ -83,17 +79,7 @@ public void Constructor_ValidParameters_CreatesInstance() /// Validates that factory properly rejects null options parameter. /// Ensures proper error handling for missing configuration. /// - [Fact] - public void Constructor_NullOptions_ThrowsArgumentNullException() - { - // Act & Assert - Assert.Throws(() => - new AzureStorageScalabilityProviderFactory( - null, - this.clientProviderFactory, - this.nameResolver, - this.loggerFactory)); - } + // Test removed: Options parameter no longer exists in constructor /// /// Scenario: Constructor validation - null client provider factory. @@ -104,12 +90,11 @@ public void Constructor_NullOptions_ThrowsArgumentNullException() public void Constructor_NullClientProviderFactory_ThrowsArgumentNullException() { // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection"); + // Options no longer used - removed CreateOptions call // Act & Assert Assert.Throws(() => new AzureStorageScalabilityProviderFactory( - options, null, this.nameResolver, this.loggerFactory)); @@ -125,10 +110,9 @@ public void Constructor_NullClientProviderFactory_ThrowsArgumentNullException() public void GetScalabilityProvider_ReturnsValidProvider() { // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection"); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); @@ -140,8 +124,9 @@ public void GetScalabilityProvider_ReturnsValidProvider() Assert.NotNull(provider); Assert.IsType(provider); var azureProvider = (AzureStorageScalabilityProvider)provider; + // Azure Storage defaults to 10 for both orchestrator and activity Assert.Equal(10, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); - Assert.Equal(20, azureProvider.MaxConcurrentTaskActivityWorkItems); + Assert.Equal(10, azureProvider.MaxConcurrentTaskActivityWorkItems); } /// @@ -155,17 +140,17 @@ public void GetScalabilityProvider_ReturnsValidProvider() public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() { // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection"); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "TestConnection"); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); // Act - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert Assert.NotNull(provider); @@ -185,16 +170,17 @@ public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() [Fact] public void ValidateAzureStorageOptions_InvalidHubName_ThrowsArgumentException() { - // Arrange - Hub name too short - var options = CreateOptions("ab", 10, 20, "TestConnection"); + // Arrange - Hub name too short (invalid) var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); + var triggerMetadata = CreateTriggerMetadata("ab", 10, 20, "TestConnection"); // "ab" is too short + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + // Act & Assert - Assert.Throws(() => factory.GetScalabilityProvider()); + Assert.Throws(() => factory.GetScalabilityProvider(metadata, triggerMetadata)); } /// @@ -206,16 +192,17 @@ public void ValidateAzureStorageOptions_InvalidHubName_ThrowsArgumentException() [Fact] public void ValidateAzureStorageOptions_InvalidMaxConcurrent_ThrowsInvalidOperationException() { - // Arrange - MaxConcurrentOrchestratorFunctions is 0 - var options = CreateOptions("testHub", 0, 20, "TestConnection"); + // Arrange - MaxConcurrentOrchestratorFunctions is 0 (invalid) var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); + var triggerMetadata = CreateTriggerMetadata("testHub", 0, 20, "TestConnection"); // 0 is invalid + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + // Act & Assert - Assert.Throws(() => factory.GetScalabilityProvider()); + Assert.Throws(() => factory.GetScalabilityProvider(metadata, triggerMetadata)); } /// @@ -228,9 +215,8 @@ public void ValidateAzureStorageOptions_InvalidMaxConcurrent_ThrowsInvalidOperat public void GetScalabilityProvider_CachesDefaultProvider() { // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection"); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); @@ -243,26 +229,8 @@ public void GetScalabilityProvider_CachesDefaultProvider() Assert.Same(provider1, provider2); } - private static IOptions CreateOptions( - string hubName, - int maxOrchestrator, - int maxActivity, - string connectionName) - { - var options = new DurableTaskScaleOptions - { - HubName = hubName, - MaxConcurrentOrchestratorFunctions = maxOrchestrator, - MaxConcurrentActivityFunctions = maxActivity, - StorageProvider = new Dictionary - { - { "type", "AzureStorage" }, - { "connectionName", connectionName }, - }, - }; - - return Options.Create(options); - } + // CreateOptions helper removed - DurableTaskScaleOptions no longer exists + // Tests now rely on TriggerMetadata from Scale Controller instead of DurableTaskScaleOptions private static TriggerMetadata CreateTriggerMetadata( string hubName, @@ -301,10 +269,9 @@ private static TriggerMetadata CreateTriggerMetadata( public void GetScalabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvider() { // Arrange - Using default AzureWebJobsStorage connection - var options = CreateOptions("testHub", 10, 20, "AzureWebJobsStorage"); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); @@ -316,8 +283,9 @@ public void GetScalabilityProvider_WithDefaultAzureWebJobsStorage_CreatesProvide Assert.NotNull(provider); Assert.IsType(provider); Assert.Equal("AzureWebJobsStorage", provider.ConnectionName); + // Azure Storage defaults to 10 for both orchestrator and activity Assert.Equal(10, provider.MaxConcurrentTaskOrchestrationWorkItems); - Assert.Equal(20, provider.MaxConcurrentTaskActivityWorkItems); + Assert.Equal(10, provider.MaxConcurrentTaskActivityWorkItems); } /// @@ -346,18 +314,18 @@ public void GetScalabilityProvider_WithMultipleConnections_CreatesProvidersSucce foreach (var connectionName in connectionNames) { - var options = CreateOptions("testHub", 5, 10, connectionName); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, clientFactory, this.nameResolver, this.loggerFactory); // Pass connection name via trigger metadata (Scale Controller behavior) var triggerMetadata = CreateTriggerMetadata("testHub", 5, 10, connectionName); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); // Act - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert Assert.NotNull(provider); @@ -376,9 +344,8 @@ public void GetScalabilityProvider_WithMultipleConnections_CreatesProvidersSucce public void Factory_Name_IsAzureStorage() { // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection"); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, this.clientProviderFactory, this.nameResolver, this.loggerFactory); @@ -397,21 +364,8 @@ public void Factory_Name_IsAzureStorage() [Fact] public void GetScalabilityProvider_WithAzureStorageType_UsesCorrectProvider() { - // Arrange - Explicitly set storageProvider type to "AzureStorage" - var options = new DurableTaskScaleOptions - { - HubName = "testHub", - MaxConcurrentOrchestratorFunctions = 10, - MaxConcurrentActivityFunctions = 20, - StorageProvider = new Dictionary - { - { "type", "AzureStorage" }, - { "connectionName", "AzureWebJobsStorage" }, - }, - }; - + // Arrange var factory = new AzureStorageScalabilityProviderFactory( - Options.Create(options), this.clientProviderFactory, this.nameResolver, this.loggerFactory); @@ -446,9 +400,8 @@ public void GetScalabilityProvider_RetrievesConnectionStringFromConfiguration() var config = configBuilder.Build(); var clientFactory = new StorageServiceClientProviderFactory(config, this.loggerFactory); - var options = CreateOptions("testHub", 10, 20, "MyCustomConnection"); + // Options no longer used - removed CreateOptions call var factory = new AzureStorageScalabilityProviderFactory( - options, clientFactory, this.nameResolver, this.loggerFactory); diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs index aca9b004f..09efcb6bf 100644 --- a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -102,32 +102,7 @@ public void AddDurableTask_RegistersDurableTaskScaleExtension() Assert.NotNull(extensionDescriptor); } - /// - /// Scenario: Options configuration binding. - /// Validates that DurableTaskScaleOptions is bound and available via IOptions pattern. - /// Tests configuration injection mechanism for Scale Controller settings. - /// Ensures options can be updated via configuration files or environment variables. - /// - [Fact] - public void AddDurableTask_BindsDurableTaskScaleOptions() - { - // Arrange - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - // Act - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); - var services = host.Services; - - // Assert - // Verify DurableTaskScaleOptions can be retrieved - var options = services.GetService>(); - Assert.NotNull(options); - Assert.NotNull(options.Value); - } + // Test removed: DurableTaskScaleOptions no longer exists - we now rely solely on TriggerMetadata from Scale Controller /// /// Scenario: Singleton registration for storage client factory. @@ -335,11 +310,9 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal var configuration = services.GetRequiredService(); var nameResolver = services.GetRequiredService(); var loggerFactory = services.GetRequiredService(); - var options = services.GetRequiredService>(); // Register SQL Server factory (normally done by Scale Controller) var sqlFactory = new SqlServerScalabilityProviderFactory( - options, configuration, nameResolver, loggerFactory); @@ -356,7 +329,6 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) var triggersScaleProvider = new DurableTaskTriggersScaleProvider( - options, nameResolver, loggerFactory, scalabilityProviderFactories, diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs index 81f04ef9b..0714048e5 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -64,11 +64,10 @@ private class SimpleNameResolver : INameResolver public void Constructor_WithMssqlType_CreatesInstance() { // Arrange - Specify type="mssql" in storage provider - var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); + // Options no longer used - removed CreateOptions call // Act var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); @@ -90,11 +89,10 @@ public void Constructor_WithMssqlType_CreatesInstance() public void Constructor_WithAzureStorageType_ReturnsEarly() { // Arrange - Specify type="AzureStorage" instead of "mssql" - var options = CreateOptions("testHub", 10, 20, "TestConnection", "AzureStorage"); + // Options no longer used - removed CreateOptions call // Act var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); @@ -117,7 +115,6 @@ public void Constructor_NullOptions_ThrowsArgumentNullException() Assert.Throws(() => new SqlServerScalabilityProviderFactory( null, - this.configuration, this.nameResolver, this.loggerFactory)); } @@ -133,17 +130,17 @@ public void Constructor_NullOptions_ThrowsArgumentNullException() public void GetScalabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidProvider() { // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); + // Options no longer used - removed CreateOptions call var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); var triggerMetadata = CreateTriggerMetadata("testHub", 15, 25, "TestConnection", "mssql"); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); // Act - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert Assert.NotNull(provider); @@ -160,22 +157,14 @@ public void GetScalabilityProvider_WithTriggerMetadataAndMssqlType_ReturnsValidP [Fact] public void GetScalabilityProvider_WithMssqlType_ReturnsValidProvider() { - // Arrange - var options = CreateOptions("testHub", 10, 20, "TestConnection", "mssql"); + // Arrange - SQL Server now requires metadata var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); - // Act - Without trigger metadata, uses hardcoded default - var provider = factory.GetScalabilityProvider(); - - // Assert - Assert.NotNull(provider); - Assert.IsType(provider); - // Connection name is now from hardcoded default, not from options - Assert.Equal("SQLDB_Connection", provider.ConnectionName); + // Act & Assert - Should throw NotImplementedException since SQL provider requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -189,26 +178,15 @@ public void GetScalabilityProvider_WithConnectionStringName_UsesCorrectConnectio { // Arrange - Pass connection name via trigger metadata (Scale Controller payload) var triggerMetadata = CreateTriggerMetadata("testHub", 10, 20, "TestConnection", "mssql"); - - var options = new DurableTaskScaleOptions - { - HubName = "testHub", - MaxConcurrentOrchestratorFunctions = 10, - MaxConcurrentActivityFunctions = 20, - StorageProvider = new Dictionary - { - { "type", "mssql" }, - }, - }; + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); var factory = new SqlServerScalabilityProviderFactory( - Options.Create(options), this.configuration, this.nameResolver, this.loggerFactory); // Act - Connection name comes from triggerMetadata, not from options - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert Assert.NotNull(provider); @@ -224,16 +202,14 @@ public void GetScalabilityProvider_WithConnectionStringName_UsesCorrectConnectio [Fact] public void ValidateSqlServerOptions_InvalidMaxConcurrent_ThrowsInvalidOperationException() { - // Arrange - MaxConcurrentOrchestratorFunctions is 0 - var options = CreateOptions("testHub", 0, 20, "TestConnection", "mssql"); + // Arrange - SQL Server now requires metadata var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); - // Act & Assert - Assert.Throws(() => factory.GetScalabilityProvider()); + // Act & Assert - Should throw NotImplementedException since SQL provider requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } /// @@ -250,38 +226,18 @@ public void GetScalabilityProvider_MissingConnectionString_ThrowsInvalidOperatio configBuilder.AddInMemoryCollection(new Dictionary()); var emptyConfig = configBuilder.Build(); - var options = CreateOptions("testHub", 10, 20, "NonExistentConnection", "mssql"); + // Options no longer used - removed CreateOptions call var factory = new SqlServerScalabilityProviderFactory( - options, emptyConfig, this.nameResolver, this.loggerFactory); - // Act & Assert - Assert.Throws(() => factory.GetScalabilityProvider()); + // Act & Assert - Should throw NotImplementedException since SQL provider requires metadata + Assert.Throws(() => factory.GetScalabilityProvider()); } - private static IOptions CreateOptions( - string hubName, - int maxOrchestrator, - int maxActivity, - string connectionName, - string storageType = "mssql") - { - var options = new DurableTaskScaleOptions - { - HubName = hubName, - MaxConcurrentOrchestratorFunctions = maxOrchestrator, - MaxConcurrentActivityFunctions = maxActivity, - StorageProvider = new Dictionary - { - { "type", storageType }, - { "connectionName", connectionName }, - }, - }; - - return Options.Create(options); - } + // CreateOptions helper removed - DurableTaskScaleOptions no longer exists + // Tests now rely on TriggerMetadata from Scale Controller instead of DurableTaskScaleOptions private static TriggerMetadata CreateTriggerMetadata( string hubName, @@ -332,15 +288,16 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProvider_AndTargetScale Assert.Equal("mssql", storageProvider["type"]?.ToString()); // Create factory - var options = CreateOptions(hubName, 10, 20, connectionName, "mssql"); + // Options no longer used - removed CreateOptions call var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + // Act - Create provider from triggerMetadata - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert - Verify SQL provider was created Assert.NotNull(provider); @@ -417,15 +374,16 @@ public void TriggerMetadataWithMssqlType_RetrievesConnectionStringFromConfigurat Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); // Create factory - var options = CreateOptions(hubName, 10, 20, connectionName, "mssql"); + // Options no longer used - removed CreateOptions call var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + // Act - Create provider from triggerMetadata - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert - Verify provider was created Assert.NotNull(provider); @@ -469,17 +427,18 @@ public void GetScalabilityProvider_WithTokenCredential_ExtractsAndUsesCredential } } - var options = CreateOptions(hubName, 10, 20, connectionName, "mssql"); + // Options no longer used - removed CreateOptions call var factory = new SqlServerScalabilityProviderFactory( - options, this.configuration, this.nameResolver, this.loggerFactory); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + // Act - Create provider from triggerMetadata with TokenCredential // Note: In real scenarios, the TokenCredential would be extracted and used to build // a connection string with Authentication="Active Directory Default" - var provider = factory.GetScalabilityProvider(triggerMetadata); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata); // Assert - Verify provider was created Assert.NotNull(provider); @@ -514,9 +473,8 @@ public void CreateSqlOrchestrationService_WithManagedIdentityConfig_ReadsServerN }); var testConfiguration = configBuilder.Build(); - var options = CreateOptions("testHub", 10, 20, connectionName, "mssql"); + // Options no longer used - removed CreateOptions call var factory = new SqlServerScalabilityProviderFactory( - options, testConfiguration, this.nameResolver, this.loggerFactory); diff --git a/test/ScaleTests/TestLoggerProvider.cs b/test/ScaleTests/TestLoggerProvider.cs index 43a8b06b6..9f3d630c6 100644 --- a/test/ScaleTests/TestLoggerProvider.cs +++ b/test/ScaleTests/TestLoggerProvider.cs @@ -86,3 +86,4 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except + diff --git a/test/ScaleTests/xunit.runner.json b/test/ScaleTests/xunit.runner.json index 37cfc3b31..20a368e6c 100644 --- a/test/ScaleTests/xunit.runner.json +++ b/test/ScaleTests/xunit.runner.json @@ -9,3 +9,4 @@ + From 5973886d16b094284beefb30945ccfc749269cbd Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 9 Nov 2025 20:15:10 -0800 Subject: [PATCH 08/25] add scale test ci yml --- .github/workflows/scale-tests.yml | 187 ++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .github/workflows/scale-tests.yml diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml new file mode 100644 index 000000000..e15c6d921 --- /dev/null +++ b/.github/workflows/scale-tests.yml @@ -0,0 +1,187 @@ +name: DurableTask Scale Tests + +on: + workflow_dispatch: + push: + branches: [ main, dev ] + paths: + - 'src/WebJobs.Extensions.DurableTask.Scale/**' + - 'test/ScaleTests/**' + - '.github/workflows/scale-tests.yml' + pull_request: + branches: [ main, dev ] + paths: + - 'src/WebJobs.Extensions.DurableTask.Scale/**' + - 'test/ScaleTests/**' + - '.github/workflows/scale-tests.yml' + +jobs: + scale-tests-azurestorage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Install Azurite + run: npm install -g azurite + + - name: Start Azurite + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & + + - name: Restore dependencies + run: dotnet restore WebJobs.Extensions.DurableTask.sln + + - name: Build + run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + + - name: Run Azure Storage Scale Tests + working-directory: test/ScaleTests + env: + AzureWebJobsStorage: UseDevelopmentStorage=true + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureStorage" + + scale-tests-sql: + runs-on: ubuntu-latest + env: + MSSQL_SA_PASSWORD: "Strong!Passw0rd123" + SQLDB_Connection: "Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=Strong!Passw0rd123;TrustServerCertificate=True;Encrypt=False;" + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Install Azurite + run: npm install -g azurite + + - name: Start Azurite + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & + + - name: Pull SQL Server Docker Image + run: docker pull mcr.microsoft.com/mssql/server:2022-latest + + - name: Start SQL Server Container + run: | + docker run --name mssql-server \ + -e ACCEPT_EULA=Y \ + -e "MSSQL_SA_PASSWORD=${{ env.MSSQL_SA_PASSWORD }}" \ + -e "MSSQL_PID=Express" \ + -p 1433:1433 \ + -d mcr.microsoft.com/mssql/server:2022-latest + + - name: Wait for SQL Server to be ready + run: | + echo "Waiting for SQL Server to be ready..." + sleep 30 + docker ps + + - name: Restore dependencies + run: dotnet restore WebJobs.Extensions.DurableTask.sln + + - name: Build + run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + + - name: Run SQL Server Scale Tests + working-directory: test/ScaleTests + env: + AzureWebJobsStorage: UseDevelopmentStorage=true + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~Sql" + + scale-tests-azuremanaged: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Install Azurite + run: npm install -g azurite + + - name: Start Azurite + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & + + - name: Pull DTS Emulator Docker Image + run: docker pull mcr.microsoft.com/dts/dts-emulator:latest + + - name: Start DTS Container + run: | + docker run -i \ + -p 8080:8080 \ + -p 8082:8082 \ + -d mcr.microsoft.com/dts/dts-emulator:latest + + - name: Wait for DTS to be ready + run: | + echo "Waiting for DTS to be ready..." + sleep 30 + docker ps + + - name: Restore dependencies + run: dotnet restore WebJobs.Extensions.DurableTask.sln + + - name: Build + run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + + - name: Run Azure Managed Scale Tests + working-directory: test/ScaleTests + env: + AzureWebJobsStorage: UseDevelopmentStorage=true + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged" + + scale-tests-all: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Install Azurite + run: npm install -g azurite + + - name: Start Azurite + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & + + - name: Restore dependencies + run: dotnet restore WebJobs.Extensions.DurableTask.sln + + - name: Build + run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + + - name: Run All Scale Tests (without containers) + working-directory: test/ScaleTests + env: + AzureWebJobsStorage: UseDevelopmentStorage=true + run: dotnet test --configuration Release --no-build --verbosity normal + From a2956b4c0d1233ca867b3c3757aefb522b67e0c7 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 9 Nov 2025 20:27:43 -0800 Subject: [PATCH 09/25] add nullable check to remove all build warnings --- .../AzureManagedScalabilityProviderFactory.cs | 4 ++-- .../AzureStorageScalabilityProviderFactory.cs | 9 +++++---- .../DurableTaskMetadata.cs | 12 +++++++++--- .../DurableTaskScaleExtension.cs | 10 +++++++--- .../DurableTaskTriggersScaleProvider.cs | 7 +++---- .../Sql/SqlServerScalabilityProviderFactory.cs | 7 ++++--- .../Sql/SqlServerTargetScaler.cs | 3 ++- .../TriggerMetadataExtensions.cs | 9 +-------- 8 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs index f145a3217..53ee49163 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -142,8 +142,8 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, AzureManagedOrchestrationServiceOptions.FromConnectionString(connectionString); // If triggerMetadata is provided, try to get token credential from it - if (triggerMetadata != null && triggerMetadata.Properties != null && - triggerMetadata.Properties.TryGetValue("GetAzureManagedTokenCredential", out object tokenCredentialFunc)) + if (triggerMetadata != null && triggerMetadata.Properties != null && + triggerMetadata.Properties.TryGetValue("GetAzureManagedTokenCredential", out object? tokenCredentialFunc)) { if (tokenCredentialFunc is Func getTokenCredential) { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index a4b396e47..85330992e 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -1,5 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable using System; using System.Collections.Generic; @@ -21,7 +22,7 @@ public class AzureStorageScalabilityProviderFactory : IScalabilityProviderFactor private readonly IStorageServiceClientProviderFactory clientProviderFactory; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; - private AzureStorageScalabilityProvider defaultStorageProvider; + private AzureStorageScalabilityProvider? defaultStorageProvider; /// /// Initializes a new instance of the class. @@ -128,7 +129,7 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, } // Scale Controller will return a AzureComponentWrapper which might contain a token crednetial to use. - private static global::Azure.Core.TokenCredential ExtractTokenCredential(TriggerMetadata triggerMetadata, ILogger logger) + private static global::Azure.Core.TokenCredential? ExtractTokenCredential(TriggerMetadata? triggerMetadata, ILogger? logger) { if (triggerMetadata?.Properties == null) { @@ -137,7 +138,7 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, // Check if metadata contains an AzureComponentFactory wrapper // ScaleController passes it as: metadata.Properties[nameof(AzureComponentFactory)] = new AzureComponentFactoryWrapper(...) - if (triggerMetadata.Properties.TryGetValue("AzureComponentFactory", out object componentFactoryObj) && componentFactoryObj != null) + if (triggerMetadata.Properties.TryGetValue("AzureComponentFactory", out object? componentFactoryObj) && componentFactoryObj != null) { // The AzureComponentFactoryWrapper has CreateTokenCredential method // Call it using reflection to get the TokenCredential @@ -148,7 +149,7 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, try { // Call CreateTokenCredential(null) to get the TokenCredential from the wrapper - var credential = method.Invoke(componentFactoryObj, new object[] { null }); + var credential = method.Invoke(componentFactoryObj, new object?[] { null }); if (credential is global::Azure.Core.TokenCredential tokenCredential) { return tokenCredential; diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs index cf1854f24..b5b75e7e8 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskMetadata.cs @@ -1,4 +1,8 @@ -using System.Collections.Generic; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable + +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale @@ -43,9 +47,11 @@ public class DurableTaskMetadata /// The name resolver used to resolve app setting placeholders. public static void ResolveAppSettingOptions(DurableTaskMetadata metadata, INameResolver nameResolver) { - if (metadata.StorageProvider.TryGetValue("connectionName", out object connectionNameObj) && connectionNameObj is string connectionName) + if (metadata.StorageProvider != null && + metadata.StorageProvider.TryGetValue("connectionName", out object? connectionNameObj) && + connectionNameObj is string connectionName) { - metadata.StorageProvider["connectionName"] = nameResolver.Resolve(connectionName); + metadata.StorageProvider["connectionName"] = nameResolver.Resolve(connectionName) ?? string.Empty; } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs index 7f8751ff5..72e60bcf5 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskScaleExtension.cs @@ -1,3 +1,7 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable + using System; using System.Collections.Generic; using System.Linq; @@ -79,7 +83,7 @@ internal static IScalabilityProviderFactory GetScalabilityProviderFactory( IEnumerable scalabilityProviderFactories) { const string DefaultProvider = "AzureStorage"; - object storageType = null; + object? storageType = null; bool storageTypeIsConfigured = metadata.StorageProvider != null && metadata.StorageProvider.TryGetValue("type", out storageType); if (!storageTypeIsConfigured) @@ -98,14 +102,14 @@ internal static IScalabilityProviderFactory GetScalabilityProviderFactory( try { - IScalabilityProviderFactory selectedFactory = scalabilityProviderFactories.First(f => string.Equals(f.Name, storageType.ToString(), StringComparison.OrdinalIgnoreCase)); + IScalabilityProviderFactory selectedFactory = scalabilityProviderFactories.First(f => string.Equals(f.Name, storageType!.ToString(), StringComparison.OrdinalIgnoreCase)); logger.LogInformation($"Using the {storageType} storage provider."); return selectedFactory; } catch (InvalidOperationException e) { IList factoryNames = scalabilityProviderFactories.Select(f => f.Name).ToList(); - throw new InvalidOperationException($"Storage provider type ({storageType}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); + throw new InvalidOperationException($"Storage provider type ({storageType?.ToString() ?? "null"}) was not found. Available storage providers: {string.Join(", ", factoryNames)}.", e); } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index 88e33b2da..d3a4da032 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -24,7 +24,7 @@ public DurableTaskTriggersScaleProvider( string functionId = triggerMetadata.FunctionName; var functionName = new FunctionName(functionId); - // Deserialize the configuration from triggerMetadata + // Deserialize the configuration from triggerMetadata var metadata = triggerMetadata.Metadata.ToObject() ?? throw new InvalidOperationException($"Failed to deserialize trigger metadata. Payload: {triggerMetadata.Metadata}"); @@ -35,7 +35,6 @@ public DurableTaskTriggersScaleProvider( } // Resolve app settings (e.g., %MyConnectionString% -> actual value) - DurableTaskMetadata.ResolveAppSettingOptions(metadata, nameResolver); var logger = loggerFactory.CreateLogger(); @@ -80,13 +79,13 @@ public DurableTaskTriggersScaleProvider( } // Try connectionName first - if (storageProvider.TryGetValue("connectionName", out object value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + if (storageProvider.TryGetValue("connectionName", out object? value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) { return s1; } // Try connectionStringName - if (storageProvider.TryGetValue("connectionStringName", out object value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + if (storageProvider.TryGetValue("connectionStringName", out object? value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { return s2; } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index e32256723..d3cf1acbe 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -1,5 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable using System; using DurableTask.SqlServer; @@ -104,19 +105,19 @@ private SqlOrchestrationService CreateSqlOrchestrationService( string connectionName, string taskHubName, ILogger logger, - DurableTaskMetadata metadata = null) + DurableTaskMetadata? metadata = null) { // Resolve connection name first (handles %% wrapping) string resolvedConnectionName = this.nameResolver.Resolve(connectionName); // Try to get connection string from configuration (app settings) - string connectionString = this.configuration.GetConnectionString(resolvedConnectionName) + string? connectionString = this.configuration.GetConnectionString(resolvedConnectionName) ?? this.configuration[resolvedConnectionName]; // Fallback to environment variable (matching old implementation behavior) if (string.IsNullOrEmpty(connectionString)) { - connectionString = Environment.GetEnvironmentVariable(resolvedConnectionName); + connectionString = Environment.GetEnvironmentVariable(resolvedConnectionName) ?? string.Empty; } if (string.IsNullOrEmpty(connectionString)) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs index 02d03d453..e52908f40 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerTargetScaler.cs @@ -27,6 +27,7 @@ public class SqlServerTargetScaler : ITargetScaler public SqlServerTargetScaler(string functionId, SqlServerMetricsProvider sqlMetricsProvider) { this.sqlMetricsProvider = sqlMetricsProvider ?? throw new ArgumentNullException(nameof(sqlMetricsProvider)); + // Scalers in Durable Functions are per function IDs. Scalers share the same sqlMetricsProvider in the same task hub. this.TargetScalerDescriptor = new TargetScalerDescriptor(functionId); } @@ -36,7 +37,7 @@ public SqlServerTargetScaler(string functionId, SqlServerMetricsProvider sqlMetr /// public TargetScalerDescriptor TargetScalerDescriptor { get; } - /// + /// /// Retrieves the current scale result based on SQL Server metrics, returning the recommended number of workers for the task hub. /// /// The context for scaling evaluation. diff --git a/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs index 7c251a7a1..0f460f994 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/TriggerMetadataExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable using System.Collections.Generic; using Microsoft.Azure.WebJobs.Host.Scale; @@ -23,14 +24,6 @@ internal static class TriggerMetadataExtensions return null; } - // Check if already parsed and stored in Properties - if (triggerMetadata.Properties != null && - triggerMetadata.Properties.TryGetValue("DurableTaskMetadata", out object cachedMetadata) && - cachedMetadata is DurableTaskMetadata metadata) - { - return metadata; - } - try { // Parse the JSON metadata to extract configuration values From 0e950a05bf7ac7cf4f69616ceaa7986236ebf4d9 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 9 Nov 2025 20:37:45 -0800 Subject: [PATCH 10/25] udpate test --- .github/workflows/scale-tests.yml | 48 ++----- ...TaskJobHostConfigurationExtensionsTests.cs | 120 ++++++++++++++++++ 2 files changed, 129 insertions(+), 39 deletions(-) diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml index e15c6d921..9ef76fb13 100644 --- a/.github/workflows/scale-tests.yml +++ b/.github/workflows/scale-tests.yml @@ -16,7 +16,8 @@ on: - '.github/workflows/scale-tests.yml' jobs: - scale-tests-azurestorage: + azurestorage: + name: DurableTask Scale Tests - AzureStorage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -47,9 +48,10 @@ jobs: working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureStorage" + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureStorage|FullyQualifiedName~DurableTaskJobHostConfigurationExtensions" - scale-tests-sql: + mssql: + name: DurableTask Scale Tests - MSSQL runs-on: ubuntu-latest env: MSSQL_SA_PASSWORD: "Strong!Passw0rd123" @@ -101,9 +103,10 @@ jobs: working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~Sql" + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~Sql|FullyQualifiedName~DurableTaskTriggersScaleProviderSqlServer" - scale-tests-azuremanaged: + dts: + name: DurableTask Scale Tests - DTS runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -150,38 +153,5 @@ jobs: working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged" - - scale-tests-all: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 8.0.x - - - name: Set up Node.js (needed for Azurite) - uses: actions/setup-node@v3 - with: - node-version: '18.x' - - - name: Install Azurite - run: npm install -g azurite - - - name: Start Azurite - run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & - - - name: Restore dependencies - run: dotnet restore WebJobs.Extensions.DurableTask.sln - - - name: Build - run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore - - - name: Run All Scale Tests (without containers) - working-directory: test/ScaleTests - env: - AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged|FullyQualifiedName~DurableTaskTriggersScaleProviderAzureManaged" diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs index 09efcb6bf..f6d812864 100644 --- a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -370,5 +371,124 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal Assert.NotEmpty(connectionString); } } + + /// + /// Tests for end-to-end Azure Managed (DTS) scaling integration via DurableTaskTriggersScaleProvider. + /// Validates the complete flow from triggerMetadata to working TargetScaler and ScaleMonitor. + /// + public class DurableTaskTriggersScaleProviderAzureManagedTests + { + /// + /// Scenario: End-to-end Azure Managed (DTS) scaling via triggerMetadata with type="azureManaged". + /// Validates that when triggerMetadata mentions storageProvider.type="azureManaged", DurableTaskTriggersScaleProvider creates DTS provider. + /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. + /// Verifies that both TargetScaler and ScaleMonitor successfully work with Azure Managed backend. + /// This test validates the complete integration path that Scale Controller uses. + /// + [Fact] + public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTriggersScaleProvider_AndBothScalersWork() + { + // Arrange - Create triggerMetadata with type="azureManaged" (as Scale Controller would pass) + var hubName = "testHub"; + var connectionName = "v3-dtsConnectionMI"; + var metadata = new JObject + { + { "functionName", "TestFunction" }, + { "type", "activityTrigger" }, + { "taskHubName", hubName }, + { "maxConcurrentOrchestratorFunctions", 10 }, + { "maxConcurrentActivityFunctions", 20 }, + { + "storageProvider", new JObject + { + { "type", "azureManaged" }, + { "connectionName", connectionName }, + } + }, + }; + var triggerMetadata = new TriggerMetadata(metadata); + + // Verify triggerMetadata has correct storageProvider.type + var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; + Assert.NotNull(storageProvider); + Assert.Equal("azureManaged", storageProvider["type"]?.ToString()); + Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); + + // Set up DI container with Azure Managed connection string + var hostBuilder = new HostBuilder(); + hostBuilder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { $"ConnectionStrings:{connectionName}", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, + { connectionName, "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, + }); + }); + hostBuilder.ConfigureServices(services => + { + services.AddSingleton(new SimpleNameResolver()); + }); + hostBuilder.ConfigureWebJobs(webJobsBuilder => + { + webJobsBuilder.AddDurableTask(); + }); + + var host = hostBuilder.Build(); + var services = host.Services; + + // Get configuration and register Azure Managed factory (as Scale Controller would) + var configuration = services.GetRequiredService(); + var nameResolver = services.GetRequiredService(); + var loggerFactory = services.GetRequiredService(); + + // Register Azure Managed factory (normally done by Scale Controller) + var azureManagedFactory = new AzureManagedScalabilityProviderFactory( + configuration, + nameResolver, + loggerFactory); + + // Create a list with all factories (Azure Storage from AddDurableTask + Azure Managed from Scale Controller) + var scalabilityProviderFactories = new List( + services.GetServices()); + scalabilityProviderFactories.Add(azureManagedFactory); + + // Verify Azure Managed factory is available (using case-insensitive matching like the actual code) + var azureManagedFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => string.Equals(f.Name, "AzureManaged", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(azureManagedFactoryFound); + Assert.IsType(azureManagedFactoryFound); + + // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) + var triggersScaleProvider = new DurableTaskTriggersScaleProvider( + nameResolver, + loggerFactory, + scalabilityProviderFactories, + triggerMetadata); + + // Act - Get TargetScaler from DurableTaskTriggersScaleProvider + var targetScaler = triggersScaleProvider.GetTargetScaler(); + + // Assert - TargetScaler was created successfully + Assert.NotNull(targetScaler); + // AzureManagedTargetScaler is internal, so we verify it by checking the type name + Assert.Equal("AzureManagedTargetScaler", targetScaler.GetType().Name); + + // Act - Get ScaleMonitor from DurableTaskTriggersScaleProvider + var scaleMonitor = triggersScaleProvider.GetMonitor(); + + // Assert - ScaleMonitor was created successfully (Azure Managed uses DummyScaleMonitor) + Assert.NotNull(scaleMonitor); + + // Note: We skip actual service calls (GetScaleResultAsync, GetMetricsAsync) because: + // 1. They require a real Azure Managed endpoint or DTS emulator + // 2. The test's primary goal is to verify the integration path (triggerMetadata -> provider -> scaler) + // 3. The SQL test can connect to a real SQL Server in CI, but Azure Managed requires DTS emulator + // The fact that we successfully created the provider and scalers proves the integration works correctly. + + // Verify connection string was successfully retrieved + var connectionString = configuration.GetConnectionString(connectionName) ?? configuration[connectionName]; + Assert.NotNull(connectionString); + Assert.NotEmpty(connectionString); + } + } } From 9d8dd48a8ce6ccb5e04c0b517172e4627d9de755 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 9 Nov 2025 20:48:05 -0800 Subject: [PATCH 11/25] udpate yml --- .github/workflows/scale-tests.yml | 24 +- ...TaskJobHostConfigurationExtensionsTests.cs | 225 +++++++++--------- 2 files changed, 135 insertions(+), 114 deletions(-) diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml index 9ef76fb13..aac4a21c4 100644 --- a/.github/workflows/scale-tests.yml +++ b/.github/workflows/scale-tests.yml @@ -39,10 +39,14 @@ jobs: run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & - name: Restore dependencies - run: dotnet restore WebJobs.Extensions.DurableTask.sln + run: | + dotnet restore src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj + dotnet restore test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj - name: Build - run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + run: | + dotnet build src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj --configuration Release --no-restore + dotnet build test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj --configuration Release --no-restore - name: Run Azure Storage Scale Tests working-directory: test/ScaleTests @@ -94,10 +98,14 @@ jobs: docker ps - name: Restore dependencies - run: dotnet restore WebJobs.Extensions.DurableTask.sln + run: | + dotnet restore src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj + dotnet restore test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj - name: Build - run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + run: | + dotnet build src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj --configuration Release --no-restore + dotnet build test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj --configuration Release --no-restore - name: Run SQL Server Scale Tests working-directory: test/ScaleTests @@ -144,10 +152,14 @@ jobs: docker ps - name: Restore dependencies - run: dotnet restore WebJobs.Extensions.DurableTask.sln + run: | + dotnet restore src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj + dotnet restore test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj - name: Build - run: dotnet build WebJobs.Extensions.DurableTask.sln --configuration Release --no-restore + run: | + dotnet build src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj --configuration Release --no-restore + dotnet build test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj --configuration Release --no-restore - name: Run Azure Managed Scale Tests working-directory: test/ScaleTests diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs index f6d812864..637ec420f 100644 --- a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -10,10 +10,10 @@ using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; +using Microsoft.Azure.WebJobs.Host.Config; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using Xunit; @@ -38,28 +38,27 @@ public class DurableTaskJobHostConfigurationExtensionsTests public void AddDurableTask_RegistersRequiredServices() { // Arrange - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureServices(services => - { - // Register INameResolver - required by AzureStorageScalabilityProviderFactory - services.AddSingleton(new SimpleNameResolver()); - }); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - // Act - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); - var services = host.Services; + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(new ConfigurationBuilder().Build()); + + var webJobsBuilder = new TestWebJobsBuilder(services); + + // Act + webJobsBuilder.AddDurableTask(); // Assert + // Build service provider to resolve services + var serviceProvider = services.BuildServiceProvider(); + // Verify IStorageServiceClientProviderFactory is registered - var clientProviderFactory = services.GetService(); + var clientProviderFactory = serviceProvider.GetService(); Assert.NotNull(clientProviderFactory); // Verify IScalabilityProviderFactory is registered - var scalabilityProviderFactories = services.GetServices().ToList(); + var scalabilityProviderFactories = serviceProvider.GetServices().ToList(); Assert.NotEmpty(scalabilityProviderFactories); Assert.Contains(scalabilityProviderFactories, f => f is AzureStorageScalabilityProviderFactory); Assert.Contains(scalabilityProviderFactories, f => f is AzureManagedScalabilityProviderFactory); @@ -75,30 +74,21 @@ public void AddDurableTask_RegistersRequiredServices() public void AddDurableTask_RegistersDurableTaskScaleExtension() { // Arrange - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureServices(services => - { - // Register INameResolver - required by AzureStorageScalabilityProviderFactory - services.AddSingleton(new SimpleNameResolver()); - }); + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(new ConfigurationBuilder().Build()); - IServiceCollection capturedServices = null; - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - // Capture the service collection before building - capturedServices = webJobsBuilder.Services; - - // Act - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); + var webJobsBuilder = new TestWebJobsBuilder(services); + + // Act + webJobsBuilder.AddDurableTask(); // Assert // Verify DurableTaskScaleExtension is registered by checking service descriptors - Assert.NotNull(capturedServices); - var extensionDescriptor = capturedServices - .FirstOrDefault(d => d.ServiceType == typeof(Microsoft.Azure.WebJobs.Host.Config.IExtensionConfigProvider) + var extensionDescriptor = services + .FirstOrDefault(d => d.ServiceType == typeof(IExtensionConfigProvider) && d.ImplementationType == typeof(DurableTaskScaleExtension)); Assert.NotNull(extensionDescriptor); } @@ -115,20 +105,24 @@ public void AddDurableTask_RegistersDurableTaskScaleExtension() public void AddDurableTask_RegistersSingletonClientProviderFactory() { // Arrange - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - // Act - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); - var services = host.Services; + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(new ConfigurationBuilder().Build()); + + var webJobsBuilder = new TestWebJobsBuilder(services); + + // Act + webJobsBuilder.AddDurableTask(); // Assert + // Build service provider to resolve services + var serviceProvider = services.BuildServiceProvider(); + // Verify the same instance is returned (singleton) - var factory1 = services.GetService(); - var factory2 = services.GetService(); + var factory1 = serviceProvider.GetService(); + var factory2 = serviceProvider.GetService(); Assert.Same(factory1, factory2); } @@ -195,31 +189,30 @@ public void AddDurableTask_RegistersAzureStorageAsDefaultProvider() public void AddDurableTask_WithMultipleConnections_AllCanBeResolved() { // Arrange - Set up configuration with multiple connections - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureAppConfiguration((context, config) => - { - config.AddInMemoryCollection(new Dictionary + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "AzureWebJobsStorage", "UseDevelopmentStorage=true" }, { "Connection1", "UseDevelopmentStorage=true" }, { "Connection2", "UseDevelopmentStorage=true" }, - }); - }); - hostBuilder.ConfigureServices(services => - { - services.AddSingleton(new SimpleNameResolver()); - }); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); - var services = host.Services; + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(configuration); + + var webJobsBuilder = new TestWebJobsBuilder(services); + webJobsBuilder.AddDurableTask(); // Assert + // Build service provider to resolve services + var serviceProvider = services.BuildServiceProvider(); + // Verify we can create client providers for different connections - var clientProviderFactory = services.GetService(); + var clientProviderFactory = serviceProvider.GetService(); Assert.NotNull(clientProviderFactory); // Test that we can get client providers for all connections @@ -243,6 +236,26 @@ public string Resolve(string name) } } + /// + /// Simple test implementation of IWebJobsBuilder that wraps a ServiceCollection. + /// This allows us to test AddDurableTask() without needing a full HostBuilder. + /// + internal class TestWebJobsBuilder : IWebJobsBuilder + { + public TestWebJobsBuilder(IServiceCollection services) + { + this.Services = services; + } + + public IServiceCollection Services { get; } + + public IWebJobsBuilder AddExtension() where TExtension : class, IExtensionConfigProvider + { + this.Services.AddSingleton(); + return this; + } + } + /// /// Tests for end-to-end SQL Server scaling integration via DurableTaskTriggersScaleProvider. /// Validates the complete flow from triggerMetadata to working TargetScaler and ScaleMonitor. @@ -286,31 +299,29 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); // Set up DI container with SQL connection string - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureAppConfiguration((context, config) => - { - config.AddInMemoryCollection(new Dictionary + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { $"ConnectionStrings:{connectionName}", TestHelpers.GetSqlConnectionString() }, { connectionName, TestHelpers.GetSqlConnectionString() }, - }); - }); - hostBuilder.ConfigureServices(services => - { - services.AddSingleton(new SimpleNameResolver()); - }); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); - var services = host.Services; + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(configuration); + + var webJobsBuilder = new TestWebJobsBuilder(services); + webJobsBuilder.AddDurableTask(); + + // Build service provider to resolve services + var serviceProvider = services.BuildServiceProvider(); // Get configuration and register SQL factory (as Scale Controller would) - var configuration = services.GetRequiredService(); - var nameResolver = services.GetRequiredService(); - var loggerFactory = services.GetRequiredService(); + var nameResolver = serviceProvider.GetRequiredService(); + var loggerFactory = serviceProvider.GetRequiredService(); // Register SQL Server factory (normally done by Scale Controller) var sqlFactory = new SqlServerScalabilityProviderFactory( @@ -320,7 +331,7 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal // Create a list with all factories (Azure Storage from AddDurableTask + SQL from Scale Controller) var scalabilityProviderFactories = new List( - services.GetServices()); + serviceProvider.GetServices()); scalabilityProviderFactories.Add(sqlFactory); // Verify SQL Server factory is available @@ -415,31 +426,29 @@ public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTrigg Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); // Set up DI container with Azure Managed connection string - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureAppConfiguration((context, config) => - { - config.AddInMemoryCollection(new Dictionary + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { $"ConnectionStrings:{connectionName}", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, { connectionName, "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, - }); - }); - hostBuilder.ConfigureServices(services => - { - services.AddSingleton(new SimpleNameResolver()); - }); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - webJobsBuilder.AddDurableTask(); - }); - - var host = hostBuilder.Build(); - var services = host.Services; + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(configuration); + + var webJobsBuilder = new TestWebJobsBuilder(services); + webJobsBuilder.AddDurableTask(); + + // Build service provider to resolve services + var serviceProvider = services.BuildServiceProvider(); // Get configuration and register Azure Managed factory (as Scale Controller would) - var configuration = services.GetRequiredService(); - var nameResolver = services.GetRequiredService(); - var loggerFactory = services.GetRequiredService(); + var nameResolver = serviceProvider.GetRequiredService(); + var loggerFactory = serviceProvider.GetRequiredService(); // Register Azure Managed factory (normally done by Scale Controller) var azureManagedFactory = new AzureManagedScalabilityProviderFactory( @@ -449,7 +458,7 @@ public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTrigg // Create a list with all factories (Azure Storage from AddDurableTask + Azure Managed from Scale Controller) var scalabilityProviderFactories = new List( - services.GetServices()); + serviceProvider.GetServices()); scalabilityProviderFactories.Add(azureManagedFactory); // Verify Azure Managed factory is available (using case-insensitive matching like the actual code) From 9e52edf9ce489a18cd7d44a46c997e1cef96019b Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 9 Nov 2025 21:30:28 -0800 Subject: [PATCH 12/25] udpate test --- .github/workflows/scale-tests.yml | 1 + WebJobs.Extensions.DurableTask.sln | 7 + ...eManagedScalabilityProviderFactoryTests.cs | 5 - ...eStorageScalabilityProviderFactoryTests.cs | 5 - .../AzureStorageScalabilityProviderTests.cs | 4 - .../DurableTaskScaleMonitorTests.cs | 3 +- .../DurableTaskTargetScalerTests.cs | 4 +- ...TaskJobHostConfigurationExtensionsTests.cs | 271 +++++++----------- test/ScaleTests/SimpleNameResolver.cs | 17 ++ ...qlServerScalabilityProviderFactoryTests.cs | 3 - .../Sql/SqlServerScalabilityProviderTests.cs | 1 - .../Sql/SqlServerScaleMonitorTests.cs | 3 - .../Sql/SqlServerTargetScalerTests.cs | 1 - test/ScaleTests/Sql/SqlServerTestHelpers.cs | 3 - test/ScaleTests/TestHelpers.cs | 38 +-- test/ScaleTests/TestLoggerProvider.cs | 9 +- test/ScaleTests/TestWebJobsBuilder.cs | 29 ++ ....Extensions.DurableTask.Scale.Tests.csproj | 1 - test/ScaleTests/xunit.runner.json | 2 + 19 files changed, 176 insertions(+), 231 deletions(-) create mode 100644 test/ScaleTests/SimpleNameResolver.cs create mode 100644 test/ScaleTests/TestWebJobsBuilder.cs diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml index aac4a21c4..ae8709ae7 100644 --- a/.github/workflows/scale-tests.yml +++ b/.github/workflows/scale-tests.yml @@ -111,6 +111,7 @@ jobs: working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true + SQLDB_Connection: ${{ env.SQLDB_Connection }} run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~Sql|FullyQualifiedName~DurableTaskTriggersScaleProviderSqlServer" dts: diff --git a/WebJobs.Extensions.DurableTask.sln b/WebJobs.Extensions.DurableTask.sln index 82b1fc441..21e91e4bb 100644 --- a/WebJobs.Extensions.DurableTask.sln +++ b/WebJobs.Extensions.DurableTask.sln @@ -64,6 +64,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.DurableTa EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebJobs.Extensions.DurableTask.Scale", "src\WebJobs.Extensions.DurableTask.Scale\WebJobs.Extensions.DurableTask.Scale.csproj", "{A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebJobs.Extensions.DurableTask.Scale.Tests", "test\ScaleTests\WebJobs.Extensions.DurableTask.Scale.Tests.csproj", "{B3F6E2C3-3C5C-5C7F-B1B8-7E8F1F79F2D0}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{B7FBBE6E-9AC7-4CEB-9B80-6FD9AE74415A}" ProjectSection(SolutionItems) = preProject azure-pipelines.yml = azure-pipelines.yml @@ -132,6 +134,10 @@ Global {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9}.Release|Any CPU.Build.0 = Release|Any CPU + {B3F6E2C3-3C5C-5C7F-B1B8-7E8F1F79F2D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3F6E2C3-3C5C-5C7F-B1B8-7E8F1F79F2D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3F6E2C3-3C5C-5C7F-B1B8-7E8F1F79F2D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3F6E2C3-3C5C-5C7F-B1B8-7E8F1F79F2D0}.Release|Any CPU.Build.0 = Release|Any CPU {FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -169,6 +175,7 @@ Global {FF6CD07A-A4BF-43C5-B14E-213328DEB835} = {9B0F4A0A-1B18-4E98-850A-14A2F76F673D} {5F5FAF27-D6B8-4A60-ACF2-F63D13F89CA2} = {7EC858EE-3481-4A82-AED4-CB00C34F42D0} {A2E5E1B2-2B4B-4B6F-A0A7-6D7E0E68F1C9} = {7EC858EE-3481-4A82-AED4-CB00C34F42D0} + {B3F6E2C3-3C5C-5C7F-B1B8-7E8F1F79F2D0} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} {7387E723-E153-4B7A-B105-8C67BFBD48CF} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} {FC8AD123-F949-4D21-B817-E5A4BBF7F69B} = {7387E723-E153-4B7A-B105-8C67BFBD48CF} {76DEC17C-BF6A-498A-8E8A-7D6CB2E03284} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} diff --git a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs index df5c4c4f9..1a3030565 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs @@ -3,13 +3,10 @@ using System; using System.Collections.Generic; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; using Xunit; using Xunit.Abstractions; @@ -320,5 +317,3 @@ private static TriggerMetadata CreateTriggerMetadata( } } } - - diff --git a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs index a4e6e5453..1610afa0c 100644 --- a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderFactoryTests.cs @@ -3,15 +3,10 @@ using System; using System.Collections.Generic; -using DurableTask.AzureStorage; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Moq; using Newtonsoft.Json.Linq; using Xunit; using Xunit.Abstractions; diff --git a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs index b9be10110..d3f2c0361 100644 --- a/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs +++ b/test/ScaleTests/AzureStorage/AzureStorageScalabilityProviderTests.cs @@ -2,14 +2,10 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Collections.Generic; using DurableTask.AzureStorage; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; -using Moq; using Xunit; using Xunit.Abstractions; diff --git a/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs b/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs index 35e7939fe..38b481ee2 100644 --- a/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs +++ b/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs @@ -15,7 +15,7 @@ using Xunit; using Xunit.Abstractions; -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { /// /// Tests for DurableTaskScaleMonitor. @@ -263,4 +263,3 @@ private PerformanceHeartbeat[] MatchEquivalentHeartbeats(PerformanceHeartbeat[] } } } - diff --git a/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs b/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs index d0cb2e723..7cb975fa3 100644 --- a/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs +++ b/test/ScaleTests/AzureStorage/DurableTaskTargetScalerTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using DurableTask.AzureStorage; using DurableTask.AzureStorage.Monitoring; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; @@ -12,7 +11,7 @@ using Xunit; using Xunit.Abstractions; -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { /// /// Tests for DurableTaskTargetScaler. @@ -85,4 +84,3 @@ public async Task TestTargetScaler(int maxConcurrentActivities, int maxConcurren } } } - diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs index 637ec420f..452081ce7 100644 --- a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -5,11 +5,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Config; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; @@ -43,16 +41,14 @@ public void AddDurableTask_RegistersRequiredServices() services.AddSingleton(new SimpleNameResolver()); services.AddSingleton(new LoggerFactory()); services.AddSingleton(new ConfigurationBuilder().Build()); - + var webJobsBuilder = new TestWebJobsBuilder(services); - - // Act + webJobsBuilder.AddDurableTask(); - // Assert // Build service provider to resolve services var serviceProvider = services.BuildServiceProvider(); - + // Verify IStorageServiceClientProviderFactory is registered var clientProviderFactory = serviceProvider.GetService(); Assert.NotNull(clientProviderFactory); @@ -79,13 +75,11 @@ public void AddDurableTask_RegistersDurableTaskScaleExtension() services.AddSingleton(new SimpleNameResolver()); services.AddSingleton(new LoggerFactory()); services.AddSingleton(new ConfigurationBuilder().Build()); - + var webJobsBuilder = new TestWebJobsBuilder(services); - - // Act + webJobsBuilder.AddDurableTask(); - // Assert // Verify DurableTaskScaleExtension is registered by checking service descriptors var extensionDescriptor = services .FirstOrDefault(d => d.ServiceType == typeof(IExtensionConfigProvider) @@ -93,8 +87,6 @@ public void AddDurableTask_RegistersDurableTaskScaleExtension() Assert.NotNull(extensionDescriptor); } - // Test removed: DurableTaskScaleOptions no longer exists - we now rely solely on TriggerMetadata from Scale Controller - /// /// Scenario: Singleton registration for storage client factory. /// Validates that IStorageServiceClientProviderFactory is registered as singleton. @@ -110,16 +102,14 @@ public void AddDurableTask_RegistersSingletonClientProviderFactory() services.AddSingleton(new SimpleNameResolver()); services.AddSingleton(new LoggerFactory()); services.AddSingleton(new ConfigurationBuilder().Build()); - + var webJobsBuilder = new TestWebJobsBuilder(services); - - // Act + webJobsBuilder.AddDurableTask(); - // Assert // Build service provider to resolve services var serviceProvider = services.BuildServiceProvider(); - + // Verify the same instance is returned (singleton) var factory1 = serviceProvider.GetService(); var factory2 = serviceProvider.GetService(); @@ -135,7 +125,6 @@ public void AddDurableTask_RegistersSingletonClientProviderFactory() [Fact] public void AddDurableTask_NullBuilder_ThrowsArgumentNullException() { - // Act & Assert Assert.Throws(() => { IWebJobsBuilder builder = null; @@ -144,7 +133,6 @@ public void AddDurableTask_NullBuilder_ThrowsArgumentNullException() } /// - /// ✅ KEY SCENARIO 1: Default Azure Storage provider registration. /// Validates that AddDurableTask() registers AzureStorageScalabilityProviderFactory. /// Tests that Azure Storage is configured as the default scalability provider. /// Verifies factory name is "AzureStorage" for Scale Controller identification. @@ -154,32 +142,35 @@ public void AddDurableTask_NullBuilder_ThrowsArgumentNullException() public void AddDurableTask_RegistersAzureStorageAsDefaultProvider() { // Arrange - var hostBuilder = new HostBuilder(); - hostBuilder.ConfigureServices(services => - { - services.AddSingleton(new SimpleNameResolver()); - }); - hostBuilder.ConfigureWebJobs(webJobsBuilder => - { - // Act - webJobsBuilder.AddDurableTask(); - }); + // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "AzureWebJobsStorage", "UseDevelopmentStorage=true" }, + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(new SimpleNameResolver()); + services.AddSingleton(new LoggerFactory()); + services.AddSingleton(configuration); + + var webJobsBuilder = new TestWebJobsBuilder(services); + + webJobsBuilder.AddDurableTask(); - var host = hostBuilder.Build(); - var services = host.Services; + var serviceProvider = services.BuildServiceProvider(); - // Assert // Verify AzureStorageScalabilityProviderFactory is registered as the default - var scalabilityProviderFactories = services.GetServices().ToList(); + var scalabilityProviderFactories = serviceProvider.GetServices().ToList(); Assert.NotEmpty(scalabilityProviderFactories); - + var azureStorageFactory = scalabilityProviderFactories.OfType().FirstOrDefault(); Assert.NotNull(azureStorageFactory); Assert.Equal("AzureStorage", azureStorageFactory.Name); } /// - /// ✅ KEY SCENARIO 2: Multiple connections configuration resolution. /// Validates that factory can resolve multiple connection strings from configuration. /// Tests multi-tenant scenarios where different functions use different storage accounts. /// Verifies end-to-end DI setup with IConfiguration integration. @@ -198,19 +189,18 @@ public void AddDurableTask_WithMultipleConnections_AllCanBeResolved() { "Connection2", "UseDevelopmentStorage=true" }, }) .Build(); - + var services = new ServiceCollection(); services.AddSingleton(new SimpleNameResolver()); services.AddSingleton(new LoggerFactory()); services.AddSingleton(configuration); - + var webJobsBuilder = new TestWebJobsBuilder(services); webJobsBuilder.AddDurableTask(); - // Assert // Build service provider to resolve services var serviceProvider = services.BuildServiceProvider(); - + // Verify we can create client providers for different connections var clientProviderFactory = serviceProvider.GetService(); Assert.NotNull(clientProviderFactory); @@ -223,58 +213,20 @@ public void AddDurableTask_WithMultipleConnections_AllCanBeResolved() Assert.NotNull(clientProvider); } } - } - - /// - /// Simple INameResolver implementation for tests that returns the input as-is. - /// - internal class SimpleNameResolver : INameResolver - { - public string Resolve(string name) - { - return name; - } - } - /// - /// Simple test implementation of IWebJobsBuilder that wraps a ServiceCollection. - /// This allows us to test AddDurableTask() without needing a full HostBuilder. - /// - internal class TestWebJobsBuilder : IWebJobsBuilder - { - public TestWebJobsBuilder(IServiceCollection services) - { - this.Services = services; - } - - public IServiceCollection Services { get; } - - public IWebJobsBuilder AddExtension() where TExtension : class, IExtensionConfigProvider - { - this.Services.AddSingleton(); - return this; - } - } - - /// - /// Tests for end-to-end SQL Server scaling integration via DurableTaskTriggersScaleProvider. - /// Validates the complete flow from triggerMetadata to working TargetScaler and ScaleMonitor. - /// - public class DurableTaskTriggersScaleProviderSqlServerTests - { /// - /// Scenario: End-to-end SQL Server scaling via triggerMetadata with type="mssql". - /// Validates that when triggerMetadata mentions storageProvider.type="mssql", DurableTaskTriggersScaleProvider creates SQL provider. + /// Scenario: End-to-end Azure Managed (DTS) scaling via triggerMetadata with type="azureManaged". + /// Validates that when triggerMetadata mentions storageProvider.type="azureManaged", DurableTaskTriggersScaleProvider creates DTS provider. /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. - /// Verifies that both TargetScaler and ScaleMonitor successfully work with real SQL Server. + /// Verifies that both TargetScaler and ScaleMonitor successfully work with Azure Managed backend. /// This test validates the complete integration path that Scale Controller uses. /// [Fact] - public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScaleProvider_AndBothScalersWork() + public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTriggersScaleProvider_AndBothScalersWork() { - // Arrange - Create triggerMetadata with type="mssql" (as Scale Controller would pass) + // Arrange - Create triggerMetadata with type="azureManaged" (as Scale Controller would pass) var hubName = "testHub"; - var connectionName = "TestConnection"; + var connectionName = "v3-dtsConnectionMI"; var metadata = new JObject { { "functionName", "TestFunction" }, @@ -285,7 +237,7 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal { "storageProvider", new JObject { - { "type", "mssql" }, + { "type", "azureManaged" }, { "connectionName", connectionName }, } }, @@ -295,49 +247,49 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal // Verify triggerMetadata has correct storageProvider.type var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; Assert.NotNull(storageProvider); - Assert.Equal("mssql", storageProvider["type"]?.ToString()); + Assert.Equal("azureManaged", storageProvider["type"]?.ToString()); Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); - // Set up DI container with SQL connection string + // Set up DI container with Azure Managed connection string // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - { $"ConnectionStrings:{connectionName}", TestHelpers.GetSqlConnectionString() }, - { connectionName, TestHelpers.GetSqlConnectionString() }, + { $"ConnectionStrings:{connectionName}", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, + { connectionName, "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, }) .Build(); - + var services = new ServiceCollection(); services.AddSingleton(new SimpleNameResolver()); services.AddSingleton(new LoggerFactory()); services.AddSingleton(configuration); - + var webJobsBuilder = new TestWebJobsBuilder(services); webJobsBuilder.AddDurableTask(); - + // Build service provider to resolve services var serviceProvider = services.BuildServiceProvider(); - // Get configuration and register SQL factory (as Scale Controller would) + // Get configuration and register Azure Managed factory (as Scale Controller would) var nameResolver = serviceProvider.GetRequiredService(); var loggerFactory = serviceProvider.GetRequiredService(); - - // Register SQL Server factory (normally done by Scale Controller) - var sqlFactory = new SqlServerScalabilityProviderFactory( + + // Register Azure Managed factory (normally done by Scale Controller) + var azureManagedFactory = new AzureManagedScalabilityProviderFactory( configuration, nameResolver, loggerFactory); - - // Create a list with all factories (Azure Storage from AddDurableTask + SQL from Scale Controller) + + // Create a list with all factories (Azure Storage from AddDurableTask + Azure Managed from Scale Controller) var scalabilityProviderFactories = new List( serviceProvider.GetServices()); - scalabilityProviderFactories.Add(sqlFactory); - - // Verify SQL Server factory is available - var sqlFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => f.Name == "mssql"); - Assert.NotNull(sqlFactoryFound); - Assert.IsType(sqlFactoryFound); + scalabilityProviderFactories.Add(azureManagedFactory); + + // Verify Azure Managed factory is available (using case-insensitive matching like the actual code) + var azureManagedFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => string.Equals(f.Name, "AzureManaged", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(azureManagedFactoryFound); + Assert.IsType(azureManagedFactoryFound); // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) var triggersScaleProvider = new DurableTaskTriggersScaleProvider( @@ -351,57 +303,41 @@ public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScal // Assert - TargetScaler was created successfully Assert.NotNull(targetScaler); - Assert.IsType(targetScaler); - - // Act - Get scale result from TargetScaler (connects to real SQL) - var targetScalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); - // Assert - TargetScaler returns valid result - Assert.NotNull(targetScalerResult); - Assert.True(targetScalerResult.TargetWorkerCount >= 0, "Target worker count should be non-negative"); + // AzureManagedTargetScaler is internal, so we verify it by checking the type name + Assert.Equal("AzureManagedTargetScaler", targetScaler.GetType().Name); // Act - Get ScaleMonitor from DurableTaskTriggersScaleProvider var scaleMonitor = triggersScaleProvider.GetMonitor(); - // Assert - ScaleMonitor was created successfully + // Assert - ScaleMonitor was created successfully (Azure Managed uses DummyScaleMonitor) Assert.NotNull(scaleMonitor); - Assert.IsType(scaleMonitor); - - // Act - Get metrics from ScaleMonitor (connects to real SQL) - var metrics = await scaleMonitor.GetMetricsAsync(); - // Assert - ScaleMonitor returns valid metrics - Assert.NotNull(metrics); - Assert.IsType(metrics); - var sqlMetrics = (SqlServerScaleMetric)metrics; - Assert.True(sqlMetrics.RecommendedReplicaCount >= 0, "Recommended replica count should be non-negative"); + // Note: We skip actual service calls (GetScaleResultAsync, GetMetricsAsync) because: + // 1. They require a real Azure Managed endpoint or DTS emulator + // 2. The test's primary goal is to verify the integration path (triggerMetadata -> provider -> scaler) + // 3. The SQL test can connect to a real SQL Server in CI, but Azure Managed requires DTS emulator + // The fact that we successfully created the provider and scalers proves the integration works correctly. // Verify connection string was successfully retrieved var connectionString = configuration.GetConnectionString(connectionName) ?? configuration[connectionName]; Assert.NotNull(connectionString); Assert.NotEmpty(connectionString); } - } - /// - /// Tests for end-to-end Azure Managed (DTS) scaling integration via DurableTaskTriggersScaleProvider. - /// Validates the complete flow from triggerMetadata to working TargetScaler and ScaleMonitor. - /// - public class DurableTaskTriggersScaleProviderAzureManagedTests - { /// - /// Scenario: End-to-end Azure Managed (DTS) scaling via triggerMetadata with type="azureManaged". - /// Validates that when triggerMetadata mentions storageProvider.type="azureManaged", DurableTaskTriggersScaleProvider creates DTS provider. + /// Scenario: End-to-end SQL Server scaling via triggerMetadata with type="mssql". + /// Validates that when triggerMetadata mentions storageProvider.type="mssql", DurableTaskTriggersScaleProvider creates SQL provider. /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. - /// Verifies that both TargetScaler and ScaleMonitor successfully work with Azure Managed backend. + /// Verifies that both TargetScaler and ScaleMonitor successfully work with real SQL Server. /// This test validates the complete integration path that Scale Controller uses. /// [Fact] - public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTriggersScaleProvider_AndBothScalersWork() + public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScaleProvider_AndBothScalersWork() { - // Arrange - Create triggerMetadata with type="azureManaged" (as Scale Controller would pass) + // Arrange - Create triggerMetadata with type="mssql" (as Scale Controller would pass) var hubName = "testHub"; - var connectionName = "v3-dtsConnectionMI"; + var connectionName = "TestConnection"; var metadata = new JObject { { "functionName", "TestFunction" }, @@ -412,7 +348,7 @@ public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTrigg { "storageProvider", new JObject { - { "type", "azureManaged" }, + { "type", "mssql" }, { "connectionName", connectionName }, } }, @@ -422,49 +358,49 @@ public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTrigg // Verify triggerMetadata has correct storageProvider.type var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; Assert.NotNull(storageProvider); - Assert.Equal("azureManaged", storageProvider["type"]?.ToString()); + Assert.Equal("mssql", storageProvider["type"]?.ToString()); Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); - // Set up DI container with Azure Managed connection string + // Set up DI container with SQL connection string // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - { $"ConnectionStrings:{connectionName}", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, - { connectionName, "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, + { $"ConnectionStrings:{connectionName}", TestHelpers.GetSqlConnectionString() }, + { connectionName, TestHelpers.GetSqlConnectionString() }, }) .Build(); - + var services = new ServiceCollection(); services.AddSingleton(new SimpleNameResolver()); services.AddSingleton(new LoggerFactory()); services.AddSingleton(configuration); - + var webJobsBuilder = new TestWebJobsBuilder(services); webJobsBuilder.AddDurableTask(); - + // Build service provider to resolve services var serviceProvider = services.BuildServiceProvider(); - // Get configuration and register Azure Managed factory (as Scale Controller would) + // Get configuration and register SQL factory (as Scale Controller would) var nameResolver = serviceProvider.GetRequiredService(); var loggerFactory = serviceProvider.GetRequiredService(); - - // Register Azure Managed factory (normally done by Scale Controller) - var azureManagedFactory = new AzureManagedScalabilityProviderFactory( + + // Register SQL Server factory (normally done by Scale Controller) + var sqlFactory = new SqlServerScalabilityProviderFactory( configuration, nameResolver, loggerFactory); - - // Create a list with all factories (Azure Storage from AddDurableTask + Azure Managed from Scale Controller) + + // Create a list with all factories (Azure Storage from AddDurableTask + SQL from Scale Controller) var scalabilityProviderFactories = new List( serviceProvider.GetServices()); - scalabilityProviderFactories.Add(azureManagedFactory); - - // Verify Azure Managed factory is available (using case-insensitive matching like the actual code) - var azureManagedFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => string.Equals(f.Name, "AzureManaged", StringComparison.OrdinalIgnoreCase)); - Assert.NotNull(azureManagedFactoryFound); - Assert.IsType(azureManagedFactoryFound); + scalabilityProviderFactories.Add(sqlFactory); + + // Verify SQL Server factory is available + var sqlFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => f.Name == "mssql"); + Assert.NotNull(sqlFactoryFound); + Assert.IsType(sqlFactoryFound); // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) var triggersScaleProvider = new DurableTaskTriggersScaleProvider( @@ -478,20 +414,30 @@ public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTrigg // Assert - TargetScaler was created successfully Assert.NotNull(targetScaler); - // AzureManagedTargetScaler is internal, so we verify it by checking the type name - Assert.Equal("AzureManagedTargetScaler", targetScaler.GetType().Name); + Assert.IsType(targetScaler); + + // Act - Get scale result from TargetScaler (connects to real SQL) + var targetScalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // Assert - TargetScaler returns valid result + Assert.NotNull(targetScalerResult); + Assert.True(targetScalerResult.TargetWorkerCount >= 0, "Target worker count should be non-negative"); // Act - Get ScaleMonitor from DurableTaskTriggersScaleProvider var scaleMonitor = triggersScaleProvider.GetMonitor(); - // Assert - ScaleMonitor was created successfully (Azure Managed uses DummyScaleMonitor) + // Assert - ScaleMonitor was created successfully Assert.NotNull(scaleMonitor); + Assert.IsType(scaleMonitor); - // Note: We skip actual service calls (GetScaleResultAsync, GetMetricsAsync) because: - // 1. They require a real Azure Managed endpoint or DTS emulator - // 2. The test's primary goal is to verify the integration path (triggerMetadata -> provider -> scaler) - // 3. The SQL test can connect to a real SQL Server in CI, but Azure Managed requires DTS emulator - // The fact that we successfully created the provider and scalers proves the integration works correctly. + // Act - Get metrics from ScaleMonitor (connects to real SQL) + var metrics = await scaleMonitor.GetMetricsAsync(); + + // Assert - ScaleMonitor returns valid metrics + Assert.NotNull(metrics); + Assert.IsType(metrics); + var sqlMetrics = (SqlServerScaleMetric)metrics; + Assert.True(sqlMetrics.RecommendedReplicaCount >= 0, "Recommended replica count should be non-negative"); // Verify connection string was successfully retrieved var connectionString = configuration.GetConnectionString(connectionName) ?? configuration[connectionName]; @@ -500,4 +446,3 @@ public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTrigg } } } - diff --git a/test/ScaleTests/SimpleNameResolver.cs b/test/ScaleTests/SimpleNameResolver.cs new file mode 100644 index 000000000..c53008551 --- /dev/null +++ b/test/ScaleTests/SimpleNameResolver.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + + /// + /// Simple INameResolver implementation for tests that returns the input as-is. + /// + internal class SimpleNameResolver : INameResolver + { + public string Resolve(string name) + { + return name; + } + } +} diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs index 0714048e5..430541b5a 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -4,13 +4,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Moq; using Newtonsoft.Json.Linq; using Xunit; diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs index 172b42523..80ea6884d 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs @@ -4,7 +4,6 @@ using System; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; using Xunit; diff --git a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs index 4214a561b..a3026179b 100644 --- a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs +++ b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs @@ -1,13 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. -using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; using Xunit; diff --git a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs index 330544ab8..b8d75c390 100644 --- a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs +++ b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Azure.WebJobs.Host.Scale; using Xunit; using Xunit.Abstractions; diff --git a/test/ScaleTests/Sql/SqlServerTestHelpers.cs b/test/ScaleTests/Sql/SqlServerTestHelpers.cs index f95ab6b83..3bf0a8a85 100644 --- a/test/ScaleTests/Sql/SqlServerTestHelpers.cs +++ b/test/ScaleTests/Sql/SqlServerTestHelpers.cs @@ -2,10 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Threading; -using System.Threading.Tasks; using DurableTask.SqlServer; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests; using Microsoft.Data.SqlClient; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests diff --git a/test/ScaleTests/TestHelpers.cs b/test/ScaleTests/TestHelpers.cs index 9abfcde75..e6e99a519 100644 --- a/test/ScaleTests/TestHelpers.cs +++ b/test/ScaleTests/TestHelpers.cs @@ -3,7 +3,7 @@ using System; -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { public static class TestHelpers { @@ -22,47 +22,27 @@ public static string GetSqlConnectionString() { // Priority 1: Use DTMB_SQL_CONNECTION_STRING environment variable if set // This is the standard environment variable name used for SQL connection - string sqlConnectionString = Environment.GetEnvironmentVariable("DTMB_SQL_CONNECTION_STRING"); - + string? sqlConnectionString = Environment.GetEnvironmentVariable("DTMB_SQL_CONNECTION_STRING"); + if (!string.IsNullOrEmpty(sqlConnectionString)) { return sqlConnectionString; } // Priority 2: Use SQLDB_Connection environment variable if set - // This is the standard environment variable name used by the extension + // This is the standard environment variable name used by the extension and CI pipeline sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"); - - if (!string.IsNullOrEmpty(sqlConnectionString)) - { - return sqlConnectionString; - } - - // Priority 3: Use Azure SQL Database connection string (for local testing with Azure SQL) - // Example: Server=tcp:mysqlservertny.database.windows.net,1433;Initial Catalog=testsqlscaling;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;Authentication="Active Directory Default"; - sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection_Azure"); - if (!string.IsNullOrEmpty(sqlConnectionString)) - { - return sqlConnectionString; - } - // Priority 4: Use Docker/local SQL Server connection string (for CI) - // CI environments typically set up SQL Server in Docker - // Example for Docker: Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=True;Encrypt=False; - sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection_Local"); if (!string.IsNullOrEmpty(sqlConnectionString)) { return sqlConnectionString; } - // Default: Try Docker SQL Server (common in CI) - // This assumes SQL Server is running in Docker with default settings - // For CI: Docker typically runs SQL Server on localhost:1433 - sqlConnectionString = "Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=Strong!Passw0rd;TrustServerCertificate=True;Encrypt=False;"; - - return sqlConnectionString; + // If no environment variable is set, throw an exception to ensure tests verify that + // the package correctly reads connection strings from configuration/environment variables. + // This prevents tests from silently using a hardcoded default that doesn't match the actual environment. + throw new InvalidOperationException( + "SQL connection string not found in environment variables."); } } } - - diff --git a/test/ScaleTests/TestLoggerProvider.cs b/test/ScaleTests/TestLoggerProvider.cs index 9f3d630c6..19c66b7f0 100644 --- a/test/ScaleTests/TestLoggerProvider.cs +++ b/test/ScaleTests/TestLoggerProvider.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using Xunit.Abstractions; -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { public class TestLoggerProvider : ILoggerProvider { @@ -80,10 +80,3 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } } } - - - - - - - diff --git a/test/ScaleTests/TestWebJobsBuilder.cs b/test/ScaleTests/TestWebJobsBuilder.cs new file mode 100644 index 000000000..8c90f1e5d --- /dev/null +++ b/test/ScaleTests/TestWebJobsBuilder.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.Config; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + + /// + /// Simple test implementation of IWebJobsBuilder that wraps a ServiceCollection. + /// This allows us to test AddDurableTask() without needing a full HostBuilder. + /// + internal class TestWebJobsBuilder : IWebJobsBuilder + { + public TestWebJobsBuilder(IServiceCollection services) + { + this.Services = services; + } + + public IServiceCollection Services { get; } + + public IWebJobsBuilder AddExtension() where TExtension : class, IExtensionConfigProvider + { + this.Services.AddSingleton(); + return this; + } + } +} diff --git a/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj b/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj index 8644e0409..6b1188c9f 100644 --- a/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj +++ b/test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj @@ -41,4 +41,3 @@ - diff --git a/test/ScaleTests/xunit.runner.json b/test/ScaleTests/xunit.runner.json index 20a368e6c..0aab9333f 100644 --- a/test/ScaleTests/xunit.runner.json +++ b/test/ScaleTests/xunit.runner.json @@ -10,3 +10,5 @@ + + From 8188cbe4b81874ba7d3d85c1b0bae0bffb4b5dde Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Mon, 10 Nov 2025 18:37:46 -0800 Subject: [PATCH 13/25] udpate yml --- .github/workflows/scale-tests.yml | 104 +++++++++++++++++++----------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml index ae8709ae7..43d95dd4a 100644 --- a/.github/workflows/scale-tests.yml +++ b/.github/workflows/scale-tests.yml @@ -16,8 +16,7 @@ on: - '.github/workflows/scale-tests.yml' jobs: - azurestorage: - name: DurableTask Scale Tests - AzureStorage + scale-tests-azurestorage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -38,28 +37,20 @@ jobs: - name: Start Azurite run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & - - name: Restore dependencies - run: | - dotnet restore src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj - dotnet restore test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj - - - name: Build - run: | - dotnet build src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj --configuration Release --no-restore - dotnet build test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj --configuration Release --no-restore + - name: Restore and Build Scale Tests + working-directory: test/ScaleTests + run: dotnet build --configuration Release - name: Run Azure Storage Scale Tests working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureStorage|FullyQualifiedName~DurableTaskJobHostConfigurationExtensions" + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureStorage&FullyQualifiedName!~Sql&FullyQualifiedName!~DurableTaskTriggersScaleProviderSqlServer" - mssql: - name: DurableTask Scale Tests - MSSQL + scale-tests-sql: runs-on: ubuntu-latest env: MSSQL_SA_PASSWORD: "Strong!Passw0rd123" - SQLDB_Connection: "Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=Strong!Passw0rd123;TrustServerCertificate=True;Encrypt=False;" steps: - uses: actions/checkout@v4 @@ -94,28 +85,42 @@ jobs: - name: Wait for SQL Server to be ready run: | echo "Waiting for SQL Server to be ready..." - sleep 30 + for i in {1..30}; do + # Try to connect using sqlcmd (try multiple possible paths for different SQL Server versions) + if docker exec mssql-server /opt/mssql-tools18/bin/sqlcmd -S . -U sa -P "${{ env.MSSQL_SA_PASSWORD }}" -Q "SELECT 1" -C > /dev/null 2>&1 || \ + docker exec mssql-server /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "${{ env.MSSQL_SA_PASSWORD }}" -Q "SELECT 1" -C > /dev/null 2>&1; then + echo "SQL Server is ready!" + break + fi + echo "Waiting for SQL Server... ($i/30)" + sleep 2 + done docker ps - - name: Restore dependencies + - name: Create database run: | - dotnet restore src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj - dotnet restore test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj - - - name: Build - run: | - dotnet build src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj --configuration Release --no-restore - dotnet build test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj --configuration Release --no-restore + echo "Creating TestDurableDB database..." + # Try multiple possible sqlcmd paths for different SQL Server versions + if docker exec mssql-server /opt/mssql-tools18/bin/sqlcmd -S . -U sa -P "${{ env.MSSQL_SA_PASSWORD }}" -Q "CREATE DATABASE [TestDurableDB] COLLATE Latin1_General_100_BIN2_UTF8" -C 2>/dev/null || \ + docker exec mssql-server /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "${{ env.MSSQL_SA_PASSWORD }}" -Q "CREATE DATABASE [TestDurableDB] COLLATE Latin1_General_100_BIN2_UTF8" -C; then + echo "Database created successfully" + else + echo "Failed to create database" + exit 1 + fi + + - name: Restore and Build Scale Tests + working-directory: test/ScaleTests + run: dotnet build --configuration Release - name: Run SQL Server Scale Tests working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - SQLDB_Connection: ${{ env.SQLDB_Connection }} + SQLDB_Connection: "Server=localhost,1433;Database=TestDurableDB;User Id=sa;Password=${{ env.MSSQL_SA_PASSWORD }};TrustServerCertificate=True;Encrypt=False;" run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~Sql|FullyQualifiedName~DurableTaskTriggersScaleProviderSqlServer" - dts: - name: DurableTask Scale Tests - DTS + scale-tests-azuremanaged: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -152,19 +157,44 @@ jobs: sleep 30 docker ps - - name: Restore dependencies - run: | - dotnet restore src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj - dotnet restore test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj - - - name: Build - run: | - dotnet build src/WebJobs.Extensions.DurableTask.Scale/WebJobs.Extensions.DurableTask.Scale.csproj --configuration Release --no-restore - dotnet build test/ScaleTests/WebJobs.Extensions.DurableTask.Scale.Tests.csproj --configuration Release --no-restore + - name: Restore and Build Scale Tests + working-directory: test/ScaleTests + run: dotnet build --configuration Release - name: Run Azure Managed Scale Tests working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged|FullyQualifiedName~DurableTaskTriggersScaleProviderAzureManaged" + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged&FullyQualifiedName!~Sql&FullyQualifiedName!~DurableTaskTriggersScaleProviderSqlServer" + + scale-tests-all: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' + + - name: Install Azurite + run: npm install -g azurite + + - name: Start Azurite + run: azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 & + + - name: Restore and Build Scale Tests + working-directory: test/ScaleTests + run: dotnet build --configuration Release + + - name: Run All Scale Tests (without containers) + working-directory: test/ScaleTests + env: + AzureWebJobsStorage: UseDevelopmentStorage=true + run: dotnet test --configuration Release --no-build --verbosity normal From d9fbed3d9d23943ee9a6cdbb4639684ed9bd0a1a Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Mon, 10 Nov 2025 18:57:56 -0800 Subject: [PATCH 14/25] udpate test --- .github/workflows/scale-tests.yml | 6 +- ...qlServerScalabilityProviderFactoryTests.cs | 9 +++ .../Sql/SqlServerScalabilityProviderTests.cs | 1 + .../Sql/SqlServerScaleMonitorTests.cs | 1 + .../Sql/SqlServerTargetScalerTests.cs | 1 + test/ScaleTests/Sql/SqlServerTestFixture.cs | 63 +++++++++++++++++++ 6 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 test/ScaleTests/Sql/SqlServerTestFixture.cs diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml index 43d95dd4a..63afe1b2d 100644 --- a/.github/workflows/scale-tests.yml +++ b/.github/workflows/scale-tests.yml @@ -167,7 +167,7 @@ jobs: AzureWebJobsStorage: UseDevelopmentStorage=true run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged&FullyQualifiedName!~Sql&FullyQualifiedName!~DurableTaskTriggersScaleProviderSqlServer" - scale-tests-all: + scale-tests-configuration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -192,9 +192,9 @@ jobs: working-directory: test/ScaleTests run: dotnet build --configuration Release - - name: Run All Scale Tests (without containers) + - name: Run Configuration Extension Tests working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true - run: dotnet test --configuration Release --no-build --verbosity normal + run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~DurableTaskJobHostConfigurationExtensionsTests" diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs index 430541b5a..082b45883 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -20,6 +20,15 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests /// Validates factory creation, provider instantiation, and configuration handling. /// Note: SQL Server is NOT the default provider - only created when storageProvider.type = "mssql". /// + [CollectionDefinition("SqlServerTests")] + public class SqlServerTestCollection : ICollectionFixture + { + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. + } + + [Collection("SqlServerTests")] public class SqlServerScalabilityProviderFactoryTests { private readonly ITestOutputHelper output; diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs index 80ea6884d..500b06f46 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderTests.cs @@ -16,6 +16,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests /// Validates the SQL Server implementation of ScalabilityProvider. /// Tests provider instantiation and scale monitor/scaler creation. /// + [Collection("SqlServerTests")] public class SqlServerScalabilityProviderTests { private readonly ITestOutputHelper output; diff --git a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs index a3026179b..719cd8a5b 100644 --- a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs +++ b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs @@ -18,6 +18,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests /// Tests scale metrics collection, scale status determination, and scale recommendations. /// Ensures Scale Controller can make informed autoscaling decisions based on SQL Server metrics. /// + [Collection("SqlServerTests")] public class SqlServerScaleMonitorTests { private readonly string hubName = "DurableTaskTriggerHubName"; diff --git a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs index b8d75c390..525be9e5b 100644 --- a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs +++ b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs @@ -16,6 +16,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests /// Tests worker count calculations based on SQL Server recommended replica count. /// Ensures accurate scaling decisions based on SQL Server metrics. /// + [Collection("SqlServerTests")] public class SqlServerTargetScalerTests { private readonly ITestOutputHelper output; diff --git a/test/ScaleTests/Sql/SqlServerTestFixture.cs b/test/ScaleTests/Sql/SqlServerTestFixture.cs new file mode 100644 index 000000000..56ca3e6cd --- /dev/null +++ b/test/ScaleTests/Sql/SqlServerTestFixture.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading.Tasks; +using DurableTask.SqlServer; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Test fixture that initializes SQL Server schema before running SQL tests. + /// Ensures the database schema is created once before all SQL tests run. + /// + public class SqlServerTestFixture : IAsyncLifetime + { + private static readonly object LockObject = new object(); + private static bool schemaInitialized = false; + + public Task InitializeAsync() + { + // Only initialize schema once, even if multiple test classes use this fixture + if (schemaInitialized) + { + return Task.CompletedTask; + } + + lock (LockObject) + { + if (schemaInitialized) + { + return Task.CompletedTask; + } + + try + { + var connectionString = TestHelpers.GetSqlConnectionString(); + var settings = new SqlOrchestrationServiceSettings(connectionString, "testHub", schemaName: null); + var service = new SqlOrchestrationService(settings); + + // Initialize the schema synchronously in the lock + service.CreateIfNotExistsAsync().GetAwaiter().GetResult(); + + schemaInitialized = true; + } + catch (Exception ex) + { + // If schema initialization fails, log but don't fail all tests + // Tests will fail individually if they can't connect + Console.WriteLine($"Warning: Failed to initialize SQL schema: {ex.Message}"); + } + } + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + // No cleanup needed + return Task.CompletedTask; + } + } +} From 96921547e7d298f5a8555359a763f91547c56b4b Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Mon, 10 Nov 2025 19:49:37 -0800 Subject: [PATCH 15/25] update test --- .github/workflows/scale-tests.yml | 1 + .../AzureManagedTargetScalerTests.cs | 152 +++++++++++ ...TaskJobHostConfigurationExtensionsTests.cs | 236 ------------------ ...qlServerScalabilityProviderFactoryTests.cs | 1 + .../Sql/SqlServerScaleMonitorTests.cs | 2 +- .../Sql/SqlServerTargetScalerTests.cs | 68 +++++ test/ScaleTests/TestHelpers.cs | 63 ++++- 7 files changed, 277 insertions(+), 246 deletions(-) create mode 100644 test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs diff --git a/.github/workflows/scale-tests.yml b/.github/workflows/scale-tests.yml index 63afe1b2d..67bfbfea5 100644 --- a/.github/workflows/scale-tests.yml +++ b/.github/workflows/scale-tests.yml @@ -165,6 +165,7 @@ jobs: working-directory: test/ScaleTests env: AzureWebJobsStorage: UseDevelopmentStorage=true + DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;Authentication=None" run: dotnet test --configuration Release --no-build --verbosity normal --filter "FullyQualifiedName~AzureManaged&FullyQualifiedName!~Sql&FullyQualifiedName!~DurableTaskTriggersScaleProviderSqlServer" scale-tests-configuration: diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs new file mode 100644 index 000000000..9a0c1c758 --- /dev/null +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DurableTask.Core; +using DurableTask.Core.History; +using DurableTask.Core.Query; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.DurableTask.AzureManagedBackend; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests +{ + /// + /// Tests for AzureManagedTargetScaler. + /// Validates the target-based autoscaling mechanism for Durable Functions with Azure Managed backend. + /// Tests worker count calculations based on Azure Managed metrics. + /// Ensures accurate scaling decisions based on work item metrics. + /// + public class AzureManagedTargetScalerTests + { + private readonly ITestOutputHelper output; + + public AzureManagedTargetScalerTests(ITestOutputHelper output) + { + this.output = output; + } + + /// + /// Scenario: Target scaler calculates correct worker count based on pending orchestrations. + /// Validates that with 20 pending orchestrations and MaxConcurrentOrchestrators=2, + /// the scaler returns 10 workers (20/2 = 10). + /// Tests the complete flow: create orchestrations -> set concurrency limits -> verify scaling calculation. + /// + [Fact] + public async Task TargetBasedScaling_WithPendingOrchestrations_ReturnsExpectedWorkerCount() + { + var taskHubName = "dtstesthub"; + var connectionString = TestHelpers.GetAzureManagedConnectionString(); + var options = AzureManagedOrchestrationServiceOptions.FromConnectionString(connectionString); + options.TaskHubName = taskHubName; + + // This test only cares about max concurrent orchestrations + options.MaxConcurrentOrchestrationWorkItems = 2; + options.MaxConcurrentActivityWorkItems = 2; + + this.output.WriteLine($"Creating connection to the test DTS TaskHub: {taskHubName}"); + + var loggerFactory = new LoggerFactory(); + var service = new AzureManagedOrchestrationService(options, loggerFactory); + + // Make 20 pending fake orchestration instances in the taskhub/ + + var status = new List + { + OrchestrationStatus.Pending, + OrchestrationStatus.Running, + OrchestrationStatus.Suspended, + }; + + var query = new OrchestrationQuery { RuntimeStatus = status }; + var result = await service.GetOrchestrationWithQueryAsync(query, CancellationToken.None); + + int existingCount = result.OrchestrationState?.Count ?? 0; + int orchestrationsToCreate = Math.Max(0, 20 - existingCount); + + this.output.WriteLine($"Found {existingCount} existing orchestrations. Creating {orchestrationsToCreate} new ones."); + + // Create orchestration instances + for (int i = 0; i < orchestrationsToCreate; i++) + { + var instance = new OrchestrationInstance + { + InstanceId = $"TestOrchestration_{Guid.NewGuid():N}", + ExecutionId = Guid.NewGuid().ToString(), + }; + + await service.CreateTaskOrchestrationAsync( + new TaskMessage + { + OrchestrationInstance = instance, + Event = new ExecutionStartedEvent(-1, "TestInput") + { + OrchestrationInstance = instance, + Name = "TestOrchestration", + Version = "1.0", + Input = "TestInput", + }, + }); + } + + // Wait a bit for orchestrations to be registered + await Task.Delay(2000); + + // Create target scaler using the scalability provider factory to ensure proper setup + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", connectionString }, + }) + .Build(); + + var nameResolver = new SimpleNameResolver(); + var factory = new AzureManagedScalabilityProviderFactory( + configuration, + nameResolver, + loggerFactory); + + // Create trigger metadata with proper settings + var triggerMetadata = TestHelpers.CreateTriggerMetadata(taskHubName, 2, 2, "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", "azureManaged"); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata) as AzureManagedScalabilityProvider; + + Assert.NotNull(provider); + + // Get target scaler from provider + bool targetScalerCreated = provider.TryGetTargetScaler( + "functionId", + "TestFunction", + taskHubName, + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + out ITargetScaler targetScaler); + + Assert.True(targetScalerCreated); + Assert.NotNull(targetScaler); + Assert.IsType(targetScaler); + + // Get scale result from TargetScaler + TargetScalerResult scalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // With 20 orchestrations and MaxConcurrentOrchestrators=2, we expect 10 workers (20/2 = 10) + Assert.NotNull(scalerResult); + + // The actual count might be slightly different due to existing orchestrations, but should be close to 10 + // We verify it's at least 5 (half of expected) to account for test variations + this.output.WriteLine($"Target worker count: {scalerResult.TargetWorkerCount}"); + Assert.Equal(10, scalerResult.TargetWorkerCount); + } + + private class SimpleNameResolver : INameResolver + { + public string Resolve(string name) => name; + } + } +} diff --git a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs index 452081ce7..06085f65e 100644 --- a/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs +++ b/test/ScaleTests/DurableTaskJobHostConfigurationExtensionsTests.cs @@ -1,19 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. -using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; -using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; using Microsoft.Azure.WebJobs.Host.Config; -using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; using Xunit; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests @@ -213,236 +208,5 @@ public void AddDurableTask_WithMultipleConnections_AllCanBeResolved() Assert.NotNull(clientProvider); } } - - /// - /// Scenario: End-to-end Azure Managed (DTS) scaling via triggerMetadata with type="azureManaged". - /// Validates that when triggerMetadata mentions storageProvider.type="azureManaged", DurableTaskTriggersScaleProvider creates DTS provider. - /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. - /// Verifies that both TargetScaler and ScaleMonitor successfully work with Azure Managed backend. - /// This test validates the complete integration path that Scale Controller uses. - /// - [Fact] - public async Task TriggerMetadataWithAzureManagedType_CreatesDTSProviderViaTriggersScaleProvider_AndBothScalersWork() - { - // Arrange - Create triggerMetadata with type="azureManaged" (as Scale Controller would pass) - var hubName = "testHub"; - var connectionName = "v3-dtsConnectionMI"; - var metadata = new JObject - { - { "functionName", "TestFunction" }, - { "type", "activityTrigger" }, - { "taskHubName", hubName }, - { "maxConcurrentOrchestratorFunctions", 10 }, - { "maxConcurrentActivityFunctions", 20 }, - { - "storageProvider", new JObject - { - { "type", "azureManaged" }, - { "connectionName", connectionName }, - } - }, - }; - var triggerMetadata = new TriggerMetadata(metadata); - - // Verify triggerMetadata has correct storageProvider.type - var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; - Assert.NotNull(storageProvider); - Assert.Equal("azureManaged", storageProvider["type"]?.ToString()); - Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); - - // Set up DI container with Azure Managed connection string - // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - { $"ConnectionStrings:{connectionName}", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, - { connectionName, "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure" }, - }) - .Build(); - - var services = new ServiceCollection(); - services.AddSingleton(new SimpleNameResolver()); - services.AddSingleton(new LoggerFactory()); - services.AddSingleton(configuration); - - var webJobsBuilder = new TestWebJobsBuilder(services); - webJobsBuilder.AddDurableTask(); - - // Build service provider to resolve services - var serviceProvider = services.BuildServiceProvider(); - - // Get configuration and register Azure Managed factory (as Scale Controller would) - var nameResolver = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - - // Register Azure Managed factory (normally done by Scale Controller) - var azureManagedFactory = new AzureManagedScalabilityProviderFactory( - configuration, - nameResolver, - loggerFactory); - - // Create a list with all factories (Azure Storage from AddDurableTask + Azure Managed from Scale Controller) - var scalabilityProviderFactories = new List( - serviceProvider.GetServices()); - scalabilityProviderFactories.Add(azureManagedFactory); - - // Verify Azure Managed factory is available (using case-insensitive matching like the actual code) - var azureManagedFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => string.Equals(f.Name, "AzureManaged", StringComparison.OrdinalIgnoreCase)); - Assert.NotNull(azureManagedFactoryFound); - Assert.IsType(azureManagedFactoryFound); - - // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) - var triggersScaleProvider = new DurableTaskTriggersScaleProvider( - nameResolver, - loggerFactory, - scalabilityProviderFactories, - triggerMetadata); - - // Act - Get TargetScaler from DurableTaskTriggersScaleProvider - var targetScaler = triggersScaleProvider.GetTargetScaler(); - - // Assert - TargetScaler was created successfully - Assert.NotNull(targetScaler); - - // AzureManagedTargetScaler is internal, so we verify it by checking the type name - Assert.Equal("AzureManagedTargetScaler", targetScaler.GetType().Name); - - // Act - Get ScaleMonitor from DurableTaskTriggersScaleProvider - var scaleMonitor = triggersScaleProvider.GetMonitor(); - - // Assert - ScaleMonitor was created successfully (Azure Managed uses DummyScaleMonitor) - Assert.NotNull(scaleMonitor); - - // Note: We skip actual service calls (GetScaleResultAsync, GetMetricsAsync) because: - // 1. They require a real Azure Managed endpoint or DTS emulator - // 2. The test's primary goal is to verify the integration path (triggerMetadata -> provider -> scaler) - // 3. The SQL test can connect to a real SQL Server in CI, but Azure Managed requires DTS emulator - // The fact that we successfully created the provider and scalers proves the integration works correctly. - - // Verify connection string was successfully retrieved - var connectionString = configuration.GetConnectionString(connectionName) ?? configuration[connectionName]; - Assert.NotNull(connectionString); - Assert.NotEmpty(connectionString); - } - - /// - /// Scenario: End-to-end SQL Server scaling via triggerMetadata with type="mssql". - /// Validates that when triggerMetadata mentions storageProvider.type="mssql", DurableTaskTriggersScaleProvider creates SQL provider. - /// Tests that connection string is retrieved from triggerMetadata.storageProvider.connectionName. - /// Verifies that both TargetScaler and ScaleMonitor successfully work with real SQL Server. - /// This test validates the complete integration path that Scale Controller uses. - /// - [Fact] - public async Task TriggerMetadataWithMssqlType_CreatesSqlProviderViaTriggersScaleProvider_AndBothScalersWork() - { - // Arrange - Create triggerMetadata with type="mssql" (as Scale Controller would pass) - var hubName = "testHub"; - var connectionName = "TestConnection"; - var metadata = new JObject - { - { "functionName", "TestFunction" }, - { "type", "activityTrigger" }, - { "taskHubName", hubName }, - { "maxConcurrentOrchestratorFunctions", 10 }, - { "maxConcurrentActivityFunctions", 20 }, - { - "storageProvider", new JObject - { - { "type", "mssql" }, - { "connectionName", connectionName }, - } - }, - }; - var triggerMetadata = new TriggerMetadata(metadata); - - // Verify triggerMetadata has correct storageProvider.type - var storageProvider = triggerMetadata.Metadata["storageProvider"] as JObject; - Assert.NotNull(storageProvider); - Assert.Equal("mssql", storageProvider["type"]?.ToString()); - Assert.Equal(connectionName, storageProvider["connectionName"]?.ToString()); - - // Set up DI container with SQL connection string - // Use TestWebJobsBuilder directly (no HostBuilder needed) - this matches how Scale Controller uses it - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - { $"ConnectionStrings:{connectionName}", TestHelpers.GetSqlConnectionString() }, - { connectionName, TestHelpers.GetSqlConnectionString() }, - }) - .Build(); - - var services = new ServiceCollection(); - services.AddSingleton(new SimpleNameResolver()); - services.AddSingleton(new LoggerFactory()); - services.AddSingleton(configuration); - - var webJobsBuilder = new TestWebJobsBuilder(services); - webJobsBuilder.AddDurableTask(); - - // Build service provider to resolve services - var serviceProvider = services.BuildServiceProvider(); - - // Get configuration and register SQL factory (as Scale Controller would) - var nameResolver = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - - // Register SQL Server factory (normally done by Scale Controller) - var sqlFactory = new SqlServerScalabilityProviderFactory( - configuration, - nameResolver, - loggerFactory); - - // Create a list with all factories (Azure Storage from AddDurableTask + SQL from Scale Controller) - var scalabilityProviderFactories = new List( - serviceProvider.GetServices()); - scalabilityProviderFactories.Add(sqlFactory); - - // Verify SQL Server factory is available - var sqlFactoryFound = scalabilityProviderFactories.FirstOrDefault(f => f.Name == "mssql"); - Assert.NotNull(sqlFactoryFound); - Assert.IsType(sqlFactoryFound); - - // Create DurableTaskTriggersScaleProvider (this is what Scale Controller does) - var triggersScaleProvider = new DurableTaskTriggersScaleProvider( - nameResolver, - loggerFactory, - scalabilityProviderFactories, - triggerMetadata); - - // Act - Get TargetScaler from DurableTaskTriggersScaleProvider - var targetScaler = triggersScaleProvider.GetTargetScaler(); - - // Assert - TargetScaler was created successfully - Assert.NotNull(targetScaler); - Assert.IsType(targetScaler); - - // Act - Get scale result from TargetScaler (connects to real SQL) - var targetScalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); - - // Assert - TargetScaler returns valid result - Assert.NotNull(targetScalerResult); - Assert.True(targetScalerResult.TargetWorkerCount >= 0, "Target worker count should be non-negative"); - - // Act - Get ScaleMonitor from DurableTaskTriggersScaleProvider - var scaleMonitor = triggersScaleProvider.GetMonitor(); - - // Assert - ScaleMonitor was created successfully - Assert.NotNull(scaleMonitor); - Assert.IsType(scaleMonitor); - - // Act - Get metrics from ScaleMonitor (connects to real SQL) - var metrics = await scaleMonitor.GetMetricsAsync(); - - // Assert - ScaleMonitor returns valid metrics - Assert.NotNull(metrics); - Assert.IsType(metrics); - var sqlMetrics = (SqlServerScaleMetric)metrics; - Assert.True(sqlMetrics.RecommendedReplicaCount >= 0, "Recommended replica count should be non-negative"); - - // Verify connection string was successfully retrieved - var connectionString = configuration.GetConnectionString(connectionName) ?? configuration[connectionName]; - Assert.NotNull(connectionString); - Assert.NotEmpty(connectionString); - } } } diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs index 082b45883..d90439fae 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -496,5 +496,6 @@ public void CreateSqlOrchestrationService_WithManagedIdentityConfig_ReadsServerN var configDatabaseName = testConfiguration[$"{connectionName}__databaseName"]; Assert.Equal(databaseName, configDatabaseName); } + } } diff --git a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs index 719cd8a5b..f39bc988b 100644 --- a/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs +++ b/test/ScaleTests/Sql/SqlServerScaleMonitorTests.cs @@ -40,7 +40,7 @@ public SqlServerScaleMonitorTests(ITestOutputHelper output) var connectionString = TestHelpers.GetSqlConnectionString(); var settings = new SqlOrchestrationServiceSettings(connectionString, this.hubName, schemaName: null); var sqlService = new SqlOrchestrationService(settings); - + // Create real metrics provider this.metricsProvider = new SqlServerMetricsProvider(sqlService); diff --git a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs index 525be9e5b..ae3941a67 100644 --- a/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs +++ b/test/ScaleTests/Sql/SqlServerTargetScalerTests.cs @@ -1,10 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using System.Collections.Generic; using System.Threading.Tasks; using DurableTask.SqlServer; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Xunit; using Xunit.Abstractions; @@ -26,6 +29,66 @@ public SqlServerTargetScalerTests(ITestOutputHelper output) this.output = output; } + /// + /// Scenario: Target scaler calculates correct worker count using SQL provider factory. + /// Validates that the target scaler is properly created via the scalability provider factory + /// with trigger metadata and returns valid scaling recommendations from SQL Server. + /// Tests the complete flow: factory -> provider -> target scaler -> scaling calculation. + /// + [Fact] + public async Task TargetBasedScaling_WithProviderFactory_ReturnsExpectedWorkerCount() + { + var taskHubName = "testHub"; + var connectionName = "TestConnection"; + var connectionString = TestHelpers.GetSqlConnectionString(); + + this.output.WriteLine($"Creating connection to the test SQL TaskHub: {taskHubName}"); + + // Create target scaler using the scalability provider factory to ensure proper setup + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { $"ConnectionStrings:{connectionName}", connectionString }, + { connectionName, connectionString }, + }) + .Build(); + + var loggerFactory = new LoggerFactory(); + var nameResolver = new SimpleNameResolver(); + var factory = new SqlServerScalabilityProviderFactory( + configuration, + nameResolver, + loggerFactory); + + // Create trigger metadata with proper settings + var triggerMetadata = TestHelpers.CreateTriggerMetadata(taskHubName, 10, 20, connectionName, "mssql"); + var metadata = triggerMetadata.ExtractDurableTaskMetadata(); + var provider = factory.GetScalabilityProvider(metadata, triggerMetadata) as SqlServerScalabilityProvider; + + Assert.NotNull(provider); + + // Get target scaler from provider + bool targetScalerCreated = provider.TryGetTargetScaler( + "functionId", + "TestFunction", + taskHubName, + connectionName, + out ITargetScaler targetScaler); + + Assert.True(targetScalerCreated); + Assert.NotNull(targetScaler); + Assert.IsType(targetScaler); + + // Get scale result from TargetScaler + TargetScalerResult scalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); + + // SQL Server's GetRecommendedReplicaCountAsync analyzes the database state and recommends worker count + Assert.NotNull(scalerResult); + Assert.True(scalerResult.TargetWorkerCount >= 0, "Target worker count should be non-negative"); + + this.output.WriteLine($"Target worker count: {scalerResult.TargetWorkerCount}"); + } + /// /// Scenario: Target scaler with zero recommended replica count. /// Validates that scaler correctly handles zero worker count recommendations. @@ -77,5 +140,10 @@ public async Task TargetBasedScaling_ValidatesNonNegativeResult() Assert.NotNull(result); Assert.True(result.TargetWorkerCount >= 0, "Target worker count should be clamped to 0 if negative"); } + + private class SimpleNameResolver : INameResolver + { + public string Resolve(string name) => name; + } } } diff --git a/test/ScaleTests/TestHelpers.cs b/test/ScaleTests/TestHelpers.cs index e6e99a519..4783b7355 100644 --- a/test/ScaleTests/TestHelpers.cs +++ b/test/ScaleTests/TestHelpers.cs @@ -2,6 +2,9 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; +using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale; +using Microsoft.Azure.WebJobs.Host.Scale; +using Newtonsoft.Json.Linq; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { @@ -20,29 +23,71 @@ public static string GetStorageConnectionString() public static string GetSqlConnectionString() { - // Priority 1: Use DTMB_SQL_CONNECTION_STRING environment variable if set - // This is the standard environment variable name used for SQL connection - string? sqlConnectionString = Environment.GetEnvironmentVariable("DTMB_SQL_CONNECTION_STRING"); + string sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"); if (!string.IsNullOrEmpty(sqlConnectionString)) { return sqlConnectionString; } - // Priority 2: Use SQLDB_Connection environment variable if set - // This is the standard environment variable name used by the extension and CI pipeline - sqlConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"); + // If no environment variable is set, throw an exception to ensure tests verify that + // the package correctly reads connection strings from configuration/environment variables. + // This prevents tests from silently using a hardcoded default that doesn't match the actual environment. + throw new InvalidOperationException( + "SQL connection string not found in environment variables."); + } - if (!string.IsNullOrEmpty(sqlConnectionString)) + public static string GetAzureManagedConnectionString() + { + string connectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING"); + + if (!string.IsNullOrEmpty(connectionString)) { - return sqlConnectionString; + return connectionString; } // If no environment variable is set, throw an exception to ensure tests verify that // the package correctly reads connection strings from configuration/environment variables. // This prevents tests from silently using a hardcoded default that doesn't match the actual environment. throw new InvalidOperationException( - "SQL connection string not found in environment variables."); + "Azure Managed connection string not found in environment variables."); + } + + /// + /// Creates a TriggerMetadata object for testing with the specified storage provider type. + /// + /// The task hub name. + /// Maximum concurrent orchestrator functions. + /// Maximum concurrent activity functions. + /// The connection name for the storage provider. + /// The storage provider type (e.g., "mssql", "azureManaged", "AzureStorage"). + /// The trigger type (e.g., "orchestrationTrigger", "activityTrigger"). Defaults to "orchestrationTrigger". + /// A TriggerMetadata instance configured for testing. + public static TriggerMetadata CreateTriggerMetadata( + string hubName, + int maxOrchestrator, + int maxActivity, + string connectionName, + string storageType, + string triggerType = "orchestrationTrigger") + { + var metadata = new JObject + { + { "functionName", "TestFunction" }, + { "type", triggerType }, + { "taskHubName", hubName }, + { "maxConcurrentOrchestratorFunctions", maxOrchestrator }, + { "maxConcurrentActivityFunctions", maxActivity }, + { + "storageProvider", new JObject + { + { "type", storageType }, + { "connectionName", connectionName }, + } + }, + }; + + return new TriggerMetadata(metadata); } } } From 2dd1e668e8e3e0104131d991f2f922c44873c01f Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Mon, 10 Nov 2025 20:01:34 -0800 Subject: [PATCH 16/25] update hub name --- test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index 9a0c1c758..1040449db 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -42,7 +42,7 @@ public AzureManagedTargetScalerTests(ITestOutputHelper output) [Fact] public async Task TargetBasedScaling_WithPendingOrchestrations_ReturnsExpectedWorkerCount() { - var taskHubName = "dtstesthub"; + var taskHubName = "default"; var connectionString = TestHelpers.GetAzureManagedConnectionString(); var options = AzureManagedOrchestrationServiceOptions.FromConnectionString(connectionString); options.TaskHubName = taskHubName; From f94ff062664fc6d21aea265bccfbc7c65a24f441 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 18:22:08 -0800 Subject: [PATCH 17/25] update --- .../AzureManaged/AzureManagedConnectionString.cs | 15 ++++++++++++--- .../AzureManagedScalabilityProvider.cs | 2 +- .../AzureManagedScalabilityProviderFactory.cs | 2 +- .../AzureManaged/AzureManagedTargetScaler.cs | 6 +++++- .../AzureStorageScalabilityProvider.cs | 2 -- .../AzureStorageScalabilityProviderFactory.cs | 12 +++++------- .../DurableTaskTriggersScaleProvider.cs | 6 ++++-- .../Sql/SqlServerScalabilityProvider.cs | 1 - .../Sql/SqlServerScalabilityProviderFactory.cs | 13 ++++++------- .../AzureManaged/AzureManagedTargetScalerTests.cs | 2 +- test/ScaleTests/xunit.runner.json | 9 --------- 11 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs index 600ff04cb..9c0de2064 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedConnectionString.cs @@ -43,9 +43,18 @@ public AzureManagedConnectionString(string connectionString) private string GetValue(string name) { - return this.builder.TryGetValue(name, out object value) - ? value as string - : null; + // Case-insensitive lookup + foreach (string key in this.builder.Keys) + { + if (string.Equals(key, name, System.StringComparison.OrdinalIgnoreCase)) + { + return this.builder.TryGetValue(key, out object value) + ? value as string + : null; + } + } + + return null; } } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs index 92791113d..8750ff629 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProvider.cs @@ -73,7 +73,7 @@ public override bool TryGetTargetScaler( { // Create a target scaler that uses the orchestration service's metrics endpoint. // All target scalers share the same AzureManagedOrchestrationService in the same task hub. - targetScaler = new AzureManagedTargetScaler(this.orchestrationService, functionId); + targetScaler = new AzureManagedTargetScaler(this.orchestrationService, functionId, this.logger); return true; } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs index 53ee49163..17fa26de4 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -19,7 +19,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged /// public class AzureManagedScalabilityProviderFactory : IScalabilityProviderFactory { - private const string LoggerName = "Host.Triggers.DurableTask.AzureManaged"; + private const string LoggerName = "Triggers.DurableTask.AzureManaged"; internal const string ProviderName = "AzureManaged"; private readonly Dictionary<(string, string?, string?), AzureManagedScalabilityProvider> cachedProviders = new Dictionary<(string, string?, string?), AzureManagedScalabilityProvider>(); diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs index f7e14a41b..0ef7496bc 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedTargetScaler.cs @@ -6,6 +6,7 @@ using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.DurableTask.AzureManagedBackend; using Microsoft.DurableTask.AzureManagedBackend.Metrics; +using Microsoft.Extensions.Logging; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged { @@ -13,11 +14,13 @@ internal class AzureManagedTargetScaler : ITargetScaler { private readonly AzureManagedOrchestrationService service; private readonly TargetScalerDescriptor descriptor; + private readonly ILogger logger; - public AzureManagedTargetScaler(AzureManagedOrchestrationService service, string functionId) + public AzureManagedTargetScaler(AzureManagedOrchestrationService service, string functionId, ILogger logger) { this.service = service; this.descriptor = new TargetScalerDescriptor(functionId); + this.logger = logger; } public TargetScalerDescriptor TargetScalerDescriptor => this.descriptor; @@ -27,6 +30,7 @@ public async Task GetScaleResultAsync(TargetScalerContext co TaskHubMetrics metrics = await this.service.GetTaskHubMetricsAsync(default); if (metrics is null) { + this.logger?.LogWarning("Task hub metrics returned null from Azure Managed backend. This may indicate the DTS emulator is being used which may not support metrics. Returning 0 worker count."); return new TargetScalerResult { TargetWorkerCount = 0 }; } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs index 600b7ff8d..9044dfdd5 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProvider.cs @@ -79,8 +79,6 @@ public override bool TryGetTargetScaler( { if (this.singletonDurableTaskMetricsProvider == null) { - // This is only called by the ScaleController, it doesn't run in the Functions Host process. - // Use the StorageAccountClientProvider that was created with the credential in the actory this.singletonDurableTaskMetricsProvider = this.GetMetricsProvider( hubName, this.storageAccountClientProvider, diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index 85330992e..6a62a06d5 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -16,12 +16,13 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage /// public class AzureStorageScalabilityProviderFactory : IScalabilityProviderFactory { - private const string LoggerName = "Host.Triggers.DurableTask.AzureStorage"; + private const string LoggerName = "Triggers.DurableTask.AzureStorage"; internal const string ProviderName = "AzureStorage"; private readonly IStorageServiceClientProviderFactory clientProviderFactory; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; + private readonly ILogger logger; private AzureStorageScalabilityProvider? defaultStorageProvider; /// @@ -39,6 +40,7 @@ public AzureStorageScalabilityProviderFactory( this.clientProviderFactory = clientProviderFactory ?? throw new ArgumentNullException(nameof(clientProviderFactory)); this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.logger = this.loggerFactory.CreateLogger(LoggerName); // Default connection name for Azure Storage this.DefaultConnectionName = "AzureWebJobsStorage"; @@ -64,8 +66,6 @@ public virtual ScalabilityProvider GetScalabilityProvider() { if (this.defaultStorageProvider == null) { - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - // Create StorageAccountClientProvider without credential (connection string) var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider( this.DefaultConnectionName, @@ -74,7 +74,7 @@ public virtual ScalabilityProvider GetScalabilityProvider() this.defaultStorageProvider = new AzureStorageScalabilityProvider( storageAccountClientProvider, this.DefaultConnectionName, - logger); + this.logger); // Set default max concurrent values this.defaultStorageProvider.MaxConcurrentTaskOrchestrationWorkItems = 10; @@ -95,8 +95,6 @@ public virtual ScalabilityProvider GetScalabilityProvider() /// public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, TriggerMetadata triggerMetadata) { - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - // Validate Azure Storage specific options if metadata is present if (metadata != null) { @@ -119,7 +117,7 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, var provider = new AzureStorageScalabilityProvider( storageAccountClientProvider, connectionName, - logger); + this.logger); // Extract max concurrent values from metadata provider.MaxConcurrentTaskOrchestrationWorkItems = metadata?.MaxConcurrentOrchestratorFunctions ?? 10; diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index d3a4da032..84cd1eb59 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -14,6 +14,8 @@ internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITarget { private readonly IScaleMonitor monitor; private readonly ITargetScaler targetScaler; + const string defaultConnectionName = "connectionName"; + const string connectionNameOverride = "connectionStringName"; public DurableTaskTriggersScaleProvider( INameResolver nameResolver, @@ -79,13 +81,13 @@ public DurableTaskTriggersScaleProvider( } // Try connectionName first - if (storageProvider.TryGetValue("connectionName", out object? value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + if (storageProvider.TryGetValue(defaultConnectionName, out object? value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) { return s1; } // Try connectionStringName - if (storageProvider.TryGetValue("connectionStringName", out object? value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + if (storageProvider.TryGetValue(connectionNameOverride, out object? value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { return s2; } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs index 94ebdce35..48d57ea79 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProvider.cs @@ -91,7 +91,6 @@ public override bool TryGetTargetScaler( { if (this.singletonSqlMetricsProvider == null) { - // This is only called by the ScaleController, it doesn't run in the Functions Host process. this.singletonSqlMetricsProvider = this.GetMetricsProvider( hubName, this.service, diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index d3cf1acbe..553ba1efd 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -15,12 +15,13 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql /// public class SqlServerScalabilityProviderFactory : IScalabilityProviderFactory { - private const string LoggerName = "Host.Triggers.DurableTask.SqlServer"; + private const string LoggerName = "Triggers.DurableTask.SqlServer"; internal const string ProviderName = "mssql"; private readonly IConfiguration configuration; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; + private readonly ILogger logger; /// /// Initializes a new instance of the class. @@ -37,7 +38,7 @@ public SqlServerScalabilityProviderFactory( this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); this.nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - + this.logger = this.loggerFactory.CreateLogger(LoggerName); this.DefaultConnectionName = "SQLDB_Connection"; } @@ -70,12 +71,10 @@ public virtual ScalabilityProvider GetScalabilityProvider() /// A configured SQL Server scalability provider. public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, TriggerMetadata triggerMetadata) { - ILogger logger = this.loggerFactory.CreateLogger(LoggerName); - // Validate SQL Server specific metadata if present if (metadata != null) { - this.ValidateSqlServerMetadata(metadata, logger); + this.ValidateSqlServerMetadata(metadata, this.logger); } // Resolve connection name: prioritize metadata, fallback to default @@ -90,13 +89,13 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, var sqlOrchestrationService = this.CreateSqlOrchestrationService( connectionName, taskHubName, - logger, + this.logger, metadata); var provider = new SqlServerScalabilityProvider( sqlOrchestrationService, connectionName, - logger); + this.logger); return provider; } diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index 1040449db..a1db29879 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -46,7 +46,7 @@ public async Task TargetBasedScaling_WithPendingOrchestrations_ReturnsExpectedWo var connectionString = TestHelpers.GetAzureManagedConnectionString(); var options = AzureManagedOrchestrationServiceOptions.FromConnectionString(connectionString); options.TaskHubName = taskHubName; - + // This test only cares about max concurrent orchestrations options.MaxConcurrentOrchestrationWorkItems = 2; options.MaxConcurrentActivityWorkItems = 2; diff --git a/test/ScaleTests/xunit.runner.json b/test/ScaleTests/xunit.runner.json index 0aab9333f..286a77433 100644 --- a/test/ScaleTests/xunit.runner.json +++ b/test/ScaleTests/xunit.runner.json @@ -3,12 +3,3 @@ "parallelizeAssembly": false, "parallelizeTestCollections": false } - - - - - - - - - From 709487bf286707bf5678f5d8ee350e90d0cb1b33 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 18:27:16 -0800 Subject: [PATCH 18/25] fix const string --- .../DurableTaskTriggersScaleProvider.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs index 84cd1eb59..037752ae4 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskTriggersScaleProvider.cs @@ -12,10 +12,11 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { internal class DurableTaskTriggersScaleProvider : IScaleMonitorProvider, ITargetScalerProvider { + private const string DefaultConnectionName = "connectionName"; + private const string ConnectionNameOverride = "connectionStringName"; + private readonly IScaleMonitor monitor; private readonly ITargetScaler targetScaler; - const string defaultConnectionName = "connectionName"; - const string connectionNameOverride = "connectionStringName"; public DurableTaskTriggersScaleProvider( INameResolver nameResolver, @@ -81,13 +82,13 @@ public DurableTaskTriggersScaleProvider( } // Try connectionName first - if (storageProvider.TryGetValue(defaultConnectionName, out object? value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) + if (storageProvider.TryGetValue(DefaultConnectionName, out object? value1) && value1 is string s1 && !string.IsNullOrWhiteSpace(s1)) { return s1; } // Try connectionStringName - if (storageProvider.TryGetValue(connectionNameOverride, out object? value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) + if (storageProvider.TryGetValue(ConnectionNameOverride, out object? value2) && value2 is string s2 && !string.IsNullOrWhiteSpace(s2)) { return s2; } From fbb2acc2ada4f1f771727efd8e8724f5cc9d804e Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 18:31:02 -0800 Subject: [PATCH 19/25] fix warning --- .../AzureStorage/AzureStorageScalabilityProviderFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index 6a62a06d5..7f1b3798d 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -102,7 +102,7 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata metadata, } // Extract TokenCredential from triggerMetadata if present (for Managed Identity) - var tokenCredential = ExtractTokenCredential(triggerMetadata, logger); + var tokenCredential = ExtractTokenCredential(triggerMetadata, this.logger); // Resolve connection name: prioritize metadata, fallback to default string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(metadata?.StorageProvider); From ebfefca351b823a2e0ed229110fbffedaff39767 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 18:42:15 -0800 Subject: [PATCH 20/25] debug failed test --- .../AzureManagedTargetScalerTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index a1db29879..a7c114448 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using DurableTask.Core; @@ -132,6 +133,46 @@ await service.CreateTaskOrchestrationAsync( Assert.NotNull(targetScaler); Assert.IsType(targetScaler); + // Debug: Check if orchestrations are still there after delay + var verifyQuery = new OrchestrationQuery { RuntimeStatus = status }; + var verifyResult = await service.GetOrchestrationWithQueryAsync(verifyQuery, CancellationToken.None); + int verifyCount = verifyResult.OrchestrationState?.Count ?? 0; + this.output.WriteLine($"After delay, found {verifyCount} orchestrations via query"); + + // Debug: Try to get metrics directly from the service used by target scaler + var azureManagedTargetScaler = targetScaler as AzureManagedTargetScaler; + if (azureManagedTargetScaler != null) + { + // Use reflection to access the private service field for debugging + var serviceField = typeof(AzureManagedTargetScaler).GetField("service", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (serviceField != null) + { + var targetService = serviceField.GetValue(azureManagedTargetScaler) as AzureManagedOrchestrationService; + if (targetService != null) + { + this.output.WriteLine($"Target service MaxConcurrentTaskOrchestrationWorkItems: {targetService.MaxConcurrentTaskOrchestrationWorkItems}"); + try + { + var directMetrics = await targetService.GetTaskHubMetricsAsync(default); + if (directMetrics != null) + { + this.output.WriteLine($"Direct metrics - OrchestratorWorkItems: Pending={directMetrics.OrchestratorWorkItems?.PendingCount ?? -1}, Active={directMetrics.OrchestratorWorkItems?.ActiveCount ?? -1}"); + this.output.WriteLine($"Direct metrics - ActivityWorkItems: Pending={directMetrics.ActivityWorkItems?.PendingCount ?? -1}, Active={directMetrics.ActivityWorkItems?.ActiveCount ?? -1}"); + this.output.WriteLine($"Direct metrics - EntityWorkItems: Pending={directMetrics.EntityWorkItems?.PendingCount ?? -1}, Active={directMetrics.EntityWorkItems?.ActiveCount ?? -1}"); + } + else + { + this.output.WriteLine("Direct metrics returned NULL - DTS emulator may not support GetTaskHubMetricsAsync"); + } + } + catch (Exception ex) + { + this.output.WriteLine($"Exception getting direct metrics: {ex.Message}"); + } + } + } + } + // Get scale result from TargetScaler TargetScalerResult scalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); From ed56b507dfda8fe34f740c6c84df3bba66d2af73 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 18:52:10 -0800 Subject: [PATCH 21/25] update test --- .../AzureManagedTargetScalerTests.cs | 37 ++----------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index a7c114448..0a9ea148b 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -133,45 +133,16 @@ await service.CreateTaskOrchestrationAsync( Assert.NotNull(targetScaler); Assert.IsType(targetScaler); - // Debug: Check if orchestrations are still there after delay + // Check if orchestrations are still there after delay for best practice. var verifyQuery = new OrchestrationQuery { RuntimeStatus = status }; var verifyResult = await service.GetOrchestrationWithQueryAsync(verifyQuery, CancellationToken.None); int verifyCount = verifyResult.OrchestrationState?.Count ?? 0; this.output.WriteLine($"After delay, found {verifyCount} orchestrations via query"); - // Debug: Try to get metrics directly from the service used by target scaler + // Try to get metrics directly from the service used by target scaler var azureManagedTargetScaler = targetScaler as AzureManagedTargetScaler; - if (azureManagedTargetScaler != null) - { - // Use reflection to access the private service field for debugging - var serviceField = typeof(AzureManagedTargetScaler).GetField("service", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (serviceField != null) - { - var targetService = serviceField.GetValue(azureManagedTargetScaler) as AzureManagedOrchestrationService; - if (targetService != null) - { - this.output.WriteLine($"Target service MaxConcurrentTaskOrchestrationWorkItems: {targetService.MaxConcurrentTaskOrchestrationWorkItems}"); - try - { - var directMetrics = await targetService.GetTaskHubMetricsAsync(default); - if (directMetrics != null) - { - this.output.WriteLine($"Direct metrics - OrchestratorWorkItems: Pending={directMetrics.OrchestratorWorkItems?.PendingCount ?? -1}, Active={directMetrics.OrchestratorWorkItems?.ActiveCount ?? -1}"); - this.output.WriteLine($"Direct metrics - ActivityWorkItems: Pending={directMetrics.ActivityWorkItems?.PendingCount ?? -1}, Active={directMetrics.ActivityWorkItems?.ActiveCount ?? -1}"); - this.output.WriteLine($"Direct metrics - EntityWorkItems: Pending={directMetrics.EntityWorkItems?.PendingCount ?? -1}, Active={directMetrics.EntityWorkItems?.ActiveCount ?? -1}"); - } - else - { - this.output.WriteLine("Direct metrics returned NULL - DTS emulator may not support GetTaskHubMetricsAsync"); - } - } - catch (Exception ex) - { - this.output.WriteLine($"Exception getting direct metrics: {ex.Message}"); - } - } - } - } + + Assert.NotNull(azureManagedTargetScaler); // Get scale result from TargetScaler TargetScalerResult scalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); From 09e3ecefef6bc24f3e91ff68546c7ae63825a6f4 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 19:00:26 -0800 Subject: [PATCH 22/25] update test --- .../AzureManaged/AzureManagedTargetScalerTests.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index 0a9ea148b..04a59f9cb 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -133,16 +133,8 @@ await service.CreateTaskOrchestrationAsync( Assert.NotNull(targetScaler); Assert.IsType(targetScaler); - // Check if orchestrations are still there after delay for best practice. - var verifyQuery = new OrchestrationQuery { RuntimeStatus = status }; - var verifyResult = await service.GetOrchestrationWithQueryAsync(verifyQuery, CancellationToken.None); - int verifyCount = verifyResult.OrchestrationState?.Count ?? 0; - this.output.WriteLine($"After delay, found {verifyCount} orchestrations via query"); - - // Try to get metrics directly from the service used by target scaler - var azureManagedTargetScaler = targetScaler as AzureManagedTargetScaler; - - Assert.NotNull(azureManagedTargetScaler); + // Wait for metrics to be available - DTS emulator may need additional time + await Task.Delay(2000); // Get scale result from TargetScaler TargetScalerResult scalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); From ec4f7a1d641b33c8a783024fa434b7a14170ad3a Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Sun, 16 Nov 2025 19:03:59 -0800 Subject: [PATCH 23/25] update test --- .../AzureManaged/AzureManagedTargetScalerTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index 04a59f9cb..f22f819db 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -133,8 +133,15 @@ await service.CreateTaskOrchestrationAsync( Assert.NotNull(targetScaler); Assert.IsType(targetScaler); - // Wait for metrics to be available - DTS emulator may need additional time - await Task.Delay(2000); + // Query orchestrations to trigger metrics update in DTS emulator + // The emulator may need this query operation to refresh its internal state + var verifyQuery = new OrchestrationQuery { RuntimeStatus = status }; + var verifyResult = await service.GetOrchestrationWithQueryAsync(verifyQuery, CancellationToken.None); + int verifyCount = verifyResult.OrchestrationState?.Count ?? 0; + this.output.WriteLine($"Found {verifyCount} orchestrations via query"); + + // Wait for metrics to be available - DTS emulator may need additional time after query + await Task.Delay(3000); // Get scale result from TargetScaler TargetScalerResult scalerResult = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); From 80d13f4f8ac98fb934b008351d76d62f75fccc73 Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Tue, 18 Nov 2025 09:50:38 -0800 Subject: [PATCH 24/25] fix --- Directory.Packages.props | 4 +- .../AzureManagedScalabilityProviderFactory.cs | 53 ++++++--- .../AzureStorageScalabilityProviderFactory.cs | 2 - ...rableTaskJobHostConfigurationExtensions.cs | 109 ++++++++++++++++-- .../INameResolver.cs | 18 --- .../StorageServiceClientProviderFactory.cs | 75 +++++++----- ...eManagedScalabilityProviderFactoryTests.cs | 11 +- .../AzureManagedTargetScalerTests.cs | 3 +- 8 files changed, 183 insertions(+), 92 deletions(-) delete mode 100644 src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index cd2807ef9..98125c047 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,7 +31,7 @@ - + @@ -59,7 +59,7 @@ - + diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs index 17fa26de4..ca4777666 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureManaged/AzureManagedScalabilityProviderFactory.cs @@ -8,8 +8,6 @@ using Microsoft.DurableTask.AzureManagedBackend; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; #nullable enable namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged @@ -93,20 +91,36 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, { // Resolve connection name: prioritize metadata, fallback to default string? rawConnectionName = TriggerMetadataExtensions.ResolveConnectionName(metadata?.StorageProvider); - string resolvedName = rawConnectionName != null - ? this.nameResolver.Resolve(rawConnectionName) - : this.DefaultConnectionName; + string connectionName = rawConnectionName ?? this.DefaultConnectionName; - // Try standard configuration sources - string? connectionString = - this.configuration.GetConnectionString(resolvedName) ?? - this.configuration[resolvedName] ?? - Environment.GetEnvironmentVariable(resolvedName); + string resolvedValue = this.nameResolver.Resolve(connectionName); + + // nameResolver.Resolve() may return either: + // 1. The connection name (if it's an app setting name like "MyConnection") + // 2. The connection string value itself (if it's already resolved or is an environment variable) + // Check if resolvedValue looks like a connection string (contains "=" which is typical for connection strings) + // If it does, use it directly; otherwise, treat it as a connection name and look it up + string? connectionString = null; + + if (!string.IsNullOrEmpty(resolvedValue) && resolvedValue.Contains("=")) + { + // resolvedValue is already a connection string + connectionString = resolvedValue; + } + else + { + // resolvedValue is a connection name, look it up + connectionName = resolvedValue; + connectionString = + this.configuration.GetConnectionString(resolvedValue) ?? + this.configuration[resolvedValue] ?? + Environment.GetEnvironmentVariable(resolvedValue); + } if (string.IsNullOrEmpty(connectionString)) { throw new InvalidOperationException( - $"No valid connection string found for '{resolvedName}'. " + + $"No valid connection string found for '{resolvedValue}'. " + $"Please ensure it is defined in app settings, connection strings, or environment variables."); } @@ -116,7 +130,8 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, string taskHubName = metadata?.TaskHubName ?? azureManagedConnectionString.TaskHubName; // Include client ID in cache key to handle managed identity changes - (string, string?, string?) cacheKey = (resolvedName, taskHubName, azureManagedConnectionString.ClientId); + // Use the original connection name (rawConnectionName or default) for the cache key, not the connection string value + (string, string?, string?) cacheKey = (connectionName, taskHubName, azureManagedConnectionString.ClientId); this.logger.LogDebug( "Getting durability provider for connection '{Connection}', task hub '{TaskHub}', and client ID '{ClientId}'...", @@ -149,19 +164,19 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, { try { - TokenCredential tokenCredential = getTokenCredential(resolvedName); + TokenCredential tokenCredential = getTokenCredential(connectionName); if (tokenCredential == null) { this.logger.LogWarning( "Token credential retrieved from trigger metadata is null for connection '{Connection}'.", - resolvedName); + connectionName); } else { // Override the credential from connection string options.TokenCredential = tokenCredential; - this.logger.LogInformation("Retrieved token credential from trigger metadata for connection '{Connection}'", resolvedName); + this.logger.LogInformation("Retrieved token credential from trigger metadata for connection '{Connection}'", connectionName); } } catch (Exception ex) @@ -169,21 +184,21 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, this.logger.LogWarning( ex, "Failed to get token credential from trigger metadata for connection '{Connection}'", - resolvedName); + connectionName); } } else { this.logger.LogWarning( "Token credential function pointer in trigger metadata is not of expected type for connection '{Connection}'", - resolvedName); + connectionName); } } else { this.logger.LogInformation( "No trigger metadata provided or trigger metadata does not contain 'GetAzureManagedTokenCredential', " + - "using the token credential built from connection string for connection '{Connection}'.", resolvedName); + "using the token credential built from connection string for connection '{Connection}'.", connectionName); } // Set task hub name if configured @@ -210,7 +225,7 @@ public ScalabilityProvider GetScalabilityProvider(DurableTaskMetadata? metadata, cacheKey.Item3 ?? "null"); AzureManagedOrchestrationService service = new AzureManagedOrchestrationService(options, this.loggerFactory); - AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, resolvedName, this.logger); + AzureManagedScalabilityProvider provider = new AzureManagedScalabilityProvider(service, connectionName, this.logger); // Extract max concurrent values from trigger metadata (from Scale Controller payload) // Default: 10 times the number of processors on the current machine diff --git a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs index 7f1b3798d..0b218265e 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/AzureStorage/AzureStorageScalabilityProviderFactory.cs @@ -3,11 +3,9 @@ #nullable enable using System; -using System.Collections.Generic; using System.Linq; using Microsoft.Azure.WebJobs.Host.Scale; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage { diff --git a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs index abbf7d3b9..0589ba0d5 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/DurableTaskJobHostConfigurationExtensions.cs @@ -2,16 +2,15 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Collections.Generic; using System.Linq; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureManaged; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.AzureStorage; using Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Sql; using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale { @@ -39,12 +38,45 @@ public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) builder.AddExtension(); IServiceCollection serviceCollection = builder.Services; - serviceCollection.TryAddSingleton(); - // Register all scalability provider factories - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + // Note: IConfiguration should be provided by the Scale Controller via ConfigureAppConfiguration + // which registers it when the host is built. Our factory functions are called lazily when services + // are resolved (after host is built), so IConfiguration will be available at that time. + // We don't register IConfiguration here - we rely on the Scale Controller to provide it. + + // Register StorageServiceClientProviderFactory using factory function to ensure proper construction + serviceCollection.TryAddSingleton(serviceProvider => + { + return new StorageServiceClientProviderFactory( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + }); + + // Register all scalability provider factories using factory functions to ensure proper construction + // This ensures factories are constructed even if some dependencies are resolved lazily + serviceCollection.AddSingleton(serviceProvider => + { + return new AzureStorageScalabilityProviderFactory( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + }); + + serviceCollection.AddSingleton(serviceProvider => + { + return new AzureManagedScalabilityProviderFactory( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + }); + + serviceCollection.AddSingleton(serviceProvider => + { + return new SqlServerScalabilityProviderFactory( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + }); return builder; } @@ -58,16 +90,73 @@ public static IWebJobsBuilder AddDurableTask(this IWebJobsBuilder builder) /// The same instance, to allow for fluent chaining. internal static IWebJobsBuilder AddDurableScaleForTrigger(this IWebJobsBuilder builder, TriggerMetadata triggerMetadata) { + IServiceCollection serviceCollection = builder.Services; + + // Ensure required dependencies are registered before resolving factories + // Note: Factories are already registered by AddDurableTask() which is called first by Scale Controller + // IConfiguration should be provided by the Scale Controller with app settings + // StorageServiceClientProviderFactory is already registered by AddDurableTask(), but ensure it's available + serviceCollection.TryAddSingleton(serviceProvider => + { + return new StorageServiceClientProviderFactory( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + }); + // this segment adheres to the followings pattern: https://github.com/Azure/azure-sdk-for-net/pull/38756 DurableTaskTriggersScaleProvider provider = null; builder.Services.AddSingleton(serviceProvider => { - provider = new DurableTaskTriggersScaleProvider(serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService>(), triggerMetadata); - return provider; + // Use GetServices (plural) which returns an empty enumerable if no services are registered, + // instead of GetService which returns null + var scalabilityProviderFactories = serviceProvider.GetServices(); + + // Validate that factories were successfully resolved + if (scalabilityProviderFactories == null || !scalabilityProviderFactories.Any()) + { + throw new InvalidOperationException( + "No scalability provider factories could be resolved. " + + "Ensure that AddDurableTask() was called or that all required dependencies (IConfiguration, INameResolver, ILoggerFactory) are registered."); + } + + try + { + provider = new DurableTaskTriggersScaleProvider( + serviceProvider.GetService(), + serviceProvider.GetService(), + scalabilityProviderFactories, + triggerMetadata); + return provider; + } + catch (Exception ex) + { + var loggerFactory = serviceProvider.GetService(); + var logger = loggerFactory?.CreateLogger(typeof(DurableTaskJobHostConfigurationExtensions)); + logger?.LogError( + ex, + "Failed to create DurableTaskTriggersScaleProvider for function {FunctionName}. " + + "This may prevent scaling from working correctly.", + triggerMetadata.FunctionName); + throw; + } }); // builder.Services.AddSingleton(serviceProvider => serviceProvider.GetServices().Single(x => x == provider)); - builder.Services.AddSingleton(serviceProvider => serviceProvider.GetServices().Single(x => x == provider)); + builder.Services.AddSingleton(serviceProvider => + { + // Get the DurableTaskTriggersScaleProvider instance - it should have been created by now + var providers = serviceProvider.GetServices(); + if (providers == null || !providers.Any()) + { + throw new InvalidOperationException( + $"DurableTaskTriggersScaleProvider was not registered for function {triggerMetadata.FunctionName}. " + + "This may indicate that AddDurableScaleForTrigger() failed during registration."); + } + + // Use SingleOrDefault to get the provider, or throw if there are multiple + var targetProvider = providers.SingleOrDefault(x => x == provider) ?? providers.Single(); + return targetProvider; + }); return builder; } } diff --git a/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs b/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs deleted file mode 100644 index 7b72ee709..000000000 --- a/src/WebJobs.Extensions.DurableTask.Scale/INameResolver.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale -{ - /// - /// Interface for resolving names of application settings. - /// - public interface INameResolver - { - /// - /// Resolves an application setting name to its value. Set from Functions Scale Controller. - /// - /// The name of the application setting. - /// The resolved value, or the original name if no resolution is found. - string Resolve(string name); - } -} diff --git a/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs index 06945a131..efa998fa7 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/StorageServiceClientProviderFactory.cs @@ -34,48 +34,59 @@ public StorageAccountClientProvider GetClientProvider(string connectionName, Tok throw new ArgumentNullException(nameof(connectionName)); } - // No TokenCredential - use connection string - if (tokenCredential == null) + // Priority 1: If tokenCredential is provided, check if Managed Identity is configured + // (account name or service URIs). This takes precedence because if these are set, + // it indicates an explicit intent to use Managed Identity. + if (tokenCredential != null) { - var connectionString = this.configuration.GetConnectionString(connectionName) ?? this.configuration[connectionName]; - - if (!string.IsNullOrEmpty(connectionString)) + // 1.1: Try to get account name first (e.g., AzureWebJobsStorage__accountName) + var accountName = this.configuration[$"{connectionName}__accountName"]; + if (!string.IsNullOrEmpty(accountName)) { - this.logger.LogInformation("Using connection string authentication for connection: {ConnectionName}", connectionName); - return new StorageAccountClientProvider(connectionString); + this.logger.LogInformation("Using Managed Identity with account name for connection: {ConnectionName}, account: {AccountName}", connectionName, accountName); + return new StorageAccountClientProvider(accountName, tokenCredential); } - throw new InvalidOperationException($"Could not find connection string for connection name: {connectionName}. " + - $"Please provide a connection string in configuration."); - } + // 1.2: Try to get service URIs (e.g., AzureWebJobsStorage__blobServiceUri, __queueServiceUri, __tableServiceUri) + var blobServiceUri = this.configuration[$"{connectionName}__blobServiceUri"]; + var queueServiceUri = this.configuration[$"{connectionName}__queueServiceUri"]; + var tableServiceUri = this.configuration[$"{connectionName}__tableServiceUri"]; - // Scenario 2: TokenCredential provided - use Managed Identity - // 2.1: Try to get account name first (e.g., AzureWebJobsStorage__accountName) - var accountName = this.configuration[$"{connectionName}__accountName"]; - if (!string.IsNullOrEmpty(accountName)) - { - this.logger.LogInformation("Using Managed Identity with account name for connection: {ConnectionName}, account: {AccountName}", connectionName, accountName); - return new StorageAccountClientProvider(accountName, tokenCredential); - } + if (!string.IsNullOrEmpty(blobServiceUri) && !string.IsNullOrEmpty(queueServiceUri) && !string.IsNullOrEmpty(tableServiceUri)) + { + this.logger.LogInformation("Using Managed Identity with service URIs for connection: {ConnectionName}", connectionName); + return new StorageAccountClientProvider( + new Uri(blobServiceUri), + new Uri(queueServiceUri), + new Uri(tableServiceUri), + tokenCredential); + } - // 2.2: Try to get service URIs (e.g., AzureWebJobsStorage__blobServiceUri, __queueServiceUri, __tableServiceUri) - var blobServiceUri = this.configuration[$"{connectionName}__blobServiceUri"]; - var queueServiceUri = this.configuration[$"{connectionName}__queueServiceUri"]; - var tableServiceUri = this.configuration[$"{connectionName}__tableServiceUri"]; + // If tokenCredential is provided but no account name or service URIs are configured, + // ignore the tokenCredential and fall through to use connection string instead. + // This handles the case where Scale Controller provides DefaultAzureCredential + // but we should use connection string. + this.logger.LogInformation( + "TokenCredential provided but no account name or service URIs found for connection: {ConnectionName}. " + + "Falling back to connection string authentication.", + connectionName); + } - if (!string.IsNullOrEmpty(blobServiceUri) && !string.IsNullOrEmpty(queueServiceUri) && !string.IsNullOrEmpty(tableServiceUri)) + // Priority 2: Use connection string (default approach) + var connectionString = this.configuration.GetConnectionString(connectionName) ?? this.configuration[connectionName]; + if (!string.IsNullOrEmpty(connectionString)) { - this.logger.LogInformation("Using Managed Identity with service URIs for connection: {ConnectionName}", connectionName); - return new StorageAccountClientProvider( - new Uri(blobServiceUri), - new Uri(queueServiceUri), - new Uri(tableServiceUri), - tokenCredential); + this.logger.LogInformation("Using connection string authentication for connection: {ConnectionName}", connectionName); + return new StorageAccountClientProvider(connectionString); } - // If we have a token credential but no account name or service URIs, throw an error - throw new InvalidOperationException($"TokenCredential provided but could not find account name or service URIs for connection: {connectionName}. " + - $"Please provide either '{connectionName}__accountName' or service URIs ('{connectionName}__blobServiceUri', '{connectionName}__queueServiceUri', '{connectionName}__tableServiceUri') in configuration."); + // No valid authentication method found + throw new InvalidOperationException( + $"Could not find valid authentication configuration for connection: {connectionName}. " + + $"Please provide either: " + + $"(1) A connection string, or " + + $"(2) TokenCredential with account name ('{connectionName}__accountName') or service URIs " + + $"('{connectionName}__blobServiceUri', '{connectionName}__queueServiceUri', '{connectionName}__tableServiceUri')."); } } } diff --git a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs index 1a3030565..1c842a70c 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedScalabilityProviderFactoryTests.cs @@ -41,11 +41,6 @@ public AzureManagedScalabilityProviderFactoryTests(ITestOutputHelper output) this.nameResolver = new SimpleNameResolver(); } - private class SimpleNameResolver : INameResolver - { - public string Resolve(string name) => name; - } - /// /// Scenario: Creating factory with valid parameters. /// Validates that factory can be instantiated with proper configuration. @@ -66,6 +61,7 @@ public void Constructor_ValidParameters_CreatesInstance() // Assert Assert.NotNull(factory); Assert.Equal("AzureManaged", factory.Name); + // DefaultConnectionName is now hardcoded, not from options Assert.Equal("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", factory.DefaultConnectionName); } @@ -173,6 +169,7 @@ public void GetScalabilityProvider_WithTriggerMetadata_ReturnsValidProvider() Assert.IsType(provider); var azureProvider = (AzureManagedScalabilityProvider)provider; Assert.Equal("v3-dtsConnectionMI", azureProvider.ConnectionName); + // TriggerMetadata values (15, 25) now take priority over options (10, 20) Assert.Equal(15, azureProvider.MaxConcurrentTaskOrchestrationWorkItems); Assert.Equal(25, azureProvider.MaxConcurrentTaskActivityWorkItems); @@ -249,7 +246,7 @@ public void GetScalabilityProvider_RetrievesConnectionStringFromConfiguration() configBuilder.AddInMemoryCollection(new Dictionary { // Use the hardcoded default connection name - { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", testConnectionString } + { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", testConnectionString }, }); var config = configBuilder.Build(); @@ -275,7 +272,7 @@ public void GetScalabilityProvider_UsesTaskHubNameFromConnectionString() configBuilder.AddInMemoryCollection(new Dictionary { // Use the hardcoded default connection name with TaskHub in connection string - { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure;TaskHub=MyTaskHub" } + { "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", "Endpoint=https://test.westus.durabletask.io;Authentication=DefaultAzure;TaskHub=MyTaskHub" }, }); var config = configBuilder.Build(); diff --git a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs index f22f819db..4cdff37f8 100644 --- a/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs +++ b/test/ScaleTests/AzureManaged/AzureManagedTargetScalerTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using DurableTask.Core; @@ -36,7 +35,7 @@ public AzureManagedTargetScalerTests(ITestOutputHelper output) /// /// Scenario: Target scaler calculates correct worker count based on pending orchestrations. - /// Validates that with 20 pending orchestrations and MaxConcurrentOrchestrators=2, + /// Validates that with 20 pending orchestrations and MaxConcurrentOrchestrators=2, /// the scaler returns 10 workers (20/2 = 10). /// Tests the complete flow: create orchestrations -> set concurrency limits -> verify scaling calculation. /// From bfb420701290ec5d6d801a276f404b2c2820ea4a Mon Sep 17 00:00:00 2001 From: "naiyuantian@microsoft.com" Date: Tue, 18 Nov 2025 18:01:34 -0800 Subject: [PATCH 25/25] update --- .../SqlServerScalabilityProviderFactory.cs | 27 +++++--- .../DurableTaskScaleMonitorTests.cs | 47 +------------ test/ScaleTests/SimpleNameResolver.cs | 1 - ...qlServerScalabilityProviderFactoryTests.cs | 69 ------------------- test/ScaleTests/TestWebJobsBuilder.cs | 1 - 5 files changed, 20 insertions(+), 125 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs index 553ba1efd..5f752d187 100644 --- a/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask.Scale/Sql/SqlServerScalabilityProviderFactory.cs @@ -107,22 +107,33 @@ private SqlOrchestrationService CreateSqlOrchestrationService( DurableTaskMetadata? metadata = null) { // Resolve connection name first (handles %% wrapping) - string resolvedConnectionName = this.nameResolver.Resolve(connectionName); + string resolvedValue = this.nameResolver.Resolve(connectionName); - // Try to get connection string from configuration (app settings) - string? connectionString = this.configuration.GetConnectionString(resolvedConnectionName) - ?? this.configuration[resolvedConnectionName]; + // nameResolver.Resolve() may return either: + // 1. The connection name (if it's an app setting name like "MyConnection") + // 2. The connection string value itself (if it's already resolved or is an environment variable) + // Check if resolvedValue looks like a connection string (contains "=" which is typical for connection strings) + // If it does, use it directly; otherwise, treat it as a connection name and look it up + string? connectionString = null; - // Fallback to environment variable (matching old implementation behavior) - if (string.IsNullOrEmpty(connectionString)) + if (!string.IsNullOrEmpty(resolvedValue) && resolvedValue.Contains("=")) + { + // resolvedValue is already a connection string + connectionString = resolvedValue; + } + else { - connectionString = Environment.GetEnvironmentVariable(resolvedConnectionName) ?? string.Empty; + // resolvedValue is a connection name, look it up + connectionString = + this.configuration.GetConnectionString(resolvedValue) ?? + this.configuration[resolvedValue] ?? + Environment.GetEnvironmentVariable(resolvedValue); } if (string.IsNullOrEmpty(connectionString)) { throw new InvalidOperationException( - $"No SQL connection string configuration was found for the app setting or environment variable named '{resolvedConnectionName}'."); + $"No SQL connection string configuration was found for the app setting or environment variable named '{resolvedValue}'."); } // Create SQL Server orchestration service settings - following durabletask-mssql pattern diff --git a/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs b/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs index 38b481ee2..083c13449 100644 --- a/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs +++ b/test/ScaleTests/AzureStorage/DurableTaskScaleMonitorTests.cs @@ -40,9 +40,8 @@ public DurableTaskScaleMonitorTests(ITestOutputHelper output) this.loggerFactory = new LoggerFactory(); this.loggerProvider = new TestLoggerProvider(output); this.loggerFactory.AddProvider(this.loggerProvider); - this.clientProvider = new StorageAccountClientProvider(TestHelpers.GetStorageConnectionString()); - + ILogger logger = this.loggerFactory.CreateLogger(); this.performanceMonitor = new Mock(MockBehavior.Strict, new AzureStorageOrchestrationServiceSettings { @@ -89,50 +88,6 @@ public async Task GetMetrics_ReturnsExpectedResult() Assert.Equal(expectedMetrics[0].WorkItemQueueLatency, actualMetrics.WorkItemQueueLatency); } - [Fact] - public async Task GetMetrics_HandlesExceptions() - { - // StorageException - var errorMsg = "Uh oh"; - this.performanceMonitor - .Setup(m => m.PulseAsync()) - .Throws(new Exception("Failure", new RequestFailedException(errorMsg))); - - var metrics = await this.scaleMonitor.GetMetricsAsync(); - - var messages = this.loggerProvider.GetAllLogMessages(); - var warningMessage = messages.FirstOrDefault(m => m.Contains("Failure") && m.Contains(errorMsg)); - Assert.NotNull(warningMessage); - } - - // Since this extension doesn't contain any scaling logic, the point of these tests is to test - // that DurableTaskTriggerMetrics are being properly deserialized into PerformanceHeartbeats. - // DurableTask already contains tests for conversion/scaling logic past that. - [Fact] - public void GetScaleStatus_DeserializesMetrics() - { - PerformanceHeartbeat[] heartbeats; - DurableTaskTriggerMetrics[] metrics; - - this.GetCorrespondingHeartbeatsAndMetrics(out heartbeats, out metrics); - - var context = new ScaleStatusContext - { - WorkerCount = 1, - Metrics = metrics, - }; - - // MatchEquivalentHeartbeats will ensure that an exception is thrown if GetScaleStatus - // tried to call MakeScaleRecommendation with an unexpected PerformanceHeartbeat[] - this.performanceMonitor - .Setup(m => m.MakeScaleRecommendation(1, this.MatchEquivalentHeartbeats(heartbeats))) - .Returns(null); - - var recommendation = this.scaleMonitor.GetScaleStatus(context); - - Assert.Equal(ScaleVote.None, recommendation.Vote); - } - [Fact] public void GetScaleStatus_HandlesMalformedMetrics() { diff --git a/test/ScaleTests/SimpleNameResolver.cs b/test/ScaleTests/SimpleNameResolver.cs index c53008551..00f90b530 100644 --- a/test/ScaleTests/SimpleNameResolver.cs +++ b/test/ScaleTests/SimpleNameResolver.cs @@ -3,7 +3,6 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { - /// /// Simple INameResolver implementation for tests that returns the input as-is. /// diff --git a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs index d90439fae..9a2d5c954 100644 --- a/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs +++ b/test/ScaleTests/Sql/SqlServerScalabilityProviderFactoryTests.cs @@ -56,75 +56,6 @@ public SqlServerScalabilityProviderFactoryTests(ITestOutputHelper output) this.nameResolver = new SimpleNameResolver(); } - private class SimpleNameResolver : INameResolver - { - public string Resolve(string name) => name; - } - - /// - /// Scenario: Creating factory with valid parameters when type="mssql" is specified. - /// Validates that factory can be instantiated with proper configuration when SQL Server type is specified. - /// Verifies factory name is "mssql" and connection name is set correctly. - /// - [Fact] - public void Constructor_WithMssqlType_CreatesInstance() - { - // Arrange - Specify type="mssql" in storage provider - // Options no longer used - removed CreateOptions call - - // Act - var factory = new SqlServerScalabilityProviderFactory( - this.configuration, - this.nameResolver, - this.loggerFactory); - - // Assert - Assert.NotNull(factory); - Assert.Equal("mssql", factory.Name); - // DefaultConnectionName is now hardcoded, not from options - Assert.Equal("SQLDB_Connection", factory.DefaultConnectionName); - } - - /// - /// Scenario: Factory returns early when type is NOT "mssql". - /// Validates that factory does not initialize when storage provider type is different (e.g., "AzureStorage"). - /// Tests that SQL Server factory respects the storage provider type selection. - /// Ensures factory can be registered but only activates for SQL Server. - /// - [Fact] - public void Constructor_WithAzureStorageType_ReturnsEarly() - { - // Arrange - Specify type="AzureStorage" instead of "mssql" - // Options no longer used - removed CreateOptions call - - // Act - var factory = new SqlServerScalabilityProviderFactory( - this.configuration, - this.nameResolver, - this.loggerFactory); - - // Assert - Factory should be created but not initialized for non-SQL types - Assert.NotNull(factory); - Assert.Equal("mssql", factory.Name); - // DefaultConnectionName may be null or default since factory returns early - } - - /// - /// Scenario: Constructor validation - null options. - /// Validates that factory properly rejects null options parameter. - /// Ensures proper error handling for missing configuration. - /// - [Fact] - public void Constructor_NullOptions_ThrowsArgumentNullException() - { - // Act & Assert - Assert.Throws(() => - new SqlServerScalabilityProviderFactory( - null, - this.nameResolver, - this.loggerFactory)); - } - /// /// Scenario: Creating durability provider with trigger metadata containing type="mssql". /// Validates the end-to-end flow when Scale Controller calls with SQL Server trigger metadata. diff --git a/test/ScaleTests/TestWebJobsBuilder.cs b/test/ScaleTests/TestWebJobsBuilder.cs index 8c90f1e5d..e2ea3598f 100644 --- a/test/ScaleTests/TestWebJobsBuilder.cs +++ b/test/ScaleTests/TestWebJobsBuilder.cs @@ -6,7 +6,6 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Scale.Tests { - /// /// Simple test implementation of IWebJobsBuilder that wraps a ServiceCollection. /// This allows us to test AddDurableTask() without needing a full HostBuilder.