Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Memory Leak in Microsoft.Extensions.Caching.Memory when handling exceptions #42321

Closed
eerhardt opened this issue Sep 16, 2020 · 5 comments · Fixed by #42355
Closed

Memory Leak in Microsoft.Extensions.Caching.Memory when handling exceptions #42321

eerhardt opened this issue Sep 16, 2020 · 5 comments · Fixed by #42355

Comments

@eerhardt
Copy link
Member

We are leaking objects when calling MemoryCache.GetOrCreate and the factory method is throwing an exception.

Repro

Run the following application, take a memory snapshot, run for a while and take another snapshot

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

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var cache = new MemoryCache(new MemoryCacheOptions());
            while (true)
            {
                try
                {
                    int value = cache.GetOrCreate("hello", entry =>
                    {
                        throw new Exception();
                        return 10;
                    });
                }
                catch
                {
                    Console.WriteLine("Get Failed");
                }
            }
        }
    }
}

image

Analysis

It appears the issue is that we aren't Disposing the CacheEntry instances when an exception is being thrown:

public static TItem GetOrCreate<TItem>(this IMemoryCache cache, object key, Func<ICacheEntry, TItem> factory)
{
if (!cache.TryGetValue(key, out object result))
{
ICacheEntry entry = cache.CreateEntry(key);
result = factory(entry);
entry.SetValue(result);
// need to manually call dispose instead of having a using
// in case the factory passed in throws, in which case we
// do not want to add the entry to the cache
entry.Dispose();
}
return (TItem)result;
}

The reason we aren't calling Dispose is to fix an issue that we were caching null when an Exception was being thrown. See aspnet/Caching#216.

Disposing the CacheEntry is important because every time you create a CacheEntry, it gets stuck in an AsyncLocal "stack":

internal static IDisposable EnterScope(CacheEntry entry)
{
CacheEntryStack scopes = GetOrCreateScopes();
var scopeLease = new ScopeLease(scopes);
Scopes = scopes.Push(entry);
return scopeLease;
}

and disposing it is what "pops" it off the stack:

private sealed class ScopeLease : IDisposable
{
private readonly CacheEntryStack _cacheEntryStack;
public ScopeLease(CacheEntryStack cacheEntryStack)
{
_cacheEntryStack = cacheEntryStack;
}
public void Dispose()
{
Scopes = _cacheEntryStack;
}
}

We should always be Disposing the entry. That way the ScopeLease object is always disposed, and the stack is cleared.

However, we still need to fix the original problem: Don't cache null when an Exception happens. The reason this happens is because Disposing the CacheEntry is what "commits the entry into the cache". This method gets called from CacheEntry.Dispose:

private void SetEntry(CacheEntry entry)
{
if (_disposed)
{
// No-op instead of throwing since this is called during CacheEntry.Dispose
return;
}
if (_options.SizeLimit.HasValue && !entry.Size.HasValue)
{
throw new InvalidOperationException($"Cache entry must specify a value for {nameof(entry.Size)} when {nameof(_options.SizeLimit)} is set.");
}
DateTimeOffset utcNow = _options.Clock.UtcNow;
DateTimeOffset? absoluteExpiration = null;
if (entry._absoluteExpirationRelativeToNow.HasValue)
{
absoluteExpiration = utcNow + entry._absoluteExpirationRelativeToNow;
}
else if (entry._absoluteExpiration.HasValue)
{
absoluteExpiration = entry._absoluteExpiration;
}
// Applying the option's absolute expiration only if it's not already smaller.
// This can be the case if a dependent cache entry has a smaller value, and
// it was set by cascading it to its parent.
if (absoluteExpiration.HasValue)
{
if (!entry._absoluteExpiration.HasValue || absoluteExpiration.Value < entry._absoluteExpiration.Value)
{
entry._absoluteExpiration = absoluteExpiration;
}
}
// Initialize the last access timestamp at the time the entry is added
entry.LastAccessed = utcNow;
if (_entries.TryGetValue(entry.Key, out CacheEntry priorEntry))
{
priorEntry.SetExpired(EvictionReason.Replaced);
}
bool exceedsCapacity = UpdateCacheSizeExceedsCapacity(entry);
if (!entry.CheckExpired(utcNow) && !exceedsCapacity)
{
bool entryAdded = false;
if (priorEntry == null)
{
// Try to add the new entry if no previous entries exist.
entryAdded = _entries.TryAdd(entry.Key, entry);
}
else
{
// Try to update with the new entry if a previous entries exist.
entryAdded = _entries.TryUpdate(entry.Key, entry, priorEntry);
if (entryAdded)
{
if (_options.SizeLimit.HasValue)
{
// The prior entry was removed, decrease the by the prior entry's size
Interlocked.Add(ref _cacheSize, -priorEntry.Size.Value);
}
}
else
{
// The update will fail if the previous entry was removed after retrival.
// Adding the new entry will succeed only if no entry has been added since.
// This guarantees removing an old entry does not prevent adding a new entry.
entryAdded = _entries.TryAdd(entry.Key, entry);
}
}
if (entryAdded)
{
entry.AttachTokens();
}
else
{
if (_options.SizeLimit.HasValue)
{
// Entry could not be added, reset cache size
Interlocked.Add(ref _cacheSize, -entry.Size.Value);
}
entry.SetExpired(EvictionReason.Replaced);
entry.InvokeEvictionCallbacks();
}
if (priorEntry != null)
{
priorEntry.InvokeEvictionCallbacks();
}
}
else
{
if (exceedsCapacity)
{
// The entry was not added due to overcapacity
entry.SetExpired(EvictionReason.Capacity);
TriggerOvercapacityCompaction();
}
else
{
if (_options.SizeLimit.HasValue)
{
// Entry could not be added due to being expired, reset cache size
Interlocked.Add(ref _cacheSize, -entry.Size.Value);
}
}
entry.InvokeEvictionCallbacks();
if (priorEntry != null)
{
RemoveEntry(priorEntry);
}
}
StartScanForExpiredItems(utcNow);
}

