Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions NuGet.config
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="Foundatio" value="https://f.feedz.io/foundatio/foundatio/nuget/index.json" />
Comment on lines 3 to 4
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did it change this?

Copy link
Member

Choose a reason for hiding this comment

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

@copilot this had nothing todo with the issue, revert it stay focused only change what's needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 3be2315. Reverted the unrelated NuGet.config changes to stay focused only on the MaxMemorySize feature. The original package sources and encoding have been restored.

</packageSources>
</configuration>
147 changes: 136 additions & 11 deletions src/Foundatio/Caching/InMemoryCacheClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ public class InMemoryCacheClient : IMemoryCacheClient, IHaveTimeProvider, IHaveL
private readonly bool _shouldClone;
private readonly bool _shouldThrowOnSerializationErrors;
private readonly int? _maxItems;
private readonly long? _maxMemorySize;
private long _writes;
private long _hits;
private long _misses;
private long _currentMemorySize;
private readonly TimeProvider _timeProvider;
private readonly IResiliencePolicyProvider _resiliencePolicyProvider;
private readonly ILogger _logger;
Expand All @@ -39,6 +41,7 @@ public InMemoryCacheClient(InMemoryCacheClientOptions options = null)
_shouldClone = options.CloneValues;
_shouldThrowOnSerializationErrors = options.ShouldThrowOnSerializationError;
_maxItems = options.MaxItems;
_maxMemorySize = options.MaxMemorySize;
_timeProvider = options.TimeProvider ?? TimeProvider.System;
_resiliencePolicyProvider = options.ResiliencePolicyProvider;
_loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance;
Expand All @@ -51,6 +54,8 @@ public InMemoryCacheClient(Builder<InMemoryCacheClientOptionsBuilder, InMemoryCa

public int Count => _memory.Count(i => !i.Value.IsExpired);
public int? MaxItems => _maxItems;
public long? MaxMemorySize => _maxMemorySize;
public long CurrentMemorySize => _currentMemorySize;
public long Calls => _writes + _hits + _misses;
public long Writes => _writes;
public long Reads => _hits + _misses;
Expand All @@ -64,7 +69,10 @@ public InMemoryCacheClient(Builder<InMemoryCacheClientOptionsBuilder, InMemoryCa

public override string ToString()
{
return $"Count: {Count} Calls: {Calls} Reads: {Reads} Writes: {Writes} Hits: {Hits} Misses: {Misses}";
var result = $"Count: {Count} Calls: {Calls} Reads: {Reads} Writes: {Writes} Hits: {Hits} Misses: {Misses}";
if (_maxMemorySize.HasValue)
result += $" Memory: {CurrentMemorySize:N0}/{MaxMemorySize:N0} bytes";
return result;
}

public void ResetStats()
Expand Down Expand Up @@ -126,7 +134,12 @@ public Task<bool> RemoveAsync(string key)
return Task.FromException<bool>(new ArgumentNullException(nameof(key), "Key cannot be null or empty."));

_logger.LogTrace("RemoveAsync: Removing key: {Key}", key);
return Task.FromResult(_memory.TryRemove(key, out _));

bool removed = _memory.TryRemove(key, out var removedEntry);
if (removed && _maxMemorySize.HasValue && removedEntry != null)
Interlocked.Add(ref _currentMemorySize, -removedEntry.EstimatedSize);
Copy link
Member

Choose a reason for hiding this comment

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

@copilot what if it goes negative? Wouldn't it be better just to sum all the estimated sizes when we do an add/set? Can we do that really cheaply?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 529335a. Added thread-safe UpdateMemorySize() method that prevents negative values using CompareExchange loops for safe subtraction. Also added RecalculateMemorySize() method for O(n) summation approach when accuracy is preferred over performance. The incremental O(1) tracking is kept by default for performance, but the summation approach is available for smaller caches where accuracy is more important than speed.


return Task.FromResult(removed);
}

public async Task<bool> RemoveIfEqualAsync<T>(string key, T expected)
Expand Down Expand Up @@ -164,6 +177,8 @@ public Task<int> RemoveAllAsync(IEnumerable<string> keys = null)
{
int count = _memory.Count;
_memory.Clear();
if (_maxMemorySize.HasValue)
Interlocked.Exchange(ref _currentMemorySize, 0);
return Task.FromResult(count);
}

Expand All @@ -174,8 +189,12 @@ public Task<int> RemoveAllAsync(IEnumerable<string> keys = null)
continue;

_logger.LogTrace("RemoveAllAsync: Removing key: {Key}", key);
if (_memory.TryRemove(key, out _))
if (_memory.TryRemove(key, out var removedEntry))
{
removed++;
if (_maxMemorySize.HasValue && removedEntry != null)
Interlocked.Add(ref _currentMemorySize, -removedEntry.EstimatedSize);
}
}

