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 @@
+
+
+
+