diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.Typeforwards.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.Typeforwards.netcoreapp.cs new file mode 100644 index 0000000000000..fcc1284ccccdf --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.Typeforwards.netcoreapp.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// The compiler emits a reference to the internal copy of this type in our non-NETCoreApp assembly +// so we must include a forward to be compatible with libraries compiled against non-NETCoreApp Microsoft.Extensions.Caching.Abstractions +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs index 2805ac7644c9d..3bda5a0a27b00 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs @@ -126,6 +126,14 @@ public MemoryCacheEntryOptions() { } public long? Size { get { throw null; } set { } } public System.TimeSpan? SlidingExpiration { get { throw null; } set { } } } + public partial class MemoryCacheStatistics + { + public MemoryCacheStatistics() { } + public long CurrentEntryCount { get { throw null; } init { } } + public long? CurrentEstimatedSize { get { throw null; } init { } } + public long TotalHits { get { throw null; } init { } } + public long TotalMisses { get { throw null; } init { } } + } public partial class PostEvictionCallbackRegistration { public PostEvictionCallbackRegistration() { } diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.csproj b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.csproj index 9d967d4f3adb2..36666f11889e4 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.csproj +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.csproj @@ -6,6 +6,10 @@ + + + diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net6.0.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net6.0.cs new file mode 100644 index 0000000000000..d3812319a7e19 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net6.0.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ------------------------------------------------------------------------------ +// Changes to this file must follow the https://aka.ms/api-review process. +// ------------------------------------------------------------------------------ + +namespace Microsoft.Extensions.Caching.Memory +{ + public partial interface IMemoryCache : System.IDisposable + { + MemoryCacheStatistics? GetCurrentStatistics() => null; + } +} \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs index 88576eba04c74..ccce249b23d95 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs @@ -30,5 +30,13 @@ public interface IMemoryCache : IDisposable /// /// An object identifying the entry. void Remove(object key); + +#if NET6_0_OR_GREATER + /// + /// Gets a snapshot of the cache statistics if available. + /// + /// An instance of containing a snapshot of the cache statistics. + MemoryCacheStatistics? GetCurrentStatistics() => null; +#endif } } diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs new file mode 100644 index 0000000000000..2040522c0b46b --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Caching.Memory +{ + /// + /// Holds a snapshot of statistics for a memory cache. + /// + public class MemoryCacheStatistics + { + /// + /// Initializes an instance of MemoryCacheStatistics. + /// + public MemoryCacheStatistics() { } + + /// + /// Gets the number of instances currently in the memory cache. + /// + public long CurrentEntryCount { get; init; } + + /// + /// Gets an estimated sum of all the values currently in the memory cache. + /// + /// Returns if size isn't being tracked. The common MemoryCache implementation tracks size whenever a SizeLimit is set on the cache. + public long? CurrentEstimatedSize { get; init; } + + /// + /// Gets the total number of cache misses. + /// + public long TotalMisses { get; init; } + + /// + /// Gets the total number of cache hits. + /// + public long TotalHits { get; init; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.Typeforwards.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.Typeforwards.netcoreapp.cs new file mode 100644 index 0000000000000..b6510c56d117a --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.Typeforwards.netcoreapp.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// The compiler emits a reference to the internal copy of this type in our non-NETCoreApp assembly +// so we must include a forward to be compatible with libraries compiled against non-NETCoreApp Microsoft.Extensions.Caching.Abstractions +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.csproj b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.csproj index ddd5093c1436a..25d77c9de8c99 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.csproj +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Microsoft.Extensions.Caching.Abstractions.csproj @@ -18,4 +18,9 @@ Microsoft.Extensions.Caching.Memory.IMemoryCache + + + + + diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/ref/Microsoft.Extensions.Caching.Memory.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/ref/Microsoft.Extensions.Caching.Memory.cs index e61a5b0be20ba..7a13cdf999acb 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/ref/Microsoft.Extensions.Caching.Memory.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/ref/Microsoft.Extensions.Caching.Memory.cs @@ -33,6 +33,7 @@ public void Compact(double percentage) { } public void Dispose() { } protected virtual void Dispose(bool disposing) { } ~MemoryCache() { } + public Microsoft.Extensions.Caching.Memory.MemoryCacheStatistics? GetCurrentStatistics() { throw null; } public void Remove(object key) { } public bool TryGetValue(object key, out object? result) { throw null; } } @@ -45,6 +46,7 @@ public MemoryCacheOptions() { } Microsoft.Extensions.Caching.Memory.MemoryCacheOptions Microsoft.Extensions.Options.IOptions.Value { get { throw null; } } public long? SizeLimit { get { throw null; } set { } } public bool TrackLinkedCacheEntries { get { throw null; } set { } } + public bool TrackStatistics { get { throw null; } set { } } } public partial class MemoryDistributedCacheOptions : Microsoft.Extensions.Caching.Memory.MemoryCacheOptions { diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs index 182cea820b063..127b2250f592c 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs @@ -25,6 +25,9 @@ public class MemoryCache : IMemoryCache private readonly MemoryCacheOptions _options; + private readonly List>? _allStats; + private readonly Stats? _accumulatedStats; + private readonly ThreadLocal? _stats; private CoherentState _coherentState; private bool _disposed; private DateTimeOffset _lastExpirationScan; @@ -53,6 +56,13 @@ public MemoryCache(IOptions optionsAccessor!!, ILoggerFactor _options.Clock = new SystemClock(); } + if (_options.TrackStatistics) + { + _allStats = new List>(); + _accumulatedStats = new Stats(); + _stats = new ThreadLocal(() => new Stats(this)); + } + _lastExpirationScan = _options.Clock.UtcNow; TrackLinkedCacheEntries = _options.TrackLinkedCacheEntries; // we store the setting now so it's consistent for entire MemoryCache lifetime } @@ -219,6 +229,14 @@ public bool TryGetValue(object key!!, out object? result) } StartScanForExpiredItemsIfNeeded(utcNow); + // Hit + if (_allStats is not null) + { + if (IntPtr.Size == 4) + Interlocked.Increment(ref GetStats().Hits); + else + GetStats().Hits++; + } return true; } @@ -232,6 +250,15 @@ public bool TryGetValue(object key!!, out object? result) StartScanForExpiredItemsIfNeeded(utcNow); result = null; + // Miss + if (_allStats is not null) + { + if (IntPtr.Size == 4) + Interlocked.Increment(ref GetStats().Misses); + else + GetStats().Misses++; + } + return false; } @@ -270,6 +297,27 @@ public void Clear() } } + /// + /// Gets a snapshot of the current statistics for the memory cache. + /// + /// Returns if statistics are not being tracked because is . + public MemoryCacheStatistics? GetCurrentStatistics() + { + if (_allStats is not null) + { + (long hit, long miss) sumTotal = Sum(); + return new MemoryCacheStatistics() + { + TotalMisses = sumTotal.miss, + TotalHits = sumTotal.hit, + CurrentEntryCount = Count, + CurrentEstimatedSize = _options.SizeLimit.HasValue ? Size : null + }; + } + + return null; + } + internal void EntryExpired(CacheEntry entry) { // TODO: For efficiency consider processing these expirations in batches. @@ -295,6 +343,72 @@ void ScheduleTask(DateTimeOffset utcNow) } } + private (long, long) Sum() + { + lock (_allStats!) + { + long hits = _accumulatedStats!.Hits; + long misses = _accumulatedStats.Misses; + + foreach (WeakReference wr in _allStats) + { + if (wr.TryGetTarget(out Stats? stats)) + { + hits += Interlocked.Read(ref stats.Hits); + misses += Interlocked.Read(ref stats.Misses); + } + } + + return (hits, misses); + } + } + + private Stats GetStats() => _stats!.Value!; + + internal sealed class Stats + { + private readonly MemoryCache? _memoryCache; + public long Hits; + public long Misses; + + public Stats() { } + + public Stats(MemoryCache memoryCache) + { + _memoryCache = memoryCache; + _memoryCache.AddToStats(this); + } + + ~Stats() => _memoryCache?.RemoveFromStats(this); + } + + private void RemoveFromStats(Stats current) + { + lock (_allStats!) + { + for (int i = 0; i < _allStats.Count; i++) + { + if (_allStats[i].TryGetTarget(out Stats? stats) && stats == current) + { + _allStats.RemoveAt(i); + break; + } + } + + _accumulatedStats!.Hits += Interlocked.Read(ref current.Hits); + _accumulatedStats.Misses += Interlocked.Read(ref current.Misses); + _allStats.TrimExcess(); + } + } + + private void AddToStats(Stats current) + { + lock (_allStats!) + { + _allStats.Add(new WeakReference(current)); + } + } + private static void ScanForExpiredItems(MemoryCache cache) { DateTimeOffset now = cache._lastExpirationScan = cache._options.Clock!.UtcNow; @@ -469,6 +583,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { + _stats?.Dispose(); GC.SuppressFinalize(this); } diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs index 82968cefd33b1..a37111ad60749 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs @@ -59,6 +59,11 @@ public double CompactionPercentage /// Prior to .NET 7 this feature was always enabled. public bool TrackLinkedCacheEntries { get; set; } + /// + /// Gets or sets whether to track memory cache statistics. Disabled by default. + /// + public bool TrackStatistics { get; set; } + MemoryCacheOptions IOptions.Value { get { return this; } diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs new file mode 100644 index 0000000000000..cee6245f3e9c6 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Caching.Memory.Infrastructure; +using Xunit; + +namespace Microsoft.Extensions.Caching.Memory +{ + public class MemoryCacheHasStatisticsTests + { + [Fact] + public void GetCurrentStatistics_TrackStatisticsFalse_ReturnsNull() + { + var cache = new MemoryCache(new MemoryCacheOptions { TrackStatistics = false }); + Assert.Null(cache.GetCurrentStatistics()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetCurrentStatistics_GetCache_UpdatesStatistics(bool sizeLimitIsSet) + { + var cache = sizeLimitIsSet ? + new MemoryCache(new MemoryCacheOptions { TrackStatistics = true, SizeLimit = 10 }) : + new MemoryCache(new MemoryCacheOptions { TrackStatistics = true }); + + cache.Set("key", "value", new MemoryCacheEntryOptions { Size = 2 }); + for (int i = 0; i < 100; i++) + { + Assert.Equal("value", cache.Get("key")); + Assert.Null(cache.Get("missingKey1")); + Assert.Null(cache.Get("missingKey2")); + } + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + + Assert.NotNull(stats); + Assert.Equal(200, stats.TotalMisses); + Assert.Equal(100, stats.TotalHits); + Assert.Equal(1, stats.CurrentEntryCount); + VerifyCurrentEstimatedSize(2, sizeLimitIsSet, stats); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetCurrentStatistics_UpdateExistingCache_UpdatesStatistics(bool sizeLimitIsSet) + { + var cache = sizeLimitIsSet ? + new MemoryCache(new MemoryCacheOptions { TrackStatistics = true, SizeLimit = 10 }) : + new MemoryCache(new MemoryCacheOptions { TrackStatistics = true }); + + cache.Set("key", "value", new MemoryCacheEntryOptions { Size = 2 }); + Assert.Equal("value", cache.Get("key")); + + cache.Set("key", "updated value", new MemoryCacheEntryOptions { Size = 3 }); + Assert.Equal("updated value", cache.Get("key")); + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + + Assert.NotNull(stats); + Assert.Equal(1, stats.CurrentEntryCount); + Assert.Equal(0, stats.TotalMisses); + Assert.Equal(2, stats.TotalHits); + VerifyCurrentEstimatedSize(3, sizeLimitIsSet, stats); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetCurrentStatistics_UpdateAfterExistingItemExpired_CurrentEstimatedSizeResets(bool sizeLimitIsSet) + { + const string Key = "myKey"; + + var cache = new MemoryCache(sizeLimitIsSet ? + new MemoryCacheOptions { TrackStatistics = true, Clock = new SystemClock(), SizeLimit = 10 } : + new MemoryCacheOptions { TrackStatistics = true, Clock = new SystemClock() } + ); + + ICacheEntry entry; + using (entry = cache.CreateEntry(Key)) + { + var expirationToken = new TestExpirationToken() { ActiveChangeCallbacks = true }; + var mc = new MemoryCacheEntryOptions { Size = 5 }; + cache.Set(Key, new object(), mc.AddExpirationToken(expirationToken)); + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + + Assert.NotNull(stats); + Assert.Equal(1, cache.Count); + Assert.Equal(1, stats.CurrentEntryCount); + VerifyCurrentEstimatedSize(5, sizeLimitIsSet, stats); + + expirationToken.HasChanged = true; + cache.Set(Key, new object(), mc.AddExpirationToken(expirationToken)); + stats = cache.GetCurrentStatistics(); + + Assert.NotNull(stats); + Assert.Equal(0, cache.Count); + Assert.Equal(0, stats.CurrentEntryCount); + VerifyCurrentEstimatedSize(0, sizeLimitIsSet, stats); + } + } + +#if NET6_0_OR_GREATER + [Fact] + public void GetCurrentStatistics_DIMReturnsNull() + { + Assert.Null((new FakeMemoryCache() as IMemoryCache).GetCurrentStatistics()); + } +#endif + + private class FakeMemoryCache : IMemoryCache + { + public ICacheEntry CreateEntry(object key) => throw new NotImplementedException(); + public void Dispose() => throw new NotImplementedException(); + public void Remove(object key) => throw new NotImplementedException(); + public bool TryGetValue(object key, out object? value) => throw new NotImplementedException(); + } + + private void VerifyCurrentEstimatedSize(long expected, bool sizeLimitIsSet, MemoryCacheStatistics stats) + { + if (sizeLimitIsSet) + { + Assert.Equal(expected, stats.CurrentEstimatedSize ); + } + else + { + Assert.Null(stats.CurrentEstimatedSize ); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/Microsoft.Extensions.Caching.Memory.Tests.csproj b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/Microsoft.Extensions.Caching.Memory.Tests.csproj index 9773db13ad771..429cde4c0db43 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/Microsoft.Extensions.Caching.Memory.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/Microsoft.Extensions.Caching.Memory.Tests.csproj @@ -11,7 +11,8 @@ - + +