From 193bb075b64b1af3a476a4f2e7940693e25dc819 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 12 Oct 2026 22:41:20 -0500 Subject: [PATCH 1/7] Preserve list expiration when an indexed add changes no items --- .agents/skills/foundatio/SKILL.md | 2 +- docs/guide/caching.md | 1 + src/Foundatio/Caching/InMemoryCacheClient.cs | 4 +-- .../Caching/InMemoryCacheClientTests.cs | 28 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index ec05393e4..2edcdcfba 100644 --- a/.agents/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -315,7 +315,7 @@ public class OrderServiceTests : TestLoggerBase - **In-memory expired entries**: Writes treat an expired entry as missing before maintenance sweeps it (as Redis does): increments and `SetIfHigher`/`SetIfLower` start fresh, `ReplaceAsync`, `SetExpirationAsync`, `RemoveIfEqualAsync` and `ReplaceIfEqualAsync` miss. The write reclaims the expired entry and raises `ItemExpired` once. Successful live conditional removal and empty-list removal free memory without that event. Size-rejected updates preserve value, TTL, and tracked size. - **Custom `SizeCalculator` and lists**: runs while a per-key-stripe lock may be held during contended list writes, so it must not call back into the cache. It receives a `Dictionary` copy of the whole list on each write; the built-in `WithDynamicSizing`/`WithFixedSizing` avoid that copy. - **In-memory numeric TTLs**: `SetIfHigherAsync` and `SetIfLowerAsync` apply the requested expiration even when the numeric value is unchanged. A zero return value does not mean the TTL was preserved; `null` clears it. Size-rejected updates remain unchanged. -- **In-memory lists and cloning**: List writes preserve supported dictionary types and comparers; copying an unsupported `IDictionary` throws `NotSupportedException` without changing the entry. Batch large updates because cloning, custom sizing, and raw dictionary access can require whole-list copies. `Items` honors `CloneValues` without affecting LRU. Collection snapshots do not isolate shared mutable elements when cloning is disabled. See the [caching guide](https://foundatio.readthedocs.io/guide/caching.html#in-memory-update-behavior) for details. +- **In-memory lists and cloning**: List writes preserve supported dictionary types and comparers; copying an unsupported `IDictionary` throws `NotSupportedException` without changing the entry. Batch large updates because cloning, custom sizing, and raw dictionary access can require whole-list copies. `Items` honors `CloneValues` without affecting LRU. Collection snapshots do not isolate shared mutable elements when cloning is disabled. See the [caching guide](https://foundatio.readthedocs.io/guide/caching.html#in-memory-update-behavior) for details. Adding existing list items still reapplies their resulting expiration, even when the list contents are unchanged. - **Register as singletons**: All infrastructure services (`ICacheClient`, `IMessageBus`, `IQueue`, `IFileStorage`, `ILockProvider`) maintain internal state and connections -- always register as singletons. - **CacheLockProvider + IMessageBus**: `IMessageBus` is optional but recommended. Without it, lock release falls back to polling. With it, locks are released instantly via pub/sub notification. - **In-memory for tests**: Use in-memory implementations for fast, isolated tests, but retain provider integration coverage for serialization, expiration, and concurrency behavior. A common interface does not guarantee identical backend semantics. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 2fd0dcbf2..42789ff14 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -277,6 +277,7 @@ Successful removal of a live matching value, or removal of the last list value, - `CurrentMemorySize` is approximate while `RemoveAllAsync()` or `Dispose` races concurrent writes. This is unchanged from earlier versions; it self-corrects on the next recount after compaction. - List size limits (`MaxEntrySize`, a negative `SizeCalculator` result) apply to the whole resulting list on every `ListAddAsync` and `ListRemoveAsync`, so a removal that leaves the list over the limit is rejected and returns `0`. - A rejected `ListAddAsync` on an expired key still reclaims the expired entry and raises `ItemExpired`, and every `ListAddAsync` call counts as a write in `Writes`, including rejected ones. +- Adding items reapplies the resulting list expiration, even when every supplied item is already present with the same TTL. This can replace a key expiration previously set with `SetExpirationAsync`. - List item expirations read back with `DateTimeKind.Utc`, whatever kind was passed in. ::: diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index f805d9642..7d9c9db29 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -153,7 +153,7 @@ private void UpdateMemorySize(long delta) } /// - /// Recalculates the current memory size by summing all non-expired cache entries. + /// Recalculates the current memory size by summing all stored cache entries. /// /// /// @@ -678,7 +678,7 @@ private long ListAdd(string key, Dictionary items, DateTime? ex { var list = stored.Update(items.Keys, expiresAt, _timeProvider.GetUtcNow().UtcDateTime, remove: false, out _); if (ReferenceEquals(list, stored)) - return (current, (long)items.Count); + return (current.WithExpiration(list.ExpiresAtUtc), (long)items.Count); long listSize = CalculateListSize(list); return listSize < 0 ? (current, 0L) : (current.WithValue(list, list.ExpiresAtUtc, listSize), (long)items.Count); diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index b090048dd..add3783f5 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -810,6 +810,34 @@ public override Task ListAddAsync_WithSingleString_StoresAsStringNotCharArray() return base.ListAddAsync_WithSingleString_StoresAsStringNotCharArray(); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ListAddAsync_WithUnchangedIndexedList_RestoresItemExpiration(bool cloneValues, bool permanent) + { + // Arrange + var timeProvider = new FakeTimeProvider(); + using var cache = new InMemoryCacheClient(o => o.CloneValues(cloneValues).TimeProvider(timeProvider).LoggerFactory(Log)); + TimeSpan? expiresIn = permanent ? null : TimeSpan.FromMinutes(10); + await cache.ListAddAsync("list", Enumerable.Range(0, 100), expiresIn); + await cache.ListAddAsync("list", new[] { 100 }, expiresIn); + await cache.ListAddAsync("list", new[] { 101 }, expiresIn); + await cache.SetExpirationAsync("list", TimeSpan.FromMinutes(1)); + + // Act + long added = await cache.ListAddAsync("list", new[] { 101 }, expiresIn); + + // Assert + Assert.Equal(1, added); + Assert.Equal(expiresIn, await cache.GetExpirationAsync("list")); + timeProvider.Advance(TimeSpan.FromMinutes(2)); + var values = await cache.GetListAsync("list"); + Assert.True(values.HasValue); + Assert.Equal(Enumerable.Range(0, 102), values.Value.Order()); + } + [Fact] public override Task ListAddAsync_WithVariousInputs_HandlesCorrectly() { From 28f680757311ee1164c66b2427b6674aa1d162b9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 12 Oct 2026 23:25:12 -0500 Subject: [PATCH 2/7] Preserve custom list size calculator contracts --- src/Foundatio/Caching/InMemoryCacheClient.cs | 7 +- .../Caching/InMemoryCacheClientTests.cs | 93 +++++++++++++------ 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index 7d9c9db29..cbae38cf1 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -84,7 +84,12 @@ public InMemoryCacheClient(InMemoryCacheClientOptions? options = null) _sizeCalculator = options.SizeCalculator; _hasSizeCalculator = _sizeCalculator is not null; // Only the built-in calculators are known to accept a list's read-only view; custom ones get a Dictionary - _sizesListsInPlace = _sizeCalculator?.Target is Utility.SizeCalculator or InMemoryCacheClientOptionsBuilder.FixedSizeCalculator; + _sizesListsInPlace = _sizeCalculator?.Target switch + { + Utility.SizeCalculator calculator => _sizeCalculator == calculator.CalculateSize, + InMemoryCacheClientOptionsBuilder.FixedSizeCalculator calculator => _sizeCalculator == calculator.Calculate, + _ => false + }; _shouldTrackMemory = _hasSizeCalculator && _maxMemorySize.HasValue; _memory = new ConcurrentDictionary(); diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index add3783f5..716b8305a 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Foundatio.Caching; +using Foundatio.Utility; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -653,6 +654,66 @@ public async Task ListAddAsync_WithCustomComparer_PreservesComparerAcrossUpdates Assert.True(stored.ContainsKey("FIRST")); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ListAddAsync_WithCustomDerivedSizeCalculator_ReceivesDictionary(bool multicast) + { + // Arrange + using var calculator = new DictionarySizeCalculator(Log); + Func calculateSize = calculator.CalculateSize; + if (multicast) + calculateSize += ((SizeCalculator)calculator).CalculateSize; + using var cache = new InMemoryCacheClient(o => o.CloneValues(false).MaxMemorySize(100_000_000) + .SizeCalculator(calculateSize).LoggerFactory(Log)); + await cache.ListAddAsync("list", Enumerable.Range(0, 1000)); + + // Act + await cache.ListAddAsync("list", [-1]); + long sizeAfterAdd = cache.CurrentMemorySize; + await cache.ListRemoveAsync("list", [0]); + + // Assert + var expected = Enumerable.Range(0, 1000).Append(-1).ToDictionary(item => item, _ => (DateTime?)null); + Assert.Equal(calculateSize(expected), sizeAfterAdd); + expected.Remove(0); + Assert.Equal(calculateSize(expected), cache.CurrentMemorySize); + Assert.Equal(expected.Keys.Order(), (await cache.GetListAsync("list")).Value.Order()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ListAddAsync_WithCustomSizeCalculator_ReceivesDictionaryOfCurrentItems(bool multicast) + { + // Arrange + var seen = new List<(int Count, bool IsDictionary, bool HasNewItem, bool HasRemovedItem)>(); + Func calculateSize = value => + { + if (value is IDictionary list) + seen.Add((list.Count, list is Dictionary, list.ContainsKey(-1), list.ContainsKey(0))); + return 100; + }; + if (multicast) + calculateSize += new InMemoryCacheClientOptionsBuilder().WithFixedSizing(100_000_000, 100).Build().SizeCalculator; + using var cache = new InMemoryCacheClient(o => o.CloneValues(false).MaxMemorySize(100_000_000) + .SizeCalculator(calculateSize).LoggerFactory(Log)); + await cache.ListAddAsync("list", Enumerable.Range(0, 1000)); + seen.Clear(); + + // Act + await cache.ListAddAsync("list", [-1]); + await cache.ListRemoveAsync("list", [0]); + + // Assert + Assert.Equal([(1001, true, true, true), (1000, true, true, false)], seen); + var list = await cache.GetListAsync("list"); + Assert.Equal(1000, list.Value!.Count); + var dictionary = await cache.GetAsync>("list"); + Assert.IsType>(dictionary.Value); + Assert.Equal(1000, dictionary.Value!.Count); + } + [Fact] public async Task ListAddAsync_WithExistingClonedValues_DoesNotCloneThemAgain() { @@ -899,33 +960,6 @@ public override Task ListRemoveAsync_WithInvalidInputs_ThrowsAppropriateExceptio return base.ListRemoveAsync_WithInvalidInputs_ThrowsAppropriateException(); } - [Fact] - public async Task ListAddAsync_WithCustomSizeCalculator_ReceivesDictionaryOfCurrentItems() - { - // Arrange - var seen = new List<(int Count, bool IsDictionary, bool HasNewItem, bool HasRemovedItem)>(); - using var cache = new InMemoryCacheClient(o => o.CloneValues(false).MaxMemorySize(100_000_000).SizeCalculator(value => - { - if (value is IDictionary list) - seen.Add((list.Count, list is Dictionary, list.ContainsKey(-1), list.ContainsKey(0))); - return 100; - }).LoggerFactory(Log)); - await cache.ListAddAsync("list", Enumerable.Range(0, 1000)); - seen.Clear(); - - // Act - await cache.ListAddAsync("list", [-1]); - await cache.ListRemoveAsync("list", [0]); - - // Assert - Assert.Equal([(1001, true, true, true), (1000, true, true, false)], seen); - var list = await cache.GetListAsync("list"); - Assert.Equal(1000, list.Value!.Count); - var dictionary = await cache.GetAsync>("list"); - Assert.IsType>(dictionary.Value); - Assert.Equal(1000, dictionary.Value!.Count); - } - [Fact] public async Task ListRemoveAsync_WithMissingValue_DoesNotAllocateInProportionToListSize() { @@ -3189,6 +3223,11 @@ public void UpdateEntry_WhenKeyRemovedDuringUpdate_AddsInsteadOfUpdating() Assert.Equal(50, cache.CurrentMemorySize); } + private sealed class DictionarySizeCalculator(ILoggerFactory loggerFactory) : SizeCalculator(loggerFactory) + { + public new long CalculateSize(object? value) => ((Dictionary)value!).Count; + } + private record MyData(string Name, int Value); } From 0b7d6d61b81ba744512eff05f43045be88d4d453 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 14 Oct 2026 22:22:09 -0500 Subject: [PATCH 3/7] Preserve incremental writes after cloned reads and enforce fallback size limits --- .agents/skills/foundatio/SKILL.md | 4 +- docs/guide/caching.md | 3 +- src/Foundatio/Caching/InMemoryCacheClient.cs | 61 +++++-------- src/Foundatio/Caching/InMemoryListSnapshot.cs | 15 ++-- .../Caching/InMemoryCacheClientTests.cs | 89 +++++++++++++++++++ 5 files changed, 127 insertions(+), 45 deletions(-) diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index 2edcdcfba..bc966c928 100644 --- a/.agents/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -312,10 +312,10 @@ public class OrderServiceTests : TestLoggerBase - **Failure semantics depend on job type**: `JobResult` only has `IsSuccess` -- there is no separate "failed but don't retry" status. For **queue-processed jobs** (`QueueJobBase.ProcessQueueEntryAsync`, or setting `context.Result` in a `WorkItemJob` handler), a non-success result triggers `AbandonAsync`, which re-queues the entry and eventually dead-letters it after `Retries` is exhausted -- reserve `FailedWithMessage`/`FromException` for transient errors you want retried, and log + return `JobResult.Success`/`SuccessWithMessage(...)` for permanent errors to avoid a pointless retry loop. For **standalone/manual jobs** (`JobBase`, a one-off `RunAsync()`/`RunInConsoleAsync()` run, or scheduled/cron jobs via `Foundatio.Extensions.Hosting`), there is no built-in retry or dead letter queue -- a failed result just produces an error-level log, a non-zero exit code from `RunInConsoleAsync`, or a failed entry in the job run history. Returning `FailedWithMessage`/`FromException` there is correct even for permanent errors, since nothing inside Foundatio will retry it. - **JobWithLockBase vs manual locking**: Use `JobWithLockBase` when the entire run must be single-instance (leader election). Use manual `ILockProvider.AcquireAsync` inside `JobBase` for finer-grained locking within a job. - **JobContext.RenewLockAsync**: Call in long-running jobs (both `JobBase` and `QueueJobBase`) to prevent lock expiration mid-processing. -- **In-memory expired entries**: Writes treat an expired entry as missing before maintenance sweeps it (as Redis does): increments and `SetIfHigher`/`SetIfLower` start fresh, `ReplaceAsync`, `SetExpirationAsync`, `RemoveIfEqualAsync` and `ReplaceIfEqualAsync` miss. The write reclaims the expired entry and raises `ItemExpired` once. Successful live conditional removal and empty-list removal free memory without that event. Size-rejected updates preserve value, TTL, and tracked size. +- **In-memory expired entries**: Writes treat an expired entry as missing before maintenance sweeps it (as Redis does): increments and `SetIfHigher`/`SetIfLower` start fresh, `ReplaceAsync`, `SetExpirationAsync`, `RemoveIfEqualAsync` and `ReplaceIfEqualAsync` miss. The write reclaims the expired entry and raises `ItemExpired` once. Successful live conditional removal and empty-list removal free memory without that event. Size-rejected updates preserve value, TTL, and tracked size; this also applies when a throwing custom calculator produces an oversized fallback estimate. - **Custom `SizeCalculator` and lists**: runs while a per-key-stripe lock may be held during contended list writes, so it must not call back into the cache. It receives a `Dictionary` copy of the whole list on each write; the built-in `WithDynamicSizing`/`WithFixedSizing` avoid that copy. - **In-memory numeric TTLs**: `SetIfHigherAsync` and `SetIfLowerAsync` apply the requested expiration even when the numeric value is unchanged. A zero return value does not mean the TTL was preserved; `null` clears it. Size-rejected updates remain unchanged. -- **In-memory lists and cloning**: List writes preserve supported dictionary types and comparers; copying an unsupported `IDictionary` throws `NotSupportedException` without changing the entry. Batch large updates because cloning, custom sizing, and raw dictionary access can require whole-list copies. `Items` honors `CloneValues` without affecting LRU. Collection snapshots do not isolate shared mutable elements when cloning is disabled. See the [caching guide](https://foundatio.readthedocs.io/guide/caching.html#in-memory-update-behavior) for details. Adding existing list items still reapplies their resulting expiration, even when the list contents are unchanged. +- **In-memory lists and cloning**: List writes preserve supported dictionary types and comparers; copying an unsupported `IDictionary` throws `NotSupportedException` without changing the entry. Batch large updates because cloning, custom sizing, and raw dictionary access can require whole-list copies. `Items` honors `CloneValues` without affecting LRU. Cloned dictionary reads preserve incremental list writes; uncloned dictionary reads expose a shared copy and require subsequent writers to copy it. Collection snapshots do not isolate shared mutable elements when cloning is disabled. See the [caching guide](https://foundatio.readthedocs.io/guide/caching.html#in-memory-update-behavior) for details. Adding existing list items still reapplies their resulting expiration, even when the list contents are unchanged. - **Register as singletons**: All infrastructure services (`ICacheClient`, `IMessageBus`, `IQueue`, `IFileStorage`, `ILockProvider`) maintain internal state and connections -- always register as singletons. - **CacheLockProvider + IMessageBus**: `IMessageBus` is optional but recommended. Without it, lock release falls back to polling. With it, locks are released instantly via pub/sub notification. - **In-memory for tests**: Use in-memory implementations for fast, isolated tests, but retain provider integration coverage for serialization, expiration, and concurrency behavior. A common interface does not guarantee identical backend semantics. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 42789ff14..ba18322eb 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -273,6 +273,7 @@ Successful removal of a live matching value, or removal of the last list value, - `ListAddAsync` returns `0` and leaves the list unchanged when the merged list fails size validation. - `Items` returns the cached values rather than the internal entry objects. - `IncrementAsync` recalculates the entry's tracked size after each increment. +- If a custom `SizeCalculator` throws, the fallback estimate is also checked against `MaxEntrySize`. An oversized fallback now rejects the write (or throws `MaxEntrySizeExceededCacheException` when configured), preserving the previous entry. Earlier versions bypassed this limit on the fallback path. - `ReplaceAsync` on an expired key returns `false` and raises `ItemExpired` once while reclaiming it. - `CurrentMemorySize` is approximate while `RemoveAllAsync()` or `Dispose` races concurrent writes. This is unchanged from earlier versions; it self-corrects on the next recount after compaction. - List size limits (`MaxEntrySize`, a negative `SizeCalculator` result) apply to the whole resulting list on every `ListAddAsync` and `ListRemoveAsync`, so a removal that leaves the list over the limit is rejected and returns `0`. @@ -286,7 +287,7 @@ List updates preserve the collection structure of previously returned snapshots. `Items` returns cached values without changing eviction order. Both `Items` and ordinary reads honor `CloneValues`. With cloning disabled, mutable payloads and list elements can remain shared: a collection snapshot is not a deep copy. Do not mutate shared objects concurrently with cache operations. ::: tip Large list updates -Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` still receives a `Dictionary` copy of the whole list on each write. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync>`) materializes a copy the first time it's read. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free. +Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` still receives a `Dictionary` copy of the whole list on each write. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync>`) with cloning disabled materializes a shared dictionary and makes subsequent writes copy it. With cloning enabled, dictionary reads and `Items` return independent copies without disabling incremental writes. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free. ::: ### HybridCacheClient diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index cbae38cf1..08edccd5f 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -1559,7 +1559,7 @@ internal void RecordAccess() /// internal object? PeekValue() { - object? value = _cacheValue is IListSnapshot list ? list.GetSnapshot() : _cacheValue; + object? value = _cacheValue is IListSnapshot list ? list.GetSnapshot(shared: !_shouldClone) : _cacheValue; return _shouldClone ? value.DeepClone() : value; } @@ -1626,52 +1626,39 @@ private long CalculateEntrySize(object? value) if (value is null) return 0; + long size; try { - long size = _sizeCalculator!(value); - - // Validate the size returned by the calculator - if (size < 0) - { - _logger.LogWarning("SizeCalculator returned negative size {Size} for type {EntryType}. Entry will not be cached. " + - "Custom SizeCalculator functions must return non-negative values.", - size, value?.GetType().Name ?? "null"); - return -1; - } - - // Check if entry exceeds maximum size - if (_maxEntrySize.HasValue && size > _maxEntrySize.Value) - { - if (_shouldThrowOnMaxEntrySizeExceeded) - { - throw new MaxEntrySizeExceededCacheException(size, _maxEntrySize.Value, value?.GetType().Name ?? "null"); - } - - _logger.LogWarning("Cache entry size {EntrySize:N0} bytes exceeds maximum allowed size {MaxEntrySize:N0} bytes for type {EntryType}. Entry will not be cached.", - size, _maxEntrySize.Value, value?.GetType().Name ?? "null"); - - return -1; - } - - return size; + size = _sizeCalculator!(value); } catch (MaxEntrySizeExceededCacheException) { - throw; // Re-throw MaxEntrySizeExceededCacheException from size check + throw; } catch (Exception ex) { - _logger.LogError(ex, "Error calculating entry size for type {EntryType}, using fallback estimation", - value?.GetType().Name ?? "null"); + _logger.LogError(ex, "Error calculating entry size for type {EntryType}, using fallback estimation", value.GetType().Name); + size = value is string stringValue ? 24 + ((long)stringValue.Length * 2) : 64; + } - // Fallback to simple estimation - return value switch - { - null => 8, - string stringValue => 24 + ((long)stringValue.Length * 2), - _ => 64 // Default object overhead - }; + if (size < 0) + { + _logger.LogWarning("SizeCalculator returned negative size {Size} for type {EntryType}. Entry will not be cached. " + + "Custom SizeCalculator functions must return non-negative values.", size, value.GetType().Name); + return -1; } + + if (_maxEntrySize.HasValue && size > _maxEntrySize.Value) + { + if (_shouldThrowOnMaxEntrySizeExceeded) + throw new MaxEntrySizeExceededCacheException(size, _maxEntrySize.Value, value.GetType().Name); + + _logger.LogWarning("Cache entry size {EntrySize:N0} bytes exceeds maximum allowed size {MaxEntrySize:N0} bytes for type {EntryType}. Entry will not be cached.", + size, _maxEntrySize.Value, value.GetType().Name); + return -1; + } + + return size; } private static bool TypeRequiresCloning(Type? t) diff --git a/src/Foundatio/Caching/InMemoryListSnapshot.cs b/src/Foundatio/Caching/InMemoryListSnapshot.cs index c16e743c3..05a2dbf23 100644 --- a/src/Foundatio/Caching/InMemoryListSnapshot.cs +++ b/src/Foundatio/Caching/InMemoryListSnapshot.cs @@ -11,7 +11,7 @@ namespace Foundatio.Caching; internal interface IListSnapshot { - object GetSnapshot(); + object GetSnapshot(bool shared = true); } /// @@ -39,6 +39,7 @@ internal sealed class ListSnapshot : IListSnapshot where T : notnull private readonly long _maxExpiresAtUtcTicks; private Layout? _layout; private Dictionary? _snapshot; + private bool _snapshotExposed; /// The items, owned by the snapshot from now on. /// @@ -76,10 +77,10 @@ private ListSnapshot(Layout layout, IEqualityComparer comparer, int count) public bool IsIndexed => Volatile.Read(ref _layout) is not null; /// - /// false once a raw read has materialized a dictionary: that dictionary may be changed by its reader when + /// false once an uncloned raw read has exposed a dictionary: that dictionary may be changed by its reader when /// cloning is off, so from then on it, not this snapshot, holds the list, and writers copy it instead. /// - public bool IsSourceOfTruth => Volatile.Read(ref _snapshot) is null; + public bool IsSourceOfTruth => !Volatile.Read(ref _snapshotExposed); /// The latest item expiration, or null when any item is permanent or the list is empty. public DateTime? ExpiresAtUtc => _permanentCount > 0 || Count == 0 ? null : FromUtcTicks(_maxExpiresAtUtcTicks); @@ -126,10 +127,14 @@ public T[] Read(DateTime utcNow) } /// - /// The dictionary handed to raw reads: a copy made once, so a reader changing it can't corrupt this snapshot. + /// The dictionary used for raw reads, materialized once. Only reads that expose it without cloning change the + /// source of truth; cloned reads cannot mutate it and leave incremental writes enabled. /// - public object GetSnapshot() + public object GetSnapshot(bool shared = true) { + if (shared) + Volatile.Write(ref _snapshotExposed, true); + if (Volatile.Read(ref _snapshot) is { } snapshot) return snapshot; diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index 716b8305a..9d463e6aa 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -181,6 +181,58 @@ public override Task GetAllExpirationAsync_WithMixedKeys_ReturnsExpectedResults( return base.GetAllExpirationAsync_WithMixedKeys_ReturnsExpectedResults(); } + [Fact] + public async Task GetAsync_WithClonedList_CopiesMutableComparer() + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(true)); + var values = new Dictionary(new CountingComparer()); + foreach (int value in Enumerable.Range(0, 100)) + values.Add(value, null); + await cache.SetAsync("set", values); + await cache.ListAddAsync("set", [-1]); + + // Act + var first = (await cache.GetAsync>("set")).Value!; + var second = (await cache.GetAsync>("set")).Value!; + var firstComparer = Assert.IsType(first.Comparer); + var secondComparer = Assert.IsType(second.Comparer); + int calls = secondComparer.Calls; + first.ContainsKey(0); + + // Assert + Assert.NotSame(firstComparer, secondComparer); + Assert.Equal(calls, secondComparer.Calls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetAsync_WithClonedList_PreservesIncrementalWriteAllocations(bool diagnosticRead) + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(true)); + await cache.ListAddAsync("set", Enumerable.Range(0, 10_000)); + await cache.ListAddAsync("set", [-1]); + await cache.ListAddAsync("set", [-2]); + long before = GC.GetAllocatedBytesForCurrentThread(); + await cache.ListAddAsync("set", [-3]); + long baseline = GC.GetAllocatedBytesForCurrentThread() - before; + + // Act + var copy = diagnosticRead + ? (IDictionary)cache.Items.Single().Value! + : (await cache.GetAsync>("set")).Value!; + copy.Clear(); + before = GC.GetAllocatedBytesForCurrentThread(); + await cache.ListAddAsync("set", [-4]); + long afterRead = GC.GetAllocatedBytesForCurrentThread() - before; + + // Assert + Assert.Equal(10_004, (await cache.GetListAsync("set")).Value!.Count); + Assert.True(afterRead < baseline * 2, $"Write allocation grew from {baseline} to {afterRead} bytes after a cloned read."); + } + [Fact] public override Task GetAsync_WithComplexObject_PreservesPropertiesAndReturnsNewInstance() { @@ -2805,6 +2857,30 @@ await Assert.ThrowsAsync(async () => Assert.Equal(3, cache.CurrentMemorySize); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SetAsync_WithOversizedCalculatorFallback_PreservesEntry(bool shouldThrow) + { + // Arrange + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + using var cache = new InMemoryCacheClient(o => o.TimeProvider(timeProvider).MaxMemorySize(1000) + .SizeCalculator(value => (string)value! == "old" ? 3 : throw new InvalidOperationException("Sizing failed")) + .MaxEntrySize(10).ShouldThrowOnMaxEntrySizeExceeded(shouldThrow)); + await cache.SetAsync("key", "old", TimeSpan.FromMinutes(1)); + + // Act + if (shouldThrow) + await Assert.ThrowsAsync(() => cache.SetAsync("key", "replacement", TimeSpan.FromMinutes(5))); + else + Assert.False(await cache.SetAsync("key", "replacement", TimeSpan.FromMinutes(5))); + + // Assert + Assert.Equal("old", (await cache.GetAsync("key")).Value); + Assert.Equal(TimeSpan.FromMinutes(1), await cache.GetExpirationAsync("key")); + Assert.Equal(3, cache.CurrentMemorySize); + } + [Fact] public async Task SetAsync_WithOversizedEntry_LogsWarningAndReturnsFalse() { @@ -3223,6 +3299,19 @@ public void UpdateEntry_WhenKeyRemovedDuringUpdate_AddsInsteadOfUpdating() Assert.Equal(50, cache.CurrentMemorySize); } + private sealed class CountingComparer : IEqualityComparer + { + public int Calls { get; private set; } + + public bool Equals(int x, int y) + { + Calls++; + return x == y; + } + + public int GetHashCode(int value) => value; + } + private sealed class DictionarySizeCalculator(ILoggerFactory loggerFactory) : SizeCalculator(loggerFactory) { public new long CalculateSize(object? value) => ((Dictionary)value!).Count; From c121125043c3ec4e76387d30ce49ce729af45d3e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 14 Oct 2026 23:00:44 -0500 Subject: [PATCH 4/7] Allow custom sizing through immutable collection views --- .agents/skills/foundatio/SKILL.md | 2 +- docs/guide/caching.md | 2 +- src/Foundatio/Caching/InMemoryCacheClient.cs | 9 ++- .../Caching/InMemoryCacheClientOptions.cs | 24 ++++++- .../Caching/InMemoryCacheClientTests.cs | 66 +++++++++++++++++++ 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index bc966c928..178c006e1 100644 --- a/.agents/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -313,7 +313,7 @@ public class OrderServiceTests : TestLoggerBase - **JobWithLockBase vs manual locking**: Use `JobWithLockBase` when the entire run must be single-instance (leader election). Use manual `ILockProvider.AcquireAsync` inside `JobBase` for finer-grained locking within a job. - **JobContext.RenewLockAsync**: Call in long-running jobs (both `JobBase` and `QueueJobBase`) to prevent lock expiration mid-processing. - **In-memory expired entries**: Writes treat an expired entry as missing before maintenance sweeps it (as Redis does): increments and `SetIfHigher`/`SetIfLower` start fresh, `ReplaceAsync`, `SetExpirationAsync`, `RemoveIfEqualAsync` and `ReplaceIfEqualAsync` miss. The write reclaims the expired entry and raises `ItemExpired` once. Successful live conditional removal and empty-list removal free memory without that event. Size-rejected updates preserve value, TTL, and tracked size; this also applies when a throwing custom calculator produces an oversized fallback estimate. -- **Custom `SizeCalculator` and lists**: runs while a per-key-stripe lock may be held during contended list writes, so it must not call back into the cache. It receives a `Dictionary` copy of the whole list on each write; the built-in `WithDynamicSizing`/`WithFixedSizing` avoid that copy. +- **Custom `SizeCalculator` and lists**: runs while a per-key-stripe lock may be held during contended list writes, so it must not call back into the cache. By default it receives a `Dictionary` copy of the whole list on each write. Interface-based calculators that never mutate inputs can opt in via `SizeCalculator(calculateSize, supportsReadOnlyCollections: true)` to avoid that copy for immutable lists; both ordinary dictionaries and read-only views must be supported. Built-in `WithDynamicSizing`/`WithFixedSizing` already avoid the copy. - **In-memory numeric TTLs**: `SetIfHigherAsync` and `SetIfLowerAsync` apply the requested expiration even when the numeric value is unchanged. A zero return value does not mean the TTL was preserved; `null` clears it. Size-rejected updates remain unchanged. - **In-memory lists and cloning**: List writes preserve supported dictionary types and comparers; copying an unsupported `IDictionary` throws `NotSupportedException` without changing the entry. Batch large updates because cloning, custom sizing, and raw dictionary access can require whole-list copies. `Items` honors `CloneValues` without affecting LRU. Cloned dictionary reads preserve incremental list writes; uncloned dictionary reads expose a shared copy and require subsequent writers to copy it. Collection snapshots do not isolate shared mutable elements when cloning is disabled. See the [caching guide](https://foundatio.readthedocs.io/guide/caching.html#in-memory-update-behavior) for details. Adding existing list items still reapplies their resulting expiration, even when the list contents are unchanged. - **Register as singletons**: All infrastructure services (`ICacheClient`, `IMessageBus`, `IQueue`, `IFileStorage`, `ILockProvider`) maintain internal state and connections -- always register as singletons. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index ba18322eb..0eb1b339c 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -287,7 +287,7 @@ List updates preserve the collection structure of previously returned snapshots. `Items` returns cached values without changing eviction order. Both `Items` and ordinary reads honor `CloneValues`. With cloning disabled, mutable payloads and list elements can remain shared: a collection snapshot is not a deep copy. Do not mutate shared objects concurrently with cache operations. ::: tip Large list updates -Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` still receives a `Dictionary` copy of the whole list on each write. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync>`) with cloning disabled materializes a shared dictionary and makes subsequent writes copy it. With cloning enabled, dictionary reads and `Items` return independent copies without disabling incremental writes. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free. +Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` receives a `Dictionary` copy of the whole list on each write by default. Calculators that use collection interfaces can opt in to read-only views with `SizeCalculator(calculateSize, supportsReadOnlyCollections: true)` (or `SizeCalculatorSupportsReadOnlyCollections` on the options). They must accept ordinary dictionaries and read-only dictionary views and must not modify either. This avoids the sizing copy for immutable lists; it does not change cache reads, element cloning, or the cost of enumerating all items. Prefer `WithFixedSizing` for a constant estimate. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync>`) with cloning disabled materializes a shared dictionary and makes subsequent writes copy it. With cloning enabled, dictionary reads and `Items` return independent copies without disabling incremental writes. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free. ::: ### HybridCacheClient diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index 08edccd5f..f446fbd4f 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -83,13 +83,12 @@ public InMemoryCacheClient(InMemoryCacheClientOptions? options = null) _sizeCalculator = options.SizeCalculator; _hasSizeCalculator = _sizeCalculator is not null; - // Only the built-in calculators are known to accept a list's read-only view; custom ones get a Dictionary - _sizesListsInPlace = _sizeCalculator?.Target switch + _sizesListsInPlace = options.SizeCalculatorSupportsReadOnlyCollections || (_sizeCalculator?.Target switch { Utility.SizeCalculator calculator => _sizeCalculator == calculator.CalculateSize, InMemoryCacheClientOptionsBuilder.FixedSizeCalculator calculator => _sizeCalculator == calculator.Calculate, _ => false - }; + }); _shouldTrackMemory = _hasSizeCalculator && _maxMemorySize.HasValue; _memory = new ConcurrentDictionary(); @@ -732,8 +731,8 @@ private static class ListItemCloning } /// - /// Sizes a shared list. The built-in calculators read it through a read-only view, so it isn't copied; a custom - /// calculator gets its own , as it always has, since it may cast to it. + /// Sizes a shared list without copying when the calculator supports read-only collections. Other calculators + /// keep receiving a , since they may cast to it. /// private long CalculateListSize(ListSnapshot list) where T : notnull { diff --git a/src/Foundatio/Caching/InMemoryCacheClientOptions.cs b/src/Foundatio/Caching/InMemoryCacheClientOptions.cs index 59dab0c13..d67ab1b0d 100644 --- a/src/Foundatio/Caching/InMemoryCacheClientOptions.cs +++ b/src/Foundatio/Caching/InMemoryCacheClientOptions.cs @@ -41,6 +41,17 @@ public class InMemoryCacheClientOptions : SharedOptions /// public Func? SizeCalculator { get; set; } + /// + /// Whether the custom size calculator accepts read-only collection implementations as well as concrete collections. + /// Defaults to false, preserving the concrete dictionary passed when sizing lists. + /// + /// + /// Enable this to avoid copying immutable lists for sizing. The calculator must use collection interfaces, + /// must not mutate values, and must support both ordinary dictionaries and read-only dictionary views. + /// This does not change values returned by cache reads or remove the cost of enumerating a collection. + /// + public bool SizeCalculatorSupportsReadOnlyCollections { get; set; } + /// /// The maximum size in bytes for individual cache entries. Entries larger than this will be skipped (not cached) and a warning will be logged. /// If null, no entry size limit is enforced. Must be less than or equal to when both are set. @@ -73,7 +84,7 @@ public InMemoryCacheClientOptionsBuilder MaxItems(int? maxItems) } /// - /// Sets the maximum memory size in bytes. Use this with for custom size calculation. + /// 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. @@ -89,8 +100,19 @@ public InMemoryCacheClientOptionsBuilder MaxMemorySize(long maxMemorySize) /// /// Function that returns the size of an entry in bytes. public InMemoryCacheClientOptionsBuilder SizeCalculator(Func sizeCalculator) + { + return SizeCalculator(sizeCalculator, supportsReadOnlyCollections: false); + } + + /// + /// Sets a custom size calculator, optionally allowing read-only collection views to avoid copying lists for sizing. + /// + /// Function that returns the size of an entry in bytes. + /// Whether the calculator accepts both ordinary collections and read-only views through collection interfaces. + public InMemoryCacheClientOptionsBuilder SizeCalculator(Func sizeCalculator, bool supportsReadOnlyCollections) { Target.SizeCalculator = sizeCalculator; + Target.SizeCalculatorSupportsReadOnlyCollections = supportsReadOnlyCollections; return this; } diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index 9d463e6aa..2a0615873 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -749,6 +749,7 @@ public async Task ListAddAsync_WithCustomSizeCalculator_ReceivesDictionaryOfCurr if (multicast) calculateSize += new InMemoryCacheClientOptionsBuilder().WithFixedSizing(100_000_000, 100).Build().SizeCalculator; using var cache = new InMemoryCacheClient(o => o.CloneValues(false).MaxMemorySize(100_000_000) + .SizeCalculator(_ => 100, supportsReadOnlyCollections: true) .SizeCalculator(calculateSize).LoggerFactory(Log)); await cache.ListAddAsync("list", Enumerable.Range(0, 1000)); seen.Clear(); @@ -855,6 +856,71 @@ public async Task ListAddAsync_WithOverriddenFixedSizer_ValidatesReplacement() Assert.Equal(Enumerable.Range(0, 64), (await cache.GetListAsync("set")).Value); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ListAddAsync_WithReadOnlySizeCalculator_PreservesRejectedSnapshot(bool remove, bool shouldThrow) + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + IDictionary? observed = null; + bool reject = false; + using var cache = new InMemoryCacheClient(o => o.TimeProvider(clock).MaxMemorySize(1000).MaxEntrySize(200) + .ShouldThrowOnMaxEntrySizeExceeded(shouldThrow).SizeCalculator(value => + { + observed = (IDictionary)value; + return reject ? 201 : observed.Count; + }, supportsReadOnlyCollections: true)); + await cache.ListAddAsync("set", Enumerable.Range(0, 128), TimeSpan.FromMinutes(1)); + await cache.ListAddAsync("set", [-1], TimeSpan.FromMinutes(1)); + var previous = observed!; + reject = true; + + // Act + async Task UpdateAsync() => remove + ? await cache.ListRemoveAsync("set", [-1]) + : await cache.ListAddAsync("set", [-2], TimeSpan.FromMinutes(5)); + if (shouldThrow) + await Assert.ThrowsAsync(UpdateAsync); + else + Assert.Equal(0, await UpdateAsync()); + + // Assert + Assert.True(previous.IsReadOnly); + Assert.Throws(() => previous.Clear()); + Assert.Equal(129, previous.Count); + Assert.False(previous.ContainsKey(-2)); + Assert.Equal(remove ? 128 : 130, observed!.Count); + Assert.Equal(129, (await cache.GetListAsync("set")).Value!.Count); + Assert.Equal(129, cache.CurrentMemorySize); + Assert.Equal(TimeSpan.FromMinutes(1), await cache.GetExpirationAsync("set")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ListAddAsync_WithReadOnlySizeCalculator_UsesIncrementalWriteAllocations(bool cloneValues) + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(cloneValues).MaxMemorySize(1_000_000) + .SizeCalculator(value => ((IDictionary)value).Count, supportsReadOnlyCollections: true)); + await cache.ListAddAsync("set", Enumerable.Range(0, 10_000)); + await cache.ListAddAsync("set", [-1]); + await cache.ListAddAsync("set", [-2]); + long before = GC.GetAllocatedBytesForCurrentThread(); + + // Act + await cache.ListAddAsync("set", [-3]); + await cache.ListRemoveAsync("set", [-3]); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // Assert + Assert.True(allocated < 100_000, $"A small update allocated {allocated} bytes; it must not copy the entire list for sizing."); + Assert.Equal(10_002, cache.CurrentMemorySize); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] From 3f24b90b218f715b02402c12616ee67030ff58a4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 14 Oct 2026 23:14:28 -0500 Subject: [PATCH 5/7] Cover custom sizing under contention and in benchmarks --- benchmarks/InMemoryListBenchmarks.cs | 7 ++++--- .../Caching/InMemoryCacheClientTests.cs | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/benchmarks/InMemoryListBenchmarks.cs b/benchmarks/InMemoryListBenchmarks.cs index 8b7f0bd7c..0173f318b 100644 --- a/benchmarks/InMemoryListBenchmarks.cs +++ b/benchmarks/InMemoryListBenchmarks.cs @@ -10,7 +10,7 @@ namespace Foundatio.Benchmarks; [MemoryDiagnoser] public class InMemoryListBenchmarks { - public enum SizingMode { None, Fixed, Custom, Dynamic } + public enum SizingMode { None, Fixed, Custom, CustomReadOnly, Dynamic } [Params(false, true)] public bool CloneValues { get; set; } @@ -18,7 +18,7 @@ public enum SizingMode { None, Fixed, Custom, Dynamic } [Params(10, 1000)] public int Count { get; set; } - [Params(SizingMode.None, SizingMode.Fixed, SizingMode.Custom, SizingMode.Dynamic)] + [Params(SizingMode.None, SizingMode.Fixed, SizingMode.Custom, SizingMode.CustomReadOnly, SizingMode.Dynamic)] public SizingMode Sizing { get; set; } private InMemoryCacheClient _cache = null!; @@ -36,7 +36,8 @@ public async Task Setup() return Sizing switch { SizingMode.Fixed => o.WithFixedSizing(100000000, 100), - SizingMode.Custom => o.MaxMemorySize(100000000).SizeCalculator(_ => 100), + SizingMode.Custom => o.MaxMemorySize(100000000).SizeCalculator(value => ((IDictionary)value).Count), + SizingMode.CustomReadOnly => o.MaxMemorySize(100000000).SizeCalculator(value => ((IDictionary)value).Count, supportsReadOnlyCollections: true), SizingMode.Dynamic => o.WithDynamicSizing(100000000), _ => o }; diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index 2a0615873..9b424aa64 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -609,12 +609,22 @@ private readonly record struct ReferenceHoldingItem(int Id, List Values) } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task ListAddAsync_WithConcurrentLargeListUpdates_PreservesUnrelatedValues(bool cloneValues) + [InlineData(false, null)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, null)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ListAddAsync_WithConcurrentLargeListUpdates_PreservesUnrelatedValues(bool cloneValues, bool? supportsReadOnlyCollections) { // Arrange - using var cache = new InMemoryCacheClient(o => o.LoggerFactory(Log).CloneValues(cloneValues)); + using var cache = new InMemoryCacheClient(o => + { + o.LoggerFactory(Log).CloneValues(cloneValues); + return supportsReadOnlyCollections.HasValue + ? o.MaxMemorySize(1_000_000).SizeCalculator(value => ((IDictionary)value).Count, supportsReadOnlyCollections.Value) + : o; + }); var original = Enumerable.Range(0, 1000).ToArray(); await cache.ListAddAsync("set", original); @@ -633,6 +643,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, 4), TestCancellationToken, async // Assert var remaining = (await cache.GetListAsync("set")).Value!; Assert.Equal(original, remaining.OrderBy(value => value)); + if (supportsReadOnlyCollections.HasValue) + Assert.Equal(original.Length, cache.CurrentMemorySize); } [Fact] From f13dca96485df33b8521e0319bdabe6c645cc05b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 15 Oct 2026 00:08:39 -0500 Subject: [PATCH 6/7] Preserve live entries on expired writes and bound list allocation costs --- .agents/skills/foundatio/SKILL.md | 2 +- docs/guide/caching.md | 3 +- src/Foundatio/Caching/InMemoryCacheClient.cs | 5 +- src/Foundatio/Caching/InMemoryListSnapshot.cs | 8 +- .../Caching/InMemoryCacheClientTests.cs | 128 ++++++++++++++++++ 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index 693c63bad..2515bcd5d 100644 --- a/.agents/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -308,7 +308,7 @@ public class OrderServiceTests : TestLoggerBase - **Lock returns null**: `TryAcquireAsync` returns `null` when the lock cannot be acquired -- always guard with `is not null` before doing work. `AcquireAsync` throws `LockAcquisitionTimeoutException` instead of returning null. - **Dispose streams and locks**: `ILock` is `IAsyncDisposable` -- use `await using`. Streams from `GetFileStreamAsync` are `IDisposable` -- use `using var`. -- **Cache TTL floor**: Expiration values below 5ms are treated as already expired. Key writes remove the key; `ListAddAsync` removes only the supplied values. Guard dynamically computed TTLs against near-zero values. +- **Cache TTL floor**: Expiration values below 5ms are treated as already expired. Key writes remove the key; `ListAddAsync` removes only the supplied values. Guard dynamically computed TTLs against near-zero values. In-memory `AddAsync`/`SetAsync`/`ReplaceAsync` that expire during value preparation return false without deleting a live entry; this differs from explicitly requesting a TTL below the floor. - **Cache `GetAsync` returns `CacheValue`**: Check `result.HasValue` before accessing `result.Value`. A missing key returns `HasValue = false`, not an exception. - **Cache stampede (thundering herd)**: The cache-aside pattern (`Get` -> miss -> load -> `Set`) is vulnerable to stampedes when a popular key expires and many callers regenerate simultaneously. Use `CacheLockProvider` to serialize regeneration: acquire a lock keyed on the cache key, double-check the cache after acquiring, and only then call the backing store. See the [Cache Stampede Protection](https://foundatio.readthedocs.io/guide/caching.html#cache-stampede-protection) docs for the full pattern. - **Queue auto-complete**: `QueueJobBase` auto-completes entries based on `JobResult` by default. Set `AutoComplete = false` only when you need manual `CompleteAsync()`/`AbandonAsync()` control. Manual `DequeueAsync` does NOT auto-complete. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 0eb1b339c..e8e803763 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -273,6 +273,7 @@ Successful removal of a live matching value, or removal of the last list value, - `ListAddAsync` returns `0` and leaves the list unchanged when the merged list fails size validation. - `Items` returns the cached values rather than the internal entry objects. - `IncrementAsync` recalculates the entry's tracked size after each increment. +- If an accepted TTL elapses while `AddAsync`, `SetAsync`, or `ReplaceAsync` prepares its value (for example, during sizing or cloning), the write returns `false` without deleting an existing live entry. This differs from explicitly supplying a TTL below 5ms, which still requests removal. - If a custom `SizeCalculator` throws, the fallback estimate is also checked against `MaxEntrySize`. An oversized fallback now rejects the write (or throws `MaxEntrySizeExceededCacheException` when configured), preserving the previous entry. Earlier versions bypassed this limit on the fallback path. - `ReplaceAsync` on an expired key returns `false` and raises `ItemExpired` once while reclaiming it. - `CurrentMemorySize` is approximate while `RemoveAllAsync()` or `Dispose` races concurrent writes. This is unchanged from earlier versions; it self-corrects on the next recount after compaction. @@ -287,7 +288,7 @@ List updates preserve the collection structure of previously returned snapshots. `Items` returns cached values without changing eviction order. Both `Items` and ordinary reads honor `CloneValues`. With cloning disabled, mutable payloads and list elements can remain shared: a collection snapshot is not a deep copy. Do not mutate shared objects concurrently with cache operations. ::: tip Large list updates -Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` receives a `Dictionary` copy of the whole list on each write by default. Calculators that use collection interfaces can opt in to read-only views with `SizeCalculator(calculateSize, supportsReadOnlyCollections: true)` (or `SizeCalculatorSupportsReadOnlyCollections` on the options). They must accept ordinary dictionaries and read-only dictionary views and must not modify either. This avoids the sizing copy for immutable lists; it does not change cache reads, element cloning, or the cost of enumerating all items. Prefer `WithFixedSizing` for a constant estimate. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync>`) with cloning disabled materializes a shared dictionary and makes subsequent writes copy it. With cloning enabled, dictionary reads and `Items` return independent copies without disabling incremental writes. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free. +Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` receives a `Dictionary` copy of the whole list on each write by default. Calculators that use collection interfaces can opt in to read-only views with `SizeCalculator(calculateSize, supportsReadOnlyCollections: true)` (or `SizeCalculatorSupportsReadOnlyCollections` on the options). They must accept ordinary dictionaries and read-only dictionary views and must not modify either. This avoids the sizing copy for immutable lists; it does not change cache reads, element cloning, or the cost of enumerating all items. Sampling `IReadOnlyDictionary.Keys` or `.Values` enumerates lazily. Lists that shrink substantially rebuild their sparse storage on a subsequent write; that occasional full copy prevents ongoing costs from remaining tied to the historical peak size. Prefer `WithFixedSizing` for a constant estimate. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync>`) with cloning disabled materializes a shared dictionary and makes subsequent writes copy it. With cloning enabled, dictionary reads and `Items` return independent copies without disabling incremental writes. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free. ::: ### HybridCacheClient diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index f446fbd4f..91bc25d50 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -895,14 +895,11 @@ private async Task SetInternalAsync(string key, CacheEntry entry, WriteMod ArgumentException.ThrowIfNullOrEmpty(key); if (entry.IsExpired) - { - RemoveExpiredKey(key); return false; - } Interlocked.Increment(ref _writes); - bool wasUpdated = UpdateEntry(key, current => mode switch + bool wasUpdated = UpdateEntry(key, current => entry.IsExpired ? (current, false) : mode switch { WriteMode.Add when current is not null => (current, false), WriteMode.Replace when current is null => (current, false), diff --git a/src/Foundatio/Caching/InMemoryListSnapshot.cs b/src/Foundatio/Caching/InMemoryListSnapshot.cs index 05a2dbf23..067443181 100644 --- a/src/Foundatio/Caching/InMemoryListSnapshot.cs +++ b/src/Foundatio/Caching/InMemoryListSnapshot.cs @@ -154,7 +154,9 @@ public ListSnapshot Update(ICollection items, DateTime? expiresAtUtc, Date // and it avoids indexing short-lived bulk lists. The first small change to an unindexed list also copies, // since a copy (~3 us at 1,000 items) is far cheaper than building the index for a list that may not change again. bool batch = items.Count >= Math.Max(1, Count / BlockSize); - if (batch || (!IsIndexed && !_fromIncrementalChange)) + // Rebuild after substantial shrinkage so small writes don't keep copying peak-sized storage. + bool compact = Volatile.Read(ref _layout) is { } layout && Count < layout.NextSlot / 4; + if (batch || compact || (!IsIndexed && !_fromIncrementalChange)) { var dictionary = ToDictionary(); if (prune) @@ -473,8 +475,8 @@ public DateTime? this[T key] public ICollection Keys => new ReadOnlyCollection(snapshot.EnumeratePairs().Select(pair => pair.Key).ToArray()); public ICollection Values => new ReadOnlyCollection(snapshot.EnumeratePairs().Select(pair => pair.Value).ToArray()); - IEnumerable IReadOnlyDictionary.Keys => Keys; - IEnumerable IReadOnlyDictionary.Values => Values; + IEnumerable IReadOnlyDictionary.Keys => snapshot.EnumeratePairs().Select(pair => pair.Key); + IEnumerable IReadOnlyDictionary.Values => snapshot.EnumeratePairs().Select(pair => pair.Value); public bool ContainsKey(T key) => snapshot.ContainsKey(key); public bool TryGetValue(T key, out DateTime? value) => snapshot.TryGetExpiresAtUtc(key, out value); diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index 9b424aa64..e133b9b78 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -868,6 +868,32 @@ public async Task ListAddAsync_WithOverriddenFixedSizer_ValidatesReplacement() Assert.Equal(Enumerable.Range(0, 64), (await cache.GetListAsync("set")).Value); } + [Fact] + public async Task ListAddAsync_WithReadOnlySizeCalculator_DoesNotMaterializeSampledKeysAndValues() + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(false).MaxMemorySize(1_000_000) + .SizeCalculator(value => + { + var dictionary = (IReadOnlyDictionary)value; + return dictionary.Count + dictionary.Keys.Take(1).Count() + dictionary.Values.Take(1).Count(); + }, supportsReadOnlyCollections: true)); + await cache.ListAddAsync("set", Enumerable.Range(0, 10_000)); + await cache.ListAddAsync("set", [-1]); + await cache.ListAddAsync("set", [-2]); + long before = GC.GetAllocatedBytesForCurrentThread(); + + // Act + await cache.ListAddAsync("set", [-3]); + await cache.ListRemoveAsync("set", [-3]); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // Assert + _logger.LogInformation("Sampled key/value sizing allocated {Allocated} bytes", allocated); + Assert.True(allocated < 100_000, $"Sampling a key and value allocated {allocated} bytes; it must not materialize both collections."); + Assert.Equal(10_004, cache.CurrentMemorySize); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] @@ -1084,6 +1110,73 @@ public async Task ListRemoveAsync_WhenLastValueRemoved_RemovesKeyAndReleasesMemo Assert.False(await cache.ExistsAsync("set")); } + [Fact] + public async Task ListRemoveAsync_WithIncrementalShrink_PreservesComparerAndExpirations() + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + using var cache = new InMemoryCacheClient(o => o.CloneValues(false).TimeProvider(clock)); + var values = Enumerable.Range(0, 1000).ToDictionary(i => $"item-{i}", + i => (DateTime?)clock.GetUtcNow().UtcDateTime.AddMinutes(i % 2 + 1), StringComparer.OrdinalIgnoreCase); + await cache.SetAsync("set", values); + for (int i = 0; i < 800; i++) + await cache.ListRemoveAsync("set", [$"ITEM-{i}"]); + clock.Advance(TimeSpan.FromSeconds(90)); + + // Act + long removed = await cache.ListRemoveAsync("set", ["ITEM-999"]); + var current = await cache.GetListAsync("set"); + + // Assert + Assert.Equal(1, removed); + Assert.Equal(Enumerable.Range(800, 199).Where(i => i % 2 == 1).Select(i => $"item-{i}").Order(), current.Value!.Order()); + Assert.Equal(TimeSpan.FromSeconds(30), await cache.GetExpirationAsync("set")); + Assert.Equal(1000, values.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ListRemoveAsync_WithIncrementalShrink_ReclaimsSparseStorage(bool cloneValues) + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + using var cache = new InMemoryCacheClient(o => o.CloneValues(cloneValues).TimeProvider(clock)); + var ttl = TimeSpan.FromMinutes(5); + await cache.ListAddAsync("shrunk", Enumerable.Range(0, 10_000), ttl); + await cache.ListAddAsync("fresh", Enumerable.Range(9_000, 1_000), ttl); + await UpdateAsync("fresh"); + var previous = GetEntry(cache, "shrunk").StoredValue; + for (int i = 0; i < 9_000; i++) + await cache.ListRemoveAsync("shrunk", [i]); + await UpdateAsync("shrunk"); + + // Act + long freshAllocation = await MeasureAsync("fresh"); + long shrunkAllocation = await MeasureAsync("shrunk"); + + // Assert + _logger.LogInformation("List update allocated {Fresh} bytes fresh and {Shrunk} bytes after shrinking", freshAllocation, shrunkAllocation); + Assert.True(shrunkAllocation <= freshAllocation + 8192, + $"Storage should follow the live list, not its peak size: fresh={freshAllocation}, shrunk={shrunkAllocation} bytes."); + Assert.Equal(Enumerable.Range(9_000, 1_000), (await cache.GetListAsync("shrunk")).Value!.Order()); + Assert.Equal(ttl, await cache.GetExpirationAsync("shrunk")); + Assert.Equal(Enumerable.Range(0, 10_000), Assert.IsType>(previous).Read(clock.GetUtcNow().UtcDateTime)); + + async Task UpdateAsync(string key) + { + await cache.ListAddAsync(key, [-1], ttl); + await cache.ListRemoveAsync(key, [-1]); + } + + async Task MeasureAsync(string key) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + await UpdateAsync(key); + return GC.GetAllocatedBytesForCurrentThread() - before; + } + } + [Fact] public override Task ListRemoveAsync_WithInvalidInputs_ThrowsAppropriateException() { @@ -3062,6 +3155,41 @@ public async Task SetAsync_WithEntryUnderLimit_CachesSuccessfully() } } + [Theory] + [InlineData("Add")] + [InlineData("Replace")] + [InlineData("Set")] + public async Task WriteAsync_WithExpirationDuringSizing_PreservesLiveEntry(string operation) + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + bool delaySizing = false; + using var cache = new InMemoryCacheClient(o => o.TimeProvider(clock).MaxMemorySize(1000).SizeCalculator(_ => + { + if (delaySizing) + clock.Advance(TimeSpan.FromSeconds(1)); + return 50; + })); + await cache.SetAsync("key", "current-owner", TimeSpan.FromMinutes(1)); + var original = GetEntry(cache, "key"); + delaySizing = true; + + // Act + bool result = operation switch + { + "Add" => await cache.AddAsync("key", "stale-owner", TimeSpan.FromMilliseconds(10)), + "Replace" => await cache.ReplaceAsync("key", "stale-owner", TimeSpan.FromMilliseconds(10)), + _ => await cache.SetAsync("key", "stale-owner", TimeSpan.FromMilliseconds(10)) + }; + + // Assert + Assert.False(result); + Assert.Equal("current-owner", (await cache.GetAsync("key")).Value); + Assert.Same(original, GetEntry(cache, "key")); + Assert.Equal(TimeSpan.FromSeconds(59), await cache.GetExpirationAsync("key")); + Assert.Equal(50, cache.CurrentMemorySize); + } + [Theory] [InlineData("Add", true)] [InlineData("Increment", 10L)] From a039ccf8106dc9edf7c0fc7fd40cf56dad7a5e9d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 23 Oct 2026 06:54:44 -0500 Subject: [PATCH 7/7] Fix expired entry reclamation after value preparation --- .agents/skills/foundatio/SKILL.md | 2 +- docs/guide/caching.md | 2 +- src/Foundatio/Caching/InMemoryCacheClient.cs | 2 +- .../Caching/InMemoryCacheClientTests.cs | 33 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index 2515bcd5d..fadd19e29 100644 --- a/.agents/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -308,7 +308,7 @@ public class OrderServiceTests : TestLoggerBase - **Lock returns null**: `TryAcquireAsync` returns `null` when the lock cannot be acquired -- always guard with `is not null` before doing work. `AcquireAsync` throws `LockAcquisitionTimeoutException` instead of returning null. - **Dispose streams and locks**: `ILock` is `IAsyncDisposable` -- use `await using`. Streams from `GetFileStreamAsync` are `IDisposable` -- use `using var`. -- **Cache TTL floor**: Expiration values below 5ms are treated as already expired. Key writes remove the key; `ListAddAsync` removes only the supplied values. Guard dynamically computed TTLs against near-zero values. In-memory `AddAsync`/`SetAsync`/`ReplaceAsync` that expire during value preparation return false without deleting a live entry; this differs from explicitly requesting a TTL below the floor. +- **Cache TTL floor**: Expiration values below 5ms are treated as already expired. Key writes remove the key; `ListAddAsync` removes only the supplied values. Guard dynamically computed TTLs against near-zero values. In-memory `AddAsync`/`SetAsync`/`ReplaceAsync` that expire during value preparation return false without deleting a live entry; expired stored entries are still reclaimed and raise `ItemExpired`; this differs from explicitly requesting a TTL below the floor. - **Cache `GetAsync` returns `CacheValue`**: Check `result.HasValue` before accessing `result.Value`. A missing key returns `HasValue = false`, not an exception. - **Cache stampede (thundering herd)**: The cache-aside pattern (`Get` -> miss -> load -> `Set`) is vulnerable to stampedes when a popular key expires and many callers regenerate simultaneously. Use `CacheLockProvider` to serialize regeneration: acquire a lock keyed on the cache key, double-check the cache after acquiring, and only then call the backing store. See the [Cache Stampede Protection](https://foundatio.readthedocs.io/guide/caching.html#cache-stampede-protection) docs for the full pattern. - **Queue auto-complete**: `QueueJobBase` auto-completes entries based on `JobResult` by default. Set `AutoComplete = false` only when you need manual `CompleteAsync()`/`AbandonAsync()` control. Manual `DequeueAsync` does NOT auto-complete. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index e8e803763..6487f5bbe 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -273,7 +273,7 @@ Successful removal of a live matching value, or removal of the last list value, - `ListAddAsync` returns `0` and leaves the list unchanged when the merged list fails size validation. - `Items` returns the cached values rather than the internal entry objects. - `IncrementAsync` recalculates the entry's tracked size after each increment. -- If an accepted TTL elapses while `AddAsync`, `SetAsync`, or `ReplaceAsync` prepares its value (for example, during sizing or cloning), the write returns `false` without deleting an existing live entry. This differs from explicitly supplying a TTL below 5ms, which still requests removal. +- If an accepted TTL elapses while `AddAsync`, `SetAsync`, or `ReplaceAsync` prepares its value (for example, during sizing or cloning), the write returns `false` without deleting an existing live entry. An expired stored entry is still reclaimed and raises `ItemExpired`. This differs from explicitly supplying a TTL below 5ms, which still requests removal. - If a custom `SizeCalculator` throws, the fallback estimate is also checked against `MaxEntrySize`. An oversized fallback now rejects the write (or throws `MaxEntrySizeExceededCacheException` when configured), preserving the previous entry. Earlier versions bypassed this limit on the fallback path. - `ReplaceAsync` on an expired key returns `false` and raises `ItemExpired` once while reclaiming it. - `CurrentMemorySize` is approximate while `RemoveAllAsync()` or `Dispose` races concurrent writes. This is unchanged from earlier versions; it self-corrects on the next recount after compaction. diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index 91bc25d50..0a7a04f39 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -895,7 +895,7 @@ private async Task SetInternalAsync(string key, CacheEntry entry, WriteMod ArgumentException.ThrowIfNullOrEmpty(key); if (entry.IsExpired) - return false; + return UpdateEntry(key, current => (current, false)); Interlocked.Increment(ref _writes); diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index e133b9b78..5e3719580 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -3190,6 +3190,39 @@ public async Task WriteAsync_WithExpirationDuringSizing_PreservesLiveEntry(strin Assert.Equal(50, cache.CurrentMemorySize); } + [Theory] + [InlineData("Add")] + [InlineData("Replace")] + [InlineData("Set")] + public async Task WriteAsync_WithExpirationDuringSizing_ReclaimsExpiredEntry(string operation) + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + using var cache = new InMemoryCacheClient(o => o.TimeProvider(clock).MaxMemorySize(1000).SizeCalculator(_ => + { + clock.Advance(TimeSpan.FromSeconds(1)); + return 50; + })); + var expired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = cache.ItemExpired.AddSyncHandler((_, args) => expired.TrySetResult(args.Key)); + Publish(cache, "key", CreateEntry("expired-owner", clock, TimeSpan.FromMinutes(1))); + clock.Advance(TimeSpan.FromMinutes(2)); + + // Act + bool result = operation switch + { + "Add" => await cache.AddAsync("key", "stale-owner", TimeSpan.FromMilliseconds(10)), + "Replace" => await cache.ReplaceAsync("key", "stale-owner", TimeSpan.FromMilliseconds(10)), + _ => await cache.SetAsync("key", "stale-owner", TimeSpan.FromMilliseconds(10)) + }; + + // Assert + Assert.False(result); + Assert.Equal(0, cache.CurrentMemorySize); + Assert.Equal("key", await expired.Task.WaitAsync(TimeSpan.FromSeconds(5), TestCancellationToken)); + Assert.False((await cache.GetAsync("key")).HasValue); + } + [Theory] [InlineData("Add", true)] [InlineData("Increment", 10L)]