Skip to content

Conversation

@nytian
Copy link
Collaborator

@nytian nytian commented Oct 29, 2025

Issue describing the changes in this PR

Solve #3193

This PR introduces a new light-weight package for Functions Scaling use specifically as the current size of Webjobs.exntesions.durabletask and the new dependencies like Open Telemetry.

Right now it's a draft version for AzureStorage. MSSQL and DTS support will be added later.

Pull request checklist

  • My changes do not require documentation changes
    • Otherwise: Documentation PR is ready to merge and referenced in pending_docs.md
  • My changes should not be added to the release notes for the next release
    • Otherwise: I've added my notes to release_notes.md
  • My changes do not need to be backported to a previous version
    • Otherwise: Backport tracked by issue/PR #issue_or_pr
  • I have added all required tests (Unit tests, E2E tests)
  • My changes do not require any extra work to be leveraged by OutOfProc SDKs
    • Otherwise: That work is being tracked here: #issue_or_pr_in_each_sdk
  • My changes do not change the version of the WebJobs.Extensions.DurableTask package
    • Otherwise: major or minor version updates are reflected in /src/Worker.Extensions.DurableTask/AssemblyInfo.cs
  • My changes do not add EventIds to our EventSource logs
    • Otherwise: Ensure the EventIds are within the supported range in our existing Windows infrastructure. You may validate this with a deployed app's telemetry. You may also extend the range by completing a PR such as this one.
  • My changes should be added to v2.x branch.
    • Otherwise: This change applies exclusively to WebJobs.Extensions.DurableTask v3.x. It will be retained only in the dev and main branches and will not be merged into the v2.x branch.

@nytian nytian marked this pull request as draft October 29, 2025 22:36
int maxConcurrentEntitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount;
int maxEntityOperationBatchSizeDefault = this.inConsumption ? 50 : 5000;

if (this.inConsumption)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should keep the logic from line 72 to line 92. Cause when I checked the TriggerMetadata json payload from SC I didn't find info about runtime. Also I think this basically is not related with scaling?

@cgillum cgillum requested a review from Copilot October 29, 2025 23:06
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces a new WebJobs.Extensions.DurableTask.Scale package that separates scaling-related functionality from the main Durable Task extension. The package provides autoscaling capabilities for Durable Functions using Azure Storage as the backend provider.

Key Changes:

  • Created a new independent Scale package with minimal dependencies (using System.Text.Json instead of Newtonsoft.Json where possible)
  • Implemented scale monitoring and target-based scaling for Durable Task triggers
  • Added Azure Storage-specific durability provider with metrics collection and scaling logic

Reviewed Changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 33 comments.

Show a summary per file
File Description
WebJobs.Extensions.DurableTask.Scale.csproj New project file defining the Scale package with net6.0 target and necessary dependencies
ScaleUtils.cs Utility class for creating scale monitors and target scalers with no-op implementations
IStorageServiceClientProviderFactory.cs Interface for retrieving Azure Storage service client providers
IDurabilityProviderFactory.cs Interface for building durability provider instances
FunctionName.cs Value type representing a durable function name with equality semantics
DurableTaskTriggersScaleProvider.cs Main provider implementing scale monitoring and target scaling for triggers
DurableTaskScaleExtension.cs Extension class for registering Durable Task scaling configuration
DurableTaskOptions.cs Minimal options class containing settings for scaling decisions
DurableTaskJobHostConfigurationExtensions.cs Extension methods for registering Durable Task with WebJobs builder
DurableClientAttribute.cs Attribute for binding function parameters to Durable clients
DurabilityProvider.cs Base class for backend storage providers with scaling support
NameValidator.cs Utility for validating Azure Storage resource names
DurableTaskTriggerMetrics.cs Metrics class for scale monitoring
DurableTaskTargetScaler.cs Implementation of target-based scaling logic
DurableTaskScaleMonitor.cs Implementation of scale monitoring
DurableTaskMetricsProvider.cs Provider for collecting metrics from Azure Storage
AzureStorageOptions.cs Configuration options for Azure Storage provider
AzureStorageDurabilityProviderFactory.cs Factory for creating Azure Storage durability providers
AzureStorageDurabilityProvider.cs Azure Storage implementation of durability provider

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private readonly IDurabilityProviderFactory durabilityProviderFactory;
private readonly DurabilityProvider defaultDurabilityProvider;
private readonly DurableTaskOptions options;
private readonly ILogger logger;
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: This line uses tabs while surrounding lines use tabs with different alignment. The entire file should use consistent indentation (either spaces or tabs, with consistent alignment).

Copilot uses AI. Check for mistakes.
Comment on lines 25 to 27
DurableTaskOptions options,
ILogger logger,
IEnumerable<IDurabilityProviderFactory> durabilityProviderFactories)
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation in parameter list: Parameters are indented differently (varying tab/space combinations). All parameters should have consistent indentation.

Suggested change
DurableTaskOptions options,
ILogger logger,
IEnumerable<IDurabilityProviderFactory> durabilityProviderFactories)
DurableTaskOptions options,
ILogger logger,
IEnumerable<IDurabilityProviderFactory> durabilityProviderFactories)

