Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,10 @@ public MemoryCacheStatistics() { }
/// Gets the total number of cache hits.
/// </summary>
public long TotalHits { get; init; }

/// <summary>
/// Gets the total number of cache evictions.
/// </summary>
Comment on lines +40 to +41
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new TotalEvictions documentation is vague about what counts as an eviction. The implementation/tests exclude explicit Remove/Clear (EvictionReason.Removed) and also don’t appear to count replacements; consider clarifying this in the XML docs to prevent consumers from misinterpreting the metric/statistic.

Suggested change
/// Gets the total number of cache evictions.
/// </summary>
/// Gets the total number of cache entries evicted by the cache.
/// </summary>
/// <remarks>
/// 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 <c>Remove</c> or <c>Clear</c>),
/// and does not include entries that were replaced by new values.
/// </remarks>

Copilot uses AI. Check for mistakes.
public long TotalEvictions { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor) { }
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { }
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory) { }
public MemoryCache(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Caching.Memory.MemoryCacheOptions> optionsAccessor, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory, System.Diagnostics.Metrics.IMeterFactory? meterFactory) { }
public int Count { get { throw null; } }
public System.Collections.Generic.IEnumerable<object> Keys { get { throw null; } }
public void Clear() { }
Expand All @@ -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
{
Expand Down
137 changes: 128 additions & 9 deletions src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,7 +31,9 @@ public class MemoryCache : IMemoryCache
private readonly List<Stats>? _allStats;
private long _accumulatedHits;
private long _accumulatedMisses;
private long _accumulatedEvictions;
private readonly ThreadLocal<StatsHandler>? _stats;
private readonly Meter? _meter;
private CoherentState _coherentState;
private bool _disposed;
private DateTime _lastExpirationScan;
Expand All @@ -47,20 +50,37 @@ public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor)
/// </summary>
/// <param name="optionsAccessor">The options of the cache.</param>
/// <param name="loggerFactory">The factory used to create loggers.</param>
public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory loggerFactory)
public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory? loggerFactory)
: this(optionsAccessor, loggerFactory, meterFactory: null) { }

/// <summary>
/// Creates a new <see cref="MemoryCache"/> instance.
/// </summary>
/// <param name="optionsAccessor">The options of the cache.</param>
/// <param name="loggerFactory">The factory used to create loggers.</param>
/// <param name="meterFactory">The factory used to create meters for metrics collection.</param>
public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory? loggerFactory, IMeterFactory? meterFactory)
{
ArgumentNullException.ThrowIfNull(optionsAccessor);
ArgumentNullException.ThrowIfNull(loggerFactory);

_options = optionsAccessor.Value;
_logger = loggerFactory.CreateLogger<MemoryCache>();
_logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<MemoryCache>();

_coherentState = new CoherentState();

if (_options.TrackStatistics)
{
_allStats = new List<Stats>();
_stats = new ThreadLocal<StatsHandler>(() => new StatsHandler(this));

if (meterFactory is not null)
{
_meter = meterFactory.Create(new MeterOptions("Microsoft.Extensions.Caching.Memory.MemoryCache")
{
Tags = [new("cache.name", _options.Name)]
});
InitializeMetrics(_meter);
}
}

_lastExpirationScan = UtcNow;
Expand Down Expand Up @@ -296,7 +316,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);
}
}
}

Expand Down Expand Up @@ -367,7 +390,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)
};
}

Expand All @@ -377,7 +401,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);
}

Expand Down Expand Up @@ -486,7 +514,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);
}
}
}
}
Expand Down Expand Up @@ -637,9 +668,18 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> 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 (_allStats is not null && actuallyRemoved > 0)
{
Interlocked.Add(ref _accumulatedEvictions, actuallyRemoved);
}

// Policy:
Expand Down Expand Up @@ -691,6 +731,7 @@ protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_meter?.Dispose();
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposing the Meter returned from IMeterFactory can be problematic because the factory owns meter lifetime and may return shared/cached meters (the default DI factory explicitly makes Meter.Dispose a no-op). Consider not disposing meters created via IMeterFactory (or only disposing when you created/own a private/shared Meter yourself) to avoid interfering with other components using the same cached Meter in custom IMeterFactory implementations.

