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 a1cb299f7..db4441fb9 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();