Skip to content
This repository has been archived by the owner on Dec 14, 2018. It is now read-only.

Add size based eviction to MemoryCache #332

Merged
merged 1 commit into from
Jul 8, 2017
Merged

Add size based eviction to MemoryCache #332

merged 1 commit into from
Jul 8, 2017

Conversation

JunTaoLuo
Copy link
Contributor

@JunTaoLuo JunTaoLuo commented Jul 4, 2017

Addresses #326

TODO:

  • Doc comments
  • Naming
  • Add more tests
  • Evaluate perf impact
  • Investigate appveyor and travis failures

@@ -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; }
Copy link
Contributor Author

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.

Copy link
Member

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; }
Copy link
Member

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.");
Copy link
Member

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.");
Copy link
Member

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)
Copy link
Member

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)
Copy link
Member

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)
Copy link
Member

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)));
Copy link
Member

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.

@Tratcher
Copy link
Member

Tratcher commented Jul 5, 2017

MemoryDistributedCache should set sizes by default. This will enable Session to work without changes.

@@ -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
Copy link
Contributor Author

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);
Copy link
Contributor Author

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)
Copy link
Member

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(_ =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper method

Copy link
Contributor Author

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.

Copy link
Member

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);
Copy link
Member

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.

Copy link
Contributor Author

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(_ =>
Copy link
Member

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
Copy link
Member

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.

Copy link
Contributor Author

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

Copy link
Member

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)
Copy link

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"?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

@JunTaoLuo JunTaoLuo changed the title WIP Add size based eviction to MemoryCache Add size based eviction to MemoryCache Jul 6, 2017
@@ -90,7 +96,15 @@ private void SetEntry(CacheEntry entry)
return;
}

var utcNow = _clock.UtcNow;
if (_options.SizeLimit != null)
Copy link
Member

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))
Copy link
Member

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)
Copy link
Member

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
Copy link
Member

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 ICacheEntry entry,
long size)
{
if (size < 0)
Copy link
Contributor Author

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)
Copy link
Member

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
Copy link
Member

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)
Copy link
Member

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.
Copy link
Member

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)
Copy link
Member

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.

Copy link
Contributor Author

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)
Copy link
Member

@Tratcher Tratcher Jul 7, 2017

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.

Copy link
Contributor Author

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));
Copy link
Contributor Author

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.

var sizeRead = Interlocked.Read(ref _cacheSize);
newSize = sizeRead + entry.Size.Value;

if (newSize < 0)
Copy link
Member

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove

@JunTaoLuo JunTaoLuo merged commit 45d42c2 into dev Jul 8, 2017
@JunTaoLuo JunTaoLuo deleted the johluo/max-size branch July 8, 2017 04:21
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants