From f9eec752e97a1b6f3423dd6fc86fd7431a4101d2 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 3 Jan 2026 20:46:37 +0100 Subject: [PATCH 1/4] Cleaning and modernizing --- .github/workflows/dotnet.yml | 2 +- Directory.Build.props | 5 + Directory.Packages.props | 13 + .../FastCache.Benchmarks.csproj | 4 +- FastCache.Benchmarks/Program.cs | 2 +- FastCache.sln | 37 -- FastCache.slnx | 11 + FastCache/FastCache.cs | 609 +++++++++--------- FastCache/FastCache.csproj | 2 +- LICENSE | 2 +- UnitTests/EvictionCallbackTests.cs | 1 - UnitTests/TestHelper.cs | 31 +- UnitTests/UnitTests.cs | 392 ++++++----- UnitTests/UnitTests.csproj | 6 +- UnitTests/UnitTests2.cs | 489 +++++++------- 15 files changed, 796 insertions(+), 810 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props delete mode 100644 FastCache.sln create mode 100644 FastCache.slnx diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 3ff5812..1d7bed6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..022c43e --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + latest + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..e47931e --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,13 @@ + + + true + + + + + + + + + + \ No newline at end of file diff --git a/FastCache.Benchmarks/FastCache.Benchmarks.csproj b/FastCache.Benchmarks/FastCache.Benchmarks.csproj index 5de6907..44b21ff 100644 --- a/FastCache.Benchmarks/FastCache.Benchmarks.csproj +++ b/FastCache.Benchmarks/FastCache.Benchmarks.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/FastCache.Benchmarks/Program.cs b/FastCache.Benchmarks/Program.cs index 273654e..456e86f 100644 --- a/FastCache.Benchmarks/Program.cs +++ b/FastCache.Benchmarks/Program.cs @@ -9,7 +9,7 @@ [ShortRunJob, MemoryDiagnoser] public class BenchMark { - private static FastCache _cache = new FastCache(600_000); + private static FastCache _cache = new(600_000); private static ConcurrentDictionary _dict = new(); private static DateTime _dtPlus10Mins = DateTime.Now.AddMinutes(10); diff --git a/FastCache.sln b/FastCache.sln deleted file mode 100644 index 4f5a0f4..0000000 --- a/FastCache.sln +++ /dev/null @@ -1,37 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.3.32901.215 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FastCache", "FastCache\FastCache.csproj", "{3DD23A5F-11D4-43E4-8BD0-1354FBD154CF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{0CD62E2D-1D7F-43A7-AA85-EB0C6F0F69EA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastCache.Benchmarks", "FastCache.Benchmarks\FastCache.Benchmarks.csproj", "{DD603B8C-5216-4079-B6B2-5AA54ED1B1F3}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {3DD23A5F-11D4-43E4-8BD0-1354FBD154CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3DD23A5F-11D4-43E4-8BD0-1354FBD154CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3DD23A5F-11D4-43E4-8BD0-1354FBD154CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3DD23A5F-11D4-43E4-8BD0-1354FBD154CF}.Release|Any CPU.Build.0 = Release|Any CPU - {0CD62E2D-1D7F-43A7-AA85-EB0C6F0F69EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0CD62E2D-1D7F-43A7-AA85-EB0C6F0F69EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0CD62E2D-1D7F-43A7-AA85-EB0C6F0F69EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0CD62E2D-1D7F-43A7-AA85-EB0C6F0F69EA}.Release|Any CPU.Build.0 = Release|Any CPU - {DD603B8C-5216-4079-B6B2-5AA54ED1B1F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DD603B8C-5216-4079-B6B2-5AA54ED1B1F3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DD603B8C-5216-4079-B6B2-5AA54ED1B1F3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DD603B8C-5216-4079-B6B2-5AA54ED1B1F3}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {B73D44CA-57C0-4F81-87D4-D2D72ED26C51} - EndGlobalSection -EndGlobal diff --git a/FastCache.slnx b/FastCache.slnx new file mode 100644 index 0000000..f90f74d --- /dev/null +++ b/FastCache.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FastCache/FastCache.cs b/FastCache/FastCache.cs index eb57e60..38f50e5 100644 --- a/FastCache/FastCache.cs +++ b/FastCache/FastCache.cs @@ -5,383 +5,382 @@ using System.Threading; using System.Threading.Tasks; -namespace Jitbit.Utils +namespace Jitbit.Utils; + +internal static class FastCacheStatics { - internal static class FastCacheStatics - { - internal static readonly SemaphoreSlim GlobalStaticLock = new(1); //moved this static field to separate class, otherwise a static field in a generic class is not a true singleton - } + internal static readonly SemaphoreSlim GlobalStaticLock = new(1); //moved this static field to separate class, otherwise a static field in a generic class is not a true singleton +} + +/// +/// faster MemoryCache alternative. basically a concurrent dictionary with expiration +/// +public class FastCache : IEnumerable>, IDisposable +{ + private readonly ConcurrentDictionary _dict = new ConcurrentDictionary(); + + private readonly Lock _lock = new(); + private readonly Timer _cleanUpTimer; + private readonly EvictionCallback _itemEvicted; + + /// + /// Callback (RUNS ON THREAD POOL!) when an item is evicted from the cache. + /// + /// + public delegate void EvictionCallback(TKey key); /// - /// faster MemoryCache alternative. basically a concurrent dictionary with expiration + /// Initializes a new empty instance of /// - public class FastCache : IEnumerable>, IDisposable + /// cleanup interval in milliseconds, default is 10000 + /// Optional callback (RUNS ON THREAD POOL!) when an item is evicted from the cache + public FastCache(int cleanupJobInterval = 10000, EvictionCallback itemEvicted = null) { - private readonly ConcurrentDictionary _dict = new ConcurrentDictionary(); + _itemEvicted = itemEvicted; + _cleanUpTimer = new Timer(s => { _ = EvictExpiredJob(); }, null, cleanupJobInterval, cleanupJobInterval); + } - private readonly Lock _lock = new(); - private readonly Timer _cleanUpTimer; - private readonly EvictionCallback _itemEvicted; + private async Task EvictExpiredJob() + { + //if an applicaiton has many-many instances of FastCache objects, make sure the timer-based + //cleanup jobs don't clash with each other, i.e. there are no clean-up jobs running in parallel + //so we don't waste CPU resources, because cleanup is a busy-loop that iterates a collection and does calculations + //so we use a lock to "throttle" the job and make it serial + //HOWEVER, we still allow the user to execute eviction explicitly - /// - /// Callback (RUNS ON THREAD POOL!) when an item is evicted from the cache. - /// - /// - public delegate void EvictionCallback(TKey key); + //use Semaphore instead of a "lock" to free up thread, otherwise - possible thread starvation - /// - /// Initializes a new empty instance of - /// - /// cleanup interval in milliseconds, default is 10000 - /// Optional callback (RUNS ON THREAD POOL!) when an item is evicted from the cache - public FastCache(int cleanupJobInterval = 10000, EvictionCallback itemEvicted = null) + await FastCacheStatics.GlobalStaticLock.WaitAsync() + .ConfigureAwait(false); + try { - _itemEvicted = itemEvicted; - _cleanUpTimer = new Timer(s => { _ = EvictExpiredJob(); }, null, cleanupJobInterval, cleanupJobInterval); + EvictExpired(); } + finally { FastCacheStatics.GlobalStaticLock.Release(); } + } - private async Task EvictExpiredJob() + /// + /// Cleans up expired items (dont' wait for the background job) + /// There's rarely a need to execute this method, b/c getting an item checks TTL anyway. + /// + public void EvictExpired() + { + //Eviction already started by another thread? forget it, lets move on + if (_lock.TryEnter()) //use the new System.Threading.Lock class for faster locking in .NET9+ { - //if an applicaiton has many-many instances of FastCache objects, make sure the timer-based - //cleanup jobs don't clash with each other, i.e. there are no clean-up jobs running in parallel - //so we don't waste CPU resources, because cleanup is a busy-loop that iterates a collection and does calculations - //so we use a lock to "throttle" the job and make it serial - //HOWEVER, we still allow the user to execute eviction explicitly - - //use Semaphore instead of a "lock" to free up thread, otherwise - possible thread starvation - - await FastCacheStatics.GlobalStaticLock.WaitAsync() - .ConfigureAwait(false); + List evictedKeys = null; // Batch eviction callbacks try { - EvictExpired(); - } - finally { FastCacheStatics.GlobalStaticLock.Release(); } - } + //cache current tick count in a var to prevent calling it every iteration inside "IsExpired()" in a tight loop. + //On a 10000-items cache this allows us to slice 30 microseconds: 330 vs 360 microseconds which is 10% faster + //On a 50000-items cache it's even more: 2.057ms vs 2.817ms which is 35% faster!! + //the bigger the cache the bigger the win + var currTime = Environment.TickCount64; - /// - /// Cleans up expired items (dont' wait for the background job) - /// There's rarely a need to execute this method, b/c getting an item checks TTL anyway. - /// - public void EvictExpired() - { - //Eviction already started by another thread? forget it, lets move on - if (_lock.TryEnter()) //use the new System.Threading.Lock class for faster locking in .NET9+ - { - List evictedKeys = null; // Batch eviction callbacks - try + foreach (var p in _dict) { - //cache current tick count in a var to prevent calling it every iteration inside "IsExpired()" in a tight loop. - //On a 10000-items cache this allows us to slice 30 microseconds: 330 vs 360 microseconds which is 10% faster - //On a 50000-items cache it's even more: 2.057ms vs 2.817ms which is 35% faster!! - //the bigger the cache the bigger the win - var currTime = Environment.TickCount64; - - foreach (var p in _dict) + if (p.Value.IsExpired(currTime)) //call IsExpired with "currTime" to avoid calling Environment.TickCount64 multiple times { - if (p.Value.IsExpired(currTime)) //call IsExpired with "currTime" to avoid calling Environment.TickCount64 multiple times + if (_dict.TryRemove(p) && _itemEvicted != null) // collect key for later batch processing (only if callback exists) { - if (_dict.TryRemove(p) && _itemEvicted != null) // collect key for later batch processing (only if callback exists) - { - evictedKeys ??= new List(); //lazy initialize the list - evictedKeys.Add(p.Key); - } + evictedKeys ??= new List(); //lazy initialize the list + evictedKeys.Add(p.Key); } } } - finally - { + } + finally + { _lock.Exit(); - } - - // Trigger batched eviction callbacks outside the loop to prevent flooding the thread pool - OnEviction(evictedKeys); } + + // Trigger batched eviction callbacks outside the loop to prevent flooding the thread pool + OnEviction(evictedKeys); } + } - /// - /// Returns total count, including expired items too, if they were not yet cleaned by the eviction job - /// - public int Count => _dict.Count; + /// + /// Returns total count, including expired items too, if they were not yet cleaned by the eviction job + /// + public int Count => _dict.Count; - /// - /// Removes all items from the cache - /// - public void Clear() => _dict.Clear(); + /// + /// Removes all items from the cache + /// + public void Clear() => _dict.Clear(); - /// - /// Adds an item to cache if it does not exist, updates the existing item otherwise. Updating an item resets its TTL, essentially "sliding expiration". - /// - /// The key to add - /// The value to add - /// TTL of the item - public void AddOrUpdate(TKey key, TValue value, TimeSpan ttl) - { - var ttlValue = new TtlValue(value, ttl); + /// + /// Adds an item to cache if it does not exist, updates the existing item otherwise. Updating an item resets its TTL, essentially "sliding expiration". + /// + /// The key to add + /// The value to add + /// TTL of the item + public void AddOrUpdate(TKey key, TValue value, TimeSpan ttl) + { + var ttlValue = new TtlValue(value, ttl); - _dict.AddOrUpdate(key, static (_, c) => c, static (_, _, c) => c, ttlValue); - } + _dict.AddOrUpdate(key, static (_, c) => c, static (_, _, c) => c, ttlValue); + } - /// - /// Factory pattern overload. Adds an item to cache if it does not exist, updates the existing item otherwise. Updating an item resets its TTL, essentially "sliding expiration". - /// - /// The key to add or update - /// The factory function used to generate the item for the key - /// The factory function used to update the item for the key - /// TTL of the item - public void AddOrUpdate(TKey key, Func addValueFactory, Func updateValueFactory, TimeSpan ttl) - { - _dict.AddOrUpdate(key, - addValueFactory: k => new TtlValue(addValueFactory(k), ttl), - updateValueFactory: (k, v) => new TtlValue(updateValueFactory(k, v.Value), ttl)); - } + /// + /// Factory pattern overload. Adds an item to cache if it does not exist, updates the existing item otherwise. Updating an item resets its TTL, essentially "sliding expiration". + /// + /// The key to add or update + /// The factory function used to generate the item for the key + /// The factory function used to update the item for the key + /// TTL of the item + public void AddOrUpdate(TKey key, Func addValueFactory, Func updateValueFactory, TimeSpan ttl) + { + _dict.AddOrUpdate(key, + addValueFactory: k => new TtlValue(addValueFactory(k), ttl), + updateValueFactory: (k, v) => new TtlValue(updateValueFactory(k, v.Value), ttl)); + } - /// - /// Attempts to get a value by key - /// - /// The key to get - /// When method returns, contains the object with the key if found, otherwise default value of the type - /// True if value exists, otherwise false - public bool TryGet(TKey key, out TValue value) - { - value = default(TValue); + /// + /// Attempts to get a value by key + /// + /// The key to get + /// When method returns, contains the object with the key if found, otherwise default value of the type + /// True if value exists, otherwise false + public bool TryGet(TKey key, out TValue value) + { + value = default; - if (!_dict.TryGetValue(key, out TtlValue ttlValue)) - return false; //not found + if (!_dict.TryGetValue(key, out TtlValue ttlValue)) + return false; //not found - if (ttlValue.IsExpired()) //found but expired - { - var kv = new KeyValuePair(key, ttlValue); - - //secret atomic removal method (only if both key and value match condition - //https://devblogs.microsoft.com/pfxteam/little-known-gems-atomic-conditional-removals-from-concurrentdictionary/ - //so that we don't need any locks!! woohoo - _dict.TryRemove(kv); - - /* EXPLANATION: - * when an item was "found but is expired" - we need to treat as "not found" and discard it. - * One solution is to use a lock - * so that the three steps "exist? expired? remove!" are performed atomically. - * Otherwise another tread might chip in, and ADD a non-expired item with the same key while we're evicting it. - * And we'll be removing a non-expired key that was just added. - * - * BUT instead of using locks we can remove by key AND value. So if another thread has just rushed in - * and added another item with the same key - that other item won't be removed. - * - * basically, instead of doing this - * - * lock { - * exists? - * expired? - * remove by key! - * } - * - * we do this - * - * exists? (if yes returns the value) - * expired? - * remove by key AND value - * - * If another thread has modified the value - it won't remove it. - * - * Locks suck becasue add extra 50ns to benchmark, so it becomes 110ns instead of 70ns which sucks. - * So - no locks then!!! - * - * */ + if (ttlValue.IsExpired()) //found but expired + { + var kv = new KeyValuePair(key, ttlValue); + + //secret atomic removal method (only if both key and value match condition + //https://devblogs.microsoft.com/pfxteam/little-known-gems-atomic-conditional-removals-from-concurrentdictionary/ + //so that we don't need any locks!! woohoo + _dict.TryRemove(kv); + + /* EXPLANATION: + * when an item was "found but is expired" - we need to treat as "not found" and discard it. + * One solution is to use a lock + * so that the three steps "exist? expired? remove!" are performed atomically. + * Otherwise another tread might chip in, and ADD a non-expired item with the same key while we're evicting it. + * And we'll be removing a non-expired key that was just added. + * + * BUT instead of using locks we can remove by key AND value. So if another thread has just rushed in + * and added another item with the same key - that other item won't be removed. + * + * basically, instead of doing this + * + * lock { + * exists? + * expired? + * remove by key! + * } + * + * we do this + * + * exists? (if yes returns the value) + * expired? + * remove by key AND value + * + * If another thread has modified the value - it won't remove it. + * + * Locks suck becasue add extra 50ns to benchmark, so it becomes 110ns instead of 70ns which sucks. + * So - no locks then!!! + * + * */ + + OnEviction(key); + + return false; + } - OnEviction(key); + value = ttlValue.Value; + return true; + } - return false; - } + /// + /// Attempts to add a key/value item + /// + /// The key to add + /// The value to add + /// TTL of the item + /// True if value was added, otherwise false (already exists) + public bool TryAdd(TKey key, TValue value, TimeSpan ttl) + { + if (TryGet(key, out _)) + return false; - value = ttlValue.Value; - return true; - } + return _dict.TryAdd(key, new TtlValue(value, ttl)); + } - /// - /// Attempts to add a key/value item - /// - /// The key to add - /// The value to add - /// TTL of the item - /// True if value was added, otherwise false (already exists) - public bool TryAdd(TKey key, TValue value, TimeSpan ttl) - { - if (TryGet(key, out _)) - return false; + private TValue GetOrAddCore(TKey key, Func valueFactory, TimeSpan ttl) + { + bool wasAdded = false; //flag to indicate "add vs get". TODO: wrap in ref type some day to avoid captures/closures + var ttlValue = _dict.GetOrAdd( + key, + (_) => + { + wasAdded = true; + return new TtlValue(valueFactory(), ttl); + }); - return _dict.TryAdd(key, new TtlValue(value, ttl)); + //if the item is expired, update value and TTL + //since TtlValue is a reference type we can update its properties in-place, instead of removing and re-adding to the dictionary (extra lookups) + if (!wasAdded) //performance hack: skip expiration check if a brand item was just added + { + if (ttlValue.ModifyIfExpired(valueFactory, ttl)) + OnEviction(key); } - private TValue GetOrAddCore(TKey key, Func valueFactory, TimeSpan ttl) - { - bool wasAdded = false; //flag to indicate "add vs get". TODO: wrap in ref type some day to avoid captures/closures - var ttlValue = _dict.GetOrAdd( - key, - (_) => - { - wasAdded = true; - return new TtlValue(valueFactory(), ttl); - }); + return ttlValue.Value; + } - //if the item is expired, update value and TTL - //since TtlValue is a reference type we can update its properties in-place, instead of removing and re-adding to the dictionary (extra lookups) - if (!wasAdded) //performance hack: skip expiration check if a brand item was just added - { - if (ttlValue.ModifyIfExpired(valueFactory, ttl)) - OnEviction(key); - } + /// + /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing value if the key exists. + /// + /// The key to add + /// The factory function used to generate the item for the key + /// TTL of the item + public TValue GetOrAdd(TKey key, Func valueFactory, TimeSpan ttl) + => GetOrAddCore(key, () => valueFactory(key), ttl); - return ttlValue.Value; - } + /// + /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing value if the key exists. + /// + /// The key to add + /// The factory function used to generate the item for the key + /// TTL of the item + /// Argument value to pass into valueFactory + public TValue GetOrAdd(TKey key, Func valueFactory, TimeSpan ttl, TArg factoryArgument) + => GetOrAddCore(key, () => valueFactory(key, factoryArgument), ttl); - /// - /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing value if the key exists. - /// - /// The key to add - /// The factory function used to generate the item for the key - /// TTL of the item - public TValue GetOrAdd(TKey key, Func valueFactory, TimeSpan ttl) - => GetOrAddCore(key, () => valueFactory(key), ttl); + /// + /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing value if the key exists. + /// + /// The key to add + /// The value to add + /// TTL of the item + public TValue GetOrAdd(TKey key, TValue value, TimeSpan ttl) + => GetOrAddCore(key, () => value, ttl); - /// - /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing value if the key exists. - /// - /// The key to add - /// The factory function used to generate the item for the key - /// TTL of the item - /// Argument value to pass into valueFactory - public TValue GetOrAdd(TKey key, Func valueFactory, TimeSpan ttl, TArg factoryArgument) - => GetOrAddCore(key, () => valueFactory(key, factoryArgument), ttl); + /// + /// Tries to remove item with the specified key + /// + /// The key of the element to remove + public void Remove(TKey key) + { + _dict.TryRemove(key, out _); + } - /// - /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing value if the key exists. - /// - /// The key to add - /// The value to add - /// TTL of the item - public TValue GetOrAdd(TKey key, TValue value, TimeSpan ttl) - => GetOrAddCore(key, () => value, ttl); + /// + /// Tries to remove item with the specified key, also returns the object removed in an "out" var + /// + /// The key of the element to remove + /// Contains the object removed or the default value if not found + public bool TryRemove(TKey key, out TValue value) + { + bool res = _dict.TryRemove(key, out var ttlValue) && !ttlValue.IsExpired(); + value = res ? ttlValue.Value : default(TValue); + return res; + } - /// - /// Tries to remove item with the specified key - /// - /// The key of the element to remove - public void Remove(TKey key) + /// + public IEnumerator> GetEnumerator() + { + var currTime = Environment.TickCount64; //save to a var to prevent multiple calls to Environment.TickCount64 + foreach (var kvp in _dict) { - _dict.TryRemove(key, out _); + if (!kvp.Value.IsExpired(currTime)) + yield return new KeyValuePair(kvp.Key, kvp.Value.Value); } + } - /// - /// Tries to remove item with the specified key, also returns the object removed in an "out" var - /// - /// The key of the element to remove - /// Contains the object removed or the default value if not found - public bool TryRemove(TKey key, out TValue value) - { - bool res = _dict.TryRemove(key, out var ttlValue) && !ttlValue.IsExpired(); - value = res ? ttlValue.Value : default(TValue); - return res; - } + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + private void OnEviction(TKey key) + { + if (_itemEvicted == null) return; - /// - public IEnumerator> GetEnumerator() + Task.Run(() => //run on thread pool to avoid blocking { - var currTime = Environment.TickCount64; //save to a var to prevent multiple calls to Environment.TickCount64 - foreach (var kvp in _dict) + try { - if (!kvp.Value.IsExpired(currTime)) - yield return new KeyValuePair(kvp.Key, kvp.Value.Value); + _itemEvicted(key); } - } + catch { } //to prevent any exceptions from crashing the thread + }); + } - IEnumerator IEnumerable.GetEnumerator() - { - return this.GetEnumerator(); - } + // same as OnEviction(TKey) but for batching + private void OnEviction(List keys) + { + if (keys == null || keys.Count == 0) return; + if (_itemEvicted == null) return; - private void OnEviction(TKey key) + Task.Run(() => //run on thread pool to avoid blocking { - if (_itemEvicted == null) return; - - Task.Run(() => //run on thread pool to avoid blocking + try { - try + foreach (var key in keys) { _itemEvicted(key); } - catch { } //to prevent any exceptions from crashing the thread - }); - } - - // same as OnEviction(TKey) but for batching - private void OnEviction(List keys) - { - if (keys == null || keys.Count == 0) return; - if (_itemEvicted == null) return; + } + catch { } //to prevent any exceptions from crashing the thread + }); + } - Task.Run(() => //run on thread pool to avoid blocking - { - try - { - foreach (var key in keys) - { - _itemEvicted(key); - } - } - catch { } //to prevent any exceptions from crashing the thread - }); - } + private class TtlValue + { + public TValue Value { get; private set; } + private long TickCountWhenToKill; - private class TtlValue + public TtlValue(TValue value, TimeSpan ttl) { - public TValue Value { get; private set; } - private long TickCountWhenToKill; - - public TtlValue(TValue value, TimeSpan ttl) - { - Value = value; - TickCountWhenToKill = Environment.TickCount64 + (long)ttl.TotalMilliseconds; - } + Value = value; + TickCountWhenToKill = Environment.TickCount64 + (long)ttl.TotalMilliseconds; + } - public bool IsExpired() => IsExpired(Environment.TickCount64); + public bool IsExpired() => IsExpired(Environment.TickCount64); - //use an overload instead of optional param to avoid extra IF's - public bool IsExpired(long currTime) => currTime > TickCountWhenToKill; + //use an overload instead of optional param to avoid extra IF's + public bool IsExpired(long currTime) => currTime > TickCountWhenToKill; - /// - /// Updates the value and TTL only if the item is expired - /// - /// True if the item expired and was updated, otherwise false - public bool ModifyIfExpired(Func newValueFactory, TimeSpan newTtl) + /// + /// Updates the value and TTL only if the item is expired + /// + /// True if the item expired and was updated, otherwise false + public bool ModifyIfExpired(Func newValueFactory, TimeSpan newTtl) + { + var ticks = Environment.TickCount64; //save to a var to prevent multiple calls to Environment.TickCount64 + if (IsExpired(ticks)) //if expired - update the value and TTL { - var ticks = Environment.TickCount64; //save to a var to prevent multiple calls to Environment.TickCount64 - if (IsExpired(ticks)) //if expired - update the value and TTL - { - TickCountWhenToKill = ticks + (long)newTtl.TotalMilliseconds; //update the expiration time first for better concurrency - Value = newValueFactory(); - return true; - } - return false; + TickCountWhenToKill = ticks + (long)newTtl.TotalMilliseconds; //update the expiration time first for better concurrency + Value = newValueFactory(); + return true; } + return false; } + } - //IDispisable members - private bool _disposedValue; - /// - public void Dispose() => Dispose(true); - /// - protected virtual void Dispose(bool disposing) + //IDisposable members + private bool _disposedValue; + /// + public void Dispose() => Dispose(true); + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) { - if (!_disposedValue) + if (disposing) { - if (disposing) - { - _cleanUpTimer.Dispose(); - } - - _disposedValue = true; + _cleanUpTimer.Dispose(); } + + _disposedValue = true; } } } diff --git a/FastCache/FastCache.csproj b/FastCache/FastCache.csproj index b8d7060..eb89ac0 100644 --- a/FastCache/FastCache.csproj +++ b/FastCache/FastCache.csproj @@ -29,7 +29,7 @@ - + all analyzers diff --git a/LICENSE b/LICENSE index f1ed971..577d60d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Jitbit (the company behind "Jitbit Helpdesk" software) +Copyright (c) 2026 Jitbit (the company behind "Jitbit Helpdesk" software) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/UnitTests/EvictionCallbackTests.cs b/UnitTests/EvictionCallbackTests.cs index d7952c5..54748ae 100644 --- a/UnitTests/EvictionCallbackTests.cs +++ b/UnitTests/EvictionCallbackTests.cs @@ -1,4 +1,3 @@ -using System; using Jitbit.Utils; namespace UnitTests; diff --git a/UnitTests/TestHelper.cs b/UnitTests/TestHelper.cs index aadd42a..6179192 100644 --- a/UnitTests/TestHelper.cs +++ b/UnitTests/TestHelper.cs @@ -1,26 +1,25 @@ [assembly: Parallelize(Workers = 3, Scope = ExecutionScope.ClassLevel)] -namespace UnitTests +namespace UnitTests; + +internal class TestHelper { - internal class TestHelper + public static async Task RunConcurrently(int numThreads, Action action) { - public static async Task RunConcurrently(int numThreads, Action action) - { - var tasks = new Task[numThreads]; - ManualResetEvent m = new ManualResetEvent(false); + var tasks = new Task[numThreads]; + ManualResetEvent m = new ManualResetEvent(false); - for (int i = 0; i < numThreads; i++) + for (int i = 0; i < numThreads; i++) + { + tasks[i] = Task.Run(() => { - tasks[i] = Task.Run(() => - { - m.WaitOne(); //dont start just yet - action(); - }); - } + m.WaitOne(); //dont start just yet + action(); + }); + } - m.Set(); //off we go + m.Set(); //off we go - await Task.WhenAll(tasks); - } + await Task.WhenAll(tasks); } } diff --git a/UnitTests/UnitTests.cs b/UnitTests/UnitTests.cs index e5f4169..f89b031 100644 --- a/UnitTests/UnitTests.cs +++ b/UnitTests/UnitTests.cs @@ -1,248 +1,246 @@ using Jitbit.Utils; +namespace UnitTests; -namespace UnitTests +[TestClass] +public class UnitTests { - [TestClass] - public class UnitTests + [TestMethod] + public async Task TestGetSetCleanup() { - [TestMethod] - public async Task TestGetSetCleanup() - { - using var _cache = new FastCache(cleanupJobInterval: 200); //add "using" to stop cleanup timer, to prevent cleanup job from clashing with other tests - _cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(100)); - Assert.IsTrue(_cache.TryGet(42, out int v)); - Assert.AreEqual(42, v); + using var _cache = new FastCache(cleanupJobInterval: 200); //add "using" to stop cleanup timer, to prevent cleanup job from clashing with other tests + _cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(100)); + Assert.IsTrue(_cache.TryGet(42, out int v)); + Assert.AreEqual(42, v); - await Task.Delay(300); - Assert.AreEqual(0, _cache.Count); //cleanup job has run? - } + await Task.Delay(300); + Assert.AreEqual(0, _cache.Count); //cleanup job has run? + } - [TestMethod] - public async Task TestEviction() + [TestMethod] + public async Task TestEviction() + { + var list = new List>(); + for (int i = 0; i < 20; i++) { - var list = new List>(); - for (int i = 0; i < 20; i++) - { - var cache = new FastCache(cleanupJobInterval: 200); - cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(100)); - list.Add(cache); - } - await Task.Delay(300); - - for (int i = 0; i < 20; i++) - { - Assert.AreEqual(0, list[i].Count); //cleanup job has run? - } - - //cleanup - for (int i = 0; i < 20; i++) - { - list[i].Dispose(); - } + var cache = new FastCache(cleanupJobInterval: 200); + cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(100)); + list.Add(cache); } + await Task.Delay(300); - [TestMethod] - public async Task Shortdelay() + for (int i = 0; i < 20; i++) { - var cache = new FastCache(); - cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(500)); - - await Task.Delay(50); - - Assert.IsTrue(cache.TryGet(42, out int result)); //not evicted - Assert.AreEqual(42, result); + Assert.AreEqual(0, list[i].Count); //cleanup job has run? } - [TestMethod] - public async Task TestWithDefaultJobInterval() + //cleanup + for (int i = 0; i < 20; i++) { - var _cache2 = new FastCache(); - _cache2.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); - Assert.IsTrue(_cache2.TryGet("42", out _)); - await Task.Delay(150); - Assert.IsFalse(_cache2.TryGet("42", out _)); + list[i].Dispose(); } + } - [TestMethod] - public void TestRemove() - { - var cache = new FastCache(); - cache.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); - cache.Remove("42"); - Assert.IsFalse(cache.TryGet("42", out _)); - } + [TestMethod] + public async Task Shortdelay() + { + var cache = new FastCache(); + cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(500)); - [TestMethod] - public void TestTryRemove() - { - var cache = new FastCache(); - cache.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); - var res = cache.TryRemove("42", out int value); - Assert.IsTrue(res && value == 42); - Assert.IsFalse(cache.TryGet("42", out _)); - - //now try remove non-existing item - res = cache.TryRemove("blabblah", out value); - Assert.IsFalse(res); - Assert.AreEqual(0, value); - } + await Task.Delay(50); - [TestMethod] - public async Task TestTryRemoveWithTtl() - { - var cache = new FastCache(); - cache.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); - await Task.Delay(120); //let the item expire + Assert.IsTrue(cache.TryGet(42, out int result)); //not evicted + Assert.AreEqual(42, result); + } - var res = cache.TryRemove("42", out int value); - Assert.IsFalse(res); - Assert.AreEqual(0, value); - } + [TestMethod] + public async Task TestWithDefaultJobInterval() + { + var _cache2 = new FastCache(); + _cache2.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); + Assert.IsTrue(_cache2.TryGet("42", out _)); + await Task.Delay(150); + Assert.IsFalse(_cache2.TryGet("42", out _)); + } - [TestMethod] - public async Task TestTryAdd() - { - var cache = new FastCache(); - Assert.IsTrue(cache.TryAdd("42", 42, TimeSpan.FromMilliseconds(100))); - Assert.IsFalse(cache.TryAdd("42", 42, TimeSpan.FromMilliseconds(100))); + [TestMethod] + public void TestRemove() + { + var cache = new FastCache(); + cache.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); + cache.Remove("42"); + Assert.IsFalse(cache.TryGet("42", out _)); + } - await Task.Delay(120); //wait for it to expire + [TestMethod] + public void TestTryRemove() + { + var cache = new FastCache(); + cache.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); + var res = cache.TryRemove("42", out int value); + Assert.IsTrue(res && value == 42); + Assert.IsFalse(cache.TryGet("42", out _)); + + //now try remove non-existing item + res = cache.TryRemove("blabblah", out value); + Assert.IsFalse(res); + Assert.AreEqual(0, value); + } - Assert.IsTrue(cache.TryAdd("42", 42, TimeSpan.FromMilliseconds(100))); - } + [TestMethod] + public async Task TestTryRemoveWithTtl() + { + var cache = new FastCache(); + cache.AddOrUpdate("42", 42, TimeSpan.FromMilliseconds(100)); + await Task.Delay(120); //let the item expire - [TestMethod] - public async Task TestGetOrAdd() - { - var cache = new FastCache(); - cache.GetOrAdd("key", k => 1024, TimeSpan.FromMilliseconds(100)); - Assert.AreEqual(1024, cache.GetOrAdd("key", k => 1025, TimeSpan.FromMilliseconds(100))); //old value - Assert.IsTrue(cache.TryGet("key", out int res) && res == 1024); //another way to retrieve - await Task.Delay(110); - - Assert.IsFalse(cache.TryGet("key", out _)); //expired - - //now try non-factory overloads - Assert.AreEqual(123321, cache.GetOrAdd("key123", 123321, TimeSpan.FromMilliseconds(100))); - Assert.AreEqual(123321, cache.GetOrAdd("key123", -1, TimeSpan.FromMilliseconds(100))); //still old value - await Task.Delay(110); - Assert.AreEqual(-1, cache.GetOrAdd("key123", -1, TimeSpan.FromMilliseconds(100))); //new value - } + var res = cache.TryRemove("42", out int value); + Assert.IsFalse(res); + Assert.AreEqual(0, value); + } + [TestMethod] + public async Task TestTryAdd() + { + var cache = new FastCache(); + Assert.IsTrue(cache.TryAdd("42", 42, TimeSpan.FromMilliseconds(100))); + Assert.IsFalse(cache.TryAdd("42", 42, TimeSpan.FromMilliseconds(100))); - [TestMethod] - public async Task TestGetOrAddExpiration() - { - var cache = new FastCache(); - cache.GetOrAdd("key", k => 1024, TimeSpan.FromMilliseconds(100)); + await Task.Delay(120); //wait for it to expire - Assert.AreEqual(1024, cache.GetOrAdd("key", k => 1025, TimeSpan.FromMilliseconds(100))); //old value - Assert.IsTrue(cache.TryGet("key", out int res) && res == 1024); //another way to retrieve - - await Task.Delay(110); //let the item expire + Assert.IsTrue(cache.TryAdd("42", 42, TimeSpan.FromMilliseconds(100))); + } - Assert.AreEqual(1025, cache.GetOrAdd("key", k => 1025, TimeSpan.FromMilliseconds(100))); //new value - Assert.IsTrue(cache.TryGet("key", out res) && res == 1025); //another way to retrieve - } + [TestMethod] + public async Task TestGetOrAdd() + { + var cache = new FastCache(); + cache.GetOrAdd("key", k => 1024, TimeSpan.FromMilliseconds(100)); + Assert.AreEqual(1024, cache.GetOrAdd("key", k => 1025, TimeSpan.FromMilliseconds(100))); //old value + Assert.IsTrue(cache.TryGet("key", out int res) && res == 1024); //another way to retrieve + await Task.Delay(110); + + Assert.IsFalse(cache.TryGet("key", out _)); //expired + + //now try non-factory overloads + Assert.AreEqual(123321, cache.GetOrAdd("key123", 123321, TimeSpan.FromMilliseconds(100))); + Assert.AreEqual(123321, cache.GetOrAdd("key123", -1, TimeSpan.FromMilliseconds(100))); //still old value + await Task.Delay(110); + Assert.AreEqual(-1, cache.GetOrAdd("key123", -1, TimeSpan.FromMilliseconds(100))); //new value + } - [TestMethod] - public async Task TestGetOrAddWithArg() - { - var cache = new FastCache(); - cache.GetOrAdd("key", (k, arg) => 1024 + arg.Length, TimeSpan.FromMilliseconds(100), "test123"); - Assert.IsTrue(cache.TryGet("key", out int res) && res == 1031); - - //eviction - await Task.Delay(110); - Assert.IsFalse(cache.TryGet("key", out _)); - - //now try without "TryGet" - Assert.AreEqual(24, cache.GetOrAdd("key2", (k, arg) => 21 + arg.Length, TimeSpan.FromMilliseconds(100), "123")); - Assert.AreEqual(24, cache.GetOrAdd("key2", (k, arg) => 2211 + arg.Length, TimeSpan.FromMilliseconds(100), "123")); - await Task.Delay(110); - Assert.AreEqual(2214, cache.GetOrAdd("key2", (k, arg) => 2211 + arg.Length, TimeSpan.FromMilliseconds(100), "123")); - } - [TestMethod] - public void TestClear() - { - var cache = new FastCache(); - cache.GetOrAdd("key", k => 1024, TimeSpan.FromSeconds(100)); + [TestMethod] + public async Task TestGetOrAddExpiration() + { + var cache = new FastCache(); + cache.GetOrAdd("key", k => 1024, TimeSpan.FromMilliseconds(100)); - cache.Clear(); + Assert.AreEqual(1024, cache.GetOrAdd("key", k => 1025, TimeSpan.FromMilliseconds(100))); //old value + Assert.IsTrue(cache.TryGet("key", out int res) && res == 1024); //another way to retrieve + + await Task.Delay(110); //let the item expire - Assert.IsFalse(cache.TryGet("key", out int res)); - } + Assert.AreEqual(1025, cache.GetOrAdd("key", k => 1025, TimeSpan.FromMilliseconds(100))); //new value + Assert.IsTrue(cache.TryGet("key", out res) && res == 1025); //another way to retrieve + } - [TestMethod] - public async Task TestTryAddAtomicness() - { - int i = 0; - - var cache = new FastCache(); - cache.TryAdd(42, 42, TimeSpan.FromMilliseconds(50)); //add item with short TTL + [TestMethod] + public async Task TestGetOrAddWithArg() + { + var cache = new FastCache(); + cache.GetOrAdd("key", (k, arg) => 1024 + arg.Length, TimeSpan.FromMilliseconds(100), "test123"); + Assert.IsTrue(cache.TryGet("key", out int res) && res == 1031); + + //eviction + await Task.Delay(110); + Assert.IsFalse(cache.TryGet("key", out _)); + + //now try without "TryGet" + Assert.AreEqual(24, cache.GetOrAdd("key2", (k, arg) => 21 + arg.Length, TimeSpan.FromMilliseconds(100), "123")); + Assert.AreEqual(24, cache.GetOrAdd("key2", (k, arg) => 2211 + arg.Length, TimeSpan.FromMilliseconds(100), "123")); + await Task.Delay(110); + Assert.AreEqual(2214, cache.GetOrAdd("key2", (k, arg) => 2211 + arg.Length, TimeSpan.FromMilliseconds(100), "123")); + } - await Task.Delay(100); //wait for tha value to expire + [TestMethod] + public void TestClear() + { + var cache = new FastCache(); + cache.GetOrAdd("key", k => 1024, TimeSpan.FromSeconds(100)); - await TestHelper.RunConcurrently(20, () => { - if (cache.TryAdd(42, 42, TimeSpan.FromSeconds(1))) - i++; - }); + cache.Clear(); - Assert.AreEqual(1, i); - } + Assert.IsFalse(cache.TryGet("key", out int res)); + } - //this text can occasionally fail becasue factory is not guaranteed to be called only once. only panic if it fails ALL THE TIME - [TestMethod] - public async Task TestGetOrAddAtomicNess() - { - int i = 0; + [TestMethod] + public async Task TestTryAddAtomicness() + { + int i = 0; + + var cache = new FastCache(); + cache.TryAdd(42, 42, TimeSpan.FromMilliseconds(50)); //add item with short TTL - var cache = new FastCache(); - - cache.GetOrAdd(42, 42, TimeSpan.FromMilliseconds(100)); + await Task.Delay(100); //wait for tha value to expire - await Task.Delay(110); //wait for tha value to expire + await TestHelper.RunConcurrently(20, () => { + if (cache.TryAdd(42, 42, TimeSpan.FromSeconds(1))) + i++; + }); - await TestHelper.RunConcurrently(20, () => { - cache.GetOrAdd(42, k => { return ++i; }, TimeSpan.FromSeconds(1)); - }); + Assert.AreEqual(1, i); + } - //test that only the first value was added - cache.TryGet(42, out i); - Assert.AreEqual(1, i); - } + //this text can occasionally fail becasue factory is not guaranteed to be called only once. only panic if it fails ALL THE TIME + [TestMethod] + public async Task TestGetOrAddAtomicNess() + { + int i = 0; - [TestMethod] - public async Task Enumerator() - { - var cache = new FastCache(); //now with default cleanup interval - cache.GetOrAdd("key", k => 1024, TimeSpan.FromMilliseconds(100)); + var cache = new FastCache(); + + cache.GetOrAdd(42, 42, TimeSpan.FromMilliseconds(100)); - Assert.AreEqual(1024, cache.FirstOrDefault().Value); + await Task.Delay(110); //wait for tha value to expire - await Task.Delay(110); + await TestHelper.RunConcurrently(20, () => { + cache.GetOrAdd(42, k => { return ++i; }, TimeSpan.FromSeconds(1)); + }); - Assert.IsFalse(cache.Any()); - } + //test that only the first value was added + cache.TryGet(42, out i); + Assert.AreEqual(1, i); + } - [TestMethod] - public async Task TestTtlExtended() - { - var _cache = new FastCache(); - _cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(300)); + [TestMethod] + public async Task Enumerator() + { + var cache = new FastCache(); //now with default cleanup interval + cache.GetOrAdd("key", k => 1024, TimeSpan.FromMilliseconds(100)); - await Task.Delay(50); - Assert.IsTrue(_cache.TryGet(42, out int result)); //not evicted - Assert.AreEqual(42, result); + Assert.AreEqual(1024, cache.FirstOrDefault().Value); - _cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(300)); + await Task.Delay(110); - await Task.Delay(250); + Assert.IsFalse(cache.Any()); + } - Assert.IsTrue(_cache.TryGet(42, out int result2)); //still not evicted - Assert.AreEqual(42, result2); - } + [TestMethod] + public async Task TestTtlExtended() + { + var _cache = new FastCache(); + _cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(300)); + + await Task.Delay(50); + Assert.IsTrue(_cache.TryGet(42, out int result)); //not evicted + Assert.AreEqual(42, result); + + _cache.AddOrUpdate(42, 42, TimeSpan.FromMilliseconds(300)); + + await Task.Delay(250); + + Assert.IsTrue(_cache.TryGet(42, out int result2)); //still not evicted + Assert.AreEqual(42, result2); } } \ No newline at end of file diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index e8f6d25..f7d087c 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -9,9 +9,9 @@ - - - + + + diff --git a/UnitTests/UnitTests2.cs b/UnitTests/UnitTests2.cs index a68b05a..44216c1 100644 --- a/UnitTests/UnitTests2.cs +++ b/UnitTests/UnitTests2.cs @@ -2,252 +2,251 @@ //some more unit tests. Thanks Claude! :)) -namespace UnitTests +namespace UnitTests; + +[TestClass] +public class UnitTests2 { - [TestClass] - public class UnitTests2 + [TestMethod] + public void AddOrUpdate_NewItem_AddsSuccessfully() + { + // Arrange + var cache = new FastCache(); + + // Act + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + bool exists = cache.TryGet("key1", out int value); + + // Assert + Assert.IsTrue(exists); + Assert.AreEqual(42, value); + } + + [TestMethod] + public void AddOrUpdate_ExistingItem_UpdatesSuccessfully() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + + // Act + cache.AddOrUpdate("key1", 43, TimeSpan.FromMinutes(1)); + bool exists = cache.TryGet("key1", out int value); + + // Assert + Assert.IsTrue(exists); + Assert.AreEqual(43, value); + } + + [TestMethod] + public async Task TryGet_ExpiredItem_ReturnsFalse() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMilliseconds(100)); + + // Act + await Task.Delay(200); // Wait for expiration + bool exists = cache.TryGet("key1", out int value); + + // Assert + Assert.IsFalse(exists); + Assert.AreEqual(default(int), value); + } + + [TestMethod] + public void TryAdd_NewItem_ReturnsTrue() + { + // Arrange + var cache = new FastCache(); + + // Act + bool added = cache.TryAdd("key1", 42, TimeSpan.FromMinutes(1)); + + // Assert + Assert.IsTrue(added); + Assert.IsTrue(cache.TryGet("key1", out int value)); + Assert.AreEqual(42, value); + } + + [TestMethod] + public void TryAdd_ExistingItem_ReturnsFalse() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + + // Act + bool added = cache.TryAdd("key1", 43, TimeSpan.FromMinutes(1)); + + // Assert + Assert.IsFalse(added); + Assert.IsTrue(cache.TryGet("key1", out int value)); + Assert.AreEqual(42, value); // Original value should remain + } + + [TestMethod] + public void GetOrAdd_NewItem_AddsAndReturnsValue() + { + // Arrange + var cache = new FastCache(); + + // Act + int value = cache.GetOrAdd("key1", k => 42, TimeSpan.FromMinutes(1)); + + // Assert + Assert.AreEqual(42, value); + Assert.IsTrue(cache.TryGet("key1", out int retrieved)); + Assert.AreEqual(42, retrieved); + } + + [TestMethod] + public void GetOrAdd_ExistingNonExpiredItem_ReturnsExistingValue() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + + // Act + int value = cache.GetOrAdd("key1", k => 43, TimeSpan.FromMinutes(1)); + + // Assert + Assert.AreEqual(42, value); // Should return existing value + } + + [TestMethod] + public async Task GetOrAdd_ExistingExpiredItem_ReturnsNewValue() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMilliseconds(100)); + await Task.Delay(200); // Wait for expiration + + // Act + int value = cache.GetOrAdd("key1", k => 43, TimeSpan.FromMinutes(1)); + + // Assert + Assert.AreEqual(43, value); // Should return new value + } + + [TestMethod] + public void GetOrAddWithArg_NewItem_AddsAndReturnsValue() + { + // Arrange + var cache = new FastCache(); + int multiplier = 2; + + // Act + int value = cache.GetOrAdd("key1", (k, m) => 21 * m, TimeSpan.FromMinutes(1), multiplier); + + // Assert + Assert.AreEqual(42, value); + } + + [TestMethod] + public void Remove_ExistingItem_RemovesSuccessfully() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + + // Act + cache.Remove("key1"); + + // Assert + Assert.IsFalse(cache.TryGet("key1", out _)); + } + + [TestMethod] + public void TryRemove_ExistingItem_RemovesAndReturnsValue() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + + // Act + bool removed = cache.TryRemove("key1", out int value); + + // Assert + Assert.IsTrue(removed); + Assert.AreEqual(42, value); + Assert.IsFalse(cache.TryGet("key1", out _)); + } + + [TestMethod] + public void Clear_RemovesAllItems() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + cache.AddOrUpdate("key2", 43, TimeSpan.FromMinutes(1)); + + // Act + cache.Clear(); + + // Assert + Assert.AreEqual(0, cache.Count); + Assert.IsFalse(cache.TryGet("key1", out _)); + Assert.IsFalse(cache.TryGet("key2", out _)); + } + + [TestMethod] + public void Enumeration_ReturnsOnlyNonExpiredItems() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); + cache.AddOrUpdate("key2", 43, TimeSpan.FromMilliseconds(1)); + Thread.Sleep(50); // Wait for second item to expire + + // Act + var items = cache.ToList(); + + // Assert + Assert.HasCount(1, items); + Assert.AreEqual(42, items[0].Value); + Assert.AreEqual("key1", items[0].Key); + } + + [TestMethod] + public async Task EvictExpired_RemovesExpiredItems() + { + // Arrange + var cache = new FastCache(); + cache.AddOrUpdate("key1", 42, TimeSpan.FromMilliseconds(100)); + cache.AddOrUpdate("key2", 43, TimeSpan.FromMinutes(1)); + + // Act + await Task.Delay(200); // Wait for first item to expire + cache.EvictExpired(); + + // Assert + Assert.IsFalse(cache.TryGet("key1", out _)); + Assert.IsTrue(cache.TryGet("key2", out int value)); + Assert.AreEqual(43, value); + } + + [TestMethod] + public void AddOrUpdate_WithFactory() { - [TestMethod] - public void AddOrUpdate_NewItem_AddsSuccessfully() - { - // Arrange - var cache = new FastCache(); - - // Act - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - bool exists = cache.TryGet("key1", out int value); - - // Assert - Assert.IsTrue(exists); - Assert.AreEqual(42, value); - } - - [TestMethod] - public void AddOrUpdate_ExistingItem_UpdatesSuccessfully() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - - // Act - cache.AddOrUpdate("key1", 43, TimeSpan.FromMinutes(1)); - bool exists = cache.TryGet("key1", out int value); - - // Assert - Assert.IsTrue(exists); - Assert.AreEqual(43, value); - } - - [TestMethod] - public async Task TryGet_ExpiredItem_ReturnsFalse() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMilliseconds(100)); - - // Act - await Task.Delay(200); // Wait for expiration - bool exists = cache.TryGet("key1", out int value); - - // Assert - Assert.IsFalse(exists); - Assert.AreEqual(default(int), value); - } - - [TestMethod] - public void TryAdd_NewItem_ReturnsTrue() - { - // Arrange - var cache = new FastCache(); - - // Act - bool added = cache.TryAdd("key1", 42, TimeSpan.FromMinutes(1)); - - // Assert - Assert.IsTrue(added); - Assert.IsTrue(cache.TryGet("key1", out int value)); - Assert.AreEqual(42, value); - } - - [TestMethod] - public void TryAdd_ExistingItem_ReturnsFalse() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - - // Act - bool added = cache.TryAdd("key1", 43, TimeSpan.FromMinutes(1)); - - // Assert - Assert.IsFalse(added); - Assert.IsTrue(cache.TryGet("key1", out int value)); - Assert.AreEqual(42, value); // Original value should remain - } - - [TestMethod] - public void GetOrAdd_NewItem_AddsAndReturnsValue() - { - // Arrange - var cache = new FastCache(); - - // Act - int value = cache.GetOrAdd("key1", k => 42, TimeSpan.FromMinutes(1)); - - // Assert - Assert.AreEqual(42, value); - Assert.IsTrue(cache.TryGet("key1", out int retrieved)); - Assert.AreEqual(42, retrieved); - } - - [TestMethod] - public void GetOrAdd_ExistingNonExpiredItem_ReturnsExistingValue() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - - // Act - int value = cache.GetOrAdd("key1", k => 43, TimeSpan.FromMinutes(1)); - - // Assert - Assert.AreEqual(42, value); // Should return existing value - } - - [TestMethod] - public async Task GetOrAdd_ExistingExpiredItem_ReturnsNewValue() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMilliseconds(100)); - await Task.Delay(200); // Wait for expiration - - // Act - int value = cache.GetOrAdd("key1", k => 43, TimeSpan.FromMinutes(1)); - - // Assert - Assert.AreEqual(43, value); // Should return new value - } - - [TestMethod] - public void GetOrAddWithArg_NewItem_AddsAndReturnsValue() - { - // Arrange - var cache = new FastCache(); - int multiplier = 2; - - // Act - int value = cache.GetOrAdd("key1", (k, m) => 21 * m, TimeSpan.FromMinutes(1), multiplier); - - // Assert - Assert.AreEqual(42, value); - } - - [TestMethod] - public void Remove_ExistingItem_RemovesSuccessfully() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - - // Act - cache.Remove("key1"); - - // Assert - Assert.IsFalse(cache.TryGet("key1", out _)); - } - - [TestMethod] - public void TryRemove_ExistingItem_RemovesAndReturnsValue() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - - // Act - bool removed = cache.TryRemove("key1", out int value); - - // Assert - Assert.IsTrue(removed); - Assert.AreEqual(42, value); - Assert.IsFalse(cache.TryGet("key1", out _)); - } - - [TestMethod] - public void Clear_RemovesAllItems() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - cache.AddOrUpdate("key2", 43, TimeSpan.FromMinutes(1)); - - // Act - cache.Clear(); - - // Assert - Assert.AreEqual(0, cache.Count); - Assert.IsFalse(cache.TryGet("key1", out _)); - Assert.IsFalse(cache.TryGet("key2", out _)); - } - - [TestMethod] - public void Enumeration_ReturnsOnlyNonExpiredItems() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMinutes(1)); - cache.AddOrUpdate("key2", 43, TimeSpan.FromMilliseconds(1)); - Thread.Sleep(50); // Wait for second item to expire - - // Act - var items = cache.ToList(); - - // Assert - Assert.HasCount(1, items); - Assert.AreEqual(42, items[0].Value); - Assert.AreEqual("key1", items[0].Key); - } - - [TestMethod] - public async Task EvictExpired_RemovesExpiredItems() - { - // Arrange - var cache = new FastCache(); - cache.AddOrUpdate("key1", 42, TimeSpan.FromMilliseconds(100)); - cache.AddOrUpdate("key2", 43, TimeSpan.FromMinutes(1)); - - // Act - await Task.Delay(200); // Wait for first item to expire - cache.EvictExpired(); - - // Assert - Assert.IsFalse(cache.TryGet("key1", out _)); - Assert.IsTrue(cache.TryGet("key2", out int value)); - Assert.AreEqual(43, value); - } - - [TestMethod] - public void AddOrUpdate_WithFactory() - { - // Arrange - var cache = new FastCache(); - int callCount = 0; - - // Act - cache.AddOrUpdate("key1", _ => { callCount++; return 42; }, (_, _) => { callCount++; return 43; }, TimeSpan.FromMinutes(1)); - bool exists = cache.TryGet("key1", out int value); - - // Assert - Assert.IsTrue(exists); - Assert.AreEqual(42, value); - Assert.AreEqual(1, callCount); // Factory should be called exactly once - - callCount = 0; - cache.AddOrUpdate("key1", _ => { callCount++; return 44; }, (_, _) => { callCount++; return 45; }, TimeSpan.FromMinutes(1)); - exists = cache.TryGet("key1", out value); - Assert.IsTrue(exists); - Assert.AreEqual(45, value); - Assert.AreEqual(1, callCount); // Factory should be called exactly once - } + // Arrange + var cache = new FastCache(); + int callCount = 0; + + // Act + cache.AddOrUpdate("key1", _ => { callCount++; return 42; }, (_, _) => { callCount++; return 43; }, TimeSpan.FromMinutes(1)); + bool exists = cache.TryGet("key1", out int value); + + // Assert + Assert.IsTrue(exists); + Assert.AreEqual(42, value); + Assert.AreEqual(1, callCount); // Factory should be called exactly once + + callCount = 0; + cache.AddOrUpdate("key1", _ => { callCount++; return 44; }, (_, _) => { callCount++; return 45; }, TimeSpan.FromMinutes(1)); + exists = cache.TryGet("key1", out value); + Assert.IsTrue(exists); + Assert.AreEqual(45, value); + Assert.AreEqual(1, callCount); // Factory should be called exactly once } } \ No newline at end of file From 39abb9e4456fdb2e490fa4861f20e5d3f998e7d0 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 3 Jan 2026 20:47:36 +0100 Subject: [PATCH 2/4] Cleaning --- FastCache/FastCache.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/FastCache/FastCache.csproj b/FastCache/FastCache.csproj index eb89ac0..8f51c14 100644 --- a/FastCache/FastCache.csproj +++ b/FastCache/FastCache.csproj @@ -2,7 +2,6 @@ net6.0;net8.0;net9.0;net10.0 - latest Jitbit.FastCache FastCache Alex from Jitbit From eb637db19ba6e981f10d9868c8cdd5f8e4ff8c3a Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 3 Jan 2026 20:50:33 +0100 Subject: [PATCH 3/4] More cleaning --- Directory.Build.props | 1 + .../FastCache.Benchmarks.csproj | 25 ++++---- FastCache/FastCache.cs | 8 +-- FastCache/FastCache.csproj | 62 +++++++++---------- UnitTests/UnitTests.csproj | 29 +++++---- 5 files changed, 60 insertions(+), 65 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 022c43e..77c852e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,6 @@ latest + enable \ No newline at end of file diff --git a/FastCache.Benchmarks/FastCache.Benchmarks.csproj b/FastCache.Benchmarks/FastCache.Benchmarks.csproj index 44b21ff..6d62d89 100644 --- a/FastCache.Benchmarks/FastCache.Benchmarks.csproj +++ b/FastCache.Benchmarks/FastCache.Benchmarks.csproj @@ -1,19 +1,18 @@ - - Exe - net10.0 - enable - enable - + + Exe + net10.0 + enable + - - - - + + + + - - - + + + diff --git a/FastCache/FastCache.cs b/FastCache/FastCache.cs index 38f50e5..07c4647 100644 --- a/FastCache/FastCache.cs +++ b/FastCache/FastCache.cs @@ -1,9 +1,5 @@ -using System; using System.Collections; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; namespace Jitbit.Utils; @@ -15,9 +11,9 @@ internal static class FastCacheStatics /// /// faster MemoryCache alternative. basically a concurrent dictionary with expiration /// -public class FastCache : IEnumerable>, IDisposable +public class FastCache : IEnumerable>, IDisposable where TKey : notnull { - private readonly ConcurrentDictionary _dict = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _dict = new(); private readonly Lock _lock = new(); private readonly Timer _cleanUpTimer; diff --git a/FastCache/FastCache.csproj b/FastCache/FastCache.csproj index 8f51c14..e2444a4 100644 --- a/FastCache/FastCache.csproj +++ b/FastCache/FastCache.csproj @@ -1,37 +1,37 @@  - - net6.0;net8.0;net9.0;net10.0 - Jitbit.FastCache - FastCache - Alex from Jitbit - FastCache - https://github.com/jitbit/fastcache - README.md - https://github.com/jitbit/fastcache - LICENSE - 1.1.3 - cache;caching;MemoryCache - Fastest in-memory cache for .NET - True - + + net6.0;net8.0;net9.0;net10.0 + Jitbit.FastCache + FastCache + Alex from Jitbit + FastCache + https://github.com/jitbit/fastcache + README.md + https://github.com/jitbit/fastcache + LICENSE + 1.1.3 + cache;caching;MemoryCache + Fastest in-memory cache for .NET + True + - - - True - \ - - - True - \ - - + + + True + \ + + + True + \ + + - - - all - analyzers - - + + + all + analyzers + + diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index f7d087c..055f837 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -1,21 +1,20 @@ - - net10.0 - enable - enable + + net10.0 + enable + false + true + - false - + + + + + - - - - - - - - - + + + From 108e01d86952c65bdc570a6fbf7014c302754fae Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 3 Jan 2026 20:51:53 +0100 Subject: [PATCH 4/4] Cleaned up default --- FastCache/FastCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FastCache/FastCache.cs b/FastCache/FastCache.cs index 07c4647..c95e3fd 100644 --- a/FastCache/FastCache.cs +++ b/FastCache/FastCache.cs @@ -276,7 +276,7 @@ public void Remove(TKey key) public bool TryRemove(TKey key, out TValue value) { bool res = _dict.TryRemove(key, out var ttlValue) && !ttlValue.IsExpired(); - value = res ? ttlValue.Value : default(TValue); + value = res ? ttlValue.Value : default; return res; }