diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index 09b8d1415..c64291298 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; 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. @@ -316,10 +316,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. `ReplaceIfEqualAsync` also rejects a replacement that expires during preparation or a retry, preserving a still-live entry. -- **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 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. `ReplaceIfEqualAsync` also rejects a replacement that expires during preparation or a retry, preserving a still-live entry. +- **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. 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. 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**: In-memory implementations follow the same interfaces as production providers, but some behaviors differ (delivery delay limits, ordering, deduplication, group ids). Swap via DI for fast, isolated unit tests, and check [provider behavioral gaps](https://foundatio.dev/guide/provider-behavioral-gaps) before relying on provider-specific behavior. 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/docs/guide/caching.md b/docs/guide/caching.md index e120fee4d..8c35276b9 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -273,11 +273,14 @@ 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. 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. - `ReplaceIfEqualAsync` returns `false` if the replacement expires during preparation or a retry, preserving any still-live entry's value, expiration, and tracked size. - `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. ::: @@ -286,7 +289,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` 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 96d0efa1e..3241edd5b 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -83,8 +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 is Utility.SizeCalculator or InMemoryCacheClientOptionsBuilder.FixedSizeCalculator; + _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(); @@ -153,7 +157,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. /// /// /// @@ -680,7 +684,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); @@ -729,8 +733,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 { @@ -893,14 +897,11 @@ private async Task SetInternalAsync(string key, CacheEntry entry, WriteMod ArgumentException.ThrowIfNullOrEmpty(key); if (entry.IsExpired) - { - RemoveExpiredKey(key); - return false; - } + return UpdateEntry(key, current => (current, 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), @@ -1556,7 +1557,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; } @@ -1623,52 +1624,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/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/src/Foundatio/Caching/InMemoryListSnapshot.cs b/src/Foundatio/Caching/InMemoryListSnapshot.cs index c16e743c3..067443181 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; @@ -149,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) @@ -468,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 8c498b09b..354c0235e 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; @@ -180,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() { @@ -556,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); @@ -580,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] @@ -653,6 +718,67 @@ 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(_ => 100, supportsReadOnlyCollections: true) + .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() { @@ -742,6 +868,97 @@ 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)] + [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)] @@ -810,6 +1027,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() { @@ -866,36 +1111,76 @@ public async Task ListRemoveAsync_WhenLastValueRemoved_RemovesKeyAndReleasesMemo } [Fact] - public override Task ListRemoveAsync_WithInvalidInputs_ThrowsAppropriateException() + public async Task ListRemoveAsync_WithIncrementalShrink_PreservesComparerAndExpirations() { - return base.ListRemoveAsync_WithInvalidInputs_ThrowsAppropriateException(); + // 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); } - [Fact] - public async Task ListAddAsync_WithCustomSizeCalculator_ReceivesDictionaryOfCurrentItems() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ListRemoveAsync_WithIncrementalShrink_ReclaimsSparseStorage(bool cloneValues) { // 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(); + 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 - await cache.ListAddAsync("list", [-1]); - await cache.ListRemoveAsync("list", [0]); + long freshAllocation = await MeasureAsync("fresh"); + long shrunkAllocation = await MeasureAsync("shrunk"); // 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); + _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() + { + return base.ListRemoveAsync_WithInvalidInputs_ThrowsAppropriateException(); } [Fact] @@ -2784,6 +3069,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() { @@ -2887,6 +3196,74 @@ 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")] + [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)] @@ -3202,6 +3579,24 @@ 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; + } + private record MyData(string Name, int Value); }