diff --git a/.agents/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md index b4e650bcd..09b8d1415 100644 --- a/.agents/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -317,6 +317,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. `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 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. - **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 0ae7bfd5c..e120fee4d 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -276,6 +276,9 @@ Successful removal of a live matching value, or removal of the last list value, - `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. +- List item expirations read back with `DateTimeKind.Utc`, whatever kind was passed in. ::: List updates preserve the collection structure of previously returned snapshots. They preserve the comparers of `Dictionary`, `SortedDictionary`, `SortedList`, `ConcurrentDictionary`, and, on .NET 9 or later, `OrderedDictionary`. Other `IDictionary` implementations throw `NotSupportedException` when an update requires copying them; the original entry remains unchanged. @@ -283,7 +286,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. Cloning, custom size calculation, and raw dictionary access can require copies proportional to the list size. 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>`) 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. ::: ### HybridCacheClient diff --git a/src/Foundatio/Caching/InMemoryCacheClient.cs b/src/Foundatio/Caching/InMemoryCacheClient.cs index 6f00ef7c2..96d0efa1e 100644 --- a/src/Foundatio/Caching/InMemoryCacheClient.cs +++ b/src/Foundatio/Caching/InMemoryCacheClient.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -26,7 +26,14 @@ public class InMemoryCacheClient : IMemoryCacheClient, IHaveTimeProvider, IHaveL private readonly int? _maxItems; private readonly long? _maxMemorySize; private readonly bool _hasSizeCalculator; - private readonly bool _canShareListValues; + private readonly bool _sizesListsInPlace; + // A power of two, so a key's stripe is a mask of its hash + private const int ConflictLockCount = 64; +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock[] _conflictLocks = CreateConflictLocks(); +#else + private readonly object[] _conflictLocks = CreateConflictLocks(); +#endif private readonly bool _shouldTrackMemory; private Func? _sizeCalculator; private readonly long? _maxEntrySize; @@ -76,7 +83,8 @@ public InMemoryCacheClient(InMemoryCacheClientOptions? options = null) _sizeCalculator = options.SizeCalculator; _hasSizeCalculator = _sizeCalculator is not null; - _canShareListValues = !_shouldClone && (!_hasSizeCalculator || _sizeCalculator?.Target is InMemoryCacheClientOptionsBuilder.FixedSizeCalculator); + // 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; _shouldTrackMemory = _hasSizeCalculator && _maxMemorySize.HasValue; _memory = new ConcurrentDictionary(); @@ -351,7 +359,14 @@ internal long RemoveExpiredKey(string key, bool sendNotification = true) /// null to remove it. It runs again with the latest entry when another writer changes the key first, /// so it must not have side effects; only the result of the attempt that was published is returned. /// - internal TResult UpdateEntry(string key, Func update) + /// + /// After a lost compare-and-swap, take a lock striped by key for the remaining attempts, so writers whose callback + /// is expensive (copying a large list) queue instead of each redoing that work. Publishing still uses + /// compare-and-swap, so writers that don't take the lock stay correct. The callback, including any custom size + /// calculator it invokes, then runs under the lock and must not call back into this cache: waiting on another + /// key's stripe could deadlock. + /// + internal TResult UpdateEntry(string key, Func update, bool serializeAfterConflict = false) { bool reclaimedExpired = false; var result = UpdateStoredEntry(key, stored => @@ -359,7 +374,7 @@ internal TResult UpdateEntry(string key, Func(string key, Func, but the callback sees the stored entry even when it has expired, and /// no event is raised. Only maintenance, compaction and explicit removal need this view. /// - private TResult UpdateStoredEntry(string key, Func update) + private TResult UpdateStoredEntry(string key, Func update, bool serializeAfterConflict = false) { // Retries only when another writer changed the key first, so some writer always makes progress; Dispose clears // the store, which lets in-flight updates finish on the next attempt. - while (true) + if (TryUpdateStoredEntry(key, update, out var result)) + return result; + + if (serializeAfterConflict) { - _memory.TryGetValue(key, out var current); - var (desired, result) = update(current); - if (ReferenceEquals(desired, current) || TryPublish(key, current, desired)) + lock (GetConflictLock(key)) + { + while (!TryUpdateStoredEntry(key, update, out result)) { } return result; + } } + + while (!TryUpdateStoredEntry(key, update, out result)) { } + return result; + } + + private bool TryUpdateStoredEntry(string key, Func update, out TResult result) + { + _memory.TryGetValue(key, out var current); + (var desired, result) = update(current); + return ReferenceEquals(desired, current) || TryPublish(key, current, desired); + } + +#if NET9_0_OR_GREATER + private System.Threading.Lock GetConflictLock(string key) => _conflictLocks[key.GetHashCode() & (ConflictLockCount - 1)]; + + internal bool IsConflictLockHeld(string key) => GetConflictLock(key).IsHeldByCurrentThread; +#else + private object GetConflictLock(string key) => _conflictLocks[key.GetHashCode() & (ConflictLockCount - 1)]; + + internal bool IsConflictLockHeld(string key) => Monitor.IsEntered(GetConflictLock(key)); +#endif + + private static T[] CreateConflictLocks() where T : new() + { + var locks = new T[ConflictLockCount]; + for (int i = 0; i < locks.Length; i++) + locks[i] = new T(); + + return locks; } /// @@ -621,25 +669,38 @@ private long ListAdd(string key, Dictionary items, DateTime? ex if (items.Count is 0) return 0; - var entry = CreateEntry(items, expiresAt); - if (entry is null) - return 0; - Interlocked.Increment(ref _writes); + // Sized and cloned only when a path needs the new items on their own, then reused across retries + CacheEntry? entry = null; + bool entryCreated = false; return UpdateEntry(key, current => { - if (current is null) - return (_canShareListValues && items.Count > ListSnapshot.BlockSize - ? entry.WithValue(new ListSnapshot(items), expiresAt, entry.Size) - : entry, (long)items.Count); - - if (current.StoredValue is ListSnapshot { Snapshot: null } stored) + if (current?.StoredValue is ListSnapshot { IsSourceOfTruth: true } stored) { var list = stored.Update(items.Keys, expiresAt, _timeProvider.GetUtcNow().UtcDateTime, remove: false, out _); - return (current.WithValue(list, list.ExpiresAt, entry.Size), (long)items.Count); + if (ReferenceEquals(list, stored)) + return (current, (long)items.Count); + + long listSize = CalculateListSize(list); + return listSize < 0 ? (current, 0L) : (current.WithValue(list, list.ExpiresAtUtc, listSize), (long)items.Count); } + if (!entryCreated) + { + // Items that can be shared need no clone: the dictionary is private to this call + entry = CreateEntry(items, expiresAt, cloneValue: !CanShareListValues()); + entryCreated = true; + } + + if (entry is null) + return (current, 0L); // Entry exceeds limits + + if (current is null) + return (CanShareListValues() && items.Count > ListSnapshot.BlockSize + ? entry.WithValue(new ListSnapshot(items), expiresAt, entry.Size) + : entry, (long)items.Count); + if (CopyListValues(current) is not { } dictionary) throw new InvalidOperationException($"Unable to add value for key: {key}. Cache value does not contain a set"); @@ -652,12 +713,36 @@ private long ListAdd(string key, Dictionary items, DateTime? ex return size < 0 ? (current, 0L) : (WithListValues(current, dictionary, size), (long)items.Count); - }); + }, serializeAfterConflict: true); + } + + /// + /// Whether list writes can keep an immutable instead of copying the whole list. + /// With cloning on, only items that never need cloning (strings and value types without references) can be shared: + /// raw dictionary reads still clone the materialized dictionary, and items can't be changed through it. + /// + private bool CanShareListValues() where T : notnull => !_shouldClone || !ListItemCloning.Required; + + private static class ListItemCloning + { + public static readonly bool Required = typeof(T) != typeof(string) && RuntimeHelpers.IsReferenceOrContainsReferences(); + } + + /// + /// 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. + /// + private long CalculateListSize(ListSnapshot list) where T : notnull + { + if (!_hasSizeCalculator) + return 0; + + return CalculateEntrySize(_sizesListsInPlace ? list.AsReadOnlyDictionary() : list.ToDictionary()); } private CacheEntry WithListValues(CacheEntry current, IDictionary dictionary, long size) where T : notnull { - object value = _canShareListValues && dictionary is Dictionary values && values.Count > ListSnapshot.BlockSize + object value = CanShareListValues() && dictionary is Dictionary values && values.Count > ListSnapshot.BlockSize ? new ListSnapshot(values) : dictionary; return current.WithValue(value, GetListExpiration(dictionary), size); @@ -721,11 +806,16 @@ private long ListRemove(string key, HashSet items) where T : notnull { long removed = UpdateEntry(key, current => { - if (current?.StoredValue is ListSnapshot { Snapshot: null } list) + if (current?.StoredValue is ListSnapshot { IsSourceOfTruth: true } list) { var replacement = list.Update(items, null, _timeProvider.GetUtcNow().UtcDateTime, remove: true, out long count); - return ReferenceEquals(list, replacement) ? (current, 0L) - : (replacement.Count == 0 ? null : current.WithValue(replacement, replacement.ExpiresAt, current.Size), count); + if (ReferenceEquals(list, replacement)) + return (current, 0L); + if (replacement.Count == 0) + return (null, count); + + long listSize = CalculateListSize(replacement); + return listSize < 0 ? (current, 0L) : (current.WithValue(replacement, replacement.ExpiresAtUtc, listSize), count); } object? value = current?.StoredValue is IListSnapshot snapshot ? snapshot.GetSnapshot() : current?.StoredValue; @@ -751,7 +841,7 @@ private long ListRemove(string key, HashSet items) where T : notnull return size < 0 ? (current, 0L) : (WithListValues(current, dictionary, size), removedCount); - }); + }, serializeAfterConflict: true); if (removed > 0) _logger.LogTrace("Removed value from set with cache key: {Key}", key); @@ -766,7 +856,7 @@ public async Task>> GetListAsync(string key, int? p if (page is < 1) throw new ArgumentOutOfRangeException(nameof(page), "Page cannot be less than 1"); - if (_memory.TryGetValue(key, out var entry) && !entry.IsExpired && entry.StoredValue is ListSnapshot { Snapshot: null } list) + if (_memory.TryGetValue(key, out var entry) && !entry.IsExpired && entry.StoredValue is ListSnapshot { IsSourceOfTruth: true } list) { Interlocked.Increment(ref _hits); entry.RecordAccess(); @@ -1369,244 +1459,15 @@ public virtual void Dispose() /// /// Creates a CacheEntry with pre-calculated size. Returns null if the entry should be skipped (exceeds size limits). /// - private CacheEntry? CreateEntry(T value, DateTime? expiresAt) + private CacheEntry? CreateEntry(T value, DateTime? expiresAt, bool cloneValue = true) { long size = _hasSizeCalculator ? CalculateEntrySize(value) : 0; if (size < 0) return null; - return new CacheEntry(value, expiresAt, _timeProvider, _shouldClone, size); + return new CacheEntry(value, expiresAt, _timeProvider, _shouldClone, size, cloneValue); } - private interface IListSnapshot - { - object GetSnapshot(); - } - - // Updates copy one bounded block and the block directory, not the entire list. The immutable - // key index shares unchanged nodes. Raw dictionary reads retain their existing public shape. - private sealed class ListSnapshot : IListSnapshot where T : notnull - { - public const int BlockSize = 32; - private ImmutableDictionary? _indices; - private readonly Dictionary? _initialValues; - private readonly Item[][] _blocks; - private readonly ImmutableSortedDictionary _expirations; - private readonly ImmutableStack _freeIndices; - private readonly int _nextIndex; - private readonly int _permanentCount; - private Dictionary? _snapshot; - - private readonly record struct Item(T Key, DateTime? Expiration, bool Present); - - public ListSnapshot(Dictionary values) - { - _initialValues = values; - var expirations = ImmutableSortedDictionary.CreateBuilder(); - _blocks = new Item[(values.Count + BlockSize - 1) / BlockSize][]; - for (int i = 0; i < _blocks.Length; i++) - _blocks[i] = new Item[BlockSize]; - foreach (var pair in values) - { - _blocks[_nextIndex / BlockSize][_nextIndex % BlockSize] = new Item(pair.Key, pair.Value, true); - _nextIndex++; - if (pair.Value is { } expiration) - expirations[expiration] = expirations.GetValueOrDefault(expiration) + 1; - else - _permanentCount++; - } - _expirations = expirations.ToImmutable(); - _freeIndices = ImmutableStack.Empty; - } - - private ListSnapshot(ImmutableDictionary indices, Item[][] blocks, - ImmutableSortedDictionary expirations, ImmutableStack freeIndices, int nextIndex, int permanentCount) - { - _indices = indices; - _blocks = blocks; - _expirations = expirations; - _freeIndices = freeIndices; - _nextIndex = nextIndex; - _permanentCount = permanentCount; - } - - public int Count => _indices?.Count ?? _initialValues!.Count; - private ImmutableDictionary Indices - { - get - { - if (Volatile.Read(ref _indices) is { } existing) - return existing; - - var builder = ImmutableDictionary.CreateBuilder(_initialValues!.Comparer); - int index = 0; - foreach (var key in _initialValues.Keys) - builder.Add(key, index++); - var indices = builder.ToImmutable(); - return Interlocked.CompareExchange(ref _indices, indices, null) ?? indices; - } - } - - public Dictionary? Snapshot => Volatile.Read(ref _snapshot); - public DateTime? ExpiresAt => _permanentCount > 0 || _expirations.IsEmpty ? null : _expirations.Last().Key; - - public T[] Read(DateTime utcNow) - { - var result = new T[Count]; - int count = 0; - foreach (var block in _blocks) - foreach (var item in block) - if (item.Present && (item.Expiration is null || item.Expiration >= utcNow)) - result[count++] = item.Key; - if (count != result.Length) - Array.Resize(ref result, count); - return result; - } - - public object GetSnapshot() - { - if (Snapshot is { } snapshot) - return snapshot; - - if (_initialValues is not null) - return Interlocked.CompareExchange(ref _snapshot, _initialValues, null) ?? _initialValues; - - var dictionary = CopyValues(); - return Interlocked.CompareExchange(ref _snapshot, dictionary, null) ?? dictionary; - } - - private Dictionary CopyValues() - { - if (_initialValues is not null) - return new Dictionary(_initialValues, _initialValues.Comparer); - - var dictionary = new Dictionary(Count, Indices.KeyComparer); - foreach (var block in _blocks) - foreach (var item in block) - if (item.Present) - dictionary.Add(item.Key, item.Expiration); - return dictionary; - } - - public ListSnapshot Update(ICollection items, DateTime? expiration, DateTime utcNow, bool remove, out long removed) - { - removed = 0; - bool prune = !_expirations.IsEmpty && _expirations.First().Key < utcNow; - if (remove && !prune && !items.Any(key => _initialValues?.ContainsKey(key) ?? Indices.ContainsKey(key))) - return this; - - // When a batch could copy every block, one dictionary copy is cheaper than - // rebuilding many index paths. It also avoids indexing short-lived bulk lists. - if (items.Count >= Math.Max(1, Count / BlockSize)) - { - var dictionary = CopyValues(); - if (prune) - foreach (var key in dictionary.Where(pair => pair.Value < utcNow).Select(pair => pair.Key).ToArray()) - dictionary.Remove(key); - if (remove) - removed = items.Count(dictionary.Remove); - else - foreach (var key in items) - dictionary[key] = expiration; - return new ListSnapshot(dictionary); - } - - var indices = Indices.ToBuilder(); - int capacity = Math.Max(_nextIndex, Count + (remove ? 0 : items.Count)); - var blocks = new Item[(capacity + BlockSize - 1) / BlockSize][]; - Array.Copy(_blocks, blocks, _blocks.Length); - var copied = new bool[blocks.Length]; - ImmutableSortedDictionary.Builder? expirations = null; - var freeIndices = _freeIndices; - int nextIndex = _nextIndex; - int permanentCount = _permanentCount; - bool changed = false; - - if (prune) - foreach (var block in _blocks) - foreach (var item in block) - if (item.Present && item.Expiration < utcNow) - Remove(item.Key); - - foreach (var item in items) - { - if (remove) - { - if (Remove(item)) - removed++; - continue; - } - - T key = item; - bool exists = indices.TryGetValue(key, out int index); - if (exists) - { - var previous = blocks[index / BlockSize][index % BlockSize]; - if (previous.Expiration == expiration) - continue; - key = previous.Key; - AdjustExpiration(previous.Expiration, -1); - } - else if (!freeIndices.IsEmpty) - { - index = freeIndices.Peek(); - freeIndices = freeIndices.Pop(); - } - else - index = nextIndex++; - - CopyBlock(index); - blocks[index / BlockSize][index % BlockSize] = new Item(key, expiration, true); - indices[key] = index; - AdjustExpiration(expiration, 1); - } - - if (!changed) - return this; - - int blockCount = (nextIndex + BlockSize - 1) / BlockSize; - if (blocks.Length != blockCount) - Array.Resize(ref blocks, blockCount); - return new ListSnapshot(indices.ToImmutable(), blocks, expirations?.ToImmutable() ?? _expirations, freeIndices, nextIndex, permanentCount); - - bool Remove(T key) - { - if (!indices.TryGetValue(key, out int index)) - return false; - CopyBlock(index); - AdjustExpiration(blocks[index / BlockSize][index % BlockSize].Expiration, -1); - blocks[index / BlockSize][index % BlockSize] = default; - indices.Remove(key); - freeIndices = freeIndices.Push(index); - return true; - } - - void CopyBlock(int index) - { - int blockIndex = index / BlockSize; - if (copied[blockIndex]) - return; - blocks[blockIndex] = blockIndex < _blocks.Length ? (Item[])_blocks[blockIndex].Clone() : new Item[BlockSize]; - copied[blockIndex] = true; - changed = true; - } - - void AdjustExpiration(DateTime? value, int delta) - { - if (value is not { } time) - { - permanentCount += delta; - return; - } - expirations ??= _expirations.ToBuilder(); - int count = expirations.GetValueOrDefault(time) + delta; - if (count == 0) - expirations.Remove(time); - else - expirations[time] = count; - } - } - } /// /// A cache entry's value, expiration and size never change after it is published to the cache dictionary; @@ -1631,11 +1492,13 @@ internal sealed class CacheEntry private long _usageCount; #endif - public CacheEntry(object? value, DateTime? expiresAt, TimeProvider timeProvider, bool shouldClone = true, long size = 0) + // cloneValue: false stores a value nothing else references and whose contents never need cloning as-is; + // reads still clone when shouldClone applies + public CacheEntry(object? value, DateTime? expiresAt, TimeProvider timeProvider, bool shouldClone = true, long size = 0, bool cloneValue = true) { _timeProvider = timeProvider; _shouldClone = shouldClone && TypeRequiresCloning(value?.GetType()); - _cacheValue = _shouldClone ? value.DeepClone() : value; + _cacheValue = _shouldClone && cloneValue ? value.DeepClone() : value; var utcNow = _timeProvider.GetUtcNow(); LastAccessTicks = utcNow.Ticks; LastModifiedTicks = utcNow.Ticks; diff --git a/src/Foundatio/Caching/InMemoryCacheClientOptions.cs b/src/Foundatio/Caching/InMemoryCacheClientOptions.cs index a7b6ad688..59dab0c13 100644 --- a/src/Foundatio/Caching/InMemoryCacheClientOptions.cs +++ b/src/Foundatio/Caching/InMemoryCacheClientOptions.cs @@ -35,6 +35,7 @@ public class InMemoryCacheClientOptions : SharedOptions /// Negative return values will cause the entry to be skipped (not cached) with a warning logged /// Should be thread-safe as it may be called concurrently /// Should handle null values gracefully (typically return 8 bytes for a null reference) + /// Must not call back into the cache: it can run while a lock that serializes contended writes is held /// /// /// diff --git a/src/Foundatio/Caching/InMemoryListSnapshot.cs b/src/Foundatio/Caching/InMemoryListSnapshot.cs new file mode 100644 index 000000000..c16e743c3 --- /dev/null +++ b/src/Foundatio/Caching/InMemoryListSnapshot.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Linq; +using System.Numerics; +using System.Threading; + +namespace Foundatio.Caching; + +internal interface IListSnapshot +{ + object GetSnapshot(); +} + +/// +/// An immutable list value: each update returns a new snapshot and never changes this one, so readers and +/// compare-and-swap writers can share it without locks. +/// +/// +/// A snapshot starts out backed by a plain dictionary, which makes bulk writes a single dictionary copy. The first +/// small update builds an indexed layout: items in fixed-size blocks that keep insertion order and their own +/// expiration bounds, plus a key index split into small hash partitions. Small updates then copy only the blocks and +/// partitions they touch. Expirations are stored as UTC ticks, so items read back with . +/// +internal sealed class ListSnapshot : IListSnapshot where T : notnull +{ + public const int BlockSize = 32; + private const int PartitionSize = 32; + private const long Permanent = long.MaxValue; + + // Never handed out or changed, so reads can use it even after the layout is built for the next update + private readonly Dictionary? _values; + private readonly bool _fromIncrementalChange; + private readonly IEqualityComparer _comparer; + private readonly int _permanentCount; + private readonly long _minExpiresAtUtcTicks; + private readonly long _maxExpiresAtUtcTicks; + private Layout? _layout; + private Dictionary? _snapshot; + + /// The items, owned by the snapshot from now on. + /// + /// true when this dictionary came from a small change, so the list is being modified incrementally and the + /// next small change should build the index; a list written once (or only in bulk) never pays for indexing. + /// + public ListSnapshot(Dictionary values, bool fromIncrementalChange = false) + { + _values = values; + _fromIncrementalChange = fromIncrementalChange; + _comparer = values.Comparer; + Count = values.Count; + _minExpiresAtUtcTicks = long.MaxValue; + foreach (var expiresAtUtc in values.Values) + Track(ToUtcTicks(expiresAtUtc), ref _permanentCount, ref _minExpiresAtUtcTicks, ref _maxExpiresAtUtcTicks); + } + + private ListSnapshot(Layout layout, IEqualityComparer comparer, int count) + { + _layout = layout; + _comparer = comparer; + Count = count; + _minExpiresAtUtcTicks = long.MaxValue; + foreach (var block in layout.Blocks) + { + _permanentCount += block.PermanentCount; + _minExpiresAtUtcTicks = Math.Min(_minExpiresAtUtcTicks, block.MinExpiresAtUtcTicks); + _maxExpiresAtUtcTicks = Math.Max(_maxExpiresAtUtcTicks, block.MaxExpiresAtUtcTicks); + } + } + + public int Count { get; } + + /// Whether small updates use the block and partition index rather than copying the dictionary. + 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 + /// 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; + + /// 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); + + /// A read-only dictionary over the current items, without copying them. + public IDictionary AsReadOnlyDictionary() => new ReadOnlyView(this); + + /// A new dictionary holding the current items, owned by the caller. + public Dictionary ToDictionary() + { + if (_values is { } values) + return new Dictionary(values, _comparer); + + var dictionary = new Dictionary(Count, _comparer); + foreach (var block in _layout!.Blocks) + foreach (var item in block.Items) + if (item.Present) + dictionary.Add(item.Key, FromUtcTicks(item.ExpiresAtUtcTicks)); + return dictionary; + } + + public T[] Read(DateTime utcNow) + { + var result = new T[Count]; + int count = 0; + long utcNowTicks = utcNow.Ticks; + if (_values is { } values) + { + foreach (var pair in values) + if (pair.Value is null || pair.Value.Value.Ticks >= utcNowTicks) + result[count++] = pair.Key; + } + else + { + foreach (var block in _layout!.Blocks) + foreach (var item in block.Items) + if (item.Present && item.ExpiresAtUtcTicks >= utcNowTicks) + result[count++] = item.Key; + } + + if (count != result.Length) + Array.Resize(ref result, count); + return result; + } + + /// + /// The dictionary handed to raw reads: a copy made once, so a reader changing it can't corrupt this snapshot. + /// + public object GetSnapshot() + { + if (Volatile.Read(ref _snapshot) is { } snapshot) + return snapshot; + + var dictionary = ToDictionary(); + return Interlocked.CompareExchange(ref _snapshot, dictionary, null) ?? dictionary; + } + + public ListSnapshot Update(ICollection items, DateTime? expiresAtUtc, DateTime utcNow, bool remove, out long removed) + { + removed = 0; + long utcNowTicks = utcNow.Ticks; + bool prune = _minExpiresAtUtcTicks < utcNowTicks; + if (remove && !prune && !items.Any(ContainsKey)) + return this; + + // When a batch could touch every block, one dictionary copy is cheaper than updating blocks and partitions, + // 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)) + { + var dictionary = ToDictionary(); + if (prune) + foreach (var key in dictionary.Where(pair => pair.Value?.Ticks < utcNowTicks).Select(pair => pair.Key).ToArray()) + dictionary.Remove(key); + if (remove) + removed = items.Count(dictionary.Remove); + else + foreach (var key in items) + dictionary[key] = expiresAtUtc; + return new ListSnapshot(dictionary, fromIncrementalChange: !batch); + } + + var update = new LayoutUpdate(GetLayout(), _comparer, Count); + if (prune) + update.RemoveExpired(utcNowTicks); + + long expiresAtUtcTicks = ToUtcTicks(expiresAtUtc); + foreach (var item in items) + { + if (remove) + { + if (update.Remove(item)) + removed++; + } + else + { + update.Set(item, expiresAtUtcTicks); + } + } + + return update.Changed ? new ListSnapshot(update.Build(), _comparer, update.Count) : this; + } + + private bool ContainsKey(T key) => TryGetExpiresAtUtc(key, out _); + + private bool TryGetExpiresAtUtc(T key, out DateTime? expiresAtUtc) + { + if (_values is { } values) + return values.TryGetValue(key, out expiresAtUtc); + + var layout = _layout!; + if (layout.Partitions[layout.PartitionOf(key, _comparer)].TryGetValue(key, out int index)) + { + expiresAtUtc = FromUtcTicks(layout.Blocks[index / BlockSize].Items[index % BlockSize].ExpiresAtUtcTicks); + return true; + } + + expiresAtUtc = null; + return false; + } + + private IEnumerable> EnumeratePairs() + { + if (_values is { } values) + { + foreach (var pair in values) + yield return pair; + yield break; + } + + foreach (var block in _layout!.Blocks) + foreach (var item in block.Items) + if (item.Present) + yield return new KeyValuePair(item.Key, FromUtcTicks(item.ExpiresAtUtcTicks)); + } + + /// The layout for the next small update, built from the dictionary the first time one needs it. + private Layout GetLayout() + { + if (Volatile.Read(ref _layout) is { } existing) + return existing; + + var layout = Layout.Create(_values!, _comparer); + return Interlocked.CompareExchange(ref _layout, layout, null) ?? layout; + } + + private static long ToUtcTicks(DateTime? expiresAtUtc) => expiresAtUtc?.Ticks ?? Permanent; + + private static DateTime? FromUtcTicks(long ticks) => ticks == Permanent ? null : new DateTime(ticks, DateTimeKind.Utc); + + private static void Track(long ticks, ref int permanentCount, ref long minTicks, ref long maxTicks) + { + if (ticks == Permanent) + { + permanentCount++; + return; + } + + minTicks = Math.Min(minTicks, ticks); + maxTicks = Math.Max(maxTicks, ticks); + } + + // ExpiresAtUtcTicks is Permanent for an item with no expiration; Present is false for a free slot + private readonly record struct Item(T Key, long ExpiresAtUtcTicks, bool Present = true); + + private sealed class Block + { + public Block(Item[] items) + { + Items = items; + MinExpiresAtUtcTicks = long.MaxValue; + foreach (var item in items) + if (item.Present) + Track(item.ExpiresAtUtcTicks, ref PermanentCount, ref MinExpiresAtUtcTicks, ref MaxExpiresAtUtcTicks); + } + + public readonly Item[] Items; + public readonly int PermanentCount; + public readonly long MinExpiresAtUtcTicks; + public readonly long MaxExpiresAtUtcTicks; + } + + private sealed class Layout(Block[] blocks, Dictionary[] partitions, ImmutableStack freeSlots, int nextSlot) + { + public Block[] Blocks { get; } = blocks; + public Dictionary[] Partitions { get; } = partitions; + public ImmutableStack FreeSlots { get; } = freeSlots; + public int NextSlot { get; } = nextSlot; + + public static Layout Create(Dictionary values, IEqualityComparer comparer) + { + var blocks = new Block[(values.Count + BlockSize - 1) / BlockSize]; + var items = new Item[BlockSize]; + int slot = 0; + foreach (var pair in values) + { + items[slot % BlockSize] = new Item(pair.Key, ToUtcTicks(pair.Value)); + if (++slot % BlockSize == 0 || slot == values.Count) + { + blocks[(slot - 1) / BlockSize] = new Block(items); + items = new Item[BlockSize]; + } + } + + return new Layout(blocks, BuildPartitions(blocks, values.Count, comparer), ImmutableStack.Empty, values.Count); + } + + public int PartitionOf(T key, IEqualityComparer comparer) => PartitionOf(key, comparer, Partitions.Length); + + public static Dictionary[] BuildPartitions(Block[] blocks, int count, IEqualityComparer comparer) + { + int partitionCount = (int)BitOperations.RoundUpToPowerOf2((uint)Math.Max(1, count / PartitionSize)); + var partitions = new Dictionary[partitionCount]; + for (int i = 0; i < partitions.Length; i++) + partitions[i] = new Dictionary(PartitionSize + PartitionSize / 2, comparer); + + for (int blockIndex = 0; blockIndex < blocks.Length; blockIndex++) + { + var blockItems = blocks[blockIndex].Items; + for (int i = 0; i < blockItems.Length; i++) + if (blockItems[i].Present) + partitions[PartitionOf(blockItems[i].Key, comparer, partitionCount)].Add(blockItems[i].Key, blockIndex * BlockSize + i); + } + + return partitions; + } + + private static int PartitionOf(T key, IEqualityComparer comparer, int partitionCount) + { + if (partitionCount == 1) + return 0; + + // Fibonacci hashing spreads keys whose hash codes share low bits (sequential or aligned values) + uint hash = (uint)comparer.GetHashCode(key) * 0x9E3779B9u; + return (int)(hash >> (32 - BitOperations.Log2((uint)partitionCount))); + } + } + + /// + /// Applies changes to a copy of a layout, copying each block and partition the first time it is touched. + /// + private sealed class LayoutUpdate + { + private readonly Layout _source; + private readonly IEqualityComparer _comparer; + private Block[] _blocks; + private Item[]?[] _copiedBlocks; + private readonly Dictionary[] _partitions; + private readonly bool[] _copiedPartitions; + private ImmutableStack _freeSlots; + private int _nextSlot; + + public LayoutUpdate(Layout source, IEqualityComparer comparer, int count) + { + _source = source; + _comparer = comparer; + _blocks = (Block[])source.Blocks.Clone(); + _copiedBlocks = new Item[]?[source.Blocks.Length]; + _partitions = (Dictionary[])source.Partitions.Clone(); + _copiedPartitions = new bool[_partitions.Length]; + _freeSlots = source.FreeSlots; + _nextSlot = source.NextSlot; + Count = count; + } + + public int Count { get; private set; } + public bool Changed { get; private set; } + + public void RemoveExpired(long utcNowTicks) + { + for (int blockIndex = 0; blockIndex < _source.Blocks.Length; blockIndex++) + { + var block = _source.Blocks[blockIndex]; + if (block.MinExpiresAtUtcTicks >= utcNowTicks) + continue; + + for (int i = 0; i < block.Items.Length; i++) + { + long ticks = block.Items[i].ExpiresAtUtcTicks; + if (block.Items[i].Present && ticks < utcNowTicks) + Remove(block.Items[i].Key); + } + } + } + + public bool Remove(T key) + { + var partition = _partitions[_source.PartitionOf(key, _comparer)]; + if (!partition.TryGetValue(key, out int slot)) + return false; + + WritablePartition(key).Remove(key); + WritableBlock(slot)[slot % BlockSize] = default; + _freeSlots = _freeSlots.Push(slot); + Count--; + return true; + } + + public void Set(T key, long expiresAtUtcTicks) + { + if (_partitions[_source.PartitionOf(key, _comparer)].TryGetValue(key, out int slot)) + { + ref readonly var existing = ref ReadSlot(slot); + if (existing.ExpiresAtUtcTicks == expiresAtUtcTicks) + return; + + WritableBlock(slot)[slot % BlockSize] = new Item(existing.Key, expiresAtUtcTicks); + return; + } + + if (!_freeSlots.IsEmpty) + _freeSlots = _freeSlots.Pop(out slot); + else + slot = _nextSlot++; + + WritableBlock(slot)[slot % BlockSize] = new Item(key, expiresAtUtcTicks); + WritablePartition(key).Add(key, slot); + Count++; + } + + public Layout Build() + { + for (int i = 0; i < _blocks.Length; i++) + if (_copiedBlocks[i] is { } items) + _blocks[i] = new Block(items); + + // Keep partitions small as the list grows, so a change copies a bounded amount of index + var partitions = Count > _partitions.Length * PartitionSize * 2 + ? Layout.BuildPartitions(_blocks, Count, _comparer) + : _partitions; + return new Layout(_blocks, partitions, _freeSlots, _nextSlot); + } + + private ref readonly Item ReadSlot(int slot) + { + int blockIndex = slot / BlockSize; + return ref (_copiedBlocks[blockIndex] ?? _blocks[blockIndex].Items)[slot % BlockSize]; + } + + private Item[] WritableBlock(int slot) + { + int blockIndex = slot / BlockSize; + if (blockIndex >= _blocks.Length) + { + // Slots are handed out in order, so a new slot needs at most one more block + Array.Resize(ref _blocks, blockIndex + 1); + Array.Resize(ref _copiedBlocks, blockIndex + 1); + } + + if (_copiedBlocks[blockIndex] is { } copied) + return copied; + + Changed = true; + var items = _blocks[blockIndex] is { } block ? (Item[])block.Items.Clone() : new Item[BlockSize]; + _copiedBlocks[blockIndex] = items; + return items; + } + + private Dictionary WritablePartition(T key) + { + int index = _source.PartitionOf(key, _comparer); + if (!_copiedPartitions[index]) + { + _partitions[index] = new Dictionary(_partitions[index], _comparer); + _copiedPartitions[index] = true; + } + + return _partitions[index]; + } + } + + /// A read-only over a snapshot, used to size lists without copying them. + private sealed class ReadOnlyView(ListSnapshot snapshot) : IDictionary, IReadOnlyDictionary, ICollection + { + public int Count => snapshot.Count; + public bool IsReadOnly => true; + bool ICollection.IsSynchronized => false; + object ICollection.SyncRoot => snapshot; + + public DateTime? this[T key] + { + get => snapshot.TryGetExpiresAtUtc(key, out var expiresAtUtc) ? expiresAtUtc : throw new KeyNotFoundException($"The key '{key}' was not found in the list."); + set => throw ReadOnly(); + } + + 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; + + public bool ContainsKey(T key) => snapshot.ContainsKey(key); + public bool TryGetValue(T key, out DateTime? value) => snapshot.TryGetExpiresAtUtc(key, out value); + public bool Contains(KeyValuePair item) => snapshot.TryGetExpiresAtUtc(item.Key, out var value) && value == item.Value; + public IEnumerator> GetEnumerator() => snapshot.EnumeratePairs().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + foreach (var pair in snapshot.EnumeratePairs()) + array[arrayIndex++] = pair; + } + + void ICollection.CopyTo(Array array, int index) + { + foreach (var pair in snapshot.EnumeratePairs()) + array.SetValue(pair, index++); + } + + public void Add(T key, DateTime? value) => throw ReadOnly(); + public void Add(KeyValuePair item) => throw ReadOnly(); + public bool Remove(T key) => throw ReadOnly(); + public bool Remove(KeyValuePair item) => throw ReadOnly(); + public void Clear() => throw ReadOnly(); + + private static NotSupportedException ReadOnly() => new("Cached list values are read-only."); + } +} diff --git a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs index ac12520dd..8c498b09b 100644 --- a/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs +++ b/tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs @@ -497,6 +497,64 @@ public async Task ListAddAsync_WithCloneValues_StoresCopiesOfAddedValues() Assert.DoesNotContain(stored.Keys, k => ReferenceEquals(k, added)); } + [Fact] + public async Task ListAddAsync_WithCloneValuesAndImmutableItems_SharesSnapshotAndIsolatesReads() + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(true).LoggerFactory(Log)); + await cache.ListAddAsync("set", Enumerable.Range(0, 100)); + + // Act + await cache.ListAddAsync("set", [-1]); + var read = await cache.GetAsync>("set"); + read.Value!.Clear(); + await cache.ListRemoveAsync("set", [0]); + + // Assert + Assert.Equal(Enumerable.Range(1, 99).Append(-1).Order(), (await cache.GetListAsync("set")).Value!.Order()); + var reread = await cache.GetAsync>("set"); + Assert.Equal(100, reread.Value!.Count); + Assert.NotSame(read.Value, reread.Value); + } + + [Fact] + public async Task ListAddAsync_WithCloneValuesAndImmutableItems_UsesSharedSnapshot() + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(true).LoggerFactory(Log)); + await cache.ListAddAsync("set", Enumerable.Range(0, 100)); + + // Act + await cache.ListAddAsync("set", [-1]); + + // Assert + Assert.IsType>(GetEntry(cache, "set").StoredValue); + } + + [Fact] + public async Task ListAddAsync_WithCloneValuesAndStructHoldingReference_DoesNotShareItems() + { + // Arrange + using var cache = new InMemoryCacheClient(o => o.CloneValues(true).LoggerFactory(Log)); + var items = Enumerable.Range(0, 100).Select(i => new ReferenceHoldingItem(i, [i])).ToArray(); + await cache.ListAddAsync("set", items); + + // Act + await cache.ListAddAsync("set", [new ReferenceHoldingItem(-1, [-1])]); + items[0].Values.Add(42); + + // Assert + Assert.IsNotType>(GetEntry(cache, "set").StoredValue); + var stored = (await cache.GetListAsync("set")).Value!; + Assert.DoesNotContain(stored, item => ReferenceEquals(item.Values, items[0].Values)); + } + + private readonly record struct ReferenceHoldingItem(int Id, List Values) + { + public bool Equals(ReferenceHoldingItem other) => Id == other.Id; + public override int GetHashCode() => Id; + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -524,6 +582,33 @@ await Parallel.ForEachAsync(Enumerable.Range(0, 4), TestCancellationToken, async Assert.Equal(original, remaining.OrderBy(value => value)); } + [Fact] + public async Task UpdateEntry_WithSerializeAfterConflict_RetriesUnderConflictLock() + { + // Arrange + // Runs on every target framework, so both System.Threading.Lock (net9+) and Monitor (net8) are exercised + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + using var cache = new InMemoryCacheClient(o => o.TimeProvider(timeProvider).CloneValues(false).LoggerFactory(Log)); + await cache.ListAddAsync("set", Enumerable.Range(0, 100)); + var heldDuringAttempt = new List(); + int attempt = 0; + + // Act + long result = cache.UpdateEntry(key: "set", current => + { + heldDuringAttempt.Add(cache.IsConflictLockHeld("set")); + if (attempt++ == 0) + Publish(cache, "set", CreateEntry("concurrent", timeProvider)); + return (CreateEntry("mine", timeProvider), 1L); + }, serializeAfterConflict: true); + + // Assert + Assert.Equal(1, result); + Assert.Equal([false, true], heldDuringAttempt); + Assert.False(cache.IsConflictLockHeld("set")); + Assert.Equal("mine", (await cache.GetAsync("set")).Value); + } + [Fact] public override Task ListAddAsync_WithConcurrentRequests_DoesNotLoseValues() { @@ -786,6 +871,33 @@ 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() { diff --git a/tests/Foundatio.Tests/Caching/InMemoryListSnapshotTests.cs b/tests/Foundatio.Tests/Caching/InMemoryListSnapshotTests.cs new file mode 100644 index 000000000..dc7eb2697 --- /dev/null +++ b/tests/Foundatio.Tests/Caching/InMemoryListSnapshotTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Foundatio.Caching; +using Xunit; + +namespace Foundatio.Tests.Caching; + +public class InMemoryListSnapshotTests +{ + private static readonly DateTime NowUtc = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Update_OnDictionaryBackedSnapshot_AppliesSingleItemChange(bool remove) + { + // Arrange + var snapshot = new ListSnapshot(CreateValues(1000)); + int item = remove ? 500 : -1; + + // Act + var updated = snapshot.Update([item], null, NowUtc, remove, out long removed); + + // Assert + var expected = Enumerable.Range(0, 1000).Where(i => i != 500 || !remove).ToList(); + if (!remove) + expected.Add(-1); + Assert.Equal(remove ? 1 : 0, removed); + Assert.Equal(expected.Count, updated.Count); + Assert.Equal(expected.Order(), updated.Read(NowUtc).Order()); + Assert.False(updated.IsIndexed); + + var second = updated.Update([-2], null, NowUtc, remove: false, out _); + Assert.True(second.IsIndexed); + Assert.Equal(expected.Append(-2).Order(), second.Read(NowUtc).Order()); + } + + [Fact] + public void Update_WhenGrowingPastPartitionCount_KeepsAllValues() + { + // Arrange + var snapshot = new ListSnapshot(CreateValues(64)); + + // Act + for (int i = 64; i < 2064; i++) + snapshot = snapshot.Update([i], null, NowUtc, remove: false, out _); + for (int i = 0; i < 2064; i += 2) + snapshot = snapshot.Update([i], null, NowUtc, remove: true, out _); + + // Assert + var expected = Enumerable.Range(0, 2064).Where(i => i % 2 == 1).ToArray(); + Assert.Equal(expected.Length, snapshot.Count); + Assert.Equal(expected, snapshot.Read(NowUtc).Order()); + Assert.All(expected, i => Assert.True(snapshot.AsReadOnlyDictionary().ContainsKey(i))); + Assert.False(snapshot.AsReadOnlyDictionary().ContainsKey(0)); + } + + [Fact] + public void ExpiresAt_WithMixedPermanentAndExpiringItems_ReturnsLatestOrNull() + { + // Arrange + var values = CreateValues(100, i => NowUtc.AddMinutes(1 + i % 3)); + values[1000] = null; + var snapshot = new ListSnapshot(values); + + // Act + var withoutPermanent = snapshot.Update([1000], null, NowUtc, remove: true, out _); + var withoutLatest = withoutPermanent.Update(values.Where(kvp => kvp.Value == NowUtc.AddMinutes(3)).Select(kvp => kvp.Key).ToArray(), null, NowUtc, remove: true, out _); + var singleChange = withoutLatest.Update([2000], NowUtc.AddMinutes(10), NowUtc, remove: false, out _); + + // Assert + Assert.Null(snapshot.ExpiresAtUtc); + Assert.Equal(NowUtc.AddMinutes(3), withoutPermanent.ExpiresAtUtc); + Assert.Equal(NowUtc.AddMinutes(2), withoutLatest.ExpiresAtUtc); + Assert.Equal(NowUtc.AddMinutes(10), singleChange.ExpiresAtUtc); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Update_WithExpiredItems_PrunesThem(bool indexed) + { + // Arrange + var values = CreateValues(1000, i => i < 100 ? NowUtc.AddMinutes(-1) : null); + var snapshot = new ListSnapshot(values); + if (indexed) + snapshot = Indexed(snapshot, NowUtc.AddMinutes(-2)); + + // Act + var updated = snapshot.Update([-1], null, NowUtc, remove: false, out _); + + // Assert + var expected = Enumerable.Range(100, 900).Append(-1).Concat(indexed ? [5000] : []).Order().ToArray(); + Assert.Equal(expected.Length, updated.Count); + Assert.Equal(expected, updated.Read(NowUtc).Order()); + Assert.False(updated.AsReadOnlyDictionary().ContainsKey(0)); + } + + [Theory] + [InlineData("single-add")] + [InlineData("single-remove")] + [InlineData("batch-add")] + [InlineData("batch-remove")] + [InlineData("prune")] + public void Update_DoesNotMutateSourceSnapshot(string operation) + { + // Arrange + var values = CreateValues(1000, i => i < 10 ? NowUtc.AddMinutes(-1) : null); + foreach (bool indexed in new[] { false, true }) + { + var source = new ListSnapshot(values); + if (indexed) + source = Indexed(source, NowUtc.AddMinutes(-2)); + var before = source.Read(NowUtc.AddMinutes(-2)).Order().ToArray(); + var expiresAtUtcBefore = source.ExpiresAtUtc; + + // Act + _ = operation switch + { + "single-add" => source.Update([-1], null, NowUtc.AddMinutes(-2), remove: false, out _), + "single-remove" => source.Update([500], null, NowUtc.AddMinutes(-2), remove: true, out _), + "batch-add" => source.Update(Enumerable.Range(2000, 600).ToArray(), null, NowUtc.AddMinutes(-2), remove: false, out _), + "batch-remove" => source.Update(Enumerable.Range(0, 600).ToArray(), null, NowUtc.AddMinutes(-2), remove: true, out _), + _ => source.Update([-1], null, NowUtc, remove: false, out _) + }; + + // Assert + Assert.Equal(before, source.Read(NowUtc.AddMinutes(-2)).Order()); + Assert.Equal(before.Length, source.Count); + Assert.Equal(expiresAtUtcBefore, source.ExpiresAtUtc); + Assert.Equal(before.Length, source.AsReadOnlyDictionary().Count); + } + } + + [Fact] + public void Update_WithRandomOperations_MatchesDictionaryModel() + { + // Arrange + var random = new Random(570); + var model = CreateValues(200); + var snapshot = new ListSnapshot(new Dictionary(model)); + var utcNow = NowUtc; + + for (int step = 0; step < 3000; step++) + { + // Act + utcNow = utcNow.AddSeconds(random.Next(0, 3)); + int batchSize = random.Next(10) == 0 ? random.Next(20, 400) : random.Next(1, 3); + var items = Enumerable.Range(0, batchSize).Select(_ => random.Next(0, 1500)).Distinct().ToArray(); + bool remove = random.Next(2) == 0; + DateTime? expiresAtUtc = random.Next(4) == 0 ? utcNow.AddSeconds(random.Next(1, 30)) : null; + snapshot = snapshot.Update(items, remove ? null : expiresAtUtc, utcNow, remove, out long removed); + + foreach (int key in model.Where(kvp => kvp.Value < utcNow).Select(kvp => kvp.Key).ToArray()) + model.Remove(key); + long expectedRemoved = 0; + foreach (int item in items) + { + if (remove) + expectedRemoved += model.Remove(item) ? 1 : 0; + else + model[item] = expiresAtUtc; + } + + // Assert + Assert.Equal(expectedRemoved, removed); + Assert.Equal(model.Count, snapshot.Count); + Assert.Equal(model.Keys.Order(), snapshot.Read(utcNow).Order()); + Assert.Equal(model.Values.Contains(null) || model.Count == 0 ? null : model.Values.Max(), snapshot.ExpiresAtUtc); + var view = snapshot.AsReadOnlyDictionary(); + Assert.Equal(model.Count, view.Count); + Assert.Equal(model.OrderBy(kvp => kvp.Key), view.OrderBy(kvp => kvp.Key)); + } + } + + [Fact] + public void AsReadOnlyDictionary_ExposesCurrentItemsAndRejectsChanges() + { + // Arrange + var values = CreateValues(100, i => i == 7 ? NowUtc.AddMinutes(5) : null); + var snapshot = Indexed(new ListSnapshot(values), NowUtc); + + // Act + var view = snapshot.AsReadOnlyDictionary(); + + // Assert + Assert.Equal(101, view.Count); + Assert.Equal(101, ((ICollection)view).Count); + Assert.True(view.IsReadOnly); + Assert.True(view.ContainsKey(5000)); + Assert.True(view.TryGetValue(7, out var expiresAtUtc)); + Assert.Equal(NowUtc.AddMinutes(5), expiresAtUtc); + Assert.Null(view[0]); + Assert.False(view.TryGetValue(-1, out _)); + Assert.Throws(() => view[-1]); + Assert.Equal(Enumerable.Range(0, 100).Append(5000).Order(), view.Keys.Order()); + Assert.Equal(101, view.Values.Count); + Assert.Contains(new KeyValuePair(7, NowUtc.AddMinutes(5)), view); + Assert.Throws(() => view.Add(-1, null)); + Assert.Throws(() => view.Remove(0)); + Assert.Throws(() => view[0] = NowUtc); + Assert.Throws(view.Clear); + } + + /// Adds item 5000 and makes the next small change build the index, the way repeated small writes do. + private static ListSnapshot Indexed(ListSnapshot snapshot, DateTime utcNow) + { + var indexed = snapshot.Update([5000], null, utcNow, remove: false, out _).Update([5000], null, utcNow, remove: false, out _); + Assert.True(indexed.IsIndexed); + return indexed; + } + + private static Dictionary CreateValues(int count, Func? expiresAtUtc = null) + { + return Enumerable.Range(0, count).ToDictionary(i => i, i => expiresAtUtc?.Invoke(i)); + } +}