Suggested change
_meter?.Dispose();

Copilot uses AI. Check for mistakes.
_stats?.Dispose();
GC.SuppressFinalize(this);
}
Expand Down Expand Up @@ -796,7 +837,7 @@ public IEnumerable<object> 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))
Expand All @@ -811,7 +852,10 @@ internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options)
Interlocked.Add(ref _cacheSize, -entry.Size);
}
entry.InvokeEvictionCallbacks();
return true;
}

return false;
}

#if !NET
Expand All @@ -838,5 +882,80 @@ int IEqualityComparer.GetHashCode(object obj)
}
#endif
}

private void InitializeMetrics(Meter meter)
{
var weakThis = new WeakReference<MemoryCache>(this);
KeyValuePair<string, object?> 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<long>[]
{
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<long>("cache.evictions",
() =>
{
if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
return [];
}

MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
return stats is null
? []
: [new Measurement<long>(stats.TotalEvictions, cacheNameTag)];
},
unit: "{evictions}",
description: "Total cache evictions.");

meter.CreateObservableUpDownCounter<long>("cache.entries",
() =>
{
if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
return [];
}

MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
return stats is null
? []
: [new Measurement<long>(stats.CurrentEntryCount, cacheNameTag)];
},
unit: "{entries}",
description: "Current number of cache entries.");

meter.CreateObservableGauge<long>("cache.estimated_size",
() =>
{
if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
return [];
}

MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
return stats?.CurrentEstimatedSize is long size
? [new Measurement<long>(size, cacheNameTag)]
: [];
},
unit: "By",
description: "Estimated size of the cache.");
}

Comment on lines +886 to +959
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These observable instruments are created per MemoryCache instance, but IMeterFactory implementations (e.g., DefaultMeterFactory) cache and may return the same Meter for multiple caches with the same name+tags. Meter.CreateObservable* does not use the Meter’s instrument cache, so this can publish duplicate instruments (and callbacks) on a shared Meter, leading to duplicated/incorrect metric streams and unbounded instrument/callback growth. Consider a design that ensures per-cache meter identity even for duplicate Name values, or register instruments once and have callbacks enumerate active caches (e.g., via a weak registry).

