Skip to content
Next Next commit
Adding an entryId for taggedEntries so that replaced items in the cac…
…he do not have their tags removed on eviction. Fixes #61524
  • Loading branch information
profet23 authored and github-actions committed Jun 4, 2025
commit 0e23dbcfd41cb1ff4bd2448adfc8522ae98b7f82
30 changes: 18 additions & 12 deletions src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Linq;
using Microsoft.Extensions.Caching.Memory;

namespace Microsoft.AspNetCore.OutputCaching.Memory;

internal sealed class MemoryOutputCacheStore : IOutputCacheStore
{
private readonly MemoryCache _cache;
private readonly Dictionary<string, HashSet<string>> _taggedEntries = new();
private readonly Dictionary<string, HashSet<Tuple<string,string>>> _taggedEntries = new();
private readonly object _tagsLock = new();

internal MemoryOutputCacheStore(MemoryCache cache)
Expand All @@ -20,7 +21,7 @@ internal MemoryOutputCacheStore(MemoryCache cache)
}

// For testing
internal Dictionary<string, HashSet<string>> TaggedEntries => _taggedEntries;
internal Dictionary<string, HashSet<string>> TaggedEntries => _taggedEntries.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Select(t => t.Item1).ToHashSet());

public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken)
{
Expand All @@ -40,9 +41,9 @@ public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken
while (i > 0)
{
var oldCount = keys.Count;
foreach (var key in keys)
foreach (var tuple in keys)
{
_cache.Remove(key);
_cache.Remove(tuple.Item1);
i--;
if (oldCount != keys.Count)
{
Expand Down Expand Up @@ -74,6 +75,8 @@ public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan val
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(value);

var entryId = Guid.NewGuid().ToString();

if (tags != null)
{
// Lock with SetEntry() to prevent EvictByTagAsync() from trying to remove a tag whose entry hasn't been added yet.
Expand All @@ -90,27 +93,27 @@ public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan val

if (!_taggedEntries.TryGetValue(tag, out var keys))
{
keys = new HashSet<string>();
keys = new HashSet<Tuple<string, string>>();
_taggedEntries[tag] = keys;
}

Debug.Assert(keys != null);

keys.Add(key);
keys.Add(Tuple.Create(key, entryId));
}

SetEntry(key, value, tags, validFor);
SetEntry(key, value, tags, validFor, entryId);
}
}
else
{
SetEntry(key, value, tags, validFor);
SetEntry(key, value, tags, validFor, entryId);
}

return ValueTask.CompletedTask;
}

void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor)
private void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor, string entryId)
{
Debug.Assert(key != null);

Expand All @@ -123,27 +126,30 @@ void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor)
if (tags != null && tags.Length > 0)
{
// Remove cache keys from tag lists when the entry is evicted
options.RegisterPostEvictionCallback(RemoveFromTags, tags);
options.RegisterPostEvictionCallback(RemoveFromTags, Tuple.Create(tags, entryId));
}

_cache.Set(key, value, options);
}

void RemoveFromTags(object key, object? value, EvictionReason reason, object? state)
{
var tags = state as string[];
var stateTuple = state as Tuple<string[], string>;
string[]? tags = stateTuple?.Item1;
string? entryId = stateTuple?.Item2;

Debug.Assert(tags != null);
Debug.Assert(tags.Length > 0);
Debug.Assert(key is string);
Debug.Assert(entryId != null);

lock (_tagsLock)
{
foreach (var tag in tags)
{
if (_taggedEntries.TryGetValue(tag, out var tagged))
{
tagged.Remove((string)key);
tagged.Remove(Tuple.Create((string) key, entryId));

// Remove the collection if there is no more keys in it
if (tagged.Count == 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,43 @@ public async Task ExpiredEntries_AreRemovedFromTags()
Assert.Single(tag2s);
}

[Fact]
public async Task ReplacedEntries_AreNotRemovedFromTags()
{
var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow };
var cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1000, Clock = testClock, ExpirationScanFrequency = TimeSpan.FromMilliseconds(1) });
var store = new MemoryOutputCacheStore(cache);
var value = "abc"u8.ToArray();

await store.SetAsync("a", value, new[] { "tag1", "tag2" }, TimeSpan.FromMilliseconds(5), default);
await store.SetAsync("a", value, new[] { "tag1" }, TimeSpan.FromMilliseconds(20), default);

testClock.Advance(TimeSpan.FromMilliseconds(10));

// Background expiration checks are triggered by misc cache activity.
_ = cache.Get("a");

var resulta = await store.GetAsync("a", default);

Assert.NotNull(resulta);

HashSet<string> tag1s, tag2s;

// Wait for the hashset to be removed as it happens on a separate thread

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

while (store.TaggedEntries.TryGetValue("tag2", out tag2s) && !cts.IsCancellationRequested)
{
await Task.Yield();
}

store.TaggedEntries.TryGetValue("tag1", out tag1s);

Assert.Null(tag2s);
Assert.Single(tag1s);
}

[Theory]
[InlineData(null)]
public async Task Store_Throws_OnInvalidTag(string tag)
Expand Down