To fix this, we should set a flag indicating whether the CacheEntry.Value was ever set. If it wasn't set, we shouldn't be committing the value into the cache.

cc @Tratcher

@eerhardt eerhardt added this to the 6.0.0 milestone Sep 16, 2020
@ghost
Copy link

ghost commented Sep 16, 2020

Tagging subscribers to this area: @eerhardt, @maryamariyan
See info in area-owners.md if you want to be subscribed.

@Dotnet-GitSync-Bot Dotnet-GitSync-Bot added the untriaged New issue has not been triaged by the area owner label Sep 16, 2020
@Tratcher
Copy link
Member

Tratcher commented Sep 16, 2020

Design hindsight: Dispose was the wrong term, it should have been named Commit or similar. Then Dispose could have been used for general cleanup without committing.

To fix this, we should set a flag indicating whether the CacheEntry.Value was ever set. If it wasn't set, we shouldn't be committing the value into the cache.

👍

There's still some risk of an exception being thrown after the value was set, such as when setting up the change tokens, timeouts, etc..

@eerhardt eerhardt modified the milestones: 6.0.0, 5.0.0 Sep 16, 2020
@eerhardt eerhardt added blocking-release and removed untriaged New issue has not been triaged by the area owner labels Sep 16, 2020
@eerhardt
Copy link
Member Author

Design hindsight: Dispose was the wrong term, it should have been named Commit or similar. Then Dispose could have been used for general cleanup without committing.

+💯

There's still some risk of an exception being thrown after the value was set, such as when setting up the change tokens, timeouts, etc..

Agreed, but that is more of an edge case. That won't happen when using the GetOrCreate methods, only if someone is calling CreateEntry themselves. To fix that, they can move SetValue to the last thing before calling Dispose.

One other risk is that if someone is relying on the behavior of not calling SetValue to mean "cache null". In that case, they would need to start calling SetValue(null). But I think that is much less common than the mainline here.

eerhardt added a commit to eerhardt/runtime that referenced this issue Sep 16, 2020
…ptions

When an exception is thrown inside MemoryCache.GetOrCreate, we are leaking CacheEntry objects. This is because they are not being Disposed properly, and the async local CacheEntryStack is growing indefinitely.

The fix is to ensure the CacheEntry objects are disposed correctly. In order to do this, I set a flag to indicate whether the CacheEntry.Value has been set. If it hasn't, Disposing the CacheEntry won't add it to the cache.

Fix dotnet#42321
eerhardt added a commit that referenced this issue Sep 19, 2020
…ptions (#42355)

* Memory Leak in Microsoft.Extensions.Caching.Memory when handling exceptions

When an exception is thrown inside MemoryCache.GetOrCreate, we are leaking CacheEntry objects. This is because they are not being Disposed properly, and the async local CacheEntryStack is growing indefinitely.

The fix is to ensure the CacheEntry objects are disposed correctly. In order to do this, I set a flag to indicate whether the CacheEntry.Value has been set. If it hasn't, Disposing the CacheEntry won't add it to the cache.

Fix #42321

* Fix another memory leak when one cache depends on another cache. The inner cache's entries will reference the outer cache entries through the ScopeLease object.

Null'ing out the CacheEntry._scope field when it is disposed fixes this issue.
@eerhardt
Copy link
Member Author

Reopening to back port to 5.0.

@eerhardt eerhardt reopened this Sep 19, 2020
github-actions bot pushed a commit that referenced this issue Sep 19, 2020
…ptions

When an exception is thrown inside MemoryCache.GetOrCreate, we are leaking CacheEntry objects. This is because they are not being Disposed properly, and the async local CacheEntryStack is growing indefinitely.

The fix is to ensure the CacheEntry objects are disposed correctly. In order to do this, I set a flag to indicate whether the CacheEntry.Value has been set. If it hasn't, Disposing the CacheEntry won't add it to the cache.

Fix #42321
eerhardt added a commit that referenced this issue Sep 21, 2020
…when handling exceptions (#42494)

* Memory Leak in Microsoft.Extensions.Caching.Memory when handling exceptions

When an exception is thrown inside MemoryCache.GetOrCreate, we are leaking CacheEntry objects. This is because they are not being Disposed properly, and the async local CacheEntryStack is growing indefinitely.

The fix is to ensure the CacheEntry objects are disposed correctly. In order to do this, I set a flag to indicate whether the CacheEntry.Value has been set. If it hasn't, Disposing the CacheEntry won't add it to the cache.

Fix #42321

* Fix another memory leak when one cache depends on another cache. The inner cache's entries will reference the outer cache entries through the ScopeLease object.

Null'ing out the CacheEntry._scope field when it is disposed fixes this issue.

Co-authored-by: Eric Erhardt <[email protected]>
@eerhardt
Copy link
Member Author

Closing as the fix is now in the 5.0 branch.

@ghost ghost locked as resolved and limited conversation to collaborators Dec 7, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants