From 79c5ca33f6d8ae38bb5d54c29109de7e655e6faa Mon Sep 17 00:00:00 2001 From: George Tsiokos Date: Fri, 13 Mar 2026 06:35:23 -0400 Subject: [PATCH 1/9] Add optional parameter to configuration providers for non-blocking startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuration and secret store providers now accept an `optional` parameter that allows applications to start without blocking on sidecar availability. When optional, configuration loads in the background with resilient retry logic that handles transient Dapr errors gracefully. - Add `optional` parameter to AddDaprConfigurationStore and AddDaprSecretStore - Harden LoadInBackgroundAsync to catch OperationCanceledException and DaprException separately, avoiding silent swallowing of programming errors - Dispose CancellationTokenSource in both providers to prevent resource leaks - Fix StringComparer inconsistency (InvariantCultureIgnoreCase → OrdinalIgnoreCase) - Mark fields readonly in DaprConfigurationStoreProvider for consistency - Restore UTF-8 BOM per .editorconfig and add missing trailing newlines - Add deterministic tests using reload tokens instead of fragile Task.Delay Co-Authored-By: Claude Opus 4.6 Signed-off-by: George Tsiokos --- .../DaprConfigurationStoreExtension.cs | 14 +- .../DaprConfigurationStoreProvider.cs | 77 ++++++++- .../DaprConfigurationStoreSource.cs | 11 +- .../DaprSecretStoreConfigurationExtensions.cs | 52 ++++-- .../DaprSecretStoreConfigurationProvider.cs | 83 ++++++++- .../DaprSecretStoreConfigurationSource.cs | 11 +- .../DaprConfigurationStoreProviderTest.cs | 159 +++++++++++++++++ ...aprSecretStoreConfigurationProviderTest.cs | 161 ++++++++++++++++++ 8 files changed, 528 insertions(+), 40 deletions(-) diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs index f0910ad0b..428419710 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs @@ -34,6 +34,7 @@ public static class DaprConfigurationStoreExtension /// The used for the request. /// The used to configure the timeout waiting for Dapr. /// Optional metadata sent to the configuration store. + /// When true, does not block startup waiting for the sidecar. Configuration is loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprConfigurationStore( this IConfigurationBuilder configurationBuilder, @@ -41,7 +42,8 @@ public static IConfigurationBuilder AddDaprConfigurationStore( IReadOnlyList keys, DaprClient client, TimeSpan sidecarWaitTimeout, - IReadOnlyDictionary? metadata = default) + IReadOnlyDictionary? metadata = default, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(keys, nameof(keys)); @@ -54,7 +56,8 @@ public static IConfigurationBuilder AddDaprConfigurationStore( Client = client, SidecarWaitTimeout = sidecarWaitTimeout, IsStreaming = false, - Metadata = metadata + Metadata = metadata, + IsOptional = optional }); return configurationBuilder; @@ -71,6 +74,7 @@ public static IConfigurationBuilder AddDaprConfigurationStore( /// The used for the request. /// The used to configure the timeout waiting for Dapr. /// Optional metadata sent to the configuration store. + /// When true, does not block startup waiting for the sidecar. Configuration is loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddStreamingDaprConfigurationStore( this IConfigurationBuilder configurationBuilder, @@ -78,7 +82,8 @@ public static IConfigurationBuilder AddStreamingDaprConfigurationStore( IReadOnlyList keys, DaprClient client, TimeSpan sidecarWaitTimeout, - IReadOnlyDictionary? metadata = default) + IReadOnlyDictionary? metadata = default, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(keys, nameof(keys)); @@ -91,7 +96,8 @@ public static IConfigurationBuilder AddStreamingDaprConfigurationStore( Client = client, SidecarWaitTimeout = sidecarWaitTimeout, IsStreaming = true, - Metadata = metadata + Metadata = metadata, + IsOptional = optional }); return configurationBuilder; diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs index 3e859b8d0..d32afd84b 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs @@ -26,13 +26,14 @@ namespace Dapr.Extensions.Configuration; /// internal class DaprConfigurationStoreProvider : ConfigurationProvider, IDisposable { - private string store; - private IReadOnlyList keys; - private DaprClient daprClient; - private TimeSpan sidecarWaitTimeout; - private bool isStreaming; - private IReadOnlyDictionary? metadata; - private CancellationTokenSource cts; + private readonly string store; + private readonly IReadOnlyList keys; + private readonly DaprClient daprClient; + private readonly TimeSpan sidecarWaitTimeout; + private readonly bool isStreaming; + private readonly bool isOptional; + private readonly IReadOnlyDictionary? metadata; + private readonly CancellationTokenSource cts; private Task subscribeTask = Task.CompletedTask; /// @@ -44,19 +45,22 @@ internal class DaprConfigurationStoreProvider : ConfigurationProvider, IDisposab /// The used to configure the timeout waiting for Dapr. /// Determines if the source is streaming or not. /// Optional metadata sent to the configuration store. + /// When true, does not block startup waiting for the sidecar. public DaprConfigurationStoreProvider( string store, IReadOnlyList keys, DaprClient daprClient, TimeSpan sidecarWaitTimeout, bool isStreaming = false, - IReadOnlyDictionary? metadata = default) + IReadOnlyDictionary? metadata = default, + bool isOptional = false) { this.store = store; this.keys = keys; this.daprClient = daprClient; this.sidecarWaitTimeout = sidecarWaitTimeout; this.isStreaming = isStreaming; + this.isOptional = isOptional; this.metadata = metadata ?? new Dictionary(); this.cts = new CancellationTokenSource(); } @@ -64,10 +68,60 @@ public DaprConfigurationStoreProvider( public void Dispose() { cts.Cancel(); + cts.Dispose(); } /// - public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + public override void Load() + { + if (isOptional) + { + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + _ = Task.Run(() => LoadInBackgroundAsync()); + } + else + { + LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } + + private async Task LoadInBackgroundAsync() + { + while (!cts.Token.IsCancellationRequested) + { + try + { + using var tokenSource = new CancellationTokenSource(sidecarWaitTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(tokenSource.Token, cts.Token); + await daprClient.WaitForSidecarAsync(linked.Token); + + await FetchDataAsync(); + OnReload(); + return; + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (OperationCanceledException) + { + // Sidecar wait timed out — retry after delay. + } + catch (DaprException) + { + // Transient Dapr error — retry after delay. + } + + try + { + await Task.Delay(sidecarWaitTimeout, cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } + } private async Task LoadAsync() { @@ -77,6 +131,11 @@ private async Task LoadAsync() await daprClient.WaitForSidecarAsync(tokenSource.Token); } + await FetchDataAsync(); + } + + private async Task FetchDataAsync() + { if (isStreaming) { subscribeTask = Task.Run(async () => diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs index 513e156b0..86012af5e 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs @@ -53,9 +53,16 @@ public class DaprConfigurationStoreSource : IConfigurationSource /// public IReadOnlyDictionary? Metadata { get; set; } = default; + /// + /// Gets or sets a value indicating whether this configuration source is optional. + /// When true, the provider will not block startup waiting for the Dapr sidecar and will + /// instead load configuration in the background once the sidecar becomes available. + /// + public bool IsOptional { get; set; } + /// public IConfigurationProvider Build(IConfigurationBuilder builder) { - return new DaprConfigurationStoreProvider(Store, Keys, Client, SidecarWaitTimeout, IsStreaming, Metadata); + return new DaprConfigurationStoreProvider(Store, Keys, Client, SidecarWaitTimeout, IsStreaming, Metadata, IsOptional); } -} \ No newline at end of file +} diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs index 7df531e3e..0662ebe3e 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs @@ -32,12 +32,14 @@ public static class DaprSecretStoreConfigurationExtensions /// Dapr secret store name. /// The secrets to retrieve. /// The Dapr client + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, IEnumerable secretDescriptors, - DaprClient client) + DaprClient client, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); @@ -47,7 +49,8 @@ public static IConfigurationBuilder AddDaprSecretStore( { Store = store, SecretDescriptors = secretDescriptors, - Client = client + Client = client, + IsOptional = optional }); return configurationBuilder; @@ -61,13 +64,15 @@ public static IConfigurationBuilder AddDaprSecretStore( /// The secrets to retrieve. /// The Dapr client. /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, IEnumerable secretDescriptors, DaprClient client, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); @@ -78,7 +83,8 @@ public static IConfigurationBuilder AddDaprSecretStore( Store = store, SecretDescriptors = secretDescriptors, Client = client, - SidecarWaitTimeout = sidecarWaitTimeout + SidecarWaitTimeout = sidecarWaitTimeout, + IsOptional = optional }); return configurationBuilder; @@ -89,14 +95,16 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. /// The Dapr client + /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, - IReadOnlyDictionary? metadata = null) + IReadOnlyDictionary? metadata = null, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -105,7 +113,8 @@ public static IConfigurationBuilder AddDaprSecretStore( { Store = store, Metadata = metadata, - Client = client + Client = client, + IsOptional = optional }); return configurationBuilder; @@ -116,16 +125,18 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. /// The Dapr client /// The used to configure the timeout waiting for Dapr. + /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, TimeSpan sidecarWaitTimeout, - IReadOnlyDictionary? metadata = null) + IReadOnlyDictionary? metadata = null, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -135,7 +146,8 @@ public static IConfigurationBuilder AddDaprSecretStore( Store = store, Metadata = metadata, Client = client, - SidecarWaitTimeout = sidecarWaitTimeout + SidecarWaitTimeout = sidecarWaitTimeout, + IsOptional = optional }); return configurationBuilder; @@ -146,14 +158,16 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of delimiters that will be replaced by ':' in the key of every secret. /// The Dapr client + /// A collection of delimiters that will be replaced by ':' in the key of every secret. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, - IEnumerable? keyDelimiters) + IEnumerable? keyDelimiters, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -161,7 +175,8 @@ public static IConfigurationBuilder AddDaprSecretStore( var source = new DaprSecretStoreConfigurationSource { Store = store, - Client = client + Client = client, + IsOptional = optional }; if (keyDelimiters != null) @@ -179,16 +194,18 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of delimiters that will be replaced by ':' in the key of every secret. /// The Dapr client + /// A collection of delimiters that will be replaced by ':' in the key of every secret. /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, IEnumerable? keyDelimiters, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -197,7 +214,8 @@ public static IConfigurationBuilder AddDaprSecretStore( { Store = store, Client = client, - SidecarWaitTimeout = sidecarWaitTimeout + SidecarWaitTimeout = sidecarWaitTimeout, + IsOptional = optional }; if (keyDelimiters != null) @@ -218,4 +236,4 @@ public static IConfigurationBuilder AddDaprSecretStore( /// The . public static IConfigurationBuilder AddDaprSecretStore(this IConfigurationBuilder configurationBuilder, Action configureSource) => configurationBuilder.Add(configureSource); -} \ No newline at end of file +} diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs index 31aa5b1fc..85ab2c489 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs @@ -24,7 +24,7 @@ namespace Dapr.Extensions.Configuration.DaprSecretStore; /// /// A Dapr Secret Store based . /// -internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider +internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider, IDisposable { internal static readonly TimeSpan DefaultSidecarWaitTimeout = TimeSpan.FromSeconds(5); @@ -42,6 +42,10 @@ internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider private readonly TimeSpan sidecarWaitTimeout; + private readonly bool isOptional; + + private readonly CancellationTokenSource cts = new(); + /// /// Creates a new instance of . /// @@ -83,13 +87,15 @@ public DaprSecretStoreConfigurationProvider( /// The secrets to retrieve. /// Dapr client used to retrieve Secrets /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. public DaprSecretStoreConfigurationProvider( string store, bool normalizeKey, IList? keyDelimiters, IEnumerable secretDescriptors, DaprClient client, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool isOptional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); @@ -106,6 +112,7 @@ public DaprSecretStoreConfigurationProvider( this.secretDescriptors = secretDescriptors; this.client = client; this.sidecarWaitTimeout = sidecarWaitTimeout; + this.isOptional = isOptional; } /// @@ -149,13 +156,15 @@ public DaprSecretStoreConfigurationProvider( /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. /// Dapr client used to retrieve Secrets /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. public DaprSecretStoreConfigurationProvider( string store, bool normalizeKey, IList? keyDelimiters, IReadOnlyDictionary? metadata, DaprClient client, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool isOptional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -166,6 +175,14 @@ public DaprSecretStoreConfigurationProvider( this.metadata = metadata; this.client = client; this.sidecarWaitTimeout = sidecarWaitTimeout; + this.isOptional = isOptional; + } + + /// + public void Dispose() + { + cts.Cancel(); + cts.Dispose(); } private string NormalizeKey(string key) @@ -185,18 +202,72 @@ private string NormalizeKey(string key) /// Loads the configuration by calling the asynchronous LoadAsync method and blocking the calling /// thread until the operation is completed. /// - public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + public override void Load() + { + if (isOptional) + { + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + _ = Task.Run(() => LoadInBackgroundAsync()); + } + else + { + LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } - private async Task LoadAsync() + private async Task LoadInBackgroundAsync() { - var data = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + while (!cts.Token.IsCancellationRequested) + { + try + { + using var tokenSource = new CancellationTokenSource(sidecarWaitTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(tokenSource.Token, cts.Token); + await client.WaitForSidecarAsync(linked.Token); + + await FetchSecretsAsync(); + OnReload(); + return; + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (OperationCanceledException) + { + // Sidecar wait timed out — retry after delay. + } + catch (DaprException) + { + // Transient Dapr error — retry after delay. + } + + try + { + await Task.Delay(sidecarWaitTimeout, cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } + } + private async Task LoadAsync() + { // Wait for the Dapr Sidecar to report healthy before attempting to fetch secrets. using (var tokenSource = new CancellationTokenSource(sidecarWaitTimeout)) { await client.WaitForSidecarAsync(tokenSource.Token); } + await FetchSecretsAsync(); + } + + private async Task FetchSecretsAsync() + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (secretDescriptors != null) { foreach (var secretDescriptor in secretDescriptors) diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs index de73c8518..d21dd4158 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs @@ -59,6 +59,13 @@ public class DaprSecretStoreConfigurationSource : IConfigurationSource /// public TimeSpan? SidecarWaitTimeout { get; set; } + /// + /// Gets or sets a value indicating whether this configuration source is optional. + /// When true, the provider will not block startup waiting for the Dapr sidecar and will + /// instead load secrets in the background once the sidecar becomes available. + /// + public bool IsOptional { get; set; } + /// public IConfigurationProvider Build(IConfigurationBuilder builder) { @@ -69,11 +76,11 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) throw new ArgumentException($"{nameof(Metadata)} must be null when {nameof(SecretDescriptors)} is set", nameof(Metadata)); } - return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, SecretDescriptors, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout); + return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, SecretDescriptors, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout, IsOptional); } else { - return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, Metadata, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout); + return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, Metadata, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout, IsOptional); } } } \ No newline at end of file diff --git a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs index 3042323e6..2c1d1dfd8 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; using Dapr.Client; using Grpc.Net.Client; using Microsoft.Extensions.Configuration; +using Moq; +using Shouldly; using Xunit; using Autogenerated = Dapr.Client.Autogen.Grpc.v1; @@ -187,6 +190,162 @@ private async Task SendResponseWithConfiguration(Dictionary(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + Task.Delay(Timeout.Infinite, ct)); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Build() should succeed immediately with no values + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public async Task TestConfigurationStore_OptionalAndSidecarBecomesAvailable_PopulatesConfig() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + return Task.Delay(Timeout.Infinite, ct); + } + return Task.CompletedTask; + }); + + var configResponse = new GetConfigurationResponse( + new Dictionary + { + ["testKey"] = new ConfigurationItem("testValue", "v1", null) + }); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(configResponse); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["testKey"].ShouldBeNull(); + + // Wait for the reload token to fire, indicating background load completed + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + config["testKey"].ShouldBe("testValue"); + } + + [Fact] + public async Task TestConfigurationStore_OptionalAndFetchThrowsTransientError_RetriesAndSucceeds() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var configResponse = new GetConfigurationResponse( + new Dictionary + { + ["testKey"] = new ConfigurationItem("testValue", "v1", null) + }); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns(() => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + throw new DaprException("transient error"); + } + return Task.FromResult(configResponse); + }); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["testKey"].ShouldBeNull(); + + // Wait for the reload token to fire after retry succeeds + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + config["testKey"].ShouldBe("testValue"); + } + + [Fact] + public async Task TestConfigurationStore_OptionalAndDisposed_ExitsCleanly() + { + var waitCalled = new TaskCompletionSource(); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + waitCalled.TrySetResult(true); + return Task.Delay(Timeout.Infinite, ct); + }); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Ensure background task has started + await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Dispose the provider to cancel the background task + (config as IDisposable)?.Dispose(); + + // Verify the mock was called with a token that is now cancelled + daprClient.Verify(c => c.WaitForSidecarAsync(It.IsAny()), Times.AtLeastOnce()); + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public void TestStreamingConfigurationStore_OptionalAndSidecarUnavailable_ReturnsEmptyConfig() + { + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + Task.Delay(Timeout.Infinite, ct)); + + var config = new ConfigurationBuilder() + .AddStreamingDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + config["anyKey"].ShouldBeNull(); + } + private async Task SendStreamingResponseWithConfiguration(Dictionary items, TestHttpClient.Entry entry) { var streamResponse = new Autogenerated.SubscribeConfigurationResponse(); diff --git a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs index 53cb4fc6c..c6453ef08 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs @@ -14,6 +14,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; using Dapr.Client; using Grpc.Net.Client; @@ -897,6 +898,166 @@ public void LoadSecrets_FailsIfSidecarNotAvailable() .Build()); } + [Fact] + public void LoadSecrets_OptionalAndSidecarUnavailable_ReturnsEmptyConfig() + { + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + Task.Delay(Timeout.Infinite, ct)); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, optional: true) + .Build(); + + // Build() should succeed immediately with no values + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public async Task LoadSecrets_OptionalAndSidecarBecomesAvailable_PopulatesConfig() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + // First call: simulate sidecar not ready by waiting until timeout cancels + return Task.Delay(Timeout.Infinite, ct); + } + // Subsequent calls: sidecar is ready + return Task.CompletedTask; + }); + + daprClient + .Setup(c => c.GetBulkSecretAsync("store", null, default)) + .ReturnsAsync(new Dictionary> + { + ["secret1"] = new Dictionary { ["secret1"] = "value1" } + }); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["secret1"].ShouldBeNull(); + + // Wait for the reload token to fire, indicating background load completed + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + config["secret1"].ShouldBe("value1"); + } + + [Fact] + public async Task LoadSecrets_OptionalWithDescriptors_PopulatesConfig() + { + var storeName = "store"; + var secretKey = "mySecret"; + var secretValue = "myValue"; + + var daprClient = new Mock(); + // Sidecar ready immediately + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.GetSecretAsync(storeName, secretKey, It.IsAny>(), default)) + .ReturnsAsync(new Dictionary { { secretKey, secretValue } }); + + var secretDescriptors = new[] { new DaprSecretDescriptor(secretKey) }; + + var config = CreateBuilder() + .AddDaprSecretStore(storeName, secretDescriptors, daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Wait for the reload token to fire, indicating background load completed + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + config[secretKey].ShouldBe(secretValue); + } + + [Fact] + public async Task LoadSecrets_OptionalAndFetchThrowsTransientError_RetriesAndSucceeds() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.GetBulkSecretAsync("store", null, default)) + .Returns(() => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + throw new DaprException("transient error"); + } + return Task.FromResult>>( + new Dictionary> + { + ["secret1"] = new Dictionary { ["secret1"] = "value1" } + }); + }); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["secret1"].ShouldBeNull(); + + // Wait for the reload token to fire after retry succeeds + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + config["secret1"].ShouldBe("value1"); + } + + [Fact] + public async Task LoadSecrets_OptionalAndDisposed_ExitsCleanly() + { + var waitCalled = new TaskCompletionSource(); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + waitCalled.TrySetResult(true); + return Task.Delay(Timeout.Infinite, ct); + }); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Ensure background task has started + await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Dispose the provider to cancel the background task + (config as IDisposable)?.Dispose(); + + // Verify the mock was called with a token that is now cancelled + daprClient.Verify(c => c.WaitForSidecarAsync(It.IsAny()), Times.AtLeastOnce()); + config["anyKey"].ShouldBeNull(); + } + private IConfigurationBuilder CreateBuilder() { return new ConfigurationBuilder(); From 75c856697c496f51c92251f091b3c27e46dec9be Mon Sep 17 00:00:00 2001 From: George Tsiokos Date: Sun, 15 Mar 2026 16:04:44 -0400 Subject: [PATCH 2/9] Add integration tests for optional configuration provider resilience Tests verify three scenarios using secretstores.local.file (no external deps): - optional:true with delayed sidecar: config populates once sidecar starts - optional:false (default): Build() blocks until sidecar ready, secrets populated - Disposal during background load: exits cleanly with no hang Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: George Tsiokos --- all.sln | 22 ++- .../Common/DaprHarnessBuilder.cs | 7 +- .../Harnesses/SecretStoreHarness.cs | 88 ++++++++++++ .../Dapr.IntegrationTest.Configuration.csproj | 29 ++++ .../SecretStoreOptionalTests.cs | 129 ++++++++++++++++++ 5 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs create mode 100644 test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj create mode 100644 test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs diff --git a/all.sln b/all.sln index cd6213beb..532e3dfd7 100644 --- a/all.sln +++ b/all.sln @@ -1,5 +1,4 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32929.385 MinimumVisualStudioVersion = 10.0.40219.1 @@ -297,6 +296,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Common.Generators.Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.DaprClient", "test\Dapr.IntegrationTest.DaprClient\Dapr.IntegrationTest.DaprClient.csproj", "{7DB95C53-19C1-49D2-B0BA-116DAEFC83DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Configuration", "test\Dapr.IntegrationTest.Configuration\Dapr.IntegrationTest.Configuration.csproj", "{193BBE09-E261-4D65-B4CC-18B88DB049D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1495,6 +1496,22 @@ Global {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x64.Build.0 = Release|Any CPU {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.ActiveCfg = Release|Any CPU {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.Build.0 = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x64.Build.0 = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x86.Build.0 = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|Any CPU.Build.0 = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x64.ActiveCfg = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x64.Build.0 = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x86.ActiveCfg = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x86.Build.0 = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x64.ActiveCfg = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x64.Build.0 = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.ActiveCfg = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.Build.0 = Release|Any CPU {01A20A89-53A1-4D5B-B563-89E157718474}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01A20A89-53A1-4D5B-B563-89E157718474}.Debug|Any CPU.Build.0 = Debug|Any CPU {01A20A89-53A1-4D5B-B563-89E157718474}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1905,6 +1922,7 @@ Global {DA1F9FE7-6041-4581-B9AD-685034869CE8} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {E015C5ED-F93F-4DB6-BA01-BC59B7859A60} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} {7DB95C53-19C1-49D2-B0BA-116DAEFC83DB} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {193BBE09-E261-4D65-B4CC-18B88DB049D7} = {DD020B34-460F-455F-8D17-CF4A949F100B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs b/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs index 139cd23c9..9a912832f 100644 --- a/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs +++ b/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs @@ -27,7 +27,7 @@ public sealed class DaprHarnessBuilder /// /// The Dapr container runtime options. /// - private DaprRuntimeOptions _options { get; set; } = new("1.17.0"); + private DaprRuntimeOptions _options { get; set; } = new("1.17.0-rc.3"); /// /// The isolated test environment to use with the harness, if any. /// @@ -92,6 +92,11 @@ public DaprHarnessBuilder WithEnvironment(DaprTestEnvironment environment) /// Builds a distributed lock harness. /// public DistributedLockHarness BuildDistributedLock() => new(_componentsDirectory, _startApp, _options, _environment); + + /// + /// Builds a secret store harness. + /// + public SecretStoreHarness BuildSecretStore() => new(_componentsDirectory, _startApp, _options, _environment); /// /// Builds a conversation harness. diff --git a/src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs b/src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs new file mode 100644 index 000000000..9afd20d48 --- /dev/null +++ b/src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Testcontainers.Common.Options; + +namespace Dapr.Testcontainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's secret store building block using a local file-based secret store. +/// +public sealed class SecretStoreHarness : BaseHarness +{ + private readonly string componentsDir; + + /// + /// The name of the secret store component. + /// + public const string SecretStoreComponentName = "localsecretstore"; + + /// + /// Provides an implementation harness for Dapr's secret store building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + /// The isolated environment instance. + public SecretStoreHarness(string componentsDir, System.Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment) + { + this.componentsDir = componentsDir; + } + + /// + protected override Task OnInitializeAsync(CancellationToken cancellationToken) + { + WriteSecretsFile(componentsDir); + WriteComponentYaml(componentsDir); + return Task.CompletedTask; + } + + /// + /// Writes the secrets JSON file to the specified directory. + /// + public static void WriteSecretsFile(string folderPath, string fileName = "secrets.json") + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, @"{ + ""secret1"": ""value1"", + ""secret2"": ""value2"" +}"); + } + + /// + /// Writes the component YAML file to the specified directory. + /// + public static void WriteComponentYaml(string folderPath, string fileName = "secretstore.yaml") + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, $@"apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: {SecretStoreComponentName} + namespace: default +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: /components/secrets.json + - name: nestedSeparator + value: "":"" +"); + } +} diff --git a/test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj b/test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj new file mode 100644 index 000000000..d48bd6fef --- /dev/null +++ b/test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj @@ -0,0 +1,29 @@ + + + + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs new file mode 100644 index 000000000..4afa58879 --- /dev/null +++ b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs @@ -0,0 +1,129 @@ +using Dapr.Client; +using Dapr.Extensions.Configuration; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Dapr.IntegrationTest.Configuration; + +public sealed class SecretStoreOptionalTests +{ + [Fact] + public async Task ShouldPopulateSecretsWhenSidecarStartsLate() + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("secretstore-components"); + + await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(cancellationToken: TestContext.Current.CancellationToken); + await environment.StartAsync(TestContext.Current.CancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildSecretStore(); + + var httpPort = PortUtilities.GetAvailablePort(); + var grpcPort = PortUtilities.GetAvailablePort(); + harness.SetPorts(httpPort, grpcPort); + + // Write component files before harness init (harness also writes them, but we need them for the client) + SecretStoreHarness.WriteSecretsFile(componentsDir); + SecretStoreHarness.WriteComponentYaml(componentsDir); + + using var client = new DaprClientBuilder() + .UseHttpEndpoint($"http://localhost:{httpPort}") + .UseGrpcEndpoint($"http://localhost:{grpcPort}") + .Build(); + + var config = new ConfigurationBuilder() + .AddDaprSecretStore( + SecretStoreHarness.SecretStoreComponentName, + client, + TimeSpan.FromSeconds(2), + optional: true) + .Build(); + + // Sidecar not running yet — config should be empty. + Assert.Null(config["secret1"]); + + // Register reload callback before starting the sidecar. + var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ChangeToken.OnChange(config.GetReloadToken, () => reloaded.TrySetResult()); + + // Start the sidecar on the pre-assigned ports. + await harness.InitializeAsync(TestContext.Current.CancellationToken); + + // Wait for the background loader to populate config. + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await reloaded.Task.WaitAsync(timeoutCts.Token).WaitAsync(TestContext.Current.CancellationToken); + + Assert.Equal("value1", config["secret1"]); + Assert.Equal("value2", config["secret2"]); + + await harness.DisposeAsync(); + } + + [Fact] + public async Task ShouldBlockAndPopulateSecretsWhenNotOptional() + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("secretstore-components"); + + await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(cancellationToken: TestContext.Current.CancellationToken); + await environment.StartAsync(TestContext.Current.CancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildSecretStore(); + + await harness.InitializeAsync(TestContext.Current.CancellationToken); + + using var client = new DaprClientBuilder() + .UseHttpEndpoint($"http://localhost:{harness.DaprHttpPort}") + .UseGrpcEndpoint($"http://localhost:{harness.DaprGrpcPort}") + .Build(); + + // optional defaults to false, so Build() blocks until sidecar responds. + var config = new ConfigurationBuilder() + .AddDaprSecretStore( + SecretStoreHarness.SecretStoreComponentName, + client, + TimeSpan.FromSeconds(30)) + .Build(); + + Assert.Equal("value1", config["secret1"]); + Assert.Equal("value2", config["secret2"]); + + await harness.DisposeAsync(); + } + + [Fact] + public async Task ShouldExitCleanlyWhenDisposedDuringBackgroundLoad() + { + // Pick free ports — no sidecar will run on them. + var httpPort = PortUtilities.GetAvailablePort(); + var grpcPort = PortUtilities.GetAvailablePort(); + + using var client = new DaprClientBuilder() + .UseHttpEndpoint($"http://localhost:{httpPort}") + .UseGrpcEndpoint($"http://localhost:{grpcPort}") + .Build(); + + var config = new ConfigurationBuilder() + .AddDaprSecretStore( + SecretStoreHarness.SecretStoreComponentName, + client, + TimeSpan.FromMilliseconds(500), + optional: true) + .Build(); + + // Config should be empty since there's no sidecar. + Assert.Null(config["secret1"]); + + // Dispose immediately to trigger cancellation of the background task. + (config as IDisposable)?.Dispose(); + + // Allow background task to process cancellation. + await Task.Delay(200, TestContext.Current.CancellationToken); + + // Test completes within xunit default timeout — no hang. + } +} From 0f0cd66b51836a09a4c6b7ad86ca6b0d06a771a3 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 06:44:01 -0500 Subject: [PATCH 3/9] Modified to add cancellation token support and add some best practices around Count Signed-off-by: Whit Waldo --- .../DaprSecretStoreConfigurationProvider.cs | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs index 85ab2c489..3183c7964 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs @@ -101,7 +101,8 @@ public DaprSecretStoreConfigurationProvider( ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); - if (secretDescriptors.Count() == 0) + var daprSecretDescriptors = secretDescriptors.ToList(); + if (daprSecretDescriptors.Count == 0) { throw new ArgumentException("No secret descriptor was provided", nameof(secretDescriptors)); } @@ -109,7 +110,7 @@ public DaprSecretStoreConfigurationProvider( this.store = store; this.normalizeKey = normalizeKey; this.keyDelimiters = keyDelimiters; - this.secretDescriptors = secretDescriptors; + this.secretDescriptors = daprSecretDescriptors; this.client = client; this.sidecarWaitTimeout = sidecarWaitTimeout; this.isOptional = isOptional; @@ -189,10 +190,7 @@ private string NormalizeKey(string key) { if (this.keyDelimiters?.Count > 0) { - foreach (var keyDelimiter in this.keyDelimiters) - { - key = key.Replace(keyDelimiter, ConfigurationPath.KeyDelimiter); - } + key = this.keyDelimiters.Aggregate(key, (current, keyDelimiter) => current.Replace(keyDelimiter, ConfigurationPath.KeyDelimiter)); } return key; @@ -207,7 +205,7 @@ public override void Load() if (isOptional) { Data = new Dictionary(StringComparer.OrdinalIgnoreCase); - _ = Task.Run(() => LoadInBackgroundAsync()); + _ = Task.Run(LoadInBackgroundAsync); } else { @@ -225,7 +223,7 @@ private async Task LoadInBackgroundAsync() using var linked = CancellationTokenSource.CreateLinkedTokenSource(tokenSource.Token, cts.Token); await client.WaitForSidecarAsync(linked.Token); - await FetchSecretsAsync(); + await FetchSecretsAsync(linked.Token); OnReload(); return; } @@ -264,7 +262,7 @@ private async Task LoadAsync() await FetchSecretsAsync(); } - private async Task FetchSecretsAsync() + private async Task FetchSecretsAsync(CancellationToken cancellationToken = default) { var data = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -278,7 +276,7 @@ private async Task FetchSecretsAsync() try { result = await client - .GetSecretAsync(store, secretDescriptor.SecretKey, secretDescriptor.Metadata) + .GetSecretAsync(store, secretDescriptor.SecretKey, secretDescriptor.Metadata, cancellationToken) .ConfigureAwait(false); } catch (DaprException) @@ -309,25 +307,21 @@ private async Task FetchSecretsAsync() data.Add(normalizedKey, result[key]); } } - - Data = data; } else { - var result = await client.GetBulkSecretAsync(store, metadata).ConfigureAwait(false); - foreach (var key in result.Keys) + var result = await client.GetBulkSecretAsync(store, metadata, cancellationToken).ConfigureAwait(false); + foreach (KeyValuePair secret in result.Keys.SelectMany(key => result[key])) { - foreach (var secret in result[key]) + if (data.ContainsKey(secret.Key)) { - if (data.ContainsKey(secret.Key)) - { - throw new InvalidOperationException($"A duplicate key '{secret.Key}' was found in the secret store '{store}'. Please remove any duplicates from your secret store."); - } - - data.Add(normalizeKey ? NormalizeKey(secret.Key) : secret.Key, secret.Value); + throw new InvalidOperationException($"A duplicate key '{secret.Key}' was found in the secret store '{store}'. Please remove any duplicates from your secret store."); } + + data.Add(normalizeKey ? NormalizeKey(secret.Key) : secret.Key, secret.Value); } - Data = data; } + + Data = data; } } From a31c49dd831d3ad1e1b3527b114570ad0821d947 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 06:50:51 -0500 Subject: [PATCH 4/9] Better handling for `OperationCanceledException` and cancellation tokens Signed-off-by: Whit Waldo --- .../DaprConfigurationStoreProvider.cs | 38 ++++++++- .../DaprConfigurationStoreProviderTest.cs | 77 +++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs index d32afd84b..58c39250d 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs @@ -16,6 +16,7 @@ using System.Threading; using System.Threading.Tasks; using Dapr.Client; +using Grpc.Core; using Microsoft.Extensions.Configuration; namespace Dapr.Extensions.Configuration; @@ -159,12 +160,43 @@ private async Task FetchDataAsync() OnReload(); } } - catch (Exception) + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (RpcException ex) when (cts.Token.IsCancellationRequested && ex.StatusCode == StatusCode.Cancelled) + { + return; + } + catch (Exception ex) when (ex is DaprException or RpcException) { - // If we catch an exception, try and cancel the subscription so we can connect again. if (!string.IsNullOrEmpty(id)) { - await daprClient.UnsubscribeConfiguration(store, id); + try + { + await daprClient.UnsubscribeConfiguration(store, id, cts.Token); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (RpcException unsubscribeException) when (cts.Token.IsCancellationRequested && unsubscribeException.StatusCode == StatusCode.Cancelled) + { + return; + } + catch (Exception unsubscribeException) when (unsubscribeException is DaprException or RpcException) + { + // Ignore transient unsubscribe failures and reconnect after the retry delay. + } + } + + try + { + await Task.Delay(sidecarWaitTimeout, cts.Token); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; } } } diff --git a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs index 2c1d1dfd8..63d57408a 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs @@ -346,6 +346,45 @@ public void TestStreamingConfigurationStore_OptionalAndSidecarUnavailable_Return config["anyKey"].ShouldBeNull(); } + [Fact] + public async Task TestStreamingConfigurationStore_Disposed_DoesNotUnsubscribeOrRetry() + { + var streamBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.SubscribeConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new SubscribeConfigurationResponse(new BlockingConfigurationSource(streamBlocked))); + + var config = new ConfigurationBuilder() + .AddStreamingDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromMilliseconds(100)) + .Build(); + + await streamBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + config["testKey"].ShouldBe("testValue"); + + (config as IDisposable)?.Dispose(); + await Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + + daprClient.Verify(c => c.UnsubscribeConfiguration( + "store", + "testId", + It.IsAny()), Times.Never()); + daprClient.Verify(c => c.SubscribeConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny()), Times.Once()); + } + private async Task SendStreamingResponseWithConfiguration(Dictionary items, TestHttpClient.Entry entry) { var streamResponse = new Autogenerated.SubscribeConfigurationResponse(); @@ -363,4 +402,42 @@ private async Task SendStreamingResponseWithConfiguration(Dictionary streamBlocked) : ConfigurationSource + { + public override string Id => "testId"; + + public override IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new BlockingConfigurationEnumerator(streamBlocked, cancellationToken); + } + } + + private sealed class BlockingConfigurationEnumerator(TaskCompletionSource streamBlocked, CancellationToken cancellationToken) : IAsyncEnumerator> + { + private bool hasReturnedInitialValue; + + public IDictionary Current { get; } = new Dictionary + { + ["testKey"] = new("testValue", "v1", null) + }; + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public async ValueTask MoveNextAsync() + { + if (!hasReturnedInitialValue) + { + hasReturnedInitialValue = true; + return true; + } + + streamBlocked.TrySetResult(true); + await Task.Delay(Timeout.Infinite, cancellationToken); + return false; + } + } } From aa97ee3fd9d7e3fb71391c833d31924bd6f7789e Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 07:03:14 -0500 Subject: [PATCH 5/9] Addressing concern that disposing of the cancellation token after background tasks have started may cause ObjectDisposedException races Signed-off-by: Whit Waldo --- .../DaprConfigurationStoreProvider.cs | 33 +++++++++++++++++-- .../DaprSecretStoreConfigurationProvider.cs | 30 +++++++++++++++-- ...aprSecretStoreConfigurationProviderTest.cs | 16 ++++----- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs index 58c39250d..70f3b8400 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs @@ -27,6 +27,8 @@ namespace Dapr.Extensions.Configuration; /// internal class DaprConfigurationStoreProvider : ConfigurationProvider, IDisposable { + private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(1); + private readonly string store; private readonly IReadOnlyList keys; private readonly DaprClient daprClient; @@ -35,7 +37,9 @@ internal class DaprConfigurationStoreProvider : ConfigurationProvider, IDisposab private readonly bool isOptional; private readonly IReadOnlyDictionary? metadata; private readonly CancellationTokenSource cts; + private Task loadTask = Task.CompletedTask; private Task subscribeTask = Task.CompletedTask; + private int disposed; /// /// Constructor. @@ -68,8 +72,20 @@ public DaprConfigurationStoreProvider( public void Dispose() { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + cts.Cancel(); - cts.Dispose(); + + var loadTaskCompleted = WaitForBackgroundTask(loadTask); + var subscribeTaskCompleted = WaitForBackgroundTask(subscribeTask); + if (loadTaskCompleted && subscribeTaskCompleted) + { + // Only dispose the CTS after tracked tasks have stopped using cts.Token. + cts.Dispose(); + } } /// @@ -78,7 +94,7 @@ public override void Load() if (isOptional) { Data = new Dictionary(StringComparer.OrdinalIgnoreCase); - _ = Task.Run(() => LoadInBackgroundAsync()); + loadTask = Task.Run(() => LoadInBackgroundAsync()); } else { @@ -86,6 +102,19 @@ public override void Load() } } + private static bool WaitForBackgroundTask(Task task) + { + try + { + return task.Wait(DisposeWaitTimeout); + } + catch + { + // Observe background task exceptions during disposal. + return true; + } + } + private async Task LoadInBackgroundAsync() { while (!cts.Token.IsCancellationRequested) diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs index 3183c7964..fe2b25ce4 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs @@ -27,6 +27,7 @@ namespace Dapr.Extensions.Configuration.DaprSecretStore; internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider, IDisposable { internal static readonly TimeSpan DefaultSidecarWaitTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(1); private readonly string store; @@ -45,6 +46,8 @@ internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider, IDi private readonly bool isOptional; private readonly CancellationTokenSource cts = new(); + private Task loadTask = Task.CompletedTask; + private int disposed; /// /// Creates a new instance of . @@ -182,8 +185,18 @@ public DaprSecretStoreConfigurationProvider( /// public void Dispose() { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + cts.Cancel(); - cts.Dispose(); + + if (WaitForBackgroundTask(loadTask)) + { + // Only dispose the CTS after tracked tasks have stopped using cts.Token. + cts.Dispose(); + } } private string NormalizeKey(string key) @@ -205,7 +218,7 @@ public override void Load() if (isOptional) { Data = new Dictionary(StringComparer.OrdinalIgnoreCase); - _ = Task.Run(LoadInBackgroundAsync); + loadTask = Task.Run(LoadInBackgroundAsync); } else { @@ -213,6 +226,19 @@ public override void Load() } } + private static bool WaitForBackgroundTask(Task task) + { + try + { + return task.Wait(DisposeWaitTimeout); + } + catch + { + // Observe background task exceptions during disposal. + return true; + } + } + private async Task LoadInBackgroundAsync() { while (!cts.Token.IsCancellationRequested) diff --git a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs index c6453ef08..54536896e 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs @@ -936,7 +936,7 @@ public async Task LoadSecrets_OptionalAndSidecarBecomesAvailable_PopulatesConfig }); daprClient - .Setup(c => c.GetBulkSecretAsync("store", null, default)) + .Setup(c => c.GetBulkSecretAsync("store", null, It.IsAny())) .ReturnsAsync(new Dictionary> { ["secret1"] = new Dictionary { ["secret1"] = "value1" } @@ -952,7 +952,7 @@ public async Task LoadSecrets_OptionalAndSidecarBecomesAvailable_PopulatesConfig // Wait for the reload token to fire, indicating background load completed var reloaded = new TaskCompletionSource(); config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); - await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); config["secret1"].ShouldBe("value1"); } @@ -971,7 +971,7 @@ public async Task LoadSecrets_OptionalWithDescriptors_PopulatesConfig() .Returns(Task.CompletedTask); daprClient - .Setup(c => c.GetSecretAsync(storeName, secretKey, It.IsAny>(), default)) + .Setup(c => c.GetSecretAsync(storeName, secretKey, It.IsAny>(), It.IsAny())) .ReturnsAsync(new Dictionary { { secretKey, secretValue } }); var secretDescriptors = new[] { new DaprSecretDescriptor(secretKey) }; @@ -983,7 +983,7 @@ public async Task LoadSecrets_OptionalWithDescriptors_PopulatesConfig() // Wait for the reload token to fire, indicating background load completed var reloaded = new TaskCompletionSource(); config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); - await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); config[secretKey].ShouldBe(secretValue); } @@ -999,7 +999,7 @@ public async Task LoadSecrets_OptionalAndFetchThrowsTransientError_RetriesAndSuc .Returns(Task.CompletedTask); daprClient - .Setup(c => c.GetBulkSecretAsync("store", null, default)) + .Setup(c => c.GetBulkSecretAsync("store", null, It.IsAny())) .Returns(() => { var current = Interlocked.Increment(ref callCount); @@ -1024,7 +1024,7 @@ public async Task LoadSecrets_OptionalAndFetchThrowsTransientError_RetriesAndSuc // Wait for the reload token to fire after retry succeeds var reloaded = new TaskCompletionSource(); config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); - await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); config["secret1"].ShouldBe("value1"); } @@ -1048,7 +1048,7 @@ public async Task LoadSecrets_OptionalAndDisposed_ExitsCleanly() .Build(); // Ensure background task has started - await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Dispose the provider to cancel the background task (config as IDisposable)?.Dispose(); @@ -1095,4 +1095,4 @@ private async Task SendEmptyResponse(TestHttpClient.Entry entry, HttpStatusCode var streamContent = await GrpcUtils.CreateResponseContent(response); entry.Completion.SetResult(GrpcUtils.CreateResponse(code, streamContent)); } -} \ No newline at end of file +} From 9433b9418a7f52ca292f234404a6e8d9574946f0 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 07:28:00 -0500 Subject: [PATCH 6/9] Fixed disposal issues in tests Signed-off-by: Whit Waldo --- .../SecretStoreOptionalTests.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs index 4afa58879..aa49772cd 100644 --- a/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs +++ b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs @@ -17,7 +17,7 @@ public async Task ShouldPopulateSecretsWhenSidecarStartsLate() await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(cancellationToken: TestContext.Current.CancellationToken); await environment.StartAsync(TestContext.Current.CancellationToken); - var harness = new DaprHarnessBuilder(componentsDir) + await using var harness = new DaprHarnessBuilder(componentsDir) .WithEnvironment(environment) .BuildSecretStore(); @@ -58,8 +58,6 @@ public async Task ShouldPopulateSecretsWhenSidecarStartsLate() Assert.Equal("value1", config["secret1"]); Assert.Equal("value2", config["secret2"]); - - await harness.DisposeAsync(); } [Fact] @@ -70,7 +68,7 @@ public async Task ShouldBlockAndPopulateSecretsWhenNotOptional() await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(cancellationToken: TestContext.Current.CancellationToken); await environment.StartAsync(TestContext.Current.CancellationToken); - var harness = new DaprHarnessBuilder(componentsDir) + await using var harness = new DaprHarnessBuilder(componentsDir) .WithEnvironment(environment) .BuildSecretStore(); @@ -91,8 +89,6 @@ public async Task ShouldBlockAndPopulateSecretsWhenNotOptional() Assert.Equal("value1", config["secret1"]); Assert.Equal("value2", config["secret2"]); - - await harness.DisposeAsync(); } [Fact] From a681b7283f10ec151128d616d2870e9810b6b966 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 07:28:54 -0500 Subject: [PATCH 7/9] Adding atomic updates for non-streaming fetch (using fresh dictionary instead of calling `Set` repeatedly Signed-off-by: Whit Waldo --- .../DaprConfigurationStoreProvider.cs | 5 +- .../DaprConfigurationStoreProviderTest.cs | 48 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs index 70f3b8400..2fc9df9c2 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs @@ -235,10 +235,13 @@ private async Task FetchDataAsync() { // We don't need to worry about ReloadTokens here because it is a constant response. var getConfigurationResponse = await daprClient.GetConfiguration(store, keys, metadata, cts.Token); + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in getConfigurationResponse.Items) { - Set(item.Key, item.Value.Value); + data[item.Key] = item.Value.Value; } + + Data = data; } } } diff --git a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs index 63d57408a..284dfd530 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs @@ -142,6 +142,48 @@ public async Task TestConfigurationStoreExtension_ProperlyStoresValues() Assert.Equal(item.Value, config["testKey"]); } + [Fact] + public void TestConfigurationStoreExtension_ReloadReplacesData() + { + var responses = new Queue(new[] + { + new GetConfigurationResponse( + new Dictionary + { + ["oldKey"] = new ConfigurationItem("oldValue", "v1", null) + }), + new GetConfigurationResponse( + new Dictionary + { + ["newKey"] = new ConfigurationItem("newValue", "v2", null) + }) + }); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(() => responses.Dequeue()); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(5)) + .Build(); + + config["oldKey"].ShouldBe("oldValue"); + + config.Reload(); + + config["oldKey"].ShouldBeNull(); + config["newKey"].ShouldBe("newValue"); + } + [Fact] public async Task TestStreamingConfigurationStoreExtension_ProperlyStoresValues() { @@ -249,7 +291,7 @@ public async Task TestConfigurationStore_OptionalAndSidecarBecomesAvailable_Popu // Wait for the reload token to fire, indicating background load completed var reloaded = new TaskCompletionSource(); config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); - await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); config["testKey"].ShouldBe("testValue"); } @@ -296,7 +338,7 @@ public async Task TestConfigurationStore_OptionalAndFetchThrowsTransientError_Re // Wait for the reload token to fire after retry succeeds var reloaded = new TaskCompletionSource(); config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); - await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); config["testKey"].ShouldBe("testValue"); } @@ -320,7 +362,7 @@ public async Task TestConfigurationStore_OptionalAndDisposed_ExitsCleanly() .Build(); // Ensure background task has started - await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Dispose the provider to cancel the background task (config as IDisposable)?.Dispose(); From d8286e6f56f7557ae7a170142e86930081ff35e7 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 07:35:17 -0500 Subject: [PATCH 8/9] Minor solution organization refactoring Signed-off-by: Whit Waldo --- all.sln | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/all.sln b/all.sln index 532e3dfd7..5f3dded71 100644 --- a/all.sln +++ b/all.sln @@ -272,6 +272,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SecretManagement", "SecretManagement", "{929A8AD2-DB45-B92A-7930-EBDD2DBAF802}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecretManagementSample", "examples\SecretManagement\SecretManagementSample\SecretManagementSample.csproj", "{ED74B33F-3CE7-42EB-BAA1-623F43900B15}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.AI.Microsoft.Extensions.Test", "test\Dapr.AI.Microsoft.Extensions.Test\Dapr.AI.Microsoft.Extensions.Test.csproj", "{86CBB08F-601A-4B0B-87BF-383D391A961C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.DistributedLock.Test", "test\Dapr.DistributedLock.Test\Dapr.DistributedLock.Test.csproj", "{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0}" @@ -1903,7 +1904,6 @@ Global {F99CE5A1-FDA1-415C-B1E6-C8787734ACD2} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {B42DD6AA-255C-4606-8A1B-263B26650DED} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {1BECAC48-1C83-43D2-A91F-9AFA634A68C7} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {87842296-C78B-42B9-8E96-5054E79CD700} = {DD020B34-460F-455F-8D17-CF4A949F100B} {929A8AD2-DB45-B92A-7930-EBDD2DBAF802} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {ED74B33F-3CE7-42EB-BAA1-623F43900B15} = {929A8AD2-DB45-B92A-7930-EBDD2DBAF802} {8777EAD2-419B-4683-826A-82B7C1F4F69F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} @@ -1922,7 +1922,9 @@ Global {DA1F9FE7-6041-4581-B9AD-685034869CE8} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {E015C5ED-F93F-4DB6-BA01-BC59B7859A60} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} {7DB95C53-19C1-49D2-B0BA-116DAEFC83DB} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} - {193BBE09-E261-4D65-B4CC-18B88DB049D7} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {193BBE09-E261-4D65-B4CC-18B88DB049D7} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {87842296-C78B-42B9-8E96-5054E79CD700} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {A3F7C8B2-1D45-4E92-B5F6-3C8D9E0A2F71} = {27C5D71D-0721-4221-9286-B94AB07B58CF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} From b70fde0e363eb8ab10a88dd9a3a9c49d33285283 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 5 Jun 2026 07:37:27 -0500 Subject: [PATCH 9/9] Adding more testing Signed-off-by: Whit Waldo --- .../DaprConfigurationStoreProviderTest.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs index 284dfd530..d3368594f 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Net; using System.Threading; @@ -296,6 +297,60 @@ public async Task TestConfigurationStore_OptionalAndSidecarBecomesAvailable_Popu config["testKey"].ShouldBe("testValue"); } + [Fact] + public async Task TestConfigurationStore_OptionalBackgroundLoad_DoesNotPublishPartiallyBuiltData() + { + var firstItemEnumerated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var continueEnumeration = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var configResponse = new GetConfigurationResponse( + new BlockingConfigurationItems( + new Dictionary + { + ["firstKey"] = new ConfigurationItem("firstValue", "v1", null), + ["secondKey"] = new ConfigurationItem("secondValue", "v1", null) + }, + firstItemEnumerated, + continueEnumeration)); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(configResponse); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(5), optional: true) + .Build(); + + var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + + await firstItemEnumerated.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + try + { + config["firstKey"].ShouldBeNull(); + config["secondKey"].ShouldBeNull(); + } + finally + { + continueEnumeration.TrySetResult(true); + } + + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config["firstKey"].ShouldBe("firstValue"); + config["secondKey"].ShouldBe("secondValue"); + } + [Fact] public async Task TestConfigurationStore_OptionalAndFetchThrowsTransientError_RetriesAndSucceeds() { @@ -482,4 +537,49 @@ public async ValueTask MoveNextAsync() return false; } } + + private sealed class BlockingConfigurationItems( + IReadOnlyDictionary inner, + TaskCompletionSource firstItemEnumerated, + TaskCompletionSource continueEnumeration) : IReadOnlyDictionary + { + public IEnumerable Keys => inner.Keys; + + public IEnumerable Values => inner.Values; + + public int Count => inner.Count; + + public ConfigurationItem this[string key] => inner[key]; + + public bool ContainsKey(string key) + { + return inner.ContainsKey(key); + } + + public bool TryGetValue(string key, out ConfigurationItem value) + { + return inner.TryGetValue(key, out value); + } + + public IEnumerator> GetEnumerator() + { + var shouldBlock = true; + foreach (var item in inner) + { + if (shouldBlock) + { + shouldBlock = false; + firstItemEnumerated.TrySetResult(true); + continueEnumeration.Task.GetAwaiter().GetResult(); + } + + yield return item; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } }