From bde632e20663a46632430a2adb708f3163488258 Mon Sep 17 00:00:00 2001
From: martintmk <103487740+martintmk@users.noreply.github.com>
Date: Tue, 13 Jun 2023 07:11:44 +0200
Subject: [PATCH] Allow listening for options changes (#1279)
* Allow listening for options changes
* fixes
* PR comments
* Fix delayed task not being cancelled.
* kill mutants
* fixes
---
src/Directory.Packages.props | 2 +
.../Controller/HedgingExecutionContext.cs | 3 +-
.../Polly.Extensions.Tests.csproj | 6 +-
.../ReloadableResilienceStrategyTests.cs | 129 ++++++++++++++++++
.../Telemetry/EnrichmentContextTests.cs | 1 -
...esilienceTelemetryDiagnosticSourceTests.cs | 1 -
.../TelemetryResilienceStrategyTests.cs | 2 -
.../Utils/OptionsReloadHelperTests.cs | 22 +++
.../AddResilienceStrategyContext.cs | 39 ++++++
.../PollyServiceCollectionExtensions.cs | 2 +
src/Polly.Extensions/Telemetry/Log.cs | 1 -
.../Telemetry/TelemetryResilienceStrategy.cs | 2 -
.../Utils/OptionsReloadHelper.cs | 33 +++++
.../Utils/OptionsReloadHelperRegistry.cs | 34 +++++
14 files changed, 267 insertions(+), 10 deletions(-)
create mode 100644 src/Polly.Extensions.Tests/ReloadableResilienceStrategyTests.cs
create mode 100644 src/Polly.Extensions.Tests/Utils/OptionsReloadHelperTests.cs
create mode 100644 src/Polly.Extensions/Utils/OptionsReloadHelper.cs
create mode 100644 src/Polly.Extensions/Utils/OptionsReloadHelperRegistry.cs
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 059811cd5f2..ff253f7ca37 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -7,8 +7,10 @@
+
+
diff --git a/src/Polly.Core/Hedging/Controller/HedgingExecutionContext.cs b/src/Polly.Core/Hedging/Controller/HedgingExecutionContext.cs
index 4d9048f8e82..b6a933643bb 100644
--- a/src/Polly.Core/Hedging/Controller/HedgingExecutionContext.cs
+++ b/src/Polly.Core/Hedging/Controller/HedgingExecutionContext.cs
@@ -126,7 +126,7 @@ public void Complete()
using var delayTaskCancellation = CancellationTokenSource.CreateLinkedTokenSource(Snapshot.Context.CancellationToken);
- var delayTask = _timeProvider.DelayAsync(hedgingDelay, Snapshot.Context);
+ var delayTask = _timeProvider.Delay(hedgingDelay, delayTaskCancellation.Token);
Task whenAnyHedgedTask = WaitForTaskCompetitionAsync();
var completedTask = await Task.WhenAny(whenAnyHedgedTask, delayTask).ConfigureAwait(ContinueOnCapturedContext);
@@ -138,6 +138,7 @@ public void Complete()
// cancel the ongoing delay task
// Stryker disable once boolean : no means to test this
delayTaskCancellation.Cancel(throwOnFirstException: false);
+
await whenAnyHedgedTask.ConfigureAwait(ContinueOnCapturedContext);
return TryRemoveExecutedTask();
diff --git a/src/Polly.Extensions.Tests/Polly.Extensions.Tests.csproj b/src/Polly.Extensions.Tests/Polly.Extensions.Tests.csproj
index 14f7650dd84..a4c1fb4fc91 100644
--- a/src/Polly.Extensions.Tests/Polly.Extensions.Tests.csproj
+++ b/src/Polly.Extensions.Tests/Polly.Extensions.Tests.csproj
@@ -10,16 +10,18 @@
$(NoWarn);SA1600;SA1204;S6608
[Polly.Extensions]*
+
-
+
+
+
-
diff --git a/src/Polly.Extensions.Tests/ReloadableResilienceStrategyTests.cs b/src/Polly.Extensions.Tests/ReloadableResilienceStrategyTests.cs
new file mode 100644
index 00000000000..21f8cb0f1e4
--- /dev/null
+++ b/src/Polly.Extensions.Tests/ReloadableResilienceStrategyTests.cs
@@ -0,0 +1,129 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Moq;
+using Polly.Extensions.Utils;
+using Polly.Registry;
+
+namespace Polly.Extensions.Tests;
+
+public class ReloadableResilienceStrategyTests
+{
+ private static readonly ResiliencePropertyKey TagKey = new("tests.tag");
+
+ [InlineData(null)]
+ [InlineData("custom-name")]
+ [InlineData("")]
+ [Theory]
+ public void AddResilienceStrategy_EnsureReloadable(string? name)
+ {
+ var reloadableConfig = new ReloadableConfiguration();
+ reloadableConfig.Reload(new() { { "tag", "initial-tag" } });
+ var builder = new ConfigurationBuilder().Add(reloadableConfig);
+
+ var services = new ServiceCollection();
+
+ if (name == null)
+ {
+ services.Configure(builder.Build());
+ }
+ else
+ {
+ services.Configure(name, builder.Build());
+ }
+
+ services.AddResilienceStrategy("my-strategy", (builder, context) =>
+ {
+ var options = context.GetOptions(name);
+ context.EnableReloads(name);
+
+ builder.AddStrategy(_ => new ReloadableStrategy(options.Tag), new ReloadableStrategyOptions());
+ });
+
+ var serviceProvider = services.BuildServiceProvider();
+ var strategy = serviceProvider.GetRequiredService>().Get("my-strategy");
+ var context = ResilienceContext.Get();
+ var registry = serviceProvider.GetRequiredService>();
+
+ // initial
+ strategy.Execute(_ => "dummy", context);
+ context.Properties.GetValue(TagKey, string.Empty).Should().Be("initial-tag");
+
+ // reloads
+ for (int i = 0; i < 10; i++)
+ {
+ reloadableConfig.Reload(new() { { "tag", $"reload-{i}" } });
+ strategy.Execute(_ => "dummy", context);
+ context.Properties.GetValue(TagKey, string.Empty).Should().Be($"reload-{i}");
+ }
+
+ registry.Count.Should().Be(1);
+ serviceProvider.Dispose();
+ registry.Count.Should().Be(0);
+ }
+
+ [Fact]
+ public void OptionsReloadHelperRegistry_EnsureTracksDifferentHelpers()
+ {
+ var services = new ServiceCollection().AddResilienceStrategy("dummy", builder => { });
+ var serviceProvider = services.BuildServiceProvider();
+ var registry = serviceProvider.GetRequiredService>();
+
+ registry.Get("A", null);
+ registry.Get("A", "dummy");
+ registry.Get("B", null);
+ registry.Get("B", "dummy");
+
+ registry.Count.Should().Be(4);
+
+ registry.Dispose();
+ registry.Count.Should().Be(0);
+ }
+
+ [Fact]
+ public void OptionsReloadHelper_Dispose_Ok()
+ {
+ var monitor = new Mock>();
+
+ using var helper = new OptionsReloadHelper(monitor.Object, string.Empty);
+
+ helper.Invoking(h => h.Dispose()).Should().NotThrow();
+ }
+
+ public class ReloadableStrategy : ResilienceStrategy
+ {
+ public ReloadableStrategy(string tag) => Tag = tag;
+
+ public string Tag { get; }
+
+ protected override ValueTask> ExecuteCoreAsync(
+ Func>> callback,
+ ResilienceContext context,
+ TState state)
+ {
+ context.Properties.Set(TagKey, Tag);
+ return callback(context, state);
+ }
+ }
+
+ public class ReloadableStrategyOptions : ResilienceStrategyOptions
+ {
+ public override string StrategyType => "Reloadable";
+
+ public string Tag { get; set; } = string.Empty;
+ }
+
+ private class ReloadableConfiguration : ConfigurationProvider, IConfigurationSource
+ {
+ public IConfigurationProvider Build(IConfigurationBuilder builder)
+ {
+ return this;
+ }
+
+ public void Reload(Dictionary data)
+ {
+ Data = new Dictionary(data, StringComparer.OrdinalIgnoreCase);
+ OnReload();
+ }
+ }
+}
diff --git a/src/Polly.Extensions.Tests/Telemetry/EnrichmentContextTests.cs b/src/Polly.Extensions.Tests/Telemetry/EnrichmentContextTests.cs
index 940822c98f8..fc279531758 100644
--- a/src/Polly.Extensions.Tests/Telemetry/EnrichmentContextTests.cs
+++ b/src/Polly.Extensions.Tests/Telemetry/EnrichmentContextTests.cs
@@ -1,4 +1,3 @@
-using System.Threading.Tasks;
using Polly.Extensions.Telemetry;
namespace Polly.Extensions.Tests.Telemetry;
diff --git a/src/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs b/src/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs
index 104d7fd4527..a672f50079e 100644
--- a/src/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs
+++ b/src/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using Polly.Extensions.Telemetry;
diff --git a/src/Polly.Extensions.Tests/Telemetry/TelemetryResilienceStrategyTests.cs b/src/Polly.Extensions.Tests/Telemetry/TelemetryResilienceStrategyTests.cs
index df0568fe04b..c2cd57245ee 100644
--- a/src/Polly.Extensions.Tests/Telemetry/TelemetryResilienceStrategyTests.cs
+++ b/src/Polly.Extensions.Tests/Telemetry/TelemetryResilienceStrategyTests.cs
@@ -1,5 +1,3 @@
-using System;
-using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Polly.Extensions.Telemetry;
using Polly.Telemetry;
diff --git a/src/Polly.Extensions.Tests/Utils/OptionsReloadHelperTests.cs b/src/Polly.Extensions.Tests/Utils/OptionsReloadHelperTests.cs
new file mode 100644
index 00000000000..693adc0eda5
--- /dev/null
+++ b/src/Polly.Extensions.Tests/Utils/OptionsReloadHelperTests.cs
@@ -0,0 +1,22 @@
+using Microsoft.Extensions.Options;
+using Moq;
+using Polly.Extensions.Utils;
+
+namespace Polly.Extensions.Tests.Utils;
+
+public class OptionsReloadHelperTests
+{
+ [Fact]
+ public void Ctor_NamedOptions()
+ {
+ var monitor = new Mock>();
+
+ monitor
+ .Setup(m => m.OnChange(It.IsAny>()))
+ .Returns(() => Mock.Of());
+
+ using var helper = new OptionsReloadHelper(monitor.Object, "name");
+
+ monitor.VerifyAll();
+ }
+}
diff --git a/src/Polly.Extensions/DependencyInjection/AddResilienceStrategyContext.cs b/src/Polly.Extensions/DependencyInjection/AddResilienceStrategyContext.cs
index 114fcd9284b..032604df498 100644
--- a/src/Polly.Extensions/DependencyInjection/AddResilienceStrategyContext.cs
+++ b/src/Polly.Extensions/DependencyInjection/AddResilienceStrategyContext.cs
@@ -1,3 +1,6 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Polly.Extensions.Utils;
using Polly.Registry;
namespace Polly.Extensions.DependencyInjection;
@@ -30,4 +33,40 @@ internal AddResilienceStrategyContext(ConfigureBuilderContext registryCont
/// Gets the context that is used by the registry.
///
public ConfigureBuilderContext RegistryContext { get; }
+
+ ///
+ /// Enables dynamic reloading of the resilience strategy whenever the options are changed.
+ ///
+ /// The options type to listen to.
+ /// The named options, if any.
+ ///
+ /// You can decide based on the to listen for changes in global options or named options.
+ /// If is then the global options are listened to.
+ ///
+ /// You can listen for changes only for single options. If you call this method multiple times, the preceding calls are ignored and only the last one wins.
+ ///
+ ///
+ public void EnableReloads(string? name = null)
+ {
+ var registry = ServiceProvider.GetRequiredService>();
+ var helper = registry.Get(StrategyKey, name);
+
+ RegistryContext.EnableReloads(helper.GetCancellationToken);
+ }
+
+ ///
+ /// Gets the options identified by .
+ ///
+ /// The options type.
+ /// The options name, if any.
+ /// The options instance.
+ ///
+ /// If is then the global options are returned.
+ ///
+ public TOptions GetOptions(string? name = null)
+ {
+ var monitor = ServiceProvider.GetRequiredService>();
+
+ return name == null ? monitor.CurrentValue : monitor.Get(name);
+ }
}
diff --git a/src/Polly.Extensions/DependencyInjection/PollyServiceCollectionExtensions.cs b/src/Polly.Extensions/DependencyInjection/PollyServiceCollectionExtensions.cs
index b8e76f21f4a..02d4d911c7f 100644
--- a/src/Polly.Extensions/DependencyInjection/PollyServiceCollectionExtensions.cs
+++ b/src/Polly.Extensions/DependencyInjection/PollyServiceCollectionExtensions.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Options;
using Polly.Extensions.DependencyInjection;
using Polly.Extensions.Telemetry;
+using Polly.Extensions.Utils;
using Polly.Registry;
using Polly.Utils;
@@ -173,6 +174,7 @@ private static IServiceCollection AddResilienceStrategyRegistry(this IServ
services.Add(RegistryMarker.ServiceDescriptor);
services.AddResilienceStrategyBuilder();
services.AddResilienceStrategyRegistry();
+ services.TryAddSingleton>();
services.TryAddSingleton(serviceProvider =>
{
diff --git a/src/Polly.Extensions/Telemetry/Log.cs b/src/Polly.Extensions/Telemetry/Log.cs
index c0aaf2f77dc..d6281868f47 100644
--- a/src/Polly.Extensions/Telemetry/Log.cs
+++ b/src/Polly.Extensions/Telemetry/Log.cs
@@ -1,4 +1,3 @@
-using System;
using Microsoft.Extensions.Logging;
namespace Polly.Extensions.Telemetry;
diff --git a/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs b/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
index d19433ef68d..0d42730ea91 100644
--- a/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
+++ b/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
@@ -1,6 +1,4 @@
-using System;
using System.Diagnostics.Metrics;
-using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Polly.Extensions.Utils;
using Polly.Utils;
diff --git a/src/Polly.Extensions/Utils/OptionsReloadHelper.cs b/src/Polly.Extensions/Utils/OptionsReloadHelper.cs
new file mode 100644
index 00000000000..26560ecefc9
--- /dev/null
+++ b/src/Polly.Extensions/Utils/OptionsReloadHelper.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.Options;
+
+namespace Polly.Extensions.Utils;
+
+internal sealed class OptionsReloadHelper : IDisposable
+{
+ private readonly IDisposable? _listener;
+ private CancellationTokenSource _cancellation = new();
+
+ public OptionsReloadHelper(IOptionsMonitor monitor, string name) => _listener = monitor.OnChange((_, changedNamed) =>
+ {
+ if (name == changedNamed)
+ {
+ HandleChange();
+ }
+ });
+
+ public CancellationToken GetCancellationToken() => _cancellation.Token;
+
+ public void Dispose()
+ {
+ _cancellation.Dispose();
+ _listener?.Dispose();
+ }
+
+ private void HandleChange()
+ {
+ var oldCancellation = _cancellation;
+ _cancellation = new CancellationTokenSource();
+ oldCancellation.Cancel();
+ oldCancellation.Dispose();
+ }
+}
diff --git a/src/Polly.Extensions/Utils/OptionsReloadHelperRegistry.cs b/src/Polly.Extensions/Utils/OptionsReloadHelperRegistry.cs
new file mode 100644
index 00000000000..e0270649d50
--- /dev/null
+++ b/src/Polly.Extensions/Utils/OptionsReloadHelperRegistry.cs
@@ -0,0 +1,34 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Polly.Extensions.Utils;
+
+internal sealed class OptionsReloadHelperRegistry : IDisposable
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ConcurrentDictionary<(Type optionsType, TKey key, string? name), object> _helpers = new();
+
+ public OptionsReloadHelperRegistry(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
+
+ public int Count => _helpers.Count;
+
+ public OptionsReloadHelper Get(TKey key, string? name)
+ {
+ name ??= Options.DefaultName;
+
+ return (OptionsReloadHelper)_helpers.GetOrAdd((typeof(TOptions), key, name), _ =>
+ {
+ return new OptionsReloadHelper(_serviceProvider.GetRequiredService>(), name);
+ });
+ }
+
+ public void Dispose()
+ {
+ foreach (var helper in _helpers.Values.OfType())
+ {
+ helper.Dispose();
+ }
+
+ _helpers.Clear();
+ }
+}