Suggested change
private void InitializeMetrics(Meter meter)
{
var weakThis = new WeakReference<MemoryCache>(this);
KeyValuePair<string, object?> 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<long>[]
{
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<long>("cache.evictions",
() =>
{
if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
return [];
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
return stats is null
? []
: [new Measurement<long>(stats.TotalEvictions, cacheNameTag)];
},
unit: "{evictions}",
description: "Total cache evictions.");
meter.CreateObservableUpDownCounter<long>("cache.entries",
() =>
{
if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
return [];
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
return stats is null
? []
: [new Measurement<long>(stats.CurrentEntryCount, cacheNameTag)];
},
unit: "{entries}",
description: "Current number of cache entries.");
meter.CreateObservableGauge<long>("cache.estimated_size",
() =>
{
if (!weakThis.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
return [];
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
return stats?.CurrentEstimatedSize is long size
? [new Measurement<long>(size, cacheNameTag)]
: [];
},
unit: "By",
description: "Estimated size of the cache.");
}
private static readonly ConditionalWeakTable<Meter, CacheMeterRegistration> s_meterRegistrations = new();
private sealed class CacheMeterRegistration
{
private readonly Meter _meter;
private readonly object _gate = new();
private readonly List<WeakReference<MemoryCache>> _caches = [];
internal CacheMeterRegistration(Meter meter)
{
_meter = meter ?? throw new ArgumentNullException(nameof(meter));
_meter.CreateObservableCounter("cache.requests",
ObserveRequests,
unit: "{requests}",
description: "Total cache requests.");
_meter.CreateObservableCounter<long>("cache.evictions",
ObserveEvictions,
unit: "{evictions}",
description: "Total cache evictions.");
_meter.CreateObservableUpDownCounter<long>("cache.entries",
ObserveEntries,
unit: "{entries}",
description: "Current number of cache entries.");
_meter.CreateObservableGauge<long>("cache.estimated_size",
ObserveEstimatedSize,
unit: "By",
description: "Estimated size of the cache.");
}
internal void AddCache(MemoryCache cache)
{
if (cache is null)
{
throw new ArgumentNullException(nameof(cache));
}
lock (_gate)
{
_caches.Add(new WeakReference<MemoryCache>(cache));
}
}
private IEnumerable<Measurement<long>> ObserveRequests()
{
List<Measurement<long>> measurements = [];
lock (_gate)
{
for (int i = 0; i < _caches.Count; i++)
{
WeakReference<MemoryCache> weak = _caches[i];
if (!weak.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
_caches.RemoveAt(i);
i--;
continue;
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
if (stats is null)
{
continue;
}
KeyValuePair<string, object?> cacheNameTag = new("cache.name", cache._options.Name);
measurements.Add(new Measurement<long>(stats.TotalHits, cacheNameTag, new("cache.request.type", "hit")));
measurements.Add(new Measurement<long>(stats.TotalMisses, cacheNameTag, new("cache.request.type", "miss")));
}
}
return measurements;
}
private IEnumerable<Measurement<long>> ObserveEvictions()
{
List<Measurement<long>> measurements = [];
lock (_gate)
{
for (int i = 0; i < _caches.Count; i++)
{
WeakReference<MemoryCache> weak = _caches[i];
if (!weak.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
_caches.RemoveAt(i);
i--;
continue;
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
if (stats is null)
{
continue;
}
KeyValuePair<string, object?> cacheNameTag = new("cache.name", cache._options.Name);
measurements.Add(new Measurement<long>(stats.TotalEvictions, cacheNameTag));
}
}
return measurements;
}
private IEnumerable<Measurement<long>> ObserveEntries()
{
List<Measurement<long>> measurements = [];
lock (_gate)
{
for (int i = 0; i < _caches.Count; i++)
{
WeakReference<MemoryCache> weak = _caches[i];
if (!weak.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
_caches.RemoveAt(i);
i--;
continue;
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
if (stats is null)
{
continue;
}
KeyValuePair<string, object?> cacheNameTag = new("cache.name", cache._options.Name);
measurements.Add(new Measurement<long>(stats.CurrentEntryCount, cacheNameTag));
}
}
return measurements;
}
private IEnumerable<Measurement<long>> ObserveEstimatedSize()
{
List<Measurement<long>> measurements = [];
lock (_gate)
{
for (int i = 0; i < _caches.Count; i++)
{
WeakReference<MemoryCache> weak = _caches[i];
if (!weak.TryGetTarget(out MemoryCache? cache) || cache._disposed)
{
_caches.RemoveAt(i);
i--;
continue;
}
MemoryCacheStatistics? stats = cache.GetCurrentStatistics();
if (stats?.CurrentEstimatedSize is not long size)
{
continue;
}
KeyValuePair<string, object?> cacheNameTag = new("cache.name", cache._options.Name);
measurements.Add(new Measurement<long>(size, cacheNameTag));
}
}
return measurements;
}
}
private void InitializeMetrics(Meter meter)
{
CacheMeterRegistration registration = s_meterRegistrations.GetValue(meter, static m => new CacheMeterRegistration(m));
registration.AddCache(this);
}

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ public double CompactionPercentage
/// </value>
public bool TrackStatistics { get; set; }

/// <summary>
/// Gets or sets the name of this cache instance.
/// </summary>
public string Name { get; set; } = "Default";

MemoryCacheOptions IOptions<MemoryCacheOptions>.Value
{
get { return this; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Primitives\src\Microsoft.Extensions.Primitives.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'">
<ProjectReference Include="$(LibrariesProjectRoot)System.Diagnostics.DiagnosticSource\src\System.Diagnostics.DiagnosticSource.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading