diff --git a/all.sln b/all.sln
index cd6213beb..5f3dded71 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
@@ -273,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}"
@@ -297,6 +297,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 +1497,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
@@ -1886,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}
@@ -1905,6 +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} = {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}
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..2fc9df9c2 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;
@@ -26,14 +27,19 @@ 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 static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(1);
+
+ 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 loadTask = Task.CompletedTask;
private Task subscribeTask = Task.CompletedTask;
+ private int disposed;
///
/// Constructor.
@@ -44,30 +50,108 @@ 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();
}
public void Dispose()
{
+ if (Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ return;
+ }
+
cts.Cancel();
+
+ 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();
+ }
}
///
- public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
+ public override void Load()
+ {
+ if (isOptional)
+ {
+ Data = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ loadTask = Task.Run(() => LoadInBackgroundAsync());
+ }
+ else
+ {
+ LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
+ }
+ }
+
+ 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)
+ {
+ 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 +161,11 @@ private async Task LoadAsync()
await daprClient.WaitForSidecarAsync(tokenSource.Token);
}
+ await FetchDataAsync();
+ }
+
+ private async Task FetchDataAsync()
+ {
if (isStreaming)
{
subscribeTask = Task.Run(async () =>
@@ -100,12 +189,43 @@ private async Task LoadAsync()
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;
}
}
}
@@ -115,10 +235,13 @@ private async Task LoadAsync()
{
// 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/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..fe2b25ce4 100644
--- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs
+++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs
@@ -24,9 +24,10 @@ 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);
+ private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(1);
private readonly string store;
@@ -42,6 +43,12 @@ internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider
private readonly TimeSpan sidecarWaitTimeout;
+ private readonly bool isOptional;
+
+ private readonly CancellationTokenSource cts = new();
+ private Task loadTask = Task.CompletedTask;
+ private int disposed;
+
///
/// Creates a new instance of .
///
@@ -83,19 +90,22 @@ 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));
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));
}
@@ -103,9 +113,10 @@ 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;
}
///
@@ -149,13 +160,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,16 +179,31 @@ public DaprSecretStoreConfigurationProvider(
this.metadata = metadata;
this.client = client;
this.sidecarWaitTimeout = sidecarWaitTimeout;
+ this.isOptional = isOptional;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ return;
+ }
+
+ cts.Cancel();
+
+ if (WaitForBackgroundTask(loadTask))
+ {
+ // Only dispose the CTS after tracked tasks have stopped using cts.Token.
+ cts.Dispose();
+ }
}
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;
@@ -185,18 +213,85 @@ 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);
+ loadTask = Task.Run(LoadInBackgroundAsync);
+ }
+ else
+ {
+ LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
+ }
+ }
- private async Task LoadAsync()
+ private static bool WaitForBackgroundTask(Task task)
{
- var data = new Dictionary(StringComparer.InvariantCultureIgnoreCase);
+ try
+ {
+ return task.Wait(DisposeWaitTimeout);
+ }
+ catch
+ {
+ // Observe background task exceptions during disposal.
+ return true;
+ }
+ }
+ 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 client.WaitForSidecarAsync(linked.Token);
+
+ await FetchSecretsAsync(linked.Token);
+ 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(CancellationToken cancellationToken = default)
+ {
+ var data = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
if (secretDescriptors != null)
{
foreach (var secretDescriptor in secretDescriptors)
@@ -207,7 +302,7 @@ private async Task LoadAsync()
try
{
result = await client
- .GetSecretAsync(store, secretDescriptor.SecretKey, secretDescriptor.Metadata)
+ .GetSecretAsync(store, secretDescriptor.SecretKey, secretDescriptor.Metadata, cancellationToken)
.ConfigureAwait(false);
}
catch (DaprException)
@@ -238,25 +333,21 @@ private async Task LoadAsync()
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;
}
}
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/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.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs
index 3042323e6..d3368594f 100644
--- a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs
+++ b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs
@@ -1,10 +1,14 @@
using System;
+using System.Collections;
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;
@@ -139,6 +143,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()
{
@@ -187,6 +233,255 @@ 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), TestContext.Current.CancellationToken);
+
+ 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()
+ {
+ 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), TestContext.Current.CancellationToken);
+
+ 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), TestContext.Current.CancellationToken);
+
+ // 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();
+ }
+
+ [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();
@@ -204,4 +499,87 @@ 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;
+ }
+ }
+
+ 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();
+ }
+ }
}
diff --git a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs
index 53cb4fc6c..54536896e 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, It.IsAny()))
+ .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), TestContext.Current.CancellationToken);
+
+ 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>(), It.IsAny()))
+ .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), TestContext.Current.CancellationToken);
+
+ 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, It.IsAny()))
+ .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), TestContext.Current.CancellationToken);
+
+ 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), TestContext.Current.CancellationToken);
+
+ // 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();
@@ -934,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
+}
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..aa49772cd
--- /dev/null
+++ b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs
@@ -0,0 +1,125 @@
+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);
+
+ await using 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"]);
+ }
+
+ [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);
+
+ await using 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"]);
+ }
+
+ [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.
+ }
+}