Copilot uses AI. Check for mistakes.
Comment on lines 28 to 35
{
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();
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation in constructor body: The method body uses excessive and inconsistent indentation with mixed tabs. Should use consistent indentation matching the rest of the codebase.

Suggested change
{
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();
}
{
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();
}

Copilot uses AI. Check for mistakes.
Comment on lines 40 to 44
private static IDurabilityProviderFactory GetDurabilityProviderFactory(
DurableTaskOptions options,
ILogger logger,
IEnumerable<IDurabilityProviderFactory> durabilityProviderFactories)
{
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: Method signature and body use mixed tabs with varying alignment. Should use consistent indentation throughout.

Copilot uses AI. Check for mistakes.
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,
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undefined variable 'extendedSessionTimeout' is used but never declared. This will cause a compilation error.

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +98
if (metrics[i].ControlQueueLengths == null)
{
heartbeats[i].ControlQueueLengths = new List<int>();
}
else
{
heartbeats[i].ControlQueueLengths = JsonConvert.DeserializeObject<IReadOnlyList<int>>(metrics[i].ControlQueueLengths);
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.

Suggested change
if (metrics[i].ControlQueueLengths == null)
{
heartbeats[i].ControlQueueLengths = new List<int>();
}
else
{
heartbeats[i].ControlQueueLengths = JsonConvert.DeserializeObject<IReadOnlyList<int>>(metrics[i].ControlQueueLengths);
}
heartbeats[i].ControlQueueLengths =
metrics[i].ControlQueueLengths == null
? new List<int>()
: JsonConvert.DeserializeObject<IReadOnlyList<int>>(metrics[i].ControlQueueLengths);

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +107
if (metrics[i].ControlQueueLatencies == null)
{
heartbeats[i].ControlQueueLatencies = new List<TimeSpan>();
}
else
{
heartbeats[i].ControlQueueLatencies = JsonConvert.DeserializeObject<IReadOnlyList<TimeSpan>>(metrics[i].ControlQueueLatencies);
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.

Suggested change
if (metrics[i].ControlQueueLatencies == null)
{
heartbeats[i].ControlQueueLatencies = new List<TimeSpan>();
}
else
{
heartbeats[i].ControlQueueLatencies = JsonConvert.DeserializeObject<IReadOnlyList<TimeSpan>>(metrics[i].ControlQueueLatencies);
}
heartbeats[i].ControlQueueLatencies = metrics[i].ControlQueueLatencies == null
? new List<TimeSpan>()
: JsonConvert.DeserializeObject<IReadOnlyList<TimeSpan>>(metrics[i].ControlQueueLatencies);

Copilot uses AI. Check for mistakes.
Comment on lines 48 to 55
if (string.Equals(durabilityProviderFactory.Name, AzureManagedProviderName, StringComparison.OrdinalIgnoreCase))
{
defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider(attribute: null, triggerMetadata);
}
else
{
defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider();
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.

Suggested change
if (string.Equals(durabilityProviderFactory.Name, AzureManagedProviderName, StringComparison.OrdinalIgnoreCase))
{
defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider(attribute: null, triggerMetadata);
}
else
{
defaultDurabilityProvider = durabilityProviderFactory.GetDurabilityProvider();
}
defaultDurabilityProvider = string.Equals(durabilityProviderFactory.Name, AzureManagedProviderName, StringComparison.OrdinalIgnoreCase)
? durabilityProviderFactory.GetDurabilityProvider(attribute: null, triggerMetadata)
: durabilityProviderFactory.GetDurabilityProvider();

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +29
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);
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement return - consider using '?' to express intent better.

Suggested change
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);
}
// 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);

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +85
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);
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement return - consider using '?' to express intent better.

Suggested change
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);
}
// 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 durabilityProvider.TryGetTargetScaler(
functionId,
functionName.Name,
hubName,
connectionName,
out ITargetScaler targetScaler)
? targetScaler
: new NoOpTargetScaler(functionId);

Copilot uses AI. Check for mistakes.
Copy link
Member

@cgillum cgillum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this is all new code and much of it is duplicated, which means that if we need to make a fix to it, we need to make the same fix in the other copy of the code. Is that right? Have you instead considered whether we should refactor the extension code so that we have one scale implementation that's used by both the extension and the scale controller?

Copy link
Member

@cgillum cgillum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some comments. I wonder if we should try to make this package even smaller, with fewer dependencies copied over. I think the current approach will be hard to maintain.

@nytian nytian marked this pull request as ready for review November 5, 2025 06:11
Copy link
Member

@cgillum cgillum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few initial questions to help me better understand the intent of the design.

string functionId,
string functionName,
string hubName,
string connectionName,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nytian assuming we need both of these connection names, which connectionName takes precendence? We should change the names of both these parameters and the instance member so that we know what their purposes are. For example, one can be defaultConnectionName and the other can be connectionNameOverride.

{
if (this.defaultStorageProvider == null)
{
ILogger logger = this.loggerFactory.CreateLogger(LoggerName);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just create the logger once in the constructor and reuse it?

// Create StorageAccountClientProvider without credential (connection string)
var storageAccountClientProvider = this.clientProviderFactory.GetClientProvider(
this.DefaultConnectionName,
tokenCredential: null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't we use a credential here?

@nytian nytian marked this pull request as draft November 10, 2025 04:19
@nytian nytian marked this pull request as ready for review November 11, 2025 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants