Skip to content

Commit ea724d2

Browse files
authored
[release/7.0] Remove tagged keys as entries are evicted (#43728)
* Remove tagged keys as entries are evicted * Revert middleware tests changes * Fix unit test * Add braces * Fix flaky tests * Fix tests * Feedback * Feedback * Comment * Remove left over exception * Fix test
1 parent a05e391 commit ea724d2

File tree

3 files changed

+149
-20
lines changed

3 files changed

+149
-20
lines changed

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

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics;
45
using Microsoft.Extensions.Caching.Memory;
56

67
namespace Microsoft.AspNetCore.OutputCaching.Memory;
78

89
internal sealed class MemoryOutputCacheStore : IOutputCacheStore
910
{
10-
private readonly IMemoryCache _cache;
11+
private readonly MemoryCache _cache;
1112
private readonly Dictionary<string, HashSet<string>> _taggedEntries = new();
1213
private readonly object _tagsLock = new();
1314

14-
internal MemoryOutputCacheStore(IMemoryCache cache)
15+
internal MemoryOutputCacheStore(MemoryCache cache)
1516
{
1617
ArgumentNullException.ThrowIfNull(cache);
1718

1819
_cache = cache;
1920
}
2021

22+
// For testing
23+
internal Dictionary<string, HashSet<string>> TaggedEntries => _taggedEntries;
24+
2125
public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken)
2226
{
2327
ArgumentNullException.ThrowIfNull(tag);
@@ -26,12 +30,29 @@ public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken
2630
{
2731
if (_taggedEntries.TryGetValue(tag, out var keys))
2832
{
29-
foreach (var key in keys)
33+
if (keys != null && keys.Count > 0)
3034
{
31-
_cache.Remove(key);
32-
}
35+
// If MemoryCache changed to run eviction callbacks inline in Remove, iterating over keys could throw
36+
// To prevent allocating a copy of the keys we check if the eviction callback ran,
37+
// and if it did we restart the loop.
3338

34-
_taggedEntries.Remove(tag);
39+
var i = keys.Count;
40+
while (i > 0)
41+
{
42+
var oldCount = keys.Count;
43+
foreach (var key in keys)
44+
{
45+
_cache.Remove(key);
46+
i--;
47+
if (oldCount != keys.Count)
48+
{
49+
// eviction callback ran inline, we need to restart the loop to avoid
50+
// "collection modified while iterating" errors
51+
break;
52+
}
53+
}
54+
}
55+
}
3556
}
3657
}
3758

@@ -62,35 +83,75 @@ public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan val
6283
{
6384
foreach (var tag in tags)
6485
{
86+
if (tag is null)
87+
{
88+
throw new ArgumentException(Resources.TagCannotBeNull);
89+
}
90+
6591
if (!_taggedEntries.TryGetValue(tag, out var keys))
6692
{
6793
keys = new HashSet<string>();
6894
_taggedEntries[tag] = keys;
6995
}
7096

97+
Debug.Assert(keys != null);
98+
7199
keys.Add(key);
72100
}
73101

74-
SetEntry();
102+
SetEntry(key, value, tags, validFor);
75103
}
76104
}
77105
else
78106
{
79-
SetEntry();
107+
SetEntry(key, value, tags, validFor);
80108
}
81109

82-
void SetEntry()
110+
return ValueTask.CompletedTask;
111+
}
112+
113+
void SetEntry(string key, byte[] value, string[]? tags, TimeSpan validFor)
114+
{
115+
Debug.Assert(key != null);
116+
117+
var options = new MemoryCacheEntryOptions
83118
{
84-
_cache.Set(
85-
key,
86-
value,
87-
new MemoryCacheEntryOptions
88-
{
89-
AbsoluteExpirationRelativeToNow = validFor,
90-
Size = value.Length
91-
});
119+
AbsoluteExpirationRelativeToNow = validFor,
120+
Size = value.Length
121+
};
122+
123+
if (tags != null && tags.Length > 0)
124+
{
125+
// Remove cache keys from tag lists when the entry is evicted
126+
options.RegisterPostEvictionCallback(RemoveFromTags, tags);
92127
}
93128

94-
return ValueTask.CompletedTask;
129+
_cache.Set(key, value, options);
130+
}
131+
132+
void RemoveFromTags(object key, object? value, EvictionReason reason, object? state)
133+
{
134+
var tags = state as string[];
135+
136+
Debug.Assert(tags != null);
137+
Debug.Assert(tags.Length > 0);
138+
Debug.Assert(key is string);
139+
140+
lock (_tagsLock)
141+
{
142+
foreach (var tag in tags)
143+
{
144+
if (_taggedEntries.TryGetValue(tag, out var tagged))
145+
{
146+
tagged.Remove((string)key);
147+
148+
// Remove the collection if there is no more keys in it
149+
if (tagged.Count == 0)
150+
{
151+
_taggedEntries.Remove(tag);
152+
}
153+
}
154+
}
155+
}
95156
}
96157
}

src/Middleware/OutputCaching/src/Resources.resx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
<resheader name="writer">
118118
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
119119
</resheader>
120-
<data name="Policy_InvalidType" xml:space="preserve">
121-
<value>The type '{0}' is not a valid output policy.</value>
120+
<data name="TagCannotBeNull" xml:space="preserve">
121+
<value>A tag value cannot be null.</value>
122122
</data>
123123
</root>

src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,19 @@ public async Task EvictByTag_SingleTag_SingleEntry()
7171
await store.EvictByTagAsync("tag1", default);
7272
var result = await store.GetAsync(key, default);
7373

74+
HashSet<string> tag1s;
75+
76+
// Wait for the hashset to be removed as it happens on a separate thread
77+
78+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
79+
80+
while (store.TaggedEntries.TryGetValue("tag1", out tag1s) && !cts.IsCancellationRequested)
81+
{
82+
await Task.Yield();
83+
}
84+
7485
Assert.Null(result);
86+
Assert.Null(tag1s);
7587
}
7688

7789
[Fact]
@@ -140,6 +152,62 @@ public async Task EvictByTag_MultipleTags_MultipleEntries()
140152
Assert.Null(result2);
141153
}
142154

155+
[Fact]
156+
public async Task ExpiredEntries_AreRemovedFromTags()
157+
{
158+
var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow };
159+
var cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1000, Clock = testClock, ExpirationScanFrequency = TimeSpan.FromMilliseconds(1) });
160+
var store = new MemoryOutputCacheStore(cache);
161+
var value = "abc"u8.ToArray();
162+
163+
await store.SetAsync("a", value, new[] { "tag1" }, TimeSpan.FromMilliseconds(5), default);
164+
await store.SetAsync("b", value, new[] { "tag1", "tag2" }, TimeSpan.FromMilliseconds(5), default);
165+
await store.SetAsync("c", value, new[] { "tag2" }, TimeSpan.FromMilliseconds(20), default);
166+
167+
testClock.Advance(TimeSpan.FromMilliseconds(10));
168+
169+
// Background expiration checks are triggered by misc cache activity.
170+
_ = cache.Get("a");
171+
172+
var resulta = await store.GetAsync("a", default);
173+
var resultb = await store.GetAsync("b", default);
174+
var resultc = await store.GetAsync("c", default);
175+
176+
Assert.Null(resulta);
177+
Assert.Null(resultb);
178+
Assert.NotNull(resultc);
179+
180+
HashSet<string> tag1s, tag2s;
181+
182+
// Wait for the hashset to be removed as it happens on a separate thread
183+
184+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
185+
186+
while (store.TaggedEntries.TryGetValue("tag1", out tag1s) && !cts.IsCancellationRequested)
187+
{
188+
await Task.Yield();
189+
}
190+
191+
while (store.TaggedEntries.TryGetValue("tag2", out tag2s) && tag2s.Count != 1 && !cts.IsCancellationRequested)
192+
{
193+
await Task.Yield();
194+
}
195+
196+
Assert.Null(tag1s);
197+
Assert.Single(tag2s);
198+
}
199+
200+
[Theory]
201+
[InlineData(null)]
202+
public async Task Store_Throws_OnInvalidTag(string tag)
203+
{
204+
var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions()));
205+
var value = "abc"u8.ToArray();
206+
var key = "abc";
207+
208+
await Assert.ThrowsAsync<ArgumentException>(async () => await store.SetAsync(key, value, new string[] { tag }, TimeSpan.FromMinutes(1), default));
209+
}
210+
143211
private class TestMemoryOptionsClock : Extensions.Internal.ISystemClock
144212
{
145213
public DateTimeOffset UtcNow { get; set; }

0 commit comments

Comments
 (0)