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(); + } +}