Skip to content

Commit 1a7ca4a

Browse files
authored
Associate tagged keys with entries so replacements are not evicted (#61529)
Fixes #61524
1 parent a42059b commit 1a7ca4a

File tree

2 files changed

+59
-14
lines changed

2 files changed

+59
-14
lines changed

src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs

+22-14
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.Linq;
56
using Microsoft.Extensions.Caching.Memory;
67

78
namespace Microsoft.AspNetCore.OutputCaching.Memory;
89

910
internal sealed class MemoryOutputCacheStore : IOutputCacheStore
1011
{
1112
private readonly MemoryCache _cache;
12-
private readonly Dictionary<string, HashSet<string>> _taggedEntries = new();
13+
private readonly Dictionary<string, HashSet<TaggedEntry>> _taggedEntries = [];
1314
private readonly object _tagsLock = new();
1415

1516
internal MemoryOutputCacheStore(MemoryCache cache)
@@ -20,7 +21,7 @@ internal MemoryOutputCacheStore(MemoryCache cache)
2021
}
2122

2223
// For testing
23-
internal Dictionary<string, HashSet<string>> TaggedEntries => _taggedEntries;
24+
internal Dictionary<string, HashSet<string>> TaggedEntries => _taggedEntries.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Select(t => t.Key).ToHashSet());
2425

2526
public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken)
2627
{
@@ -30,7 +31,7 @@ public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken
3031
{
3132
if (_taggedEntries.TryGetValue(tag, out var keys))
3233
{
33-
if (keys != null && keys.Count > 0)
34+
if (keys is { Count: > 0 })
3435
{
3536
// If MemoryCache changed to run eviction callbacks inline in Remove, iterating over keys could throw
3637
// To prevent allocating a copy of the keys we check if the eviction callback ran,
@@ -40,7 +41,7 @@ public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken
4041
while (i > 0)
4142
{
4243
var oldCount = keys.Count;
43-
foreach (var key in keys)
44+
foreach (var (key, _) in keys)
4445
{
4546
_cache.Remove(key);
4647
i--;
@@ -74,6 +75,8 @@ public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan val
7475
ArgumentNullException.ThrowIfNull(key);
7576
ArgumentNullException.ThrowIfNull(value);
7677

78+
var entryId = Guid.NewGuid();
79+
7780
if (tags != null)
7881
{
7982
// Lock with SetEntry() to prevent EvictByTagAsync() from trying to remove a tag whose entry hasn't been added yet.
@@ -90,27 +93,27 @@ public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan val
9093

9194
if (!_taggedEntries.TryGetValue(tag, out var keys))
9295
{
93-
keys = new HashSet<string>();
96+
keys = new HashSet<TaggedEntry>();
9497
_taggedEntries[tag] = keys;
9598
}
9699

97100
Debug.Assert(keys != null);
98101

99-
keys.Add(key);
102+
keys.Add(new TaggedEntry(key, entryId));
100103
}
101104

102-
SetEntry(key, value, tags, validFor);
105+
SetEntry(key, value, tags, validFor, entryId);
103106
}
104107
}
105108
else
106109
{
107-
SetEntry(key, value, tags, validFor);
110+
SetEntry(key, value, tags, validFor, entryId);
108111
}
109112

110113
return ValueTask.CompletedTask;
111114
}
112115

113-
void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor)
116+
private void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor, Guid entryId)
114117
{
115118
Debug.Assert(key != null);
116119

@@ -120,30 +123,33 @@ void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor)
120123
Size = value.Length
121124
};
122125

123-
if (tags != null && tags.Length > 0)
126+
if (tags is { Length: > 0 })
124127
{
125128
// Remove cache keys from tag lists when the entry is evicted
126-
options.RegisterPostEvictionCallback(RemoveFromTags, tags);
129+
options.RegisterPostEvictionCallback(RemoveFromTags, (tags, entryId));
127130
}
128131

129132
_cache.Set(key, value, options);
130133
}
131134

132-
void RemoveFromTags(object key, object? value, EvictionReason reason, object? state)
135+
private void RemoveFromTags(object key, object? value, EvictionReason reason, object? state)
133136
{
134-
var tags = state as string[];
137+
Debug.Assert(state != null);
138+
139+
var (tags, entryId) = ((string[] Tags, Guid EntryId))state;
135140

136141
Debug.Assert(tags != null);
137142
Debug.Assert(tags.Length > 0);
138143
Debug.Assert(key is string);
144+
Debug.Assert(entryId != Guid.Empty);
139145

140146
lock (_tagsLock)
141147
{
142148
foreach (var tag in tags)
143149
{
144150
if (_taggedEntries.TryGetValue(tag, out var tagged))
145151
{
146-
tagged.Remove((string)key);
152+
tagged.Remove(new TaggedEntry((string)key, entryId));
147153

148154
// Remove the collection if there is no more keys in it
149155
if (tagged.Count == 0)
@@ -154,4 +160,6 @@ void RemoveFromTags(object key, object? value, EvictionReason reason, object? st
154160
}
155161
}
156162
}
163+
164+
private record TaggedEntry(string Key, Guid EntryId);
157165
}

src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs

+37
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,43 @@ public async Task ExpiredEntries_AreRemovedFromTags()
197197
Assert.Single(tag2s);
198198
}
199199

200+
[Fact]
201+
public async Task ReplacedEntries_AreNotRemovedFromTags()
202+
{
203+
var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow };
204+
var cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1000, Clock = testClock, ExpirationScanFrequency = TimeSpan.FromMilliseconds(1) });
205+
var store = new MemoryOutputCacheStore(cache);
206+
var value = "abc"u8.ToArray();
207+
208+
await store.SetAsync("a", value, new[] { "tag1", "tag2" }, TimeSpan.FromMilliseconds(5), default);
209+
await store.SetAsync("a", value, new[] { "tag1" }, TimeSpan.FromMilliseconds(20), default);
210+
211+
testClock.Advance(TimeSpan.FromMilliseconds(10));
212+
213+
// Trigger background expiration by accessing the cache.
214+
_ = cache.Get("a");
215+
216+
var resulta = await store.GetAsync("a", default);
217+
218+
Assert.NotNull(resulta);
219+
220+
HashSet<string> tag1s, tag2s;
221+
222+
// Wait for the tag2 HashSet to be removed by the background expiration thread.
223+
224+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
225+
226+
while (store.TaggedEntries.TryGetValue("tag2", out tag2s) && !cts.IsCancellationRequested)
227+
{
228+
await Task.Yield();
229+
}
230+
231+
store.TaggedEntries.TryGetValue("tag1", out tag1s);
232+
233+
Assert.Null(tag2s);
234+
Assert.Single(tag1s);
235+
}
236+
200237
[Theory]
201238
[InlineData(null)]
202239
public async Task Store_Throws_OnInvalidTag(string tag)

0 commit comments

Comments
 (0)