-
Notifications
You must be signed in to change notification settings - Fork 217
Add size based eviction to MemoryCache #332
Conversation
@@ -13,6 +13,12 @@ public class MemoryCacheOptions : IOptions<MemoryCacheOptions> | |||
|
|||
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(1); | |||
|
|||
public bool EnforceSizeLimit { get; set; } = false; | |||
|
|||
public long HighWatermark { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potentially extracted these options to a separate class/policy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming, docs.
Make it nullable rather than having a separate EnforceSizeLimit bool?
@@ -13,6 +13,12 @@ public class MemoryCacheOptions : IOptions<MemoryCacheOptions> | |||
|
|||
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(1); | |||
|
|||
public bool EnforceSizeLimit { get; set; } = false; | |||
|
|||
public long HighWatermark { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming, docs.
Make it nullable rather than having a separate EnforceSizeLimit bool?
{ | ||
if (_options.HighWatermark <= 0) | ||
{ | ||
throw new ArgumentOutOfRangeException(nameof(_options.HighWatermark), _options.HighWatermark, $"{nameof(_options.HighWatermark)} must be positive."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move the validation to the options class.
} | ||
if (entry.Size.Value < 0) | ||
{ | ||
throw new ArgumentOutOfRangeException(nameof(entry.Size.Value), entry.Size.Value, $"{nameof(entry.Size.Value)} must be non-negative."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move this validation to the property
@@ -90,7 +115,19 @@ private void SetEntry(CacheEntry entry) | |||
return; | |||
} | |||
|
|||
var utcNow = _clock.UtcNow; | |||
if (_options.EnforceSizeLimit) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent adding the entry if doing so would exceed the cache size limit.
try | ||
{ | ||
var currentSize = Interlocked.Read(ref _cacheSize); | ||
while (currentSize > _options.HighWatermark) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No loop. Try again next time.
Compact(removalSizeTarget, entry => entry.Size.Value); | ||
} | ||
|
||
private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntrySize) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's discuss
var memoryCacheOptions = new MemoryCacheOptions(); | ||
setupAction(memoryCacheOptions); | ||
|
||
services.TryAddSingleton<IDistributedCache>(new MemoryDistributedCache(new MemoryCache(memoryCacheOptions))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#329 ?
I don't think this was the fix they had in mind.
MemoryDistributedCache should set sizes by default. This will enable Session to work without changes. |
630ae34
to
ff010f1
Compare
@@ -243,4 +243,7 @@ Global | |||
{17E332EB-D18D-4BF5-BCA5-989E36C78B79} = {459E1593-2C11-42CB-AD17-F7597E69E5D2} | |||
{ADF83AC7-3776-4E62-A222-C6979C32DD2D} = {9E78AA8E-7870-46DE-A49F-856F5A0A9166} | |||
EndGlobalSection | |||
GlobalSection(ExtensibilityGlobals) = postSolution |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strange, not sure why vs added this.
// We have only added the difference between the new size and the old size. | ||
// Since the old one was removed, we need to add the old entry's size back | ||
// to ensure the total size of the new entry is accounted for. | ||
Interlocked.Add(ref _cacheSize, priorEntry.Size.Value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could lead to a situation where the capacity is exceeded once SetEntry is complete. Will look for another way to accomplish this.
this ICacheEntry entry, | ||
long size) | ||
{ | ||
if (size <= 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Allow 0? E.g. DistributedCache allows byte array lengths of 0, just not null.
Interlocked.Add(ref _cacheSize, -sizeUpdate); | ||
|
||
// Spawn background thread for compaction | ||
ThreadPool.QueueUserWorkItem(new WaitCallback(_ => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Helper method
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used to have one but this was the only place that was calling it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you remove the closure? Seems like you're not capturing any local state.
@@ -122,7 +136,9 @@ private void SetEntry(CacheEntry entry) | |||
priorEntry.SetExpired(EvictionReason.Replaced); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not deduct the size immediately? Then you can deal with just the new size.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I keep the size operations beside the actual calls to remove from the underlying concurrent dictionary. In this case, that's the ScanForExpiredItems call. I think we decided not to remove the entry right away to ensure the value can never be null on replacement hence the awkward logic here. I don't think it's safe to deduct the size here.
private void TriggerOvercapacityCompaction() | ||
{ | ||
// Spawn background thread for compaction | ||
ThreadPool.QueueUserWorkItem(new WaitCallback(_ => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove the new WaitCallback.
public ISystemClock Clock { get; set; } | ||
|
||
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(1); | ||
|
||
public long? SizeLimit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want to do a back and forth but forcing users to calculate a percentage seems bizarre.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's talk about this in person
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calculate a percentage? What are their inputs? "If my cache is over capacity I need to purge some data. How much data do I purge?" I'll argue that they'll be more likely to start with a percentage than a fixed value, "free up 5% capacity", which is much simpler to reason about than to say "how big is my total cache? 1gb? Free up 50mb".
this MemoryCacheEntryOptions options, | ||
long size) | ||
{ | ||
if (size < 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does size==0 signify anything? Also, is this "Size in bytes"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't want to impose what size means. When we use this in response caching, it will be bytes but in theory you can have a memory cache where all the sizes are 1 and in that case size means count.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Size of zero means you can add this entry even when the cache is full.
@@ -90,7 +96,15 @@ private void SetEntry(CacheEntry entry) | |||
return; | |||
} | |||
|
|||
var utcNow = _clock.UtcNow; | |||
if (_options.SizeLimit != null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.HasValue everywhere
ThreadPool.QueueUserWorkItem(new WaitCallback(_ => | ||
{ | ||
var currentSize = Interlocked.Read(ref _cacheSize); | ||
if (currentSize > _options.SizeLimit * (1 - _options.RemovalPercentageOnOvercapacityCompaction)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
save your calculation in a local variable to avoid repetition.
Compact(removalCountTarget, _ => 1); | ||
} | ||
|
||
private void Compact(long removalSizeTarget) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this helper method isn't doing anything, do you need it?
public ISystemClock Clock { get; set; } | ||
|
||
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(1); | ||
|
||
public long? SizeLimit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calculate a percentage? What are their inputs? "If my cache is over capacity I need to purge some data. How much data do I purge?" I'll argue that they'll be more likely to start with a percentage than a fixed value, "free up 5% capacity", which is much simpler to reason about than to say "how big is my total cache? 1gb? Free up 50mb".
293b6c8
to
b411284
Compare
this ICacheEntry entry, | ||
long size) | ||
{ | ||
if (size < 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add some tests for these checks
@@ -162,6 +188,17 @@ private void SetEntry(CacheEntry entry) | |||
} | |||
else | |||
{ | |||
if (_options.SizeLimit.HasValue && updatedCacheSize > _options.SizeLimit) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isn't this just exceedsCapacity
?
} | ||
} | ||
|
||
public double RemovalPercentageOnOvercapacityCompaction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CompactionPercentage
?
get => _sizeLimit; | ||
set | ||
{ | ||
if (value <= 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
allow 0 for test scenarios. e.g. temporarily reduce the cache limit to 0 to debug an issue masked (or caused) by caching.
@@ -134,6 +134,24 @@ public static class CacheEntryExtensions | |||
} | |||
|
|||
/// <summary> | |||
/// Sets the size of the cache entry. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the cache entry Value.
The thing being cached, not the infrastructure around it.
@@ -272,6 +321,53 @@ private static void ScanForExpiredItems(MemoryCache cache) | |||
} | |||
} | |||
|
|||
private bool UpdateCacheSizeExceedsCapacity(CacheEntry entry, out bool cacheSizeUpdated) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you eliminate cacheSizeUpdated? e.g. if it exceed capacity, immediately subtract it again. Then the only info you need out of this method is the return bool.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possible, I'll take a closer look in the morning.
} | ||
|
||
var newSize = 0L; | ||
while(true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
finite retries? e.g. if it fails 100 times then give up and return that you're over capacity. Under stress you may get a bunch of threads in this loop competing with eachother and there's no gurantee how long they'll be spinning. Some poor threads could be stuck here forever burning all the CPU on your machine (unlikely but possible). MemoryCache needs to be fast and determanistic, and that includes failing fast if necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair
testClock.Add(TimeSpan.FromSeconds(10)); | ||
|
||
// Wait for compaction to complete | ||
Thread.Sleep(TimeSpan.FromSeconds(4)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Increased these waits significantly to ensure tests pass on appveyor and travis. This is a rather poor approach and I'll see if there are better ways to ensure the consistency of these tests.
537c195
to
bb2a3ad
Compare
var sizeRead = Interlocked.Read(ref _cacheSize); | ||
newSize = sizeRead + entry.Size.Value; | ||
|
||
if (newSize < 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move newSize > _options.SizeLimit
here and get rid of the decrement.
{ | ||
var cache = new MemoryCache(new MemoryCacheOptions | ||
{ | ||
ExpirationScanFrequency = TimeSpan.Zero, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove
2fcf8c2
to
45d42c2
Compare
Addresses #326
TODO: