diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs index ac6bab4c221656..39e7864b7482ae 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Threading; namespace Microsoft.Extensions.Options { @@ -16,11 +17,22 @@ public class OptionsCache<[DynamicallyAccessedMembers(Options.DynamicallyAccesse where TOptions : class { private readonly ConcurrentDictionary> _cache = new ConcurrentDictionary>(concurrencyLevel: 1, capacity: 31, StringComparer.Ordinal); // 31 == default capacity + private int _generation; + + /// + /// Gets a monotonically-increasing counter that is incremented on every mutation (Clear, TryAdd, TryRemove). + /// Consumers can use this to detect whether the cache contents may have changed since they last read an entry. + /// + internal int Generation => Volatile.Read(ref _generation); /// /// Clears all options instances from the cache. /// - public void Clear() => _cache.Clear(); + public void Clear() + { + _cache.Clear(); + Interlocked.Increment(ref _generation); + } /// /// Gets a named options instance, or adds a new instance created with . @@ -97,11 +109,16 @@ public virtual bool TryAdd(string? name, TOptions options) { ArgumentNullException.ThrowIfNull(options); - return _cache.TryAdd(name ?? Options.DefaultName, new Lazy( + bool added = _cache.TryAdd(name ?? Options.DefaultName, new Lazy( #if !(NET || NETSTANDARD2_1) () => #endif options)); + if (added) + { + Interlocked.Increment(ref _generation); + } + return added; } /// @@ -109,7 +126,14 @@ public virtual bool TryAdd(string? name, TOptions options) /// /// The name of the options instance. /// if anything was removed; otherwise, . - public virtual bool TryRemove(string? name) => - _cache.TryRemove(name ?? Options.DefaultName, out _); + public virtual bool TryRemove(string? name) + { + bool removed = _cache.TryRemove(name ?? Options.DefaultName, out _); + if (removed) + { + Interlocked.Increment(ref _generation); + } + return removed; + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 2c8180d0205674..1727c5dc19c1e8 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; using Microsoft.Extensions.Primitives; namespace Microsoft.Extensions.Options @@ -18,9 +20,12 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces where TOptions : class { private readonly IOptionsMonitorCache _cache; + private readonly OptionsCache? _fastCache; private readonly IOptionsFactory _factory; private readonly List _registrations = new List(); internal event Action? _onChange; + private TOptions? _currentValue; + private int _currentValueGeneration; /// /// Initializes a new instance of with the specified factory, sources, and cache. @@ -32,6 +37,7 @@ public OptionsMonitor(IOptionsFactory factory, IEnumerable; void RegisterSource(IOptionsChangeTokenSource source) { @@ -76,7 +82,42 @@ private void InvokeChanged(string? name) /// The does not have a public parameterless constructor or is . public TOptions CurrentValue { - get => Get(Options.DefaultName); + get + { + OptionsCache? fastCache = _fastCache; + if (fastCache is null) + { + // User-supplied IOptionsMonitorCache: no generation tracking, always go through Get. + return Get(Options.DefaultName); + } + + int gen = fastCache.Generation; + // Read generation before value. RefreshCurrentValue writes _currentValue before + // _currentValueGeneration (release), so an acquire-load of _currentValueGeneration + // here guarantees the subsequent read of _currentValue sees the matching published + // value and not a stale one. + int cachedGen = Volatile.Read(ref _currentValueGeneration); + TOptions? value = Volatile.Read(ref _currentValue); + if (value is not null && cachedGen == gen) + { + return value; + } + + return RefreshCurrentValue(gen); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private TOptions RefreshCurrentValue(int gen) + { + TOptions value = Get(Options.DefaultName); + // Write value before generation: the generation acts as a release signal that + // _currentValue is ready. A reader that acquire-loads _currentValueGeneration and + // sees gen is then guaranteed (via the release/acquire pairing) to observe the + // _currentValue written here. + Volatile.Write(ref _currentValue, value); + Volatile.Write(ref _currentValueGeneration, gen); + return value; } /// @@ -88,7 +129,7 @@ public TOptions CurrentValue /// The does not have a public parameterless constructor or is . public virtual TOptions Get(string? name) { - if (_cache is not OptionsCache optionsCache) + if (_fastCache is null) { // copying captured variables to locals avoids allocating a closure if we don't enter the if string localName = name ?? Options.DefaultName; @@ -97,8 +138,7 @@ public virtual TOptions Get(string? name) } // non-allocating fast path - return optionsCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory); - + return _fastCache.GetOrAdd(name, static (name, factory) => factory.Create(name), _factory); } /// diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs index a66a2f554b46dc..b7801964b42543 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs @@ -537,5 +537,89 @@ public WaitHandleConfigureOptions(WaitHandle waitHandle) void IConfigureNamedOptions.Configure(string? name, FakeOptions options) => _waitHandle.WaitOne(); void IConfigureOptions.Configure(FakeOptions options) => _waitHandle.WaitOne(); } + + [Fact] + public void CurrentValue_ReflectsLatestAfterCacheClear() + { + var services = new ServiceCollection().AddOptions().AddSingleton>(new CountIncrement(this)); + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + var cache = sp.GetRequiredService>(); + + Assert.Equal("1", monitor.CurrentValue.Message); + + cache.Clear(); + + Assert.Equal("2", monitor.CurrentValue.Message); + } + + [Fact] + public void CurrentValue_ReflectsLatestAfterTryRemoveDefault() + { + var services = new ServiceCollection().AddOptions().AddSingleton>(new CountIncrement(this)); + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + var cache = sp.GetRequiredService>(); + + Assert.Equal("1", monitor.CurrentValue.Message); + + cache.TryRemove(Options.DefaultName); + + Assert.Equal("2", monitor.CurrentValue.Message); + } + + [Fact] + public void CurrentValue_FallsBackToGetForCustomIOptionsMonitorCache() + { + var customCache = new CustomOptionsMonitorCache(); + var services = new ServiceCollection() + .AddOptions() + .AddSingleton>(new CountIncrement(this)) + .AddSingleton>(customCache); + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + + Assert.Equal("1", monitor.CurrentValue.Message); + + customCache.Clear(); + + Assert.Equal("2", monitor.CurrentValue.Message); + } + + private sealed class CustomOptionsMonitorCache : IOptionsMonitorCache + where TOptions : class + { + private readonly Dictionary _cache = new Dictionary(StringComparer.Ordinal); + + public TOptions GetOrAdd(string? name, Func createOptions) + { + name ??= Options.DefaultName; + if (!_cache.TryGetValue(name, out TOptions? value)) + { + value = createOptions(); + _cache[name] = value; + } + return value; + } + + public bool TryAdd(string? name, TOptions options) + { + name ??= Options.DefaultName; + if (_cache.ContainsKey(name)) + return false; + _cache[name] = options; + return true; + } + + public bool TryRemove(string? name) + { + return _cache.Remove(name ?? Options.DefaultName); + } + + public void Clear() => _cache.Clear(); + } } }