Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 89 additions & 5 deletions src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Concurrent;
using System.Linq;
using Azure.Core;
using Grpc.Net.Client;
using Microsoft.DurableTask.Client.Grpc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand Down Expand Up @@ -99,11 +102,23 @@ static void ConfigureSchedulerOptions(
/// <summary>
/// Configuration class that sets up gRPC channels for client options
/// using the provided Durable Task Scheduler options.
/// Channels are cached per configuration key and disposed when the service provider is disposed.
/// </summary>
/// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param>
class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerClientOptions> schedulerOptions) :
IConfigureNamedOptions<GrpcDurableTaskClientOptions>
sealed class ConfigureGrpcChannel : IConfigureNamedOptions<GrpcDurableTaskClientOptions>, IAsyncDisposable
{
readonly IOptionsMonitor<DurableTaskSchedulerClientOptions> schedulerOptions;
readonly ConcurrentDictionary<string, Lazy<GrpcChannel>> channels = new();
int disposed;
Comment on lines +107 to +111
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

PR description mentions IDisposable and a volatile disposed flag, but this implementation is IAsyncDisposable-only and the disposed field isn’t volatile (and is read without Volatile.Read). Either update the PR description or adjust the implementation to match (e.g., implement IDisposable and use volatile/Volatile.Read for the disposed check).

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

disposed is used as a cross-thread flag (read in Configure, written via Interlocked.Exchange in DisposeAsync) but is read without volatile/Volatile.Read. Also, Configure can race with DisposeAsync (an in-flight Configure can create/add a channel after DisposeAsync has enumerated/cleared the dictionary), which would leak the channel. Consider synchronizing Configure vs DisposeAsync (e.g., a lock/reader-writer gate) and using volatile/Volatile.Read for the flag.

Suggested change
int disposed;
volatile int disposed;

Copilot uses AI. Check for mistakes.

/// <summary>
/// Initializes a new instance of the <see cref="ConfigureGrpcChannel"/> class.
/// </summary>
/// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param>
public ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerClientOptions> schedulerOptions)
{
this.schedulerOptions = schedulerOptions;
}

/// <summary>
/// Configures the default named options instance.
/// </summary>
Expand All @@ -117,8 +132,77 @@ class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerClientOptions> sc
/// <param name="options">The options instance to configure.</param>
public void Configure(string? name, GrpcDurableTaskClientOptions options)
{
DurableTaskSchedulerClientOptions source = schedulerOptions.Get(name ?? Options.DefaultName);
options.Channel = source.CreateChannel();
#if NET7_0_OR_GREATER
ObjectDisposedException.ThrowIf(this.disposed == 1, this);
#else
if (this.disposed == 1)
{
Comment on lines +135 to +139
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

disposed is written via Interlocked.Exchange in DisposeAsync, but read here via a plain field read. For cross-thread visibility, the read should use Volatile.Read(ref this.disposed) (or make the field volatile) to ensure Configure reliably observes disposal under concurrency.

Copilot uses AI. Check for mistakes.
throw new ObjectDisposedException(nameof(ConfigureGrpcChannel));
}
#endif

string optionsName = name ?? Options.DefaultName;
DurableTaskSchedulerClientOptions source = this.schedulerOptions.Get(optionsName);

// Create a cache key that includes all properties that affect CreateChannel behavior.
// This ensures channels are reused for the same configuration
Comment on lines 144 to 148
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

CreateChannel() behavior depends on more than endpoint/task hub (e.g., ResourceId, Credential, AllowInsecureCredentials, RetryOptions). If any of those values change while EndpointAddress/TaskHubName stay the same (e.g., via options reload), the cached channel will be reused with stale settings. Consider including the channel-affecting fields in the cache key or enforcing immutability for them.

Copilot uses AI. Check for mistakes.
// but separate channels are created when any relevant property changes.
// Use a delimiter character (\u001F) that will not appear in typical endpoint URIs.
string credentialType = source.Credential?.GetType().FullName ?? "null";
string retryOptionsKey = source.RetryOptions != null
? $"{source.RetryOptions.MaxRetries}|{source.RetryOptions.InitialBackoffMs}|{source.RetryOptions.MaxBackoffMs}|{source.RetryOptions.BackoffMultiplier}|{(source.RetryOptions.RetryableStatusCodes != null ? string.Join(",", source.RetryOptions.RetryableStatusCodes) : string.Empty)}"
: "null";
string cacheKey = $"{optionsName}\u001F{source.EndpointAddress}\u001F{source.TaskHubName}\u001F{source.ResourceId}\u001F{credentialType}\u001F{source.AllowInsecureCredentials}\u001F{retryOptionsKey}";
options.Channel = this.channels.GetOrAdd(
Comment on lines +151 to +156
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The cache key only includes the credential type (FullName), but CreateChannel() captures and uses the TokenCredential instance (e.g., for AccessTokenCache and auth headers). If the credential instance changes while keeping the same type (e.g., two ManagedIdentityCredential instances with different client IDs, or config reload), this will incorrectly reuse a channel created with the old credential. Consider keying on the credential instance identity (or a stable credential-specific identifier) rather than only its type, and also normalize EndpointAddress the same way CreateChannel() does (scheme/no-scheme) to avoid duplicate channels for the same effective endpoint.

Copilot uses AI. Check for mistakes.
cacheKey,
_ => new Lazy<GrpcChannel>(source.CreateChannel)).Value;
}