return Task.FromResult(removed);
Expand Down Expand Up @@ -215,8 +234,12 @@ internal long RemoveExpiredKey(string key, bool sendNotification = true)
throw new ArgumentNullException(nameof(key), "Key cannot be null or empty");

_logger.LogTrace("Removing expired key: {Key}", key);
if (_memory.TryRemove(key, out _))
if (_memory.TryRemove(key, out var removedEntry))
{
// Update memory size tracking
if (_maxMemorySize.HasValue)
Interlocked.Add(ref _currentMemorySize, -removedEntry.EstimatedSize);

OnItemExpired(key, sendNotification);
return 1;
}
Expand All @@ -239,6 +262,10 @@ private long RemoveKeyIfExpired(string key, bool sendNotification = true)
if (!removedEntry.IsExpired)
throw new Exception("Removed item was not expired");

// Update memory size tracking
if (_maxMemorySize.HasValue)
Interlocked.Add(ref _currentMemorySize, -removedEntry.EstimatedSize);

_logger.LogDebug("Removing expired cache entry {Key}", key);
OnItemExpired(key, sendNotification);
return 1;
Expand Down Expand Up @@ -706,6 +733,8 @@ private async Task<bool> SetInternalAsync(string key, CacheEntry entry, bool add
Interlocked.Increment(ref _writes);

bool wasUpdated = true;
CacheEntry oldEntry = null;

if (addOnly)
{
_memory.AddOrUpdate(key, entry, (existingKey, existingEntry) =>
Expand All @@ -718,6 +747,7 @@ private async Task<bool> SetInternalAsync(string key, CacheEntry entry, bool add
{
_logger.LogTrace("Attempting to replacing expired cache key: {Key}", existingKey);

oldEntry = existingEntry;
wasUpdated = true;
return entry;
}
Expand All @@ -730,10 +760,25 @@ private async Task<bool> SetInternalAsync(string key, CacheEntry entry, bool add
}
else
{
_memory.AddOrUpdate(key, entry, (_, _) => entry);
_memory.AddOrUpdate(key, entry, (_, existingEntry) =>
{
oldEntry = existingEntry;
return entry;
});
_logger.LogTrace("Set cache key: {Key}", key);
}

// Update memory size tracking
if (_maxMemorySize.HasValue)
{
long sizeDelta = entry.EstimatedSize;
if (oldEntry != null)
sizeDelta -= oldEntry.EstimatedSize;

if (wasUpdated && sizeDelta != 0)
Interlocked.Add(ref _currentMemorySize, sizeDelta);
}

await StartMaintenanceAsync(ShouldCompact).AnyContext();
return wasUpdated;
}
Expand Down Expand Up @@ -992,7 +1037,7 @@ private async Task StartMaintenanceAsync(bool compactImmediately = false)
}
}

private bool ShouldCompact => _maxItems.HasValue && _memory.Count > _maxItems;
private bool ShouldCompact => (_maxItems.HasValue && _memory.Count > _maxItems) || (_maxMemorySize.HasValue && _currentMemorySize > _maxMemorySize);

private async Task CompactAsync()
{
Expand All @@ -1004,7 +1049,11 @@ private async Task CompactAsync()
string expiredKey = null;
using (await _lock.LockAsync().AnyContext())
{
if (_memory.Count <= _maxItems)
// Check if we still need compaction after acquiring the lock
bool needsItemCompaction = _maxItems.HasValue && _memory.Count > _maxItems;
bool needsMemoryCompaction = _maxMemorySize.HasValue && _currentMemorySize > _maxMemorySize;

if (!needsItemCompaction && !needsMemoryCompaction)
return;

(string Key, long LastAccessTicks, long InstanceNumber) oldest = (null, Int64.MaxValue, 0);
Expand All @@ -1023,10 +1072,18 @@ private async Task CompactAsync()
if (oldest.Key is null)
return;

_logger.LogDebug("Removing cache entry {Key} due to cache exceeding max item count limit", oldest);
_memory.TryRemove(oldest.Key, out var cacheEntry);
if (cacheEntry is { IsExpired: true })
expiredKey = oldest.Key;
_logger.LogDebug("Removing cache entry {Key} due to cache exceeding limit (Items: {ItemCount}/{MaxItems}, Memory: {MemorySize:N0}/{MaxMemorySize:N0})",
oldest.Key, _memory.Count, _maxItems, _currentMemorySize, _maxMemorySize);

if (_memory.TryRemove(oldest.Key, out var cacheEntry))
{
// Update memory size tracking
if (_maxMemorySize.HasValue && cacheEntry != null)
Interlocked.Add(ref _currentMemorySize, -cacheEntry.EstimatedSize);

if (cacheEntry is { IsExpired: true })
expiredKey = oldest.Key;
}
}

if (expiredKey != null)
Expand Down Expand Up @@ -1081,6 +1138,7 @@ private sealed record CacheEntry
private static long _instanceCount;
private readonly bool _shouldClone;
private readonly TimeProvider _timeProvider;
private long? _estimatedSize;
#if DEBUG
private long _usageCount;
#endif
Expand All @@ -1100,6 +1158,7 @@ public CacheEntry(object value, DateTime? expiresAt, TimeProvider timeProvider,
internal bool IsExpired => ExpiresAt.HasValue && ExpiresAt < _timeProvider.GetUtcNow().UtcDateTime;
internal long LastAccessTicks { get; private set; }
internal long LastModifiedTicks { get; private set; }
internal long EstimatedSize => _estimatedSize ??= CalculateEstimatedSize(_cacheValue);
#if DEBUG
internal long UsageCount => _usageCount;
#endif
Expand All @@ -1117,6 +1176,7 @@ internal object Value
set
{
_cacheValue = _shouldClone ? value.DeepClone() : value;
_estimatedSize = null; // Reset cached size calculation

var utcNow = _timeProvider.GetUtcNow();
LastAccessTicks = utcNow.Ticks;
Expand Down Expand Up @@ -1154,6 +1214,71 @@ private bool TypeRequiresCloning(Type t)

return !t.GetTypeInfo().IsValueType;
}

private static long CalculateEstimatedSize(object value)
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this how size is calculated in the .NET MemoryCache?

Copy link
Member

Choose a reason for hiding this comment

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

No idea, I need to look. Was going to look before I implemented it, figured I'd let copilot try.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Our approach differs from .NET MemoryCache. Microsoft's MemoryCache requires developers to manually specify size when adding items, while we automatically calculate estimates. .NET recommends rough estimates like strings = 64 + length, objects = 64 + fields. Our implementation is more granular: strings = 24 + (length * 2) for UTF-16, exact primitive sizes, and detailed object field counting for better accuracy.

{
if (value == null)
return 8; // Reference size

var type = value.GetType();

// Handle strings specifically
if (value is string str)
return 24 + (str.Length * 2); // Object overhead + UTF-16 chars

// Handle primitives
if (type == TypeHelper.BoolType) return 1;
if (type == TypeHelper.CharType) return 2;
if (type == typeof(byte)) return 1;
if (type == typeof(sbyte)) return 1;
if (type == typeof(short)) return 2;
if (type == typeof(ushort)) return 2;
if (type == typeof(int)) return 4;
if (type == typeof(uint)) return 4;
if (type == typeof(long)) return 8;
if (type == typeof(ulong)) return 8;
if (type == typeof(float)) return 4;
if (type == typeof(double)) return 8;
if (type == typeof(decimal)) return 16;
if (type == TypeHelper.DateTimeType) return 8;
if (type == typeof(TimeSpan)) return 8;
if (type == typeof(Guid)) return 16;

// Handle nullable types
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
{
var underlyingType = Nullable.GetUnderlyingType(type);
return CalculateEstimatedSize(Activator.CreateInstance(underlyingType)) + 1; // Add 1 for hasValue flag
Copy link
Contributor

Choose a reason for hiding this comment

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

This concerns me. Not all types can be constructed. Should probably also memoize the size of a specific type so we don't have to keep creating instances of it to see it's size.

Copy link
Member

Choose a reason for hiding this comment

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

I agree

Copy link
Member

Choose a reason for hiding this comment

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

@copilot Not all types can be constructed. Should probably also memoize the size of a specific type so we don't have to keep creating instances of it to see it's size.

Also look at how the Microsoft MemoryCache handles size for a best practice approach

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 3be2315. Removed the unsafe Activator.CreateInstance() call and replaced it with a memoized GetTypeSize() method that caches reflection-based size calculations. Nullable types now use cached type sizes instead of creating instances, eliminating the risk of construction failures.

}

// Handle collections
if (value is System.Collections.IEnumerable enumerable && !(value is string))
{
long size = 32; // Collection overhead
int count = 0;
long itemSizeSum = 0;

foreach (var item in enumerable)
{
count++;
itemSizeSum += CalculateEstimatedSize(item);
if (count > 100) // Limit sampling for performance
{
itemSizeSum = (itemSizeSum / count) * 100; // Estimate based on sample
break;
}
}

return size + itemSizeSum + (count * 8); // Collection overhead + items + reference overhead
}

// For complex objects, use a rough estimate based on field count
var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be memoized as well. Basically, we want to get an estimated size for an object type once and not keep doing this reflection.

Copy link
Member

Choose a reason for hiding this comment

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

@copilot Basically, we want to get an estimated size for an object type once and not keep doing this reflection.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 3be2315. Added _typeSizeCache static dictionary to memoize reflection-based size calculations. The expensive GetFields() and GetProperties() calls are now cached per type, improving performance for repeated size calculations of the same types.


// Basic object overhead + estimated field/property sizes
return 24 + ((fields.Length + properties.Length) * 8);
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/Foundatio/Caching/InMemoryCacheClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ public class InMemoryCacheClientOptions : SharedOptions
/// </summary>
public int? MaxItems { get; set; } = 10000;

/// <summary>
/// The maximum memory size in bytes that the cache can consume. If null, no memory limit is applied.
/// </summary>
public long? MaxMemorySize { get; set; }

/// <summary>
/// Whether or not values should be cloned during get and set to make sure that any cache entry changes are isolated
/// </summary>
Expand All @@ -26,6 +31,12 @@ public InMemoryCacheClientOptionsBuilder MaxItems(int? maxItems)
return this;
}

public InMemoryCacheClientOptionsBuilder MaxMemorySize(long? maxMemorySize)
{
Target.MaxMemorySize = maxMemorySize;
return this;
}

public InMemoryCacheClientOptionsBuilder CloneValues(bool cloneValues)
{
Target.CloneValues = cloneValues;
Expand Down
Loading