Skip to content

Commit

Permalink
Allow listening for options changes (#1279)
Browse files Browse the repository at this point in the history
* Allow listening for options changes

* fixes

* PR comments

* Fix delayed task not being cancelled.

* kill mutants

* fixes
  • Loading branch information
martintmk authored Jun 13, 2023
1 parent 90c9894 commit bde632e
Show file tree
Hide file tree
Showing 14 changed files with 267 additions and 10 deletions.
2 changes: 2 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="1.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="7.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.2" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
<PackageVersion Include="Moq" Version="4.18.4" />
Expand Down
3 changes: 2 additions & 1 deletion src/Polly.Core/Hedging/Controller/HedgingExecutionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> whenAnyHedgedTask = WaitForTaskCompetitionAsync();
var completedTask = await Task.WhenAny(whenAnyHedgedTask, delayTask).ConfigureAwait(ContinueOnCapturedContext);

Expand All @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions src/Polly.Extensions.Tests/Polly.Extensions.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
<NoWarn>$(NoWarn);SA1600;SA1204;S6608</NoWarn>
<Include>[Polly.Extensions]*</Include>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Polly.Core.Tests\Helpers\FakeTimeProvider.cs" Link="Utils\FakeTimeProvider.cs" />
<Compile Include="..\Polly.Core.Tests\Utils\ObjectPoolTests.cs" Link="Utils\ObjectPoolTests.cs" />
<Compile Include="..\Polly.Core.Tests\Utils\SystemTimeProviderTests.cs" Link="Utils\SystemTimeProviderTests.cs" />
</ItemGroup>

<ItemGroup>
<Using Include="Polly.TestUtils" />
<ProjectReference Include="..\Polly.Extensions\Polly.Extensions.csproj" />
<ProjectReference Include="..\Polly.TestUtils\Polly.TestUtils.csproj" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>

</Project>
129 changes: 129 additions & 0 deletions src/Polly.Extensions.Tests/ReloadableResilienceStrategyTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<ReloadableStrategyOptions>(builder.Build());
}
else
{
services.Configure<ReloadableStrategyOptions>(name, builder.Build());
}

services.AddResilienceStrategy("my-strategy", (builder, context) =>
{
var options = context.GetOptions<ReloadableStrategyOptions>(name);
context.EnableReloads<ReloadableStrategyOptions>(name);

builder.AddStrategy(_ => new ReloadableStrategy(options.Tag), new ReloadableStrategyOptions());
});

var serviceProvider = services.BuildServiceProvider();
var strategy = serviceProvider.GetRequiredService<ResilienceStrategyProvider<string>>().Get("my-strategy");
var context = ResilienceContext.Get();
var registry = serviceProvider.GetRequiredService<OptionsReloadHelperRegistry<string>>();

// 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<OptionsReloadHelperRegistry<string>>();

registry.Get<ReloadableStrategy>("A", null);
registry.Get<ReloadableStrategy>("A", "dummy");
registry.Get<ReloadableStrategy>("B", null);
registry.Get<ReloadableStrategy>("B", "dummy");

registry.Count.Should().Be(4);

registry.Dispose();
registry.Count.Should().Be(0);
}

[Fact]
public void OptionsReloadHelper_Dispose_Ok()
{
var monitor = new Mock<IOptionsMonitor<ReloadableStrategyOptions>>();

using var helper = new OptionsReloadHelper<ReloadableStrategyOptions>(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<Outcome<TResult>> ExecuteCoreAsync<TResult, TState>(
Func<ResilienceContext, TState, ValueTask<Outcome<TResult>>> 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<string, string?> data)
{
Data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
OnReload();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Threading.Tasks;
using Polly.Extensions.Telemetry;

namespace Polly.Extensions.Tests.Telemetry;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using Polly.Extensions.Telemetry;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Polly.Extensions.Telemetry;
using Polly.Telemetry;
Expand Down
22 changes: 22 additions & 0 deletions src/Polly.Extensions.Tests/Utils/OptionsReloadHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOptionsMonitor<string>>();

monitor
.Setup(m => m.OnChange(It.IsAny<Action<string, string?>>()))
.Returns(() => Mock.Of<IDisposable>());

using var helper = new OptionsReloadHelper<string>(monitor.Object, "name");

monitor.VerifyAll();
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Polly.Extensions.Utils;
using Polly.Registry;

namespace Polly.Extensions.DependencyInjection;
Expand Down Expand Up @@ -30,4 +33,40 @@ internal AddResilienceStrategyContext(ConfigureBuilderContext<TKey> registryCont
/// Gets the context that is used by the registry.
/// </summary>
public ConfigureBuilderContext<TKey> RegistryContext { get; }

/// <summary>
/// Enables dynamic reloading of the resilience strategy whenever the <typeparamref name="TOptions"/> options are changed.
/// </summary>
/// <typeparam name="TOptions">The options type to listen to.</typeparam>
/// <param name="name">The named options, if any.</param>
/// <remarks>
/// You can decide based on the <paramref name="name"/> to listen for changes in global options or named options.
/// If <paramref name="name"/> is <see langword="null"/> then the global options are listened to.
/// <para>
/// 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.
/// </para>
/// </remarks>
public void EnableReloads<TOptions>(string? name = null)
{
var registry = ServiceProvider.GetRequiredService<OptionsReloadHelperRegistry<TKey>>();
var helper = registry.Get<TOptions>(StrategyKey, name);

RegistryContext.EnableReloads(helper.GetCancellationToken);
}

/// <summary>
/// Gets the options identified by <paramref name="name"/>.
/// </summary>
/// <typeparam name="TOptions">The options type.</typeparam>
/// <param name="name">The options name, if any.</param>
/// <returns>The options instance.</returns>
/// <remarks>
/// If <paramref name="name"/> is <see langword="null"/> then the global options are returned.
/// </remarks>
public TOptions GetOptions<TOptions>(string? name = null)
{
var monitor = ServiceProvider.GetRequiredService<IOptionsMonitor<TOptions>>();

return name == null ? monitor.CurrentValue : monitor.Get(name);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -173,6 +174,7 @@ private static IServiceCollection AddResilienceStrategyRegistry<TKey>(this IServ
services.Add(RegistryMarker<TKey>.ServiceDescriptor);
services.AddResilienceStrategyBuilder();
services.AddResilienceStrategyRegistry<TKey>();
services.TryAddSingleton<OptionsReloadHelperRegistry<TKey>>();

services.TryAddSingleton(serviceProvider =>
{
Expand Down
1 change: 0 additions & 1 deletion src/Polly.Extensions/Telemetry/Log.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System;
using Microsoft.Extensions.Logging;

namespace Polly.Extensions.Telemetry;
Expand Down
2 changes: 0 additions & 2 deletions src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
33 changes: 33 additions & 0 deletions src/Polly.Extensions/Utils/OptionsReloadHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.Extensions.Options;

namespace Polly.Extensions.Utils;

internal sealed class OptionsReloadHelper<T> : IDisposable
{
private readonly IDisposable? _listener;
private CancellationTokenSource _cancellation = new();

public OptionsReloadHelper(IOptionsMonitor<T> 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();
}
}
34 changes: 34 additions & 0 deletions src/Polly.Extensions/Utils/OptionsReloadHelperRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Polly.Extensions.Utils;

internal sealed class OptionsReloadHelperRegistry<TKey> : 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<TOptions> Get<TOptions>(TKey key, string? name)
{
name ??= Options.DefaultName;

return (OptionsReloadHelper<TOptions>)_helpers.GetOrAdd((typeof(TOptions), key, name), _ =>
{
return new OptionsReloadHelper<TOptions>(_serviceProvider.GetRequiredService<IOptionsMonitor<TOptions>>(), name);
});
}

public void Dispose()
{
foreach (var helper in _helpers.Values.OfType<IDisposable>())
{
helper.Dispose();
}

_helpers.Clear();
}
}

0 comments on commit bde632e

Please sign in to comment.