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 2545c9d147aba9..f1184552d6a578 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 @@ -142,6 +142,7 @@ public MemoryCacheStatistics() { } public long? CurrentEstimatedSize { get { throw null; } init { } } public long TotalHits { get { throw null; } init { } } public long TotalMisses { get { throw null; } init { } } + public long TotalEvictions { get { throw null; } init { } } } public partial class PostEvictionCallbackRegistration { diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs index 2040522c0b46b1..fdcc1c68b51ab1 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/MemoryCacheStatistics.cs @@ -35,5 +35,15 @@ public MemoryCacheStatistics() { } /// Gets the total number of cache hits. /// public long TotalHits { get; init; } + + /// + /// Gets the total number of cache entries evicted by the cache. + /// + /// + /// This count includes entries removed due to cache eviction policies such as expiration or capacity limits. + /// It does not include entries removed explicitly by user code (for example, via Remove or Clear), + /// and does not include entries that were replaced by new values. + /// + public long TotalEvictions { get; init; } } } 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 d145238b2ec59d..5726a4fef1909f 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 @@ -25,7 +25,8 @@ namespace Microsoft.Extensions.Caching.Memory public partial class MemoryCache : Microsoft.Extensions.Caching.Memory.IMemoryCache, System.IDisposable { public MemoryCache(Microsoft.Extensions.Options.IOptions optionsAccessor) { } - public MemoryCache(Microsoft.Extensions.Options.IOptions optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + public MemoryCache(Microsoft.Extensions.Options.IOptions optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory) { } + public MemoryCache(Microsoft.Extensions.Options.IOptions optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory, System.Diagnostics.Metrics.IMeterFactory? meterFactory) { } public int Count { get { throw null; } } public System.Collections.Generic.IEnumerable Keys { get { throw null; } } public void Clear() { } @@ -51,6 +52,7 @@ public MemoryCacheOptions() { } public long? SizeLimit { get { throw null; } set { } } public bool TrackLinkedCacheEntries { get { throw null; } set { } } public bool TrackStatistics { get { throw null; } set { } } + public string Name { 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 ae65c24c6fae60..0f81429ef57b68 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; @@ -30,6 +31,7 @@ public class MemoryCache : IMemoryCache private readonly List? _allStats; private long _accumulatedHits; private long _accumulatedMisses; + private long _accumulatedEvictions; private readonly ThreadLocal? _stats; private CoherentState _coherentState; private bool _disposed; @@ -47,13 +49,21 @@ public MemoryCache(IOptions optionsAccessor) /// /// The options of the cache. /// The factory used to create loggers. - public MemoryCache(IOptions optionsAccessor, ILoggerFactory loggerFactory) + public MemoryCache(IOptions optionsAccessor, ILoggerFactory? loggerFactory) + : this(optionsAccessor, loggerFactory, meterFactory: null) { } + + /// + /// Creates a new instance. + /// + /// The options of the cache. + /// The factory used to create loggers. + /// The factory used to create meters for metrics collection. + public MemoryCache(IOptions optionsAccessor, ILoggerFactory? loggerFactory, IMeterFactory? meterFactory) { ArgumentNullException.ThrowIfNull(optionsAccessor); - ArgumentNullException.ThrowIfNull(loggerFactory); _options = optionsAccessor.Value; - _logger = loggerFactory.CreateLogger(); + _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); _coherentState = new CoherentState(); @@ -61,6 +71,15 @@ public MemoryCache(IOptions optionsAccessor, ILoggerFactory { _allStats = new List(); _stats = new ThreadLocal(() => new StatsHandler(this)); + + if (meterFactory is not null) + { + Meter meter = meterFactory.Create(new MeterOptions("Microsoft.Extensions.Caching.Memory.MemoryCache") + { + Tags = [new("cache.name", _options.Name)] + }); + InitializeMetrics(meter); + } } _lastExpirationScan = UtcNow; @@ -296,7 +315,10 @@ private bool PostProcessTryGetValue(CoherentState coherentState, CacheEntry? ent else { // TODO: For efficiency queue this up for batch removal - coherentState.RemoveEntry(entry, _options); + if (coherentState.RemoveEntry(entry, _options) && _allStats is not null) + { + Interlocked.Increment(ref _accumulatedEvictions); + } } } @@ -367,7 +389,8 @@ public void Clear() TotalMisses = sumTotal.miss, TotalHits = sumTotal.hit, CurrentEntryCount = Count, - CurrentEstimatedSize = _options.HasSizeLimit ? Size : null + CurrentEstimatedSize = _options.HasSizeLimit ? Size : null, + TotalEvictions = Interlocked.Read(ref _accumulatedEvictions) }; } @@ -377,7 +400,11 @@ public void Clear() internal void EntryExpired(CacheEntry entry) { // TODO: For efficiency consider processing these expirations in batches. - _coherentState.RemoveEntry(entry, _options); + if (_coherentState.RemoveEntry(entry, _options) && _allStats is not null) + { + Interlocked.Increment(ref _accumulatedEvictions); + } + StartScanForExpiredItemsIfNeeded(UtcNow); } @@ -486,7 +513,10 @@ private void ScanForExpiredItems() { if (entry.CheckExpired(utcNow)) { - coherentState.RemoveEntry(entry, _options); + if (coherentState.RemoveEntry(entry, _options) && _allStats is not null) + { + Interlocked.Increment(ref _accumulatedEvictions); + } } } } @@ -637,9 +667,18 @@ private void Compact(long removalSizeTarget, Func computeEntry ExpirePriorityBucket(ref removedSize, removalSizeTarget, computeEntrySize, entriesToRemove, normalPriEntries); ExpirePriorityBucket(ref removedSize, removalSizeTarget, computeEntrySize, entriesToRemove, highPriEntries); + int actuallyRemoved = 0; foreach (CacheEntry entry in entriesToRemove) { - coherentState.RemoveEntry(entry, _options); + if (coherentState.RemoveEntry(entry, _options)) + { + actuallyRemoved++; + } + } + + if (actuallyRemoved > 0 && _allStats is not null) + { + Interlocked.Add(ref _accumulatedEvictions, actuallyRemoved); } // Policy: @@ -796,7 +835,7 @@ public IEnumerable GetAllKeys() internal long Size => Volatile.Read(ref _cacheSize); - internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options) + internal bool RemoveEntry(CacheEntry entry, MemoryCacheOptions options) { #if NET if (entry.Key is string s ? _stringEntries.TryRemove(KeyValuePair.Create(s, entry)) @@ -811,7 +850,10 @@ internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options) Interlocked.Add(ref _cacheSize, -entry.Size); } entry.InvokeEvictionCallbacks(); + return true; } + + return false; } #if !NET @@ -838,5 +880,80 @@ int IEqualityComparer.GetHashCode(object obj) } #endif } + + private void InitializeMetrics(Meter meter) + { + var weakThis = new WeakReference(this); + KeyValuePair cacheNameTag = new("cache.name", _options.Name); + + meter.CreateObservableCounter("cache.requests", + () => + { + if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed) + { + return []; + } + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + return stats is null + ? [] + : new Measurement[] + { + new(stats.TotalHits, cacheNameTag, new("cache.request.type", "hit")), + new(stats.TotalMisses, cacheNameTag, new("cache.request.type", "miss")), + }; + }, + unit: "{requests}", + description: "Total cache requests."); + + meter.CreateObservableCounter("cache.evictions", + () => + { + if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed) + { + return []; + } + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + return stats is null + ? [] + : [new Measurement(stats.TotalEvictions, cacheNameTag)]; + }, + unit: "{evictions}", + description: "Total cache evictions."); + + meter.CreateObservableUpDownCounter("cache.entries", + () => + { + if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed) + { + return []; + } + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + return stats is null + ? [] + : [new Measurement(stats.CurrentEntryCount, cacheNameTag)]; + }, + unit: "{entries}", + description: "Current number of cache entries."); + + meter.CreateObservableGauge("cache.estimated_size", + () => + { + if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed) + { + return []; + } + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + return stats?.CurrentEstimatedSize is long size + ? [new Measurement(size, cacheNameTag)] + : []; + }, + unit: "By", + description: "Estimated size of the cache."); + } + } } diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs index 8f15cd10e3afd4..c9f9178579ba4b 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCacheOptions.cs @@ -98,6 +98,11 @@ public double CompactionPercentage /// public bool TrackStatistics { get; set; } + /// + /// Gets or sets the name of this cache instance. + /// + public string Name { get; set; } = "Default"; + MemoryCacheOptions IOptions.Value { get { return this; } diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj b/src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj index f6d2ac8dc8065c..25c37d66638c78 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs index 576b7e103aebae..62b528d13f2142 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheGetCurrentStatisticsTests.cs @@ -114,6 +114,36 @@ public void GetCurrentStatistics_DIMReturnsNull() } #endif + [Fact] + public void GetCurrentStatistics_ExplicitRemove_DoesNotTrackEviction() + { + var cache = new MemoryCache(new MemoryCacheOptions { TrackStatistics = true }); + + cache.Set("key", "value"); + cache.Remove("key"); + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + Assert.NotNull(stats); + Assert.Equal(0, stats.TotalEvictions); + } + + [Fact] + public void GetCurrentStatistics_Compact_TracksTotalEvictions() + { + var cache = new MemoryCache(new MemoryCacheOptions { TrackStatistics = true }); + + for (int i = 0; i < 10; i++) + { + cache.Set($"key{i}", $"value{i}"); + } + + cache.Compact(1.0); + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + Assert.NotNull(stats); + Assert.Equal(10, stats.TotalEvictions); + } + private class FakeMemoryCache : IMemoryCache { public ICacheEntry CreateEntry(object key) => throw new NotImplementedException(); diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheMetricsTests.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheMetricsTests.cs new file mode 100644 index 00000000000000..7d46b18f43128e --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheMetricsTests.cs @@ -0,0 +1,319 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using Xunit; + +namespace Microsoft.Extensions.Caching.Memory +{ + public class MemoryCacheMetricsTests + { + [Fact] + public void Constructor_WithMeterFactory_CreatesInstruments() + { + using var meterFactory = new TestMeterFactory(); + using var meterListener = new MeterListener(); + var measurements = new List<(string name, long value, KeyValuePair[] tags)>(); + + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Memory.MemoryCache") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + measurements.Add((instrument.Name, value, tags.ToArray())); + }); + meterListener.Start(); + + using var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = true, Name = "test-cache" }, + loggerFactory: null, + meterFactory: meterFactory); + + cache.Set("key", "value"); + cache.TryGetValue("key", out _); + cache.TryGetValue("missing", out _); + + meterListener.RecordObservableInstruments(); + + Assert.Contains(measurements, m => + m.name == "cache.requests" && + m.tags.Any(t => t.Key == "cache.request.type" && (string?)t.Value == "hit") && + m.tags.Any(t => t.Key == "cache.name" && (string?)t.Value == "test-cache")); + + Assert.Contains(measurements, m => + m.name == "cache.requests" && + m.tags.Any(t => t.Key == "cache.request.type" && (string?)t.Value == "miss") && + m.tags.Any(t => t.Key == "cache.name" && (string?)t.Value == "test-cache")); + + Assert.Contains(measurements, m => m.name == "cache.entries"); + } + + [Fact] + public void Constructor_WithNullMeterFactory_StillTracksStatistics() + { + using var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = true }, + loggerFactory: null, + meterFactory: null); + + cache.Set("key", "value"); + cache.TryGetValue("key", out _); + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + Assert.NotNull(stats); + Assert.Equal(1, stats.TotalHits); + } + + [Fact] + public void Constructor_WithNullMeterFactory_NoInstrumentsPublished() + { + using var meterListener = new MeterListener(); + var measurements = new List(); + + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Memory.MemoryCache") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + measurements.Add(value); + }); + meterListener.Start(); + + using var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = true }, + loggerFactory: null, + meterFactory: null); + + cache.Set("key", "value"); + cache.Compact(1.0); + + meterListener.RecordObservableInstruments(); + + Assert.Empty(measurements); + } + + [Fact] + public void Constructor_WithNullLoggerFactory_DoesNotThrow() + { + using var cache = new MemoryCache( + new MemoryCacheOptions(), + loggerFactory: null, + meterFactory: null); + + Assert.Equal(0, cache.Count); + } + + [Fact] + public void TrackStatisticsFalse_NoInstrumentsCreated() + { + using var meterFactory = new TestMeterFactory(); + + using var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = false }, + loggerFactory: null, + meterFactory: meterFactory); + + Assert.Empty(meterFactory.Meters); + } + + [Fact] + public void Name_DefaultsToDefault() + { + var options = new MemoryCacheOptions(); + Assert.Equal("Default", options.Name); + } + + [Fact] + public void Name_CanBeCustomized() + { + var options = new MemoryCacheOptions { Name = "my-cache" }; + Assert.Equal("my-cache", options.Name); + } + + [Fact] + public void EvictionInstrument_ReflectsCompaction() + { + using var meterFactory = new TestMeterFactory(); + using var meterListener = new MeterListener(); + var evictionMeasurements = new List(); + + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Memory.MemoryCache" && + instrument.Name == "cache.evictions") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + evictionMeasurements.Add(value); + }); + meterListener.Start(); + + using var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = true }, + loggerFactory: null, + meterFactory: meterFactory); + + for (int i = 0; i < 5; i++) + { + cache.Set($"key{i}", $"value{i}"); + } + + cache.Compact(1.0); + + meterListener.RecordObservableInstruments(); + + Assert.Contains(evictionMeasurements, v => v == 5); + } + + [Fact] + public void DisposedCache_ReturnsNoMeasurements() + { + using var meterFactory = new TestMeterFactory(); + using var meterListener = new MeterListener(); + var measurements = new List<(string name, long value)>(); + + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Memory.MemoryCache") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + measurements.Add((instrument.Name, value)); + }); + meterListener.Start(); + + var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = true }, + loggerFactory: null, + meterFactory: meterFactory); + + cache.Set("key", "value"); + + // Verify instruments publish before Dispose + meterListener.RecordObservableInstruments(); + Assert.NotEmpty(measurements); + + measurements.Clear(); + cache.Dispose(); + + // Verify instruments return empty after Dispose + meterListener.RecordObservableInstruments(); + Assert.Empty(measurements); + } + + [Fact] + public void MeterOptions_IncludesCacheNameTag() + { + using var meterFactory = new TestMeterFactory(); + + using var cache = new MemoryCache( + new MemoryCacheOptions { TrackStatistics = true, Name = "my-cache" }, + loggerFactory: null, + meterFactory: meterFactory); + + Assert.Single(meterFactory.Meters); + Meter meter = meterFactory.Meters[0]; + Assert.Equal("Microsoft.Extensions.Caching.Memory.MemoryCache", meter.Name); + Assert.Contains(meter.Tags, t => t.Key == "cache.name" && (string?)t.Value == "my-cache"); + } + + [Fact] + public void EvictionCount_NotOvercounted_WhenEntryAlreadyRemoved() + { + using var meterFactory = new TestMeterFactory(); + using var meterListener = new MeterListener(); + var evictionMeasurements = new List(); + + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Microsoft.Extensions.Caching.Memory.MemoryCache" && + instrument.Name == "cache.evictions") + { + listener.EnableMeasurementEvents(instrument); + } + }; + meterListener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + evictionMeasurements.Add(value); + }); + meterListener.Start(); + + var clock = new Microsoft.Extensions.Internal.TestClock(); + using var cache = new MemoryCache( + new MemoryCacheOptions + { + TrackStatistics = true, + Clock = clock, + ExpirationScanFrequency = TimeSpan.Zero + }, + loggerFactory: null, + meterFactory: meterFactory); + + // Add entries with short expiration + for (int i = 0; i < 3; i++) + { + cache.Set($"key{i}", $"value{i}", new MemoryCacheEntryOptions + { + AbsoluteExpiration = clock.UtcNow + TimeSpan.FromMinutes(1) + }); + } + + // Advance time past expiration + clock.Add(TimeSpan.FromMinutes(2)); + + // Access triggers expiration removal for one entry + cache.TryGetValue("key0", out _); + + // Compact tries to remove all (including already-removed key0) + cache.Compact(1.0); + + MemoryCacheStatistics? stats = cache.GetCurrentStatistics(); + Assert.NotNull(stats); + Assert.Equal(3, stats.TotalEvictions); + + meterListener.RecordObservableInstruments(); + Assert.Contains(evictionMeasurements, v => v == 3); + } + + private sealed class TestMeterFactory : IMeterFactory + { + private readonly List _meters = new(); + + public IReadOnlyList Meters => _meters; + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options); + _meters.Add(meter); + return meter; + } + + public void Dispose() + { + foreach (var meter in _meters) + { + meter.Dispose(); + } + } + } + } +} +#endif \ No newline at end of file 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 00a56d03755ada..c1642a7ecfe51b 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 @@ -18,4 +18,8 @@ + + + +