/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref this.disposed, 1) == 1)
{
return;
}

List<Exception>? exceptions = null;
foreach (Lazy<GrpcChannel> channel in this.channels.Values.Where(lazy => lazy.IsValueCreated))
{
try
{
await DisposeChannelAsync(channel.Value).ConfigureAwait(false);
}
catch (Exception ex)
{
exceptions ??= new List<Exception>();
exceptions.Add(ex);
}
Comment on lines +176 to +180
}

this.channels.Clear();
GC.SuppressFinalize(this);

if (exceptions is { Count: > 0 })
{
throw new AggregateException(exceptions);
}
Comment on lines +186 to +189
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

DisposeAsync throws an AggregateException when any channel shutdown/dispose fails. Throwing from ServiceProvider disposal can surface as app shutdown failures and is difficult for callers to handle. Consider making this best-effort (swallow/log disposal errors) instead of throwing.

Copilot uses AI. Check for mistakes.
}

static async Task DisposeChannelAsync(GrpcChannel channel)
{
// ShutdownAsync is the graceful way to close a gRPC channel.
using (channel)
{
try
{
await channel.ShutdownAsync().ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
// Ignore expected shutdown/disposal errors
}
}
}
}
}
91 changes: 86 additions & 5 deletions src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Concurrent;
using System.Linq;
using Azure.Core;
using Grpc.Net.Client;
using Microsoft.DurableTask.Worker.Grpc;
using Microsoft.DurableTask.Worker.Grpc.Internal;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -101,11 +104,23 @@ static void ConfigureSchedulerOptions(
/// <summary>
/// Configuration class that sets up gRPC channels for worker options
/// using the provided Durable Task Scheduler options.
/// Channels are cached per configuration key and disposed when the service provider is disposed.
/// </summary>
/// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param>
class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerWorkerOptions> schedulerOptions) :
IConfigureNamedOptions<GrpcDurableTaskWorkerOptions>
sealed class ConfigureGrpcChannel : IConfigureNamedOptions<GrpcDurableTaskWorkerOptions>, IAsyncDisposable
{
readonly IOptionsMonitor<DurableTaskSchedulerWorkerOptions> schedulerOptions;
readonly ConcurrentDictionary<string, Lazy<GrpcChannel>> channels = new();
int disposed;
Comment on lines +109 to +113
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

PR description mentions IDisposable and a volatile disposed flag, but this implementation is IAsyncDisposable-only and the disposed field isn’t volatile (and is read without Volatile.Read). Either update the PR description or adjust the implementation to match (e.g., implement IDisposable and use volatile/Volatile.Read for the disposed check).

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

disposed is used as a cross-thread flag (read in Configure, written via Interlocked.Exchange in DisposeAsync) but is read without volatile/Volatile.Read. Also, Configure can race with DisposeAsync (an in-flight Configure can create/add a channel after DisposeAsync has enumerated/cleared the dictionary), which would leak the channel. Consider synchronizing Configure vs DisposeAsync (e.g., a lock/reader-writer gate) and using volatile/Volatile.Read for the flag.

Suggested change
int disposed;
volatile int disposed;

Copilot uses AI. Check for mistakes.

/// <summary>
/// Initializes a new instance of the <see cref="ConfigureGrpcChannel"/> class.
/// </summary>
/// <param name="schedulerOptions">Monitor for accessing the current scheduler options configuration.</param>
public ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerWorkerOptions> schedulerOptions)
{
this.schedulerOptions = schedulerOptions;
}

/// <summary>
/// Configures the default named options instance.
/// </summary>
Expand All @@ -119,9 +134,75 @@ class ConfigureGrpcChannel(IOptionsMonitor<DurableTaskSchedulerWorkerOptions> sc
/// <param name="options">The options instance to configure.</param>
public void Configure(string? name, GrpcDurableTaskWorkerOptions options)
{
DurableTaskSchedulerWorkerOptions source = schedulerOptions.Get(name ?? Options.DefaultName);
options.Channel = source.CreateChannel();
#if NET7_0_OR_GREATER
ObjectDisposedException.ThrowIf(this.disposed == 1, this);
#else
if (this.disposed == 1)
{
throw new ObjectDisposedException(nameof(ConfigureGrpcChannel));
Comment on lines +138 to +142
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

disposed is written via Interlocked.Exchange in DisposeAsync, but read here via a plain field read. Use Volatile.Read(ref this.disposed) (or make the field volatile) so concurrent calls to Configure reliably observe disposal.

Copilot uses AI. Check for mistakes.
}
#endif

string optionsName = name ?? Options.DefaultName;
DurableTaskSchedulerWorkerOptions source = this.schedulerOptions.Get(optionsName);

// Create a cache key that includes all properties that affect CreateChannel behavior.
// This ensures channels are reused for the same configuration
Comment on lines 146 to 150
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

CreateChannel() behavior depends on more than endpoint/task hub (e.g., ResourceId, Credential, AllowInsecureCredentials, and WorkerId via the call credentials interceptor). If any of these values change while EndpointAddress/TaskHubName stay the same (e.g., via options reload), the cached channel will be reused with stale settings. Consider including these fields in the cache key or enforcing immutability for them.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

// but separate channels are created when any relevant property changes.
// Use a delimiter character (\u001F) that will not appear in typical endpoint URIs.
string credentialType = source.Credential?.GetType().FullName ?? "null";
string cacheKey = $"{optionsName}\u001F{source.EndpointAddress}\u001F{source.TaskHubName}\u001F{source.ResourceId}\u001F{credentialType}\u001F{source.AllowInsecureCredentials}\u001F{source.WorkerId}";
options.Channel = this.channels.GetOrAdd(
cacheKey,
Comment on lines +153 to +156
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The cache key only includes the credential type (FullName), but CreateChannel() uses the TokenCredential instance (token cache + auth headers). If the credential instance changes while keeping the same type, this will incorrectly reuse a channel created with the old credential. Consider keying on the credential instance identity (or a stable credential-specific identifier) rather than only its type, and normalize EndpointAddress the same way CreateChannel() does to avoid duplicate channels for equivalent endpoints.

Copilot uses AI. Check for mistakes.
_ => new Lazy<GrpcChannel>(source.CreateChannel)).Value;
options.ConfigureForAzureManaged();
}

/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref this.disposed, 1) == 1)
{
return;
}

List<Exception>? exceptions = null;
foreach (Lazy<GrpcChannel> channel in this.channels.Values.Where(lazy => lazy.IsValueCreated))
{
try
{
await DisposeChannelAsync(channel.Value).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException and not ObjectDisposedException)
{
exceptions ??= new List<Exception>();
exceptions.Add(ex);
}
}

this.channels.Clear();
GC.SuppressFinalize(this);

if (exceptions is { Count: > 0 })
{
throw new AggregateException(exceptions);
}
Comment on lines +186 to +189
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

DisposeAsync throws an AggregateException when any channel shutdown/dispose fails. Throwing from ServiceProvider disposal can surface as app shutdown failures and is difficult for callers to handle. Consider making this best-effort (swallow/log disposal errors) instead of throwing.

Copilot uses AI. Check for mistakes.
}

static async Task DisposeChannelAsync(GrpcChannel channel)
{
// ShutdownAsync is the graceful way to close a gRPC channel.
using (channel)
{
try
{
await channel.ShutdownAsync().ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
// Ignore expected shutdown/disposal errors
}
}
}
}
}
Loading
Loading