diff --git a/README.md b/README.md index 34fdb0e7..644a6863 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ The sections below contain a small subset of what's possible with Foundatio. We Caching allows you to store and access data lightning fast, saving you exspensive operations to create or get data. We provide four different cache implementations that derive from the [`ICacheClient` interface](https://github.com/FoundatioFx/Foundatio/blob/master/src/Foundatio/Caching/ICacheClient.cs): -1. [InMemoryCacheClient](https://github.com/FoundatioFx/Foundatio/blob/master/src/Foundatio/Caching/InMemoryCacheClient.cs): An in memory cache client implementation. This cache implementation is only valid for the lifetime of the process. It's worth noting that the in memory cache client has the ability to cache the last X items via the `MaxItems` property. We use this in [Exceptionless](https://github.com/exceptionless/Exceptionless) to only [keep the last 250 resolved geoip results](https://github.com/exceptionless/Exceptionless/blob/master/src/Exceptionless.Core/Geo/MaxMindGeoIpService.cs). +1. [InMemoryCacheClient](https://github.com/FoundatioFx/Foundatio/blob/master/src/Foundatio/Caching/InMemoryCacheClient.cs): An in memory cache client implementation. This cache implementation is only valid for the lifetime of the process. It's worth noting that the in memory cache client has the ability to cache the last X items via the `MaxItems` property or limit cache size via `MaxMemorySize` (in bytes) with intelligent size-aware eviction. We use this in [Exceptionless](https://github.com/exceptionless/Exceptionless) to only [keep the last 250 resolved geoip results](https://github.com/exceptionless/Exceptionless/blob/master/src/Exceptionless.Core/Geo/MaxMindGeoIpService.cs). 2. [HybridCacheClient](https://github.com/FoundatioFx/Foundatio/blob/master/src/Foundatio/Caching/HybridCacheClient.cs): This cache implementation uses both an `ICacheClient` and the `InMemoryCacheClient` and uses an `IMessageBus` to keep the cache in sync across processes. This can lead to **huge wins in performance** as you are saving a serialization operation and a call to the remote cache if the item exists in the local cache. 3. [RedisCacheClient](https://github.com/FoundatioFx/Foundatio.Redis/blob/master/src/Foundatio.Redis/Cache/RedisCacheClient.cs): A Redis cache client implementation. 4. [RedisHybridCacheClient](https://github.com/FoundatioFx/Foundatio.Redis/blob/master/src/Foundatio.Redis/Cache/RedisHybridCacheClient.cs): An implementation of `HybridCacheClient` that uses the `RedisCacheClient` as `ICacheClient` and the `RedisMessageBus` as `IMessageBus`. @@ -75,6 +75,28 @@ await cache.SetAsync("test", 1); var value = await cache.GetAsync("test"); ``` +#### Memory-Limited Cache + +The `InMemoryCacheClient` supports memory-based eviction with intelligent size-aware cleanup. When the cache exceeds the memory limit, it evicts items based on a combination of size, age, and access recency. + +```csharp +using Foundatio.Caching; + +// Use dynamic sizing for automatic size calculation (recommended for mixed object types) +var cache = new InMemoryCacheClient(o => o + .WithDynamicSizing(maxMemorySize: 100 * 1024 * 1024) // 100 MB limit + .MaxItems(10000)); // Optional: also limit by item count + +// Use fixed sizing for maximum performance (when objects are uniform) +var fixedSizeCache = new InMemoryCacheClient(o => o + .WithFixedSizing( + maxMemorySize: 50 * 1024 * 1024, // 50 MB limit + averageObjectSize: 1024)); // Assume 1KB per object + +// Check current memory usage +Console.WriteLine($"Memory: {cache.CurrentMemorySize:N0} / {cache.MaxMemorySize:N0} bytes"); +``` + ### [Queues](https://github.com/FoundatioFx/Foundatio/tree/master/src/Foundatio/Queues) Queues offer First In, First Out (FIFO) message delivery. We provide four different queue implementations that derive from the [`IQueue` interface](https://github.com/FoundatioFx/Foundatio/blob/master/src/Foundatio/Queues/IQueue.cs): diff --git a/src/Foundatio/Caching/HybridCacheClient.cs b/src/Foundatio/Caching/HybridCacheClient.cs index afbf7bbb..be59d426 100644 --- a/src/Foundatio/Caching/HybridCacheClient.cs +++ b/src/Foundatio/Caching/HybridCacheClient.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index 83f30bb8..8e52f088 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -21,9 +21,13 @@ public class InMemoryCacheClient : IMemoryCacheClient, IHaveTimeProvider, IHaveL private readonly bool _shouldClone; private readonly bool _shouldThrowOnSerializationErrors; private readonly int? _maxItems; + private readonly long? _maxMemorySize; + private Func _objectSizeCalculator; + private readonly long? _maxObjectSize; private long _writes; private long _hits; private long _misses; + private long _currentMemorySize; private readonly TimeProvider _timeProvider; private readonly IResiliencePolicyProvider _resiliencePolicyProvider; private readonly ILogger _logger; @@ -41,10 +45,19 @@ public InMemoryCacheClient(InMemoryCacheClientOptions options = null) _shouldClone = options.CloneValues; _shouldThrowOnSerializationErrors = options.ShouldThrowOnSerializationError; _maxItems = options.MaxItems; + _maxMemorySize = options.MaxMemorySize; + _maxObjectSize = options.MaxObjectSize; _timeProvider = options.TimeProvider ?? TimeProvider.System; _resiliencePolicyProvider = options.ResiliencePolicyProvider; _loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance; _logger = _loggerFactory.CreateLogger(); + + // Validate that MaxMemorySize requires an ObjectSizeCalculator + if (options.MaxMemorySize.HasValue && options.ObjectSizeCalculator == null) + throw new InvalidOperationException($"{nameof(options.MaxMemorySize)} requires an {nameof(options.ObjectSizeCalculator)}. Use UseObjectSizer() or UseFixedObjectSize() builder methods."); + + _objectSizeCalculator = options.ObjectSizeCalculator; + _memory = new ConcurrentDictionary(); } @@ -55,6 +68,69 @@ public InMemoryCacheClient(Builder _memory.Count(i => !i.Value.IsExpired); public int? MaxItems => _maxItems; + public long? MaxMemorySize => _maxMemorySize; + public long CurrentMemorySize => _currentMemorySize; + + /// + /// Safely updates the current memory size, ensuring it never goes negative and handles overflow. + /// + private void UpdateMemorySize(long delta) + { + if (!_maxMemorySize.HasValue) return; + + long currentValue; + long newValue; + + if (delta < 0) + { + // For negative deltas (removals), ensure we don't go below zero + do + { + currentValue = _currentMemorySize; + newValue = Math.Max(0, currentValue + delta); + } while (Interlocked.CompareExchange(ref _currentMemorySize, newValue, currentValue) != currentValue); + } + else + { + // For positive deltas (additions), check for overflow and clamp to long.MaxValue + do + { + currentValue = _currentMemorySize; + // Check if addition would overflow + if (currentValue > long.MaxValue - delta) + { + newValue = long.MaxValue; + _logger.LogWarning("Memory size counter would overflow. Clamping to {MaxValue}. Current={Current}, Delta={Delta}", + long.MaxValue, currentValue, delta); + } + else + { + newValue = currentValue + delta; + } + } while (Interlocked.CompareExchange(ref _currentMemorySize, newValue, currentValue) != currentValue); + } + + _logger.LogTrace("UpdateMemorySize: Delta={Delta}, Before={Before}, After={After}, Max={Max}", + delta, currentValue, newValue, _maxMemorySize); + } + + /// + /// Recalculates the current memory size by summing all cache entries. + /// + private long RecalculateMemorySize() + { + if (!_maxMemorySize.HasValue) return 0; + + long totalSize = 0; + foreach (var entry in _memory.Values) + { + totalSize += entry.EstimatedSize; + } + + Interlocked.Exchange(ref _currentMemorySize, totalSize); + return totalSize; + } + public long Calls => _writes + _hits + _misses; public long Writes => _writes; public long Reads => _hits + _misses; @@ -68,7 +144,10 @@ public InMemoryCacheClient(Builder RemoveAsync(string key) ArgumentException.ThrowIfNullOrEmpty(key); _logger.LogTrace("RemoveAsync: Removing key: {Key}", key); - if (!_memory.TryRemove(key, out var entry)) + { return Task.FromResult(false); + } + + // Key was found and removed. Update memory size. + UpdateMemorySize(-entry.EstimatedSize); // Return false if the entry was expired (consistent with Redis behavior) return Task.FromResult(!entry.IsExpired); @@ -171,6 +254,8 @@ public Task RemoveAllAsync(IEnumerable keys = null) { int count = _memory.Count; _memory.Clear(); + if (_maxMemorySize.HasValue) + Interlocked.Exchange(ref _currentMemorySize, 0); return Task.FromResult(count); } @@ -180,8 +265,12 @@ public Task RemoveAllAsync(IEnumerable keys = null) ArgumentException.ThrowIfNullOrEmpty(key, nameof(keys)); _logger.LogTrace("RemoveAllAsync: Removing key: {Key}", key); - if (_memory.TryRemove(key, out _)) + if (_memory.TryRemove(key, out var removedEntry)) + { removed++; + if (removedEntry != null) + UpdateMemorySize(-removedEntry.EstimatedSize); + } } return Task.FromResult(removed); @@ -220,8 +309,11 @@ internal long RemoveExpiredKey(string key, bool sendNotification = true) ArgumentException.ThrowIfNullOrEmpty(key); _logger.LogTrace("Removing expired key: {Key}", key); - if (_memory.TryRemove(key, out _)) + if (_memory.TryRemove(key, out var removedEntry)) { + // Update memory size tracking + UpdateMemorySize(-removedEntry.EstimatedSize); + OnItemExpired(key, sendNotification); return 1; } @@ -243,6 +335,9 @@ private long RemoveKeyIfExpired(string key, bool sendNotification = true) if (!removedEntry.IsExpired) throw new Exception("Removed item was not expired"); + // Update memory size tracking + UpdateMemorySize(-removedEntry.EstimatedSize); + _logger.LogDebug("Removing expired cache entry {Key}", key); OnItemExpired(key, sendNotification); return 1; @@ -303,7 +398,7 @@ public Task AddAsync(string key, T value, TimeSpan? expiresIn = null) ArgumentException.ThrowIfNullOrEmpty(key); DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - return SetInternalAsync(key, new CacheEntry(value, expiresAt, _timeProvider, _shouldClone), true); + return SetInternalAsync(key, new CacheEntry(value, expiresAt, _timeProvider, this, _shouldClone), true); } public Task SetAsync(string key, T value, TimeSpan? expiresIn = null) @@ -311,7 +406,7 @@ public Task SetAsync(string key, T value, TimeSpan? expiresIn = null) ArgumentException.ThrowIfNullOrEmpty(key); DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - return SetInternalAsync(key, new CacheEntry(value, expiresAt, _timeProvider, _shouldClone)); + return SetInternalAsync(key, new CacheEntry(value, expiresAt, _timeProvider, this, _shouldClone)); } public async Task SetIfHigherAsync(string key, double value, TimeSpan? expiresIn = null) @@ -327,9 +422,19 @@ public async Task SetIfHigherAsync(string key, double value, TimeSpan? e Interlocked.Increment(ref _writes); double difference = value; + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - _memory.AddOrUpdate(key, new CacheEntry(value, expiresAt, _timeProvider, _shouldClone), (_, existingEntry) => + var newEntry = new CacheEntry(value, expiresAt, _timeProvider, this, _shouldClone); + _memory.AddOrUpdate(key, _ => { + wasNewEntry = true; + newSize = newEntry.EstimatedSize; + return newEntry; + }, (_, existingEntry) => + { + oldSize = existingEntry.EstimatedSize; double? currentValue = null; try { @@ -353,9 +458,17 @@ public async Task SetIfHigherAsync(string key, double value, TimeSpan? e if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return difference; @@ -374,9 +487,19 @@ public async Task SetIfHigherAsync(string key, long value, TimeSpan? expir Interlocked.Increment(ref _writes); long difference = value; + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - _memory.AddOrUpdate(key, new CacheEntry(value, expiresAt, _timeProvider, _shouldClone), (_, existingEntry) => + var newEntry = new CacheEntry(value, expiresAt, _timeProvider, this, _shouldClone); + _memory.AddOrUpdate(key, _ => + { + wasNewEntry = true; + newSize = newEntry.EstimatedSize; + return newEntry; + }, (_, existingEntry) => { + oldSize = existingEntry.EstimatedSize; long? currentValue = null; try { @@ -400,9 +523,17 @@ public async Task SetIfHigherAsync(string key, long value, TimeSpan? expir if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return difference; @@ -421,9 +552,19 @@ public async Task SetIfLowerAsync(string key, double value, TimeSpan? ex Interlocked.Increment(ref _writes); double difference = value; + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - _memory.AddOrUpdate(key, new CacheEntry(value, expiresAt, _timeProvider, _shouldClone), (_, existingEntry) => + var newEntry = new CacheEntry(value, expiresAt, _timeProvider, this, _shouldClone); + _memory.AddOrUpdate(key, _ => + { + wasNewEntry = true; + newSize = newEntry.EstimatedSize; + return newEntry; + }, (_, existingEntry) => { + oldSize = existingEntry.EstimatedSize; double? currentValue = null; try { @@ -447,9 +588,17 @@ public async Task SetIfLowerAsync(string key, double value, TimeSpan? ex if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return difference; @@ -465,10 +614,22 @@ public async Task SetIfLowerAsync(string key, long value, TimeSpan? expire return -1; } + Interlocked.Increment(ref _writes); + long difference = value; + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - _memory.AddOrUpdate(key, new CacheEntry(value, expiresAt, _timeProvider, _shouldClone), (_, existingEntry) => + var newEntry = new CacheEntry(value, expiresAt, _timeProvider, this, _shouldClone); + _memory.AddOrUpdate(key, _ => { + wasNewEntry = true; + newSize = newEntry.EstimatedSize; + return newEntry; + }, (_, existingEntry) => + { + oldSize = existingEntry.EstimatedSize; long? currentValue = null; try { @@ -492,9 +653,17 @@ public async Task SetIfLowerAsync(string key, long value, TimeSpan? expire if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return difference; @@ -515,6 +684,10 @@ public async Task ListAddAsync(string key, IEnumerable values, TimeS Interlocked.Increment(ref _writes); + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; + if (values is string stringValue) { var items = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -522,21 +695,36 @@ public async Task ListAddAsync(string key, IEnumerable values, TimeS { stringValue, expiresAt } }; - var entry = new CacheEntry(items, expiresAt, _timeProvider, _shouldClone); - _memory.AddOrUpdate(key, entry, (existingKey, existingEntry) => + var entry = new CacheEntry(items, expiresAt, _timeProvider, this, _shouldClone); + _memory.AddOrUpdate(key, k => + { + wasNewEntry = true; + newSize = entry.EstimatedSize; + return entry; + }, (existingKey, existingEntry) => { if (existingEntry.Value is not IDictionary dictionary) throw new InvalidOperationException($"Unable to add value for key: {existingKey}. Cache value does not contain a dictionary"); + oldSize = existingEntry.EstimatedSize; + ExpireListValues(dictionary, existingKey); dictionary[stringValue] = expiresAt; existingEntry.Value = dictionary; existingEntry.ExpiresAt = dictionary.Values.Contains(null) ? null : dictionary.Values.Max(); + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return items.Count; } @@ -546,12 +734,19 @@ public async Task ListAddAsync(string key, IEnumerable values, TimeS if (items.Count == 0) return 0; - var entry = new CacheEntry(items, expiresAt, _timeProvider, _shouldClone); - _memory.AddOrUpdate(key, entry, (existingKey, existingEntry) => + var entry = new CacheEntry(items, expiresAt, _timeProvider, this, _shouldClone); + _memory.AddOrUpdate(key, k => + { + wasNewEntry = true; + newSize = entry.EstimatedSize; + return entry; + }, (existingKey, existingEntry) => { if (existingEntry.Value is not IDictionary dictionary) throw new InvalidOperationException($"Unable to add value for key: {existingKey}. Cache value does not contain a set"); + oldSize = existingEntry.EstimatedSize; + ExpireListValues(dictionary, existingKey); foreach (var kvp in items) @@ -559,9 +754,18 @@ public async Task ListAddAsync(string key, IEnumerable values, TimeS existingEntry.Value = dictionary; existingEntry.ExpiresAt = dictionary.Values.Contains(null) ? null : dictionary.Values.Max(); + + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return items.Count; } @@ -586,6 +790,9 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan Interlocked.Increment(ref _writes); long removed = 0; + long oldSize = 0; + long newSize = 0; + if (values is string stringValue) { var items = new HashSet([stringValue]); @@ -593,6 +800,8 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan { if (existingEntry.Value is IDictionary { Count: > 0 } dictionary) { + oldSize = existingEntry.EstimatedSize; + int expired = ExpireListValues(dictionary, existingKey); foreach (string value in items) @@ -608,6 +817,12 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan existingEntry.ExpiresAt = dictionary.Values.Contains(null) ? null : dictionary.Values.Max(); else existingEntry.ExpiresAt = DateTime.MinValue; + + newSize = existingEntry.EstimatedSize; + } + else + { + newSize = oldSize; } } @@ -617,6 +832,9 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan return existingEntry; }); + if (_maxMemorySize.HasValue && oldSize != newSize) + UpdateMemorySize(newSize - oldSize); + return Task.FromResult(removed); } else @@ -629,6 +847,8 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan { if (existingEntry.Value is IDictionary { Count: > 0 } dictionary) { + oldSize = existingEntry.EstimatedSize; + int expired = ExpireListValues(dictionary, existingKey); foreach (var value in items) @@ -644,6 +864,12 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan existingEntry.ExpiresAt = dictionary.Values.Contains(null) ? null : dictionary.Values.Max(); else existingEntry.ExpiresAt = DateTime.MinValue; + + newSize = existingEntry.EstimatedSize; + } + else + { + newSize = oldSize; } } @@ -653,6 +879,9 @@ public Task ListRemoveAsync(string key, IEnumerable values, TimeSpan return existingEntry; }); + if (_maxMemorySize.HasValue && oldSize != newSize) + UpdateMemorySize(newSize - oldSize); + return Task.FromResult(removed); } } @@ -695,6 +924,8 @@ private async Task 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) => @@ -707,6 +938,7 @@ private async Task SetInternalAsync(string key, CacheEntry entry, bool add { _logger.LogTrace("Attempting to replacing expired cache key: {Key}", existingKey); + oldEntry = existingEntry; wasUpdated = true; return entry; } @@ -719,10 +951,26 @@ private async Task 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) + UpdateMemorySize(sizeDelta); + } + + // Check if compaction is needed AFTER memory size update await StartMaintenanceAsync(ShouldCompact).AnyContext(); return wasUpdated; } @@ -784,13 +1032,17 @@ public async Task ReplaceIfEqualAsync(string key, T value, T expected, DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; bool wasExpectedValue = false; + long oldSize = 0; + long newSize = 0; bool success = _memory.TryUpdate(key, (_, existingEntry) => { var currentValue = existingEntry.GetValue(); if (currentValue.Equals(expected)) { + oldSize = existingEntry.EstimatedSize; existingEntry.Value = value; wasExpectedValue = true; + newSize = existingEntry.EstimatedSize; if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; @@ -800,6 +1052,11 @@ public async Task ReplaceIfEqualAsync(string key, T value, T expected, }); success = success && wasExpectedValue; + + // Update memory size tracking if the value was replaced + if (_maxMemorySize.HasValue && wasExpectedValue && oldSize != newSize) + UpdateMemorySize(newSize - oldSize); + await StartMaintenanceAsync().AnyContext(); _logger.LogTrace("ReplaceIfEqualAsync Key: {Key} Expected: {Expected} Success: {Success}", key, expected, success); @@ -819,9 +1076,19 @@ public async Task IncrementAsync(string key, double amount, TimeSpan? ex Interlocked.Increment(ref _writes); + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - var result = _memory.AddOrUpdate(key, new CacheEntry(amount, expiresAt, _timeProvider, _shouldClone), (_, existingEntry) => + var newEntry = new CacheEntry(amount, expiresAt, _timeProvider, this, _shouldClone); + var result = _memory.AddOrUpdate(key, _ => { + wasNewEntry = true; + newSize = newEntry.EstimatedSize; + return newEntry; + }, (_, existingEntry) => + { + oldSize = existingEntry.EstimatedSize; double? currentValue = null; try { @@ -840,9 +1107,17 @@ public async Task IncrementAsync(string key, double amount, TimeSpan? ex if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return result.GetValue(); @@ -860,9 +1135,19 @@ public async Task IncrementAsync(string key, long amount, TimeSpan? expire Interlocked.Increment(ref _writes); + long oldSize = 0; + long newSize = 0; + bool wasNewEntry = false; DateTime? expiresAt = expiresIn.HasValue ? _timeProvider.GetUtcNow().UtcDateTime.SafeAdd(expiresIn.Value) : null; - var result = _memory.AddOrUpdate(key, new CacheEntry(amount, expiresAt, _timeProvider, _shouldClone), (_, existingEntry) => + var newEntry = new CacheEntry(amount, expiresAt, _timeProvider, this, _shouldClone); + var result = _memory.AddOrUpdate(key, _ => + { + wasNewEntry = true; + newSize = newEntry.EstimatedSize; + return newEntry; + }, (_, existingEntry) => { + oldSize = existingEntry.EstimatedSize; long? currentValue = null; try { @@ -881,9 +1166,17 @@ public async Task IncrementAsync(string key, long amount, TimeSpan? expire if (expiresIn.HasValue) existingEntry.ExpiresAt = expiresAt; + newSize = existingEntry.EstimatedSize; return existingEntry; }); + if (_maxMemorySize.HasValue) + { + long sizeDelta = wasNewEntry ? newSize : (newSize - oldSize); + if (sizeDelta != 0) + UpdateMemorySize(sizeDelta); + } + await StartMaintenanceAsync().AnyContext(); return result.GetValue(); @@ -1037,6 +1330,8 @@ public async Task SetAllExpirationAsync(IDictionary expiratio private async Task StartMaintenanceAsync(bool compactImmediately = false) { + _logger.LogTrace("StartMaintenanceAsync called with compactImmediately={CompactImmediately}", compactImmediately); + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; if (compactImmediately) await CompactAsync().AnyContext(); @@ -1048,47 +1343,128 @@ 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() { + _logger.LogTrace("CompactAsync called. ShouldCompact={ShouldCompact}, CurrentMemory={CurrentMemory}, MaxMemory={MaxMemory}, Count={Count}", + ShouldCompact, _currentMemorySize, _maxMemorySize, _memory.Count); + if (!ShouldCompact) return; _logger.LogTrace("CompactAsync: Compacting cache"); - string expiredKey = null; + var expiredKeys = new List(); using (await _lock.LockAsync().AnyContext()) { - if (_memory.Count <= _maxItems) - return; + int removalCount = 0; + const int maxRemovals = 10; // Safety limit to prevent infinite loops - (string Key, long LastAccessTicks, long InstanceNumber) oldest = (null, Int64.MaxValue, 0); - foreach (var kvp in _memory) + while (ShouldCompact && removalCount < maxRemovals) { - bool isExpired = kvp.Value.IsExpired; - if (isExpired || - kvp.Value.LastAccessTicks < oldest.LastAccessTicks || - (kvp.Value.LastAccessTicks == oldest.LastAccessTicks && kvp.Value.InstanceNumber < oldest.InstanceNumber)) - oldest = (kvp.Key, kvp.Value.LastAccessTicks, kvp.Value.InstanceNumber); + // Check if we still need compaction + bool needsItemCompaction = _maxItems.HasValue && _memory.Count > _maxItems; + bool needsMemoryCompaction = _maxMemorySize.HasValue && _currentMemorySize > _maxMemorySize; - if (isExpired) + if (!needsItemCompaction && !needsMemoryCompaction) break; - } - if (oldest.Key is null) - return; + // For memory compaction, prefer size-aware eviction + // For item compaction, prefer traditional LRU + string keyToRemove = needsMemoryCompaction ? FindWorstSizeToUsageRatio() : FindLeastRecentlyUsed(); + + if (keyToRemove == null) + break; + + _logger.LogDebug("Removing cache entry {Key} due to cache exceeding limit (Items: {ItemCount}/{MaxItems}, Memory: {MemorySize:N0}/{MaxMemorySize:N0})", + keyToRemove, _memory.Count, _maxItems, _currentMemorySize, _maxMemorySize); + + if (_memory.TryRemove(keyToRemove, out var cacheEntry)) + { + // Update memory size tracking + if (cacheEntry != null) + UpdateMemorySize(-cacheEntry.EstimatedSize); + + if (cacheEntry is { IsExpired: true }) + expiredKeys.Add(keyToRemove); - _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; + removalCount++; + } + else + { + // Couldn't remove the item, break to prevent infinite loop + break; + } + } } - if (expiredKey != null) + // Notify about expired items + foreach (var expiredKey in expiredKeys) OnItemExpired(expiredKey); } + private string FindLeastRecentlyUsed() + { + (string Key, long LastAccessTicks, long InstanceNumber) oldest = (null, Int64.MaxValue, 0); + + foreach (var kvp in _memory) + { + bool isExpired = kvp.Value.IsExpired; + if (isExpired || + kvp.Value.LastAccessTicks < oldest.LastAccessTicks || + (kvp.Value.LastAccessTicks == oldest.LastAccessTicks && kvp.Value.InstanceNumber < oldest.InstanceNumber)) + oldest = (kvp.Key, kvp.Value.LastAccessTicks, kvp.Value.InstanceNumber); + + if (isExpired) + break; + } + + return oldest.Key; + } + + private string FindWorstSizeToUsageRatio() + { + string candidateKey = null; + double worstRatio = double.MinValue; // Start with minimum value so any score can win + var currentTime = _timeProvider.GetUtcNow().Ticks; + + _logger.LogTrace("FindWorstSizeToUsageRatio: Checking {Count} entries", _memory.Count); + + foreach (var kvp in _memory) + { + // Prioritize expired items first + if (kvp.Value.IsExpired) + return kvp.Key; + + // Calculate a "waste score" based on size vs recent usage + var size = kvp.Value.EstimatedSize; + var timeSinceLastAccess = currentTime - kvp.Value.LastAccessTicks; + var timeSinceCreation = currentTime - kvp.Value.LastModifiedTicks; + + // Avoid division by zero and give preference to older, larger, less-accessed items + var accessRecency = Math.Max(1, TimeSpan.FromTicks(timeSinceLastAccess).TotalMinutes); + var ageInMinutes = Math.Max(1, TimeSpan.FromTicks(timeSinceCreation).TotalMinutes); + + // Calculate waste score: larger size + older age + less recent access = higher score (worse) + // Normalize to prevent overflow and give reasonable weighting + var sizeWeight = Math.Log10(Math.Max(1, size / 1024.0)); // Log scale for size in KB + var ageWeight = Math.Log10(ageInMinutes); + var accessWeight = Math.Log10(accessRecency); + + var wasteScore = sizeWeight + (ageWeight * 0.5) + (accessWeight * 2.0); // Access recency weighted more heavily + + if (wasteScore > worstRatio) + { + worstRatio = wasteScore; + candidateKey = kvp.Key; + } + } + + _logger.LogTrace("FindWorstSizeToUsageRatio: Selected {Key} with score {Score}", candidateKey, worstRatio); + return candidateKey; + } + private async Task DoMaintenanceAsync() { _logger.LogTrace("DoMaintenance: Starting"); @@ -1122,8 +1498,14 @@ private async Task DoMaintenanceAsync() } if (ShouldCompact) + { await CompactAsync().AnyContext(); + // Recalculate memory size after compaction to correct any drift + if (_maxMemorySize.HasValue) + RecalculateMemorySize(); + } + _logger.LogTrace("DoMaintenance: Finished"); } @@ -1131,6 +1513,7 @@ public virtual void Dispose() { _memory.Clear(); ItemExpired?.Dispose(); + _objectSizeCalculator = null; // Allow GC to collect any captured ObjectSizer } private sealed record CacheEntry @@ -1139,14 +1522,17 @@ private sealed record CacheEntry private static long _instanceCount; private readonly bool _shouldClone; private readonly TimeProvider _timeProvider; + private readonly InMemoryCacheClient _cacheClient; + private long? _estimatedSize; #if DEBUG private long _usageCount; #endif - public CacheEntry(object value, DateTime? expiresAt, TimeProvider timeProvider, bool shouldClone = true) + public CacheEntry(object value, DateTime? expiresAt, TimeProvider timeProvider, InMemoryCacheClient cacheClient, bool shouldClone = true) { _timeProvider = timeProvider; - _shouldClone = shouldClone && TypeRequiresCloning(value?.GetType()); + _cacheClient = cacheClient; + _shouldClone = shouldClone && InMemoryCacheClient.TypeRequiresCloning(value?.GetType()); Value = value; ExpiresAt = expiresAt; LastModifiedTicks = _timeProvider.GetUtcNow().Ticks; @@ -1158,6 +1544,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 ??= _cacheClient.CalculateObjectSize(_cacheValue); #if DEBUG internal long UsageCount => _usageCount; #endif @@ -1175,6 +1562,7 @@ internal object Value set { _cacheValue = _shouldClone ? value.DeepClone() : value; + _estimatedSize = null; // Reset cached size calculation var utcNow = _timeProvider.GetUtcNow(); LastAccessTicks = utcNow.Ticks; @@ -1213,6 +1601,57 @@ private bool TypeRequiresCloning(Type t) return !t.GetTypeInfo().IsValueType; } } + + private long CalculateObjectSize(object value) + { + // Skip size calculation if no calculator is configured (opt-in behavior) + if (_objectSizeCalculator == null) + return 0; + + try + { + var size = _objectSizeCalculator(value); + + // Log warning if object exceeds maximum size + if (_maxObjectSize.HasValue && size > _maxObjectSize.Value) + { + _logger.LogWarning("Cache object size {ObjectSize:N0} bytes exceeds maximum recommended size {MaxObjectSize:N0} bytes for type {ObjectType}", + size, _maxObjectSize.Value, value?.GetType().Name ?? "null"); + } + + return size; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error calculating object size for type {ObjectType}, using fallback estimation", + value?.GetType().Name ?? "null"); + + // Fallback to simple estimation + return value switch + { + null => 8, + string str => 24 + (str.Length * 2), + _ => 64 // Default object overhead + }; + } + } + + private static bool TypeRequiresCloning(Type t) + { + if (t == null) + return true; + + if (t == TypeHelper.BoolType || + t == TypeHelper.NullableBoolType || + t == TypeHelper.StringType || + t == TypeHelper.CharType || + t == TypeHelper.NullableCharType || + t.IsNumeric() || + t.IsNullableNumeric()) + return false; + + return !t.GetTypeInfo().IsValueType; + } } public class ItemExpiredEventArgs : EventArgs diff --git a/src/Foundatio/Caching/InMemoryCacheClientOptions.cs b/src/Foundatio/Caching/InMemoryCacheClientOptions.cs index 8681008b..153595ff 100644 --- a/src/Foundatio/Caching/InMemoryCacheClientOptions.cs +++ b/src/Foundatio/Caching/InMemoryCacheClientOptions.cs @@ -1,3 +1,6 @@ +using System; +using Microsoft.Extensions.Logging; + namespace Foundatio.Caching; public class InMemoryCacheClientOptions : SharedOptions @@ -7,6 +10,26 @@ public class InMemoryCacheClientOptions : SharedOptions /// public int? MaxItems { get; set; } = 10000; + /// + /// The maximum memory size in bytes that the cache can consume. If null, no memory limit is applied. + /// + public long? MaxMemorySize { get; set; } + + /// + /// Function to calculate the size of cache objects in bytes. Required when is set. + /// + /// + /// Object sizing is opt-in for performance. When is set, an must also be provided. + /// Use for automatic size calculation using , + /// or for maximum performance with uniform object sizes. + /// + public Func ObjectSizeCalculator { get; set; } + + /// + /// The maximum size in bytes for individual cache objects. Objects larger than this will trigger a warning log. If null, no size warnings are logged. + /// + public long? MaxObjectSize { get; set; } + /// /// Whether or not values should be cloned during get and set to make sure that any cache entry changes are isolated /// @@ -26,6 +49,75 @@ public InMemoryCacheClientOptionsBuilder MaxItems(int? maxItems) return this; } + /// + /// Sets the maximum memory size in bytes. Use this with for custom size calculation. + /// For most scenarios, prefer or which set both the limit and calculator. + /// + /// The maximum memory size in bytes that the cache can consume. + public InMemoryCacheClientOptionsBuilder MaxMemorySize(long maxMemorySize) + { + Target.MaxMemorySize = maxMemorySize; + return this; + } + + /// + /// Sets a custom function to calculate the size of cache objects in bytes. + /// Must be used with to enable memory-based eviction. + /// + /// Function that returns the size of an object in bytes. + public InMemoryCacheClientOptionsBuilder ObjectSizeCalculator(Func sizeCalculator) + { + Target.ObjectSizeCalculator = sizeCalculator; + return this; + } + + /// + /// Use a fixed size for all cache objects with a memory limit. This provides maximum performance by bypassing size calculation entirely. + /// + /// The maximum memory size in bytes that the cache can consume. + /// The fixed size in bytes to use for each cache entry. + public InMemoryCacheClientOptionsBuilder WithFixedSizing(long maxMemorySize, long averageObjectSize) + { + Target.MaxMemorySize = maxMemorySize; + Target.ObjectSizeCalculator = _ => averageObjectSize; + return this; + } + + /// + /// Use the ObjectSizer to calculate object sizes dynamically with a memory limit. + /// The ObjectSizer uses fast paths for common types and falls back to JSON serialization for complex objects. + /// + /// The maximum memory size in bytes that the cache can consume. + /// Optional logger factory for diagnostic logging. + public InMemoryCacheClientOptionsBuilder WithDynamicSizing(long maxMemorySize, ILoggerFactory loggerFactory = null) + { + Target.MaxMemorySize = maxMemorySize; + var sizer = new ObjectSizer(loggerFactory); + Target.ObjectSizeCalculator = sizer.CalculateSize; + return this; + } + + /// + /// Use the ObjectSizer to calculate object sizes dynamically with a memory limit and custom type cache size. + /// The ObjectSizer uses fast paths for common types and falls back to JSON serialization for complex objects. + /// + /// The maximum memory size in bytes that the cache can consume. + /// Maximum number of types to cache size calculations for. Default is 1000. + /// Optional logger factory for diagnostic logging. + public InMemoryCacheClientOptionsBuilder WithDynamicSizing(long maxMemorySize, int maxTypeCacheSize, ILoggerFactory loggerFactory = null) + { + Target.MaxMemorySize = maxMemorySize; + var sizer = new ObjectSizer(maxTypeCacheSize, loggerFactory); + Target.ObjectSizeCalculator = sizer.CalculateSize; + return this; + } + + public InMemoryCacheClientOptionsBuilder MaxObjectSize(long? maxObjectSize) + { + Target.MaxObjectSize = maxObjectSize; + return this; + } + public InMemoryCacheClientOptionsBuilder CloneValues(bool cloneValues) { Target.CloneValues = cloneValues; diff --git a/src/Foundatio/Caching/ObjectSizer.cs b/src/Foundatio/Caching/ObjectSizer.cs new file mode 100644 index 00000000..19ac20a5 --- /dev/null +++ b/src/Foundatio/Caching/ObjectSizer.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Caching; + +/// +/// Calculates the estimated memory size of objects. Uses fast paths for common types +/// and falls back to JSON serialization for complex objects. +/// +/// +/// The type size cache is bounded to prevent unbounded memory growth. When the cache +/// reaches its limit, older entries are evicted. Call to clear +/// the cache when the sizer is no longer needed. +/// +public class ObjectSizer : IDisposable +{ + private ConcurrentDictionary _typeSizeCache; + private readonly int _maxTypeCacheSize; + private long _cacheAccessCounter; + private bool _disposed; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + MaxDepth = 64 + }; + + private readonly ILogger _logger; + + private const int DefaultMaxTypeCacheSize = 1000; + + /// + /// Creates a new ObjectSizer instance with default settings. + /// + /// Optional logger factory for diagnostic logging. + public ObjectSizer(ILoggerFactory loggerFactory) : this(DefaultMaxTypeCacheSize, loggerFactory) + { + } + + /// + /// Creates a new ObjectSizer instance. + /// + /// Maximum number of types to cache size calculations for. Default is 1000. + /// Optional logger factory for diagnostic logging. + public ObjectSizer(int maxTypeCacheSize = DefaultMaxTypeCacheSize, ILoggerFactory loggerFactory = null) + { + _maxTypeCacheSize = maxTypeCacheSize > 0 ? maxTypeCacheSize : 1000; + // Pre-size dictionary to avoid resizing; -1 uses default concurrency level + _typeSizeCache = new ConcurrentDictionary(-1, _maxTypeCacheSize); + + _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + /// + /// Gets the current number of types in the size cache. + /// + public int TypeCacheCount => _typeSizeCache?.Count ?? 0; + + /// + /// Clears the type size cache and releases resources. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _typeSizeCache?.Clear(); + _typeSizeCache = null; + } + + /// + /// Calculates the estimated memory size of an object in bytes. + /// + /// The object to calculate size for. + /// Estimated size in bytes. + /// Thrown if the sizer has been disposed. + public long CalculateSize(object value) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (value == null) + return 8; // Reference size + + var type = value.GetType(); + + // Fast paths for common types - no caching needed + switch (value) + { + case string str: + return 24 + (str.Length * 2); // Object overhead + UTF-16 chars + case bool: + return 1; + case byte: + case sbyte: + return 1; + case char: + case short: + case ushort: + return 2; + case int: + case uint: + case float: + return 4; + case long: + case ulong: + case double: + case DateTime: + case TimeSpan: + return 8; + case decimal: + case Guid: + return 16; + } + + // Handle nullable types + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + var underlyingType = Nullable.GetUnderlyingType(type); + return GetCachedTypeSize(underlyingType) + 1; // Add 1 for hasValue flag + } + + // Handle arrays efficiently + if (value is Array array) + { + long size = 24; // Array overhead + var elementType = array.GetType().GetElementType(); + var elementSize = GetCachedTypeSize(elementType); + + // For primitive arrays, we can calculate size efficiently + if (elementSize > 0 && array.Length > 0 && (elementType.IsPrimitive || elementType == typeof(string))) + { + size += array.Length * elementSize; + if (elementType == typeof(string)) + { + // For string arrays, we need to account for actual string lengths + foreach (string str in array) + { + if (str != null) + size += str.Length * 2 - elementSize; // Adjust for actual string size + } + } + return size; + } + } + + // Handle collections with sampling for large ones + if (value is IEnumerable enumerable && !(value is string)) + { + long size = 32; // Collection overhead + int count = 0; + long itemSizeSum = 0; + const int sampleLimit = 50; // Sample first 50 items for performance + + foreach (var item in enumerable) + { + count++; + itemSizeSum += CalculateSize(item); + if (count >= sampleLimit) + { + // Estimate based on sample + if (enumerable is ICollection collection) + { + var avgItemSize = itemSizeSum / count; + return size + (collection.Count * avgItemSize) + (collection.Count * 8); // refs + } + break; + } + } + + return size + itemSizeSum + (count * 8); // Collection overhead + items + reference overhead + } + + // Fall back to JSON serialization for complex objects + try + { + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value, _jsonOptions); + return jsonBytes.Length + 24; // JSON size + object overhead + } + catch (Exception ex) + { + // If JSON serialization fails, fall back to cached type size estimation + _logger.LogError(ex, "JSON serialization failed for type {TypeName}, falling back to type size estimation", type.Name); + return GetCachedTypeSize(type); + } + } + + private long GetCachedTypeSize(Type type) + { + var cache = _typeSizeCache; + if (cache == null) + return 64; // Default if disposed + + var accessOrder = Interlocked.Increment(ref _cacheAccessCounter); + + if (cache.TryGetValue(type, out var entry)) + { + // Update access time for LRU + entry.LastAccess = accessOrder; + return entry.Size; + } + + // Calculate size for this type + long size = CalculateTypeSize(type); + + // Evict old entries BEFORE adding if cache is full to ensure we never exceed limit + if (cache.Count >= _maxTypeCacheSize) + { + EvictOldestEntries(cache); + } + + // Add to cache (may fail if another thread added it, which is fine) + cache.TryAdd(type, new TypeSizeCacheEntry { Size = size, LastAccess = accessOrder }); + + return size; + } + + private long CalculateTypeSize(Type type) + { + // Handle primitive types + if (type.IsPrimitive) + { + return type == typeof(bool) || type == typeof(byte) || type == typeof(sbyte) ? 1L : + type == typeof(char) || type == typeof(short) || type == typeof(ushort) ? 2L : + type == typeof(int) || type == typeof(uint) || type == typeof(float) ? 4L : + type == typeof(long) || type == typeof(ulong) || type == typeof(double) ? 8L : 8L; + } + + // Handle common value types + if (type == typeof(decimal) || type == typeof(Guid)) return 16; + if (type == typeof(DateTime) || type == typeof(TimeSpan)) return 8; + + // For reference types, estimate based on fields/properties + try + { + var fields = type.GetFields(System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + var properties = type.GetProperties(System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public); + + // Base object overhead + estimated field/property sizes + return 24 + ((fields.Length + properties.Length) * 8); + } + catch (Exception ex) + { + // If reflection fails, return a reasonable default + _logger.LogError(ex, "Reflection failed for type {TypeName}, using default size estimation", type.Name); + return 64; + } + } + + private void EvictOldestEntries(ConcurrentDictionary cache) + { + // Simple eviction: remove entries with oldest access times + // Remove ~10% of entries (minimum 1, maximum 100) to avoid frequent evictions + // while also preventing massive evictions for very large caches + int toRemove = Math.Clamp(_maxTypeCacheSize / 10, 1, 100); + var threshold = _cacheAccessCounter - (_maxTypeCacheSize - toRemove); + + foreach (var kvp in cache) + { + if (kvp.Value.LastAccess < threshold) + { + cache.TryRemove(kvp.Key, out _); + } + } + } + + private sealed class TypeSizeCacheEntry + { + public long Size { get; init; } + public long LastAccess { get; set; } + } +} diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index f07eab5e..7ec616a5 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -667,4 +667,619 @@ public async Task DoMaintenanceAsync_WithMaxDateTimeExpiration_ShouldNotThrowExc Assert.Equal("value", result.Value); } } + + [Fact] + public async Task SetAsync_WithMaxMemorySizeLimit_EvictsWhenOverLimit() + { + // Use a memory limit that allows for testing eviction + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(200, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Add some entries with known sizes + await cache.SetAsync("small1", "test"); // ~32 bytes + _logger.LogInformation($"After adding 'test': CurrentMemorySize={cache.CurrentMemorySize}"); + Assert.True(cache.CurrentMemorySize > 0, $"Expected memory size > 0, but was {cache.CurrentMemorySize}"); + var sizeAfterFirst = cache.CurrentMemorySize; + + await cache.SetAsync("small2", "test2"); // ~34 bytes + _logger.LogInformation($"After adding 'test2': CurrentMemorySize={cache.CurrentMemorySize}"); + Assert.True(cache.CurrentMemorySize > sizeAfterFirst, $"Expected memory size > {sizeAfterFirst}, but was {cache.CurrentMemorySize}"); + + // Add medium strings to approach the limit + await cache.SetAsync("medium1", new string('a', 50)); // ~124 bytes + await cache.SetAsync("medium2", new string('b', 50)); // ~124 bytes + _logger.LogInformation($"After adding medium strings: CurrentMemorySize={cache.CurrentMemorySize}"); + + // Add one more item that should trigger eviction + await cache.SetAsync("final", "trigger"); // Should trigger cleanup + _logger.LogInformation($"After adding final item: CurrentMemorySize={cache.CurrentMemorySize}"); + + // The cache should respect the memory limit (allowing some tolerance for async cleanup) + // Give it a moment for async maintenance to run + await Task.Delay(500); + _logger.LogInformation($"After delay: CurrentMemorySize={cache.CurrentMemorySize}"); + + Assert.True(cache.CurrentMemorySize <= cache.MaxMemorySize.Value * 1.5, + $"Memory size {cache.CurrentMemorySize} should be close to or below limit {cache.MaxMemorySize} (allowing 50% tolerance for async cleanup)"); + + // At least some items should still be accessible + var hasAnyItems = (await cache.GetAsync("small1")).HasValue || + (await cache.GetAsync("small2")).HasValue || + (await cache.GetAsync("medium1")).HasValue || + (await cache.GetAsync("medium2")).HasValue || + (await cache.GetAsync("final")).HasValue; + + Assert.True(hasAnyItems, "At least some items should remain in cache"); + } + } + + [Fact] + public async Task SetAsync_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(1024, Log).CloneValues(false).LoggerFactory(Log)); + using (cache) + { + _logger.LogInformation($"Initial state: MaxMemorySize={cache.MaxMemorySize}, CurrentMemorySize={cache.CurrentMemorySize}"); + + await cache.SetAsync("key1", "value1"); + _logger.LogInformation($"After set key1: CurrentMemorySize={cache.CurrentMemorySize}"); + + // Verify the entry was actually added + var result = await cache.GetAsync("key1"); + _logger.LogInformation($"Retrieved key1: HasValue={result.HasValue}, Value='{result.Value}'"); + + Assert.True(result.HasValue, "Key should exist in cache"); + + // Only assert memory tracking if the cache is configured for it + if (cache.MaxMemorySize.HasValue) + { + Assert.True(cache.CurrentMemorySize > 0, $"Memory should be tracked when MaxMemorySize is set. CurrentMemorySize={cache.CurrentMemorySize}"); + } + } + } + + [Fact] + public async Task SetAsync_WithBothLimits_RespectsMemoryAndItemLimits() + { + // Test that both limits work together + var cache = new InMemoryCacheClient(o => o.MaxItems(5).WithDynamicSizing(512, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + + // Add items that should trigger memory limit before item limit + var mediumString = new string('a', 100); // ~224 bytes each + + await cache.SetAsync("item1", mediumString); + await cache.SetAsync("item2", mediumString); + await cache.SetAsync("item3", mediumString); // Should be close to or over 512 bytes total + + // Verify limits are respected + Assert.True(cache.Count <= cache.MaxItems); + Assert.True(cache.CurrentMemorySize <= cache.MaxMemorySize || cache.Count == 0); + } + } + + [Fact] + public async Task MemorySize_WithVariousOperations_TracksCorrectly() + { + var cache = new InMemoryCacheClient(o => o.CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + // Memory tracking should only occur when MaxMemorySize is set + Assert.Null(cache.MaxMemorySize); + + await cache.SetAsync("key1", "value1"); + // When MaxMemorySize is null, CurrentMemorySize should be 0 + Assert.Equal(0, cache.CurrentMemorySize); + } + + // Test with MaxMemorySize set + cache = new InMemoryCacheClient(o => o.WithDynamicSizing(1024, Log).CloneValues(false).LoggerFactory(Log)); + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Test adding items + await cache.SetAsync("key1", "value1"); + var sizeAfterAdd = cache.CurrentMemorySize; + Assert.True(sizeAfterAdd > 0, $"Expected memory size > 0 after add, but was {sizeAfterAdd}"); + + // Test updating items + await cache.SetAsync("key1", "longer_value1"); + var sizeAfterUpdate = cache.CurrentMemorySize; + Assert.True(sizeAfterUpdate != sizeAfterAdd, $"Expected memory size to change after update, was {sizeAfterAdd}, now {sizeAfterUpdate}"); + + // Test removing items + await cache.RemoveAsync("key1"); + Assert.Equal(0, cache.CurrentMemorySize); + + // Test removing all + await cache.SetAsync("key1", "value1"); + await cache.SetAsync("key2", "value2"); + Assert.True(cache.CurrentMemorySize > 0); + + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task SetAsync_WithWithFixedSizing_ReturnsFixedSizeForAllObjects() + { + const long fixedSize = 100; + var cache = new InMemoryCacheClient(o => o.WithFixedSizing(10000, fixedSize).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Add items of varying actual sizes - memory should increment by fixed size each time + await cache.SetAsync("small", "a"); + Assert.Equal(fixedSize, cache.CurrentMemorySize); + + await cache.SetAsync("large", new string('x', 1000)); + Assert.Equal(fixedSize * 2, cache.CurrentMemorySize); + + await cache.SetAsync("int", 42); + Assert.Equal(fixedSize * 3, cache.CurrentMemorySize); + + // Remove one item + await cache.RemoveAsync("small"); + Assert.Equal(fixedSize * 2, cache.CurrentMemorySize); + + // Update an item (should recalculate to same fixed size, so no change) + await cache.SetAsync("large", "tiny"); + Assert.Equal(fixedSize * 2, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task SetAsync_WithWithDynamicSizing_CalculatesSizesDynamically() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Add a small string + await cache.SetAsync("small", "a"); + var smallSize = cache.CurrentMemorySize; + Assert.True(smallSize > 0, "Small string should have non-zero size"); + + // Add a larger string - size should increase more + await cache.SetAsync("large", new string('x', 100)); + var totalSize = cache.CurrentMemorySize; + Assert.True(totalSize > smallSize, "Adding larger string should increase memory size"); + + // The large string should be bigger than the small string + var largeSize = totalSize - smallSize; + Assert.True(largeSize > smallSize, $"Large string size ({largeSize}) should be bigger than small string size ({smallSize})"); + } + } + + [Fact] + public async Task SetAsync_WithCustomObjectSizeCalculator_UsesCustomCalculator() + { + int callCount = 0; + var cache = new InMemoryCacheClient(o => o + .MaxMemorySize(10000) + .ObjectSizeCalculator(_ => + { + callCount++; + return 50; + }) + .LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + callCount = 0; // Reset after RemoveAllAsync + + await cache.SetAsync("key1", "value1"); + Assert.True(callCount >= 1, "Custom calculator should have been called at least once"); + + await cache.SetAsync("key2", "value2"); + Assert.True(callCount >= 2, "Custom calculator should have been called for second item"); + + Assert.Equal(100, cache.CurrentMemorySize); // 2 items * 50 bytes each + } + } + + [Fact] + public async Task ListAddAsync_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Add items to a list + await cache.ListAddAsync("mylist", new[] { "item1", "item2", "item3" }); + var sizeAfterAdd = cache.CurrentMemorySize; + Assert.True(sizeAfterAdd > 0, "Memory should be tracked for list items"); + _logger.LogInformation("Size after ListAddAsync: {Size}", sizeAfterAdd); + + // Add more items to the same list + await cache.ListAddAsync("mylist", new[] { "item4", "item5" }); + var sizeAfterMoreItems = cache.CurrentMemorySize; + Assert.True(sizeAfterMoreItems > sizeAfterAdd, "Memory should increase when adding more list items"); + _logger.LogInformation("Size after adding more items: {Size}", sizeAfterMoreItems); + + // Remove items from the list + await cache.ListRemoveAsync("mylist", new[] { "item1", "item2" }); + var sizeAfterRemove = cache.CurrentMemorySize; + Assert.True(sizeAfterRemove < sizeAfterMoreItems, "Memory should decrease when removing list items"); + _logger.LogInformation("Size after ListRemoveAsync: {Size}", sizeAfterRemove); + } + } + + [Fact] + public async Task CompactAsync_WithExpiredItems_EvictsExpiredItemsFirst() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cache = new InMemoryCacheClient(o => o + .WithFixedSizing(100, 50) + .TimeProvider(timeProvider) + .LoggerFactory(Log)); + + using (cache) + { + // Add an item that will expire soon + await cache.SetAsync("expiring", "value", TimeSpan.FromSeconds(1)); + + // Add items that won't expire + await cache.SetAsync("permanent1", "value"); + await cache.SetAsync("permanent2", "value"); + + // Advance time so the first item expires + timeProvider.Advance(TimeSpan.FromSeconds(2)); + + // Force compaction by exceeding the limit + await cache.SetAsync("trigger", "value"); + + // Advance time to allow maintenance to process + timeProvider.Advance(TimeSpan.FromSeconds(1)); + + // The expired item should be gone, permanent items should remain + Assert.False((await cache.GetAsync("expiring")).HasValue, "Expired item should be evicted first"); + Assert.True((await cache.GetAsync("permanent1")).HasValue || (await cache.GetAsync("permanent2")).HasValue, + "At least one permanent item should remain"); + } + } + + [Fact] + public async Task CompactAsync_WithMemoryLimit_EvictsLargerLessUsedItems() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + // Use a tight memory limit that will force eviction + var cache = new InMemoryCacheClient(o => o + .WithDynamicSizing(300, Log) + .TimeProvider(timeProvider) + .LoggerFactory(Log)); + + using (cache) + { + // Add a large item that won't be accessed + await cache.SetAsync("large_unused", new string('x', 100)); + var largeSize = cache.CurrentMemorySize; + _logger.LogInformation("After large_unused: size={Size}", largeSize); + + // Advance time + timeProvider.Advance(TimeSpan.FromMinutes(1)); + + // Add a small item and access it frequently + await cache.SetAsync("small_used", "tiny"); + _ = await cache.GetAsync("small_used"); + _ = await cache.GetAsync("small_used"); + _ = await cache.GetAsync("small_used"); + _logger.LogInformation("After small_used: size={Size}", cache.CurrentMemorySize); + + // Advance time again + timeProvider.Advance(TimeSpan.FromMinutes(1)); + + // Add more items to trigger eviction + await cache.SetAsync("trigger1", new string('y', 50)); + await cache.SetAsync("trigger2", new string('z', 50)); + _logger.LogInformation("After triggers: size={Size}, count={Count}", cache.CurrentMemorySize, cache.Count); + + // Advance time to allow maintenance to process + timeProvider.Advance(TimeSpan.FromSeconds(1)); + + // The small frequently-used item should have higher chance of survival + // due to the eviction algorithm favoring recently accessed items + var smallUsedExists = (await cache.GetAsync("small_used")).HasValue; + var largeUnusedExists = (await cache.GetAsync("large_unused")).HasValue; + + _logger.LogInformation("small_used exists: {SmallExists}, large_unused exists: {LargeExists}", + smallUsedExists, largeUnusedExists); + + // At minimum, verify the cache respects its memory limit + Assert.True(cache.CurrentMemorySize <= cache.MaxMemorySize.Value * 1.5, + $"Memory {cache.CurrentMemorySize} should be close to limit {cache.MaxMemorySize}"); + } + } + + [Fact] + public async Task CompactAsync_WithMaxItems_EvictsLeastRecentlyUsedItems() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cache = new InMemoryCacheClient(o => o + .MaxItems(3) + .TimeProvider(timeProvider) + .LoggerFactory(Log)); + + using (cache) + { + // Add items in sequence + await cache.SetAsync("first", "1"); + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + + await cache.SetAsync("second", "2"); + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + + await cache.SetAsync("third", "3"); + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + + // Access the first item to make it recently used + _ = await cache.GetAsync("first"); + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + + // Add a fourth item - should trigger eviction of "second" (least recently accessed) + await cache.SetAsync("fourth", "4"); + + // Advance time to allow maintenance to process + timeProvider.Advance(TimeSpan.FromSeconds(1)); + + // "first" was recently accessed, "third" and "fourth" are newer + // "second" should be evicted as least recently used + Assert.True((await cache.GetAsync("first")).HasValue, "Recently accessed 'first' should remain"); + Assert.False((await cache.GetAsync("second")).HasValue, "Least recently used 'second' should be evicted"); + Assert.True((await cache.GetAsync("fourth")).HasValue, "Newest 'fourth' should remain"); + } + } + + [Fact] + public void Constructor_WithMaxMemorySizeButNoCalculator_Throws() + { + var options = new InMemoryCacheClientOptions + { + MaxMemorySize = 1024, + ObjectSizeCalculator = null + }; + + var ex = Assert.Throws(() => new InMemoryCacheClient(options)); + Assert.Contains("ObjectSizeCalculator", ex.Message); + } + + [Fact] + public async Task IncrementAsync_Double_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Increment on new key should track memory + var result = await cache.IncrementAsync("counter", 1.5); + Assert.Equal(1.5, result); + var sizeAfterFirst = cache.CurrentMemorySize; + Assert.True(sizeAfterFirst > 0, $"Memory should be tracked after Increment on new key. CurrentMemorySize={sizeAfterFirst}"); + + // Increment again should still track memory + result = await cache.IncrementAsync("counter", 2.5); + Assert.Equal(4.0, result); + var sizeAfterSecond = cache.CurrentMemorySize; + Assert.True(sizeAfterSecond > 0, "Memory should still be tracked after incrementing"); + + // Remove and verify memory is freed + await cache.RemoveAsync("counter"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task IncrementAsync_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Increment on new key should track memory + var result = await cache.IncrementAsync("counter", 1L); + Assert.Equal(1L, result); + var sizeAfterFirst = cache.CurrentMemorySize; + Assert.True(sizeAfterFirst > 0, $"Memory should be tracked after Increment on new key. CurrentMemorySize={sizeAfterFirst}"); + + // Increment again should still track memory + result = await cache.IncrementAsync("counter", 5L); + Assert.Equal(6L, result); + var sizeAfterSecond = cache.CurrentMemorySize; + Assert.True(sizeAfterSecond > 0, "Memory should still be tracked after incrementing"); + + // Decrement (negative increment) + result = await cache.IncrementAsync("counter", -2L); + Assert.Equal(4L, result); + + // Remove and verify memory is freed + await cache.RemoveAsync("counter"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task SetIfHigherAsync_Double_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // SetIfHigher on new key should track memory + await cache.SetIfHigherAsync("counter", 100.5); + var sizeAfterFirst = cache.CurrentMemorySize; + Assert.True(sizeAfterFirst > 0, $"Memory should be tracked after SetIfHigher on new key. CurrentMemorySize={sizeAfterFirst}"); + + // SetIfHigher with higher value should update + await cache.SetIfHigherAsync("counter", 200.5); + var value = await cache.GetAsync("counter"); + Assert.Equal(200.5, value.Value); + + // Remove and verify memory is freed + await cache.RemoveAsync("counter"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task SetIfHigherAsync_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // SetIfHigher on new key should track memory + await cache.SetIfHigherAsync("counter", 100L); + var sizeAfterFirst = cache.CurrentMemorySize; + Assert.True(sizeAfterFirst > 0, $"Memory should be tracked after SetIfHigher on new key. CurrentMemorySize={sizeAfterFirst}"); + + // SetIfHigher with higher value should update (size should remain similar for same type) + await cache.SetIfHigherAsync("counter", 200L); + var sizeAfterHigher = cache.CurrentMemorySize; + Assert.True(sizeAfterHigher > 0, "Memory should still be tracked after updating to higher value"); + + // SetIfHigher with lower value should NOT update the value + await cache.SetIfHigherAsync("counter", 50L); + var value = await cache.GetAsync("counter"); + Assert.Equal(200L, value.Value); + + // Remove and verify memory is freed + await cache.RemoveAsync("counter"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task SetIfLowerAsync_Double_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // SetIfLower on new key should track memory + await cache.SetIfLowerAsync("counter", 100.5); + var sizeAfterFirst = cache.CurrentMemorySize; + Assert.True(sizeAfterFirst > 0, $"Memory should be tracked after SetIfLower on new key. CurrentMemorySize={sizeAfterFirst}"); + + // SetIfLower with lower value should update + await cache.SetIfLowerAsync("counter", 50.5); + var value = await cache.GetAsync("counter"); + Assert.Equal(50.5, value.Value); + + // Remove and verify memory is freed + await cache.RemoveAsync("counter"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task SetIfLowerAsync_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // SetIfLower on new key should track memory + await cache.SetIfLowerAsync("counter", 100L); + var sizeAfterFirst = cache.CurrentMemorySize; + Assert.True(sizeAfterFirst > 0, $"Memory should be tracked after SetIfLower on new key. CurrentMemorySize={sizeAfterFirst}"); + + // SetIfLower with lower value should update (size should remain similar for same type) + await cache.SetIfLowerAsync("counter", 50L); + var sizeAfterLower = cache.CurrentMemorySize; + Assert.True(sizeAfterLower > 0, "Memory should still be tracked after updating to lower value"); + + // SetIfLower with higher value should NOT update the value + await cache.SetIfLowerAsync("counter", 200L); + var value = await cache.GetAsync("counter"); + Assert.Equal(50L, value.Value); + + // Remove and verify memory is freed + await cache.RemoveAsync("counter"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } + + [Fact] + public async Task ReplaceIfEqualAsync_WithMaxMemorySize_TracksMemoryCorrectly() + { + var cache = new InMemoryCacheClient(o => o.WithDynamicSizing(10000, Log).CloneValues(false).LoggerFactory(Log)); + + using (cache) + { + await cache.RemoveAllAsync(); + Assert.Equal(0, cache.CurrentMemorySize); + + // Set initial value + await cache.SetAsync("key", "short"); + var sizeAfterSet = cache.CurrentMemorySize; + Assert.True(sizeAfterSet > 0, $"Memory should be tracked after Set. CurrentMemorySize={sizeAfterSet}"); + + // ReplaceIfEqual with matching value and larger replacement should update memory + var replaced = await cache.ReplaceIfEqualAsync("key", "much longer replacement string", "short"); + Assert.True(replaced, "ReplaceIfEqual should succeed when values match"); + + var sizeAfterReplace = cache.CurrentMemorySize; + Assert.True(sizeAfterReplace > sizeAfterSet, + $"Memory should increase when replacing with larger value. Before={sizeAfterSet}, After={sizeAfterReplace}"); + + // ReplaceIfEqual with non-matching value should not change memory + replaced = await cache.ReplaceIfEqualAsync("key", "tiny", "wrong expected value"); + Assert.False(replaced, "ReplaceIfEqual should fail when expected value doesn't match"); + + var sizeAfterFailedReplace = cache.CurrentMemorySize; + Assert.Equal(sizeAfterReplace, sizeAfterFailedReplace); + + // ReplaceIfEqual with smaller replacement should decrease memory + replaced = await cache.ReplaceIfEqualAsync("key", "x", "much longer replacement string"); + Assert.True(replaced, "ReplaceIfEqual should succeed"); + + var sizeAfterSmaller = cache.CurrentMemorySize; + Assert.True(sizeAfterSmaller < sizeAfterReplace, + $"Memory should decrease when replacing with smaller value. Before={sizeAfterReplace}, After={sizeAfterSmaller}"); + + // Remove and verify memory is freed + await cache.RemoveAsync("key"); + Assert.Equal(0, cache.CurrentMemorySize); + } + } } diff --git a/tests/Foundatio.Tests/Caching/ObjectSizerTests.cs b/tests/Foundatio.Tests/Caching/ObjectSizerTests.cs new file mode 100644 index 00000000..9261af62 --- /dev/null +++ b/tests/Foundatio.Tests/Caching/ObjectSizerTests.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using Foundatio.Caching; +using Foundatio.Xunit; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Foundatio.Tests.Caching; + +public class ObjectSizerTests : TestWithLoggingBase +{ + private readonly ObjectSizer _sizer; + + public ObjectSizerTests(ITestOutputHelper output) : base(output) + { + _sizer = new ObjectSizer(Log); + } + + [Fact] + public void CalculateSize_WithNull_ReturnsReferenceSize() + { + long size = _sizer.CalculateSize(null); + Assert.Equal(8, size); // Reference size + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CalculateSize_WithBoolean_ReturnsExpectedSize(bool value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size > 0, $"Expected size > 0 for boolean, got {size}"); + } + + [Theory] + [InlineData((byte)0)] + [InlineData((byte)255)] + public void CalculateSize_WithByte_ReturnsExpectedSize(byte value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size > 0, $"Expected size > 0 for byte, got {size}"); + } + + [Theory] + [InlineData((short)0)] + [InlineData((short)-32768)] + [InlineData((short)32767)] + public void CalculateSize_WithInt16_ReturnsExpectedSize(short value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size >= 2, $"Expected size >= 2 for short, got {size}"); + } + + [Theory] + [InlineData(0)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public void CalculateSize_WithInt32_ReturnsExpectedSize(int value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size >= 4, $"Expected size >= 4 for int, got {size}"); + } + + [Theory] + [InlineData(0L)] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + public void CalculateSize_WithInt64_ReturnsExpectedSize(long value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size >= 8, $"Expected size >= 8 for long, got {size}"); + } + + [Theory] + [InlineData(0.0f)] + [InlineData(float.MinValue)] + [InlineData(float.MaxValue)] + public void CalculateSize_WithFloat_ReturnsExpectedSize(float value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size >= 4, $"Expected size >= 4 for float, got {size}"); + } + + [Theory] + [InlineData(0.0d)] + [InlineData(double.MinValue)] + [InlineData(double.MaxValue)] + public void CalculateSize_WithDouble_ReturnsExpectedSize(double value) + { + long size = _sizer.CalculateSize(value); + Assert.True(size >= 8, $"Expected size >= 8 for double, got {size}"); + } + + [Theory] + [InlineData("")] + [InlineData("a")] + [InlineData("hello")] + [InlineData("hello world this is a longer string")] + public void CalculateSize_WithString_ReturnsExpectedSize(string value) + { + long size = _sizer.CalculateSize(value); + // String overhead (24 bytes) + 2 bytes per char + long expectedMinSize = 24 + (value.Length * 2); + Assert.True(size >= expectedMinSize, $"Expected size >= {expectedMinSize} for string '{value}', got {size}"); + } + + [Fact] + public void CalculateSize_WithEmptyString_ReturnsStringOverhead() + { + long size = _sizer.CalculateSize(string.Empty); + Assert.True(size >= 24, $"Expected size >= 24 for empty string (overhead), got {size}"); + } + + [Fact] + public void CalculateSize_WithChar_ReturnsExpectedSize() + { + long size = _sizer.CalculateSize('a'); + Assert.True(size >= 2, $"Expected size >= 2 for char, got {size}"); + } + + [Fact] + public void CalculateSize_WithDateTime_ReturnsExpectedSize() + { + long size = _sizer.CalculateSize(DateTime.UtcNow); + Assert.True(size >= 8, $"Expected size >= 8 for DateTime, got {size}"); + } + + [Fact] + public void CalculateSize_WithGuid_ReturnsExpectedSize() + { + long size = _sizer.CalculateSize(Guid.NewGuid()); + Assert.True(size >= 16, $"Expected size >= 16 for Guid, got {size}"); + } + + [Fact] + public void CalculateSize_WithDecimal_ReturnsExpectedSize() + { + long size = _sizer.CalculateSize(123.456m); + Assert.True(size >= 16, $"Expected size >= 16 for decimal, got {size}"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(10)] + [InlineData(100)] + public void CalculateSize_WithIntArray_ReturnsExpectedSize(int length) + { + var array = new int[length]; + long size = _sizer.CalculateSize(array); + // Array overhead (24 bytes) + element size * count + long expectedMinSize = 24 + (length * 4); + Assert.True(size >= expectedMinSize, $"Expected size >= {expectedMinSize} for int[{length}], got {size}"); + } + + [Fact] + public void CalculateSize_WithStringArray_IncludesStringContents() + { + var array = new[] { "hello", "world" }; + long size = _sizer.CalculateSize(array); + // Array overhead + string sizes + Assert.True(size > 24, $"Expected size > 24 for string array, got {size}"); + _logger.LogInformation("String array size: {Size}", size); + } + + [Fact] + public void CalculateSize_WithByteArray_ReturnsExpectedSize() + { + var array = new byte[100]; + long size = _sizer.CalculateSize(array); + // Array overhead (24 bytes) + 100 bytes + Assert.True(size >= 124, $"Expected size >= 124 for byte[100], got {size}"); + } + + [Fact] + public void CalculateSize_WithEmptyList_ReturnsOverheadSize() + { + var list = new List(); + long size = _sizer.CalculateSize(list); + Assert.True(size > 0, $"Expected size > 0 for empty list, got {size}"); + } + + [Fact] + public void CalculateSize_WithPopulatedList_IncludesElements() + { + var list = new List { 1, 2, 3, 4, 5 }; + long size = _sizer.CalculateSize(list); + var emptyListSize = _sizer.CalculateSize(new List()); + Assert.True(size > emptyListSize, $"Expected size > {emptyListSize} for populated list, got {size}"); + } + + [Fact] + public void CalculateSize_WithDictionary_ReturnsExpectedSize() + { + var dict = new Dictionary + { + { "one", 1 }, + { "two", 2 }, + { "three", 3 } + }; + long size = _sizer.CalculateSize(dict); + Assert.True(size > 0, $"Expected size > 0 for dictionary, got {size}"); + _logger.LogInformation("Dictionary size: {Size}", size); + } + + [Fact] + public void CalculateSize_WithEmptyDictionary_ReturnsOverheadSize() + { + var dict = new Dictionary(); + long size = _sizer.CalculateSize(dict); + Assert.True(size > 0, $"Expected size > 0 for empty dictionary, got {size}"); + } + + [Fact] + public void CalculateSize_WithHashSet_ReturnsExpectedSize() + { + var set = new HashSet { "a", "b", "c" }; + long size = _sizer.CalculateSize(set); + Assert.True(size > 0, $"Expected size > 0 for HashSet, got {size}"); + } + + [Fact] + public void CalculateSize_WithNullableIntWithValue_ReturnsExpectedSize() + { + int? value = 42; + long size = _sizer.CalculateSize(value); + Assert.True(size >= 4, $"Expected size >= 4 for int?, got {size}"); + } + + [Fact] + public void CalculateSize_WithNullableIntWithNull_ReturnsReferenceSize() + { + int? value = null; + long size = _sizer.CalculateSize(value); + Assert.Equal(8, size); // Null reference size + } + + [Fact] + public void CalculateSize_WithSimpleObject_ReturnsReasonableSize() + { + var obj = new SimpleTestObject { Id = 1, Name = "Test", Value = 123.45 }; + long size = _sizer.CalculateSize(obj); + Assert.True(size > 0, $"Expected size > 0 for simple object, got {size}"); + _logger.LogInformation("SimpleTestObject size: {Size}", size); + } + + [Fact] + public void CalculateSize_WithNestedObject_IncludesNestedSize() + { + var inner = new SimpleTestObject { Id = 1, Name = "Inner", Value = 1.0 }; + var outer = new NestedTestObject { Id = 2, Inner = inner }; + + long innerSize = _sizer.CalculateSize(inner); + long outerSize = _sizer.CalculateSize(outer); + + Assert.True(outerSize > innerSize, $"Expected outer size ({outerSize}) > inner size ({innerSize})"); + } + + [Fact] + public void CalculateSize_WithLargeString_ScalesWithLength() + { + var small = new string('a', 10); + var large = new string('a', 1000); + + long smallSize = _sizer.CalculateSize(small); + long largeSize = _sizer.CalculateSize(large); + + Assert.True(largeSize > smallSize, $"Expected large string size ({largeSize}) > small string size ({smallSize})"); + _logger.LogInformation("Small string (10 chars) size: {SmallSize}, Large string (1000 chars) size: {LargeSize}", smallSize, largeSize); + } + + [Fact] + public void CalculateSize_WithLargeArray_ScalesWithLength() + { + var small = new int[10]; + var large = new int[1000]; + + long smallSize = _sizer.CalculateSize(small); + long largeSize = _sizer.CalculateSize(large); + + Assert.True(largeSize > smallSize * 10, $"Expected large array size ({largeSize}) > 10x small array size ({smallSize * 10})"); + } + + [Fact] + public void CalculateSize_WithTimeSpan_ReturnsExpectedSize() + { + long size = _sizer.CalculateSize(TimeSpan.FromHours(1)); + Assert.True(size >= 8, $"Expected size >= 8 for TimeSpan, got {size}"); + } + + [Fact] + public void CalculateSize_WithDateTimeOffset_ReturnsExpectedSize() + { + long size = _sizer.CalculateSize(DateTimeOffset.UtcNow); + Assert.True(size >= 8, $"Expected size >= 8 for DateTimeOffset, got {size}"); + } + + [Fact] + public void CalculateSize_WithConsistentResults_ReturnsSameSize() + { + var obj = new SimpleTestObject { Id = 1, Name = "Test", Value = 123.45 }; + + long size1 = _sizer.CalculateSize(obj); + long size2 = _sizer.CalculateSize(obj); + + Assert.Equal(size1, size2); + } + + [Fact] + public void Dispose_WhenCalled_ClearsCache() + { + var sizer = new ObjectSizer(Log); + + // Use the sizer to populate the cache + sizer.CalculateSize(new SimpleTestObject { Id = 1, Name = "Test" }); + sizer.CalculateSize(new NestedTestObject { Id = 2, Inner = new SimpleTestObject() }); + + // Dispose should clear everything + sizer.Dispose(); + + // Calling again should throw + Assert.Throws(() => sizer.CalculateSize("test")); + } + + [Fact] + public void Dispose_WhenCalledMultipleTimes_DoesNotThrow() + { + var sizer = new ObjectSizer(Log); + sizer.CalculateSize("test"); + + // Multiple dispose calls should be safe + sizer.Dispose(); + sizer.Dispose(); + sizer.Dispose(); + } + + [Fact] + public void CalculateSize_WithManyDistinctTypes_RespectsMaxCacheSize() + { + var sizer = new ObjectSizer(Log); + + // Create many distinct types by using generic types with different type arguments + // This tests the LRU eviction logic + var types = new List(); + for (int i = 0; i < 1100; i++) + { + // Create objects that will exercise the type cache + // Using Dictionary with different value types creates distinct generic types + types.Add(new Dictionary { { $"key{i}", i } }); + } + + // Calculate sizes for all - this should trigger eviction + foreach (var obj in types) + { + long size = sizer.CalculateSize(obj); + Assert.True(size > 0); + } + + // The sizer should still work correctly after evictions + long finalSize = sizer.CalculateSize(new SimpleTestObject { Id = 999, Name = "Final" }); + Assert.True(finalSize > 0); + + sizer.Dispose(); + } + + [Fact] + public void TypeSizeCache_CachesTypeSizeCalculations() + { + // Use a small cache size so we can test caching behavior + var sizer = new ObjectSizer(maxTypeCacheSize: 10, loggerFactory: Log); + + Assert.Equal(0, sizer.TypeCacheCount); + + // The type cache is used for array element types + // Each array type will cache its element type + sizer.CalculateSize(new int[] { 1, 2, 3 }); + Assert.Equal(1, sizer.TypeCacheCount); // int type cached + + sizer.CalculateSize(new double[] { 1.0, 2.0 }); + Assert.Equal(2, sizer.TypeCacheCount); // double type cached + + sizer.CalculateSize(new bool[] { true, false }); + Assert.Equal(3, sizer.TypeCacheCount); // bool type cached + + sizer.CalculateSize(new DateTime[] { DateTime.Now }); + Assert.Equal(4, sizer.TypeCacheCount); // DateTime type cached + + sizer.CalculateSize(new Guid[] { Guid.NewGuid() }); + Assert.Equal(5, sizer.TypeCacheCount); // Guid type cached + + // Re-calculating the same array element types should NOT increase the cache count + sizer.CalculateSize(new int[] { 100, 200 }); + sizer.CalculateSize(new double[] { 2.71 }); + sizer.CalculateSize(new bool[] { false }); + Assert.Equal(5, sizer.TypeCacheCount); + + sizer.Dispose(); + } + + [Fact] + public void TypeSizeCache_EvictsWhenMaxSizeReached() + { + // Use a very small cache size to test eviction + var sizer = new ObjectSizer(maxTypeCacheSize: 5, loggerFactory: Log); + + // Add types up to the limit using arrays (which cache element types) + sizer.CalculateSize(new int[] { 1 }); // int + sizer.CalculateSize(new double[] { 1.0 }); // double + sizer.CalculateSize(new bool[] { true }); // bool + sizer.CalculateSize(new long[] { 1L }); // long + sizer.CalculateSize(new float[] { 1.0f }); // float + Assert.Equal(5, sizer.TypeCacheCount); + + // Adding more types should trigger eviction (removes ~10% = at least 1) + sizer.CalculateSize(new decimal[] { 1m }); // decimal - should trigger eviction + + // After eviction, cache should not exceed max size + Assert.True(sizer.TypeCacheCount <= 5, $"Cache count {sizer.TypeCacheCount} should not exceed max size 5"); + + sizer.Dispose(); + } + + private class SimpleTestObject + { + public int Id { get; set; } + public string Name { get; set; } + public double Value { get; set; } + } + + private class NestedTestObject + { + public int Id { get; set; } + public SimpleTestObject Inner { get; set; } + } + + private class TypeCacheTestClass1 + { + public int Value { get; set; } + } + + private class TypeCacheTestClass2 + { + public string Data { get; set; } + } + + private class TypeCacheTestClass3 + { + public bool Flag { get; set; } + } + + private class TypeCacheTestClass4 + { + public long Count { get; set; } + } +}