Skip to content

Commit b1fcbb6

Browse files
authored
Cache LastAccessed during MemoryCache compaction (#61187)
* Cache LastAccessed during MemoryCache compaction During cache compaction, we are sorting entries based on their LastAccessed time. However, since the cache entries can still be used concurrently on other threads, the LastAccessed time may be updated in the middle of sorting the entries. This leads to exceptions in a background thread, crashing the process. The fix is to cache the LastAccessed time outside of the entry when we are adding it to the list. This will ensure the time is stable during the compaction process. Fix #61032
1 parent 2d6cc77 commit b1fcbb6

4 files changed

Lines changed: 74 additions & 9 deletions

File tree

src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,10 @@ public void Compact(double percentage)
391391
private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntrySize, CoherentState coherentState)
392392
{
393393
var entriesToRemove = new List<CacheEntry>();
394-
var lowPriEntries = new List<CacheEntry>();
395-
var normalPriEntries = new List<CacheEntry>();
396-
var highPriEntries = new List<CacheEntry>();
394+
// cache LastAccessed outside of the CacheEntry so it is stable during compaction
395+
var lowPriEntries = new List<(CacheEntry entry, DateTimeOffset lastAccessed)>();
396+
var normalPriEntries = new List<(CacheEntry entry, DateTimeOffset lastAccessed)>();
397+
var highPriEntries = new List<(CacheEntry entry, DateTimeOffset lastAccessed)>();
397398
long removedSize = 0;
398399

399400
// Sort items by expired & priority status
@@ -411,13 +412,13 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
411412
switch (entry.Priority)
412413
{
413414
case CacheItemPriority.Low:
414-
lowPriEntries.Add(entry);
415+
lowPriEntries.Add((entry, entry.LastAccessed));
415416
break;
416417
case CacheItemPriority.Normal:
417-
normalPriEntries.Add(entry);
418+
normalPriEntries.Add((entry, entry.LastAccessed));
418419
break;
419420
case CacheItemPriority.High:
420-
highPriEntries.Add(entry);
421+
highPriEntries.Add((entry, entry.LastAccessed));
421422
break;
422423
case CacheItemPriority.NeverRemove:
423424
break;
@@ -441,7 +442,7 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
441442
// ?. Items with the soonest absolute expiration.
442443
// ?. Items with the soonest sliding expiration.
443444
// ?. Larger objects - estimated by object graph size, inaccurate.
444-
static void ExpirePriorityBucket(ref long removedSize, long removalSizeTarget, Func<CacheEntry, long> computeEntrySize, List<CacheEntry> entriesToRemove, List<CacheEntry> priorityEntries)
445+
static void ExpirePriorityBucket(ref long removedSize, long removalSizeTarget, Func<CacheEntry, long> computeEntrySize, List<CacheEntry> entriesToRemove, List<(CacheEntry Entry, DateTimeOffset LastAccessed)> priorityEntries)
445446
{
446447
// Do we meet our quota by just removing expired entries?
447448
if (removalSizeTarget <= removedSize)
@@ -454,8 +455,8 @@ static void ExpirePriorityBucket(ref long removedSize, long removalSizeTarget, F
454455
// TODO: Refine policy
455456

456457
// LRU
457-
priorityEntries.Sort((e1, e2) => e1.LastAccessed.CompareTo(e2.LastAccessed));
458-
foreach (CacheEntry entry in priorityEntries)
458+
priorityEntries.Sort(static (e1, e2) => e1.LastAccessed.CompareTo(e2.LastAccessed));
459+
foreach ((CacheEntry entry, _) in priorityEntries)
459460
{
460461
entry.SetExpired(EvictionReason.Capacity);
461462
entriesToRemove.Add(entry);

src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@
1616
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Primitives\src\Microsoft.Extensions.Primitives.csproj" />
1717
</ItemGroup>
1818

19+
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
20+
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
21+
</ItemGroup>
22+
1923
</Project>

src/libraries/Microsoft.Extensions.Caching.Memory/tests/CompactTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
57
using Microsoft.Extensions.Internal;
68
using Xunit;
79

@@ -83,4 +85,57 @@ public void CompactPrioritizesLRU()
8385
Assert.Equal("value4", cache.Get("key4"));
8486
}
8587
}
88+
89+
[Collection(nameof(DisableParallelization))]
90+
public class CompactTestsDisableParallelization
91+
{
92+
/// <summary>
93+
/// Tests a race condition in Compact where CacheEntry.LastAccessed is getting updated
94+
/// by a different thread than what is doing the Compact, leading to sorting failing.
95+
///
96+
/// See https://github.com/dotnet/runtime/issues/61032.
97+
/// </summary>
98+
[Fact]
99+
public void CompactLastAccessedRaceCondition()
100+
{
101+
const int numEntries = 100;
102+
MemoryCache cache = new MemoryCache(new MemoryCacheOptions());
103+
Random random = new Random();
104+
105+
void FillCache()
106+
{
107+
for (int i = 0; i < numEntries; i++)
108+
{
109+
cache.Set($"key{i}", $"value{i}");
110+
}
111+
}
112+
113+
// start a few tasks to access entries in the background
114+
Task[] backgroundAccessTasks = new Task[Environment.ProcessorCount];
115+
bool done = false;
116+
117+
for (int i = 0; i < backgroundAccessTasks.Length; i++)
118+
{
119+
backgroundAccessTasks[i] = Task.Run(async () =>
120+
{
121+
while (!done)
122+
{
123+
cache.TryGetValue($"key{random.Next(numEntries)}", out _);
124+
await Task.Yield();
125+
}
126+
});
127+
}
128+
129+
for (int i = 0; i < 1000; i++)
130+
{
131+
FillCache();
132+
133+
cache.Compact(1);
134+
}
135+
136+
done = true;
137+
138+
Task.WaitAll(backgroundAccessTasks);
139+
}
140+
}
86141
}

src/libraries/Microsoft.Extensions.Caching.Memory/tests/Microsoft.Extensions.Caching.Memory.Tests.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
<EnableDefaultItems>true</EnableDefaultItems>
66
</PropertyGroup>
77

8+
<ItemGroup>
9+
<Compile Include="$(CommonTestPath)TestUtilities\System\DisableParallelization.cs"
10+
Link="Common\TestUtilities\System\DisableParallelization.cs" />
11+
</ItemGroup>
12+
813
<ItemGroup>
914
<ProjectReference Include="..\src\Microsoft.Extensions.Caching.Memory.csproj" SkipUseReferenceAssembly="true" />
1015
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.DependencyInjection\src\Microsoft.Extensions.DependencyInjection.csproj" />

0 commit comments

Comments
 (0)