Skip to content

Commit d1a1e97

Browse files
committed
Cache LastAccessed during MemoryCache compaction (dotnet#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 dotnet#61032
1 parent e9036b0 commit d1a1e97

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
@@ -395,9 +395,10 @@ public void Compact(double percentage)
395395
private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntrySize)
396396
{
397397
var entriesToRemove = new List<CacheEntry>();
398-
var lowPriEntries = new List<CacheEntry>();
399-
var normalPriEntries = new List<CacheEntry>();
400-
var highPriEntries = new List<CacheEntry>();
398+
// cache LastAccessed outside of the CacheEntry so it is stable during compaction
399+
var lowPriEntries = new List<(CacheEntry entry, DateTimeOffset lastAccessed)>();
400+
var normalPriEntries = new List<(CacheEntry entry, DateTimeOffset lastAccessed)>();
401+
var highPriEntries = new List<(CacheEntry entry, DateTimeOffset lastAccessed)>();
401402
long removedSize = 0;
402403

403404
// Sort items by expired & priority status
@@ -415,13 +416,13 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
415416
switch (entry.Priority)
416417
{
417418
case CacheItemPriority.Low:
418-
lowPriEntries.Add(entry);
419+
lowPriEntries.Add((entry, entry.LastAccessed));
419420
break;
420421
case CacheItemPriority.Normal:
421-
normalPriEntries.Add(entry);
422+
normalPriEntries.Add((entry, entry.LastAccessed));
422423
break;
423424
case CacheItemPriority.High:
424-
highPriEntries.Add(entry);
425+
highPriEntries.Add((entry, entry.LastAccessed));
425426
break;
426427
case CacheItemPriority.NeverRemove:
427428
break;
@@ -445,7 +446,7 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
445446
// ?. Items with the soonest absolute expiration.
446447
// ?. Items with the soonest sliding expiration.
447448
// ?. Larger objects - estimated by object graph size, inaccurate.
448-
static void ExpirePriorityBucket(ref long removedSize, long removalSizeTarget, Func<CacheEntry, long> computeEntrySize, List<CacheEntry> entriesToRemove, List<CacheEntry> priorityEntries)
449+
static void ExpirePriorityBucket(ref long removedSize, long removalSizeTarget, Func<CacheEntry, long> computeEntrySize, List<CacheEntry> entriesToRemove, List<(CacheEntry Entry, DateTimeOffset LastAccessed)> priorityEntries)
449450
{
450451
// Do we meet our quota by just removing expired entries?
451452
if (removalSizeTarget <= removedSize)
@@ -458,8 +459,8 @@ static void ExpirePriorityBucket(ref long removedSize, long removalSizeTarget, F
458459
// TODO: Refine policy
459460

460461
// LRU
461-
priorityEntries.Sort((e1, e2) => e1.LastAccessed.CompareTo(e2.LastAccessed));
462-
foreach (CacheEntry entry in priorityEntries)
462+
priorityEntries.Sort(static (e1, e2) => e1.LastAccessed.CompareTo(e2.LastAccessed));
463+
foreach ((CacheEntry entry, _) in priorityEntries)
463464
{
464465
entry.SetExpired(EvictionReason.Capacity);
465466
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
@@ -14,4 +14,8 @@
1414
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Primitives\src\Microsoft.Extensions.Primitives.csproj" />
1515
</ItemGroup>
1616

17+
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
18+
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
19+
</ItemGroup>
20+
1721
</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)