From 554fe2ffdaa87a69fb55992b13db78c838c075f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:18:10 +0000 Subject: [PATCH 1/9] Implement alternate cache API Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/766875ac-21b6-44fa-a3cb-3b38b9beed94 --- .../Lru/ConcurrentLruAlternateCacheTests.cs | 108 ++++++++++++ BitFaster.Caching/Lru/ConcurrentLruCore.cs | 163 ++++++++++++++++-- BitFaster.Caching/Throw.cs | 13 +- 3 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs new file mode 100644 index 00000000..88d83252 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs @@ -0,0 +1,108 @@ +#if NET9_0_OR_GREATER +using System; +using System.Collections.Generic; +using BitFaster.Caching.Lru; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Lru +{ + public class ConcurrentLruAlternateCacheTests + { + [Fact] + public void TryGetAlternateCacheReturnsLookupForCompatibleComparer() + { + var comparer = new AlternateIntStringComparer(); + var cache = new ConcurrentLru(1, 3, comparer); + cache.GetOrAdd("42", _ => "value"); + + cache.TryGetAlternateCache(out var alternate).Should().BeTrue(); + alternate.TryGet(42, out var value).Should().BeTrue(); + value.Should().Be("value"); + comparer.CreateCallCount.Should().Be(0); + } + + [Fact] + public void GetAlternateCacheThrowsForIncompatibleComparer() + { + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); + + Action act = () => cache.GetAlternateCache(); + + act.Should().Throw().WithMessage("Incompatible comparer"); + cache.TryGetAlternateCache(out var alternate).Should().BeFalse(); + alternate.Should().BeNull(); + } + + [Fact] + public void AlternateCacheTryRemoveReturnsActualKeyAndValue() + { + var comparer = new AlternateIntStringComparer(); + var cache = new ConcurrentLru(1, 3, comparer); + cache.GetOrAdd("42", _ => "value"); + var alternate = cache.GetAlternateCache(); + + alternate.TryRemove(42, out var actualKey, out var value).Should().BeTrue(); + + actualKey.Should().Be("42"); + value.Should().Be("value"); + cache.TryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public void AlternateCacheGetOrAddUsesAlternateComparerCreateOnlyOnMiss() + { + var comparer = new AlternateIntStringComparer(); + var cache = new ConcurrentLru(1, 3, comparer); + var alternate = cache.GetAlternateCache(); + var factoryCalls = 0; + + alternate.GetOrAdd(42, key => + { + factoryCalls++; + return $"value-{key}"; + }).Should().Be("value-42"); + + alternate.GetOrAdd(42, (_, prefix) => + { + factoryCalls++; + return prefix; + }, "unused").Should().Be("value-42"); + + factoryCalls.Should().Be(1); + comparer.CreateCallCount.Should().Be(1); + } + + private sealed class AlternateIntStringComparer : IEqualityComparer, IAlternateEqualityComparer + { + public int CreateCallCount { get; private set; } + + public string Create(int alternate) + { + this.CreateCallCount++; + return alternate.ToString(); + } + + public bool Equals(int alternate, string other) + { + return StringComparer.Ordinal.Equals(alternate.ToString(), other); + } + + public int GetHashCode(int alternate) + { + return StringComparer.Ordinal.GetHashCode(alternate.ToString()); + } + + public bool Equals(string x, string y) + { + return StringComparer.Ordinal.Equals(x, y); + } + + public int GetHashCode(string obj) + { + return StringComparer.Ordinal.GetHashCode(obj); + } + } + } +} +#endif diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index 76f979f6..448c9515 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -882,21 +882,154 @@ private static Optional CreateMetrics(ConcurrentLruCore> CreateEvents(ConcurrentLruCore lru) - { - if (typeof(T) == typeof(NoTelemetryPolicy)) - { - return Optional>.None(); - } - - return new(new Proxy(lru)); - } - - // To get JIT optimizations, policies must be structs. - // If the structs are returned directly via properties, they will be copied. Since - // telemetryPolicy is a mutable struct, copy is bad. One workaround is to store the - // state within the struct in an object. Since the struct points to the same object - // it becomes immutable. However, this object is then somewhere else on the + private static Optional> CreateEvents(ConcurrentLruCore lru) + { + if (typeof(T) == typeof(NoTelemetryPolicy)) + { + return Optional>.None(); + } + + return new(new Proxy(lru)); + } + +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsCompatibleKey(ConcurrentDictionary d) + where TAlternateKey : notnull, allows ref struct + { + return d.Comparer is IAlternateEqualityComparer; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static IAlternateEqualityComparer GetAlternateComparer(ConcurrentDictionary d) + where TAlternateKey : notnull, allows ref struct + { + Debug.Assert(IsCompatibleKey(d)); + return Unsafe.As>(d.Comparer!); + } + + public IAlternateCache GetAlternateCache() + where TAlternateKey : notnull, allows ref struct + { + if (!IsCompatibleKey(this.dictionary)) + { + Throw.IncompatibleComparer(); + } + + return new AlternateCache(this); + } + + public bool TryGetAlternateCache([MaybeNullWhen(false)] out IAlternateCache lookup) + where TAlternateKey : notnull, allows ref struct + { + if (IsCompatibleKey(this.dictionary)) + { + lookup = new AlternateCache(this); + return true; + } + + lookup = default; + return false; + } + + public interface IAlternateCache + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out TValue value); + + bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey, [MaybeNullWhen(false)] out TValue value); + + TValue GetOrAdd(TAlternateKey altKey, Func valueFactory); + + TValue GetOrAdd(TAlternateKey altKey, Func valueFactory, TArg factoryArgument); + } + + internal readonly struct AlternateCache : IAlternateCache + where TAlternateKey : notnull, allows ref struct + { + internal AlternateCache(ConcurrentLruCore lru) + { + Debug.Assert(lru is not null); + Debug.Assert(IsCompatibleKey(lru.dictionary)); + this.Lru = lru; + } + + internal ConcurrentLruCore Lru { get; } + + public bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out V value) + { + var alternate = this.Lru.dictionary.GetAlternateLookup(); + + if (alternate.TryGetValue(key, out var item)) + { + return this.Lru.GetOrDiscard(item, out value); + } + + value = default; + this.Lru.telemetryPolicy.IncrementMiss(); + return false; + } + + public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey, [MaybeNullWhen(false)] out V value) + { + var alternate = this.Lru.dictionary.GetAlternateLookup(); + + if (alternate.TryRemove(key, out actualKey, out var item)) + { + this.Lru.OnRemove(actualKey, item, ItemRemovedReason.Removed); + value = item.Value; + return true; + } + + actualKey = default; + value = default; + return false; + } + + public V GetOrAdd(TAlternateKey altKey, Func valueFactory) + { + while (true) + { + if (this.TryGet(altKey, out var value)) + { + return value; + } + + K key = GetAlternateComparer(this.Lru.dictionary).Create(altKey); + value = valueFactory(altKey); + if (this.Lru.TryAdd(key, value)) + { + return value; + } + } + } + + public V GetOrAdd(TAlternateKey altKey, Func valueFactory, TArg factoryArgument) + { + while (true) + { + if (this.TryGet(altKey, out var value)) + { + return value; + } + + K key = GetAlternateComparer(this.Lru.dictionary).Create(altKey); + value = valueFactory(altKey, factoryArgument); + if (this.Lru.TryAdd(key, value)) + { + return value; + } + } + } + } +#endif + + // To get JIT optimizations, policies must be structs. + // If the structs are returned directly via properties, they will be copied. Since + // telemetryPolicy is a mutable struct, copy is bad. One workaround is to store the + // state within the struct in an object. Since the struct points to the same object + // it becomes immutable. However, this object is then somewhere else on the // heap, which slows down the policies with hit counter logic in benchmarks. Likely // this approach keeps the structs data members in the same CPU cache line as the LRU. // backcompat: remove conditional compile diff --git a/BitFaster.Caching/Throw.cs b/BitFaster.Caching/Throw.cs index 99485767..3e1de3ed 100644 --- a/BitFaster.Caching/Throw.cs +++ b/BitFaster.Caching/Throw.cs @@ -23,11 +23,14 @@ internal static class Throw [DoesNotReturn] public static void ScopedRetryFailure() => throw CreateScopedRetryFailure(); - [DoesNotReturn] - public static void Disposed() => throw CreateObjectDisposedException(); - - [MethodImpl(MethodImplOptions.NoInlining)] - private static ArgumentNullException CreateArgumentNullException(ExceptionArgument arg) => new ArgumentNullException(GetArgumentString(arg)); + [DoesNotReturn] + public static void Disposed() => throw CreateObjectDisposedException(); + + [DoesNotReturn] + public static void IncompatibleComparer() => throw new InvalidOperationException("Incompatible comparer"); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ArgumentNullException CreateArgumentNullException(ExceptionArgument arg) => new ArgumentNullException(GetArgumentString(arg)); [MethodImpl(MethodImplOptions.NoInlining)] private static ArgumentOutOfRangeException CreateArgumentOutOfRangeException(string paramName) => new ArgumentOutOfRangeException(paramName); From e088f6ff3452afb1eabdb84ec0c4769ef1fd8192 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:20:07 +0000 Subject: [PATCH 2/9] Document alternate cache API Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/766875ac-21b6-44fa-a3cb-3b38b9beed94 --- .../Lru/ConcurrentLruAlternateCacheTests.cs | 2 +- BitFaster.Caching/Lru/ConcurrentLruCore.cs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs index 88d83252..268cba18 100644 --- a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs @@ -1,4 +1,4 @@ -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER using System; using System.Collections.Generic; using BitFaster.Caching.Lru; diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index 448c9515..d0658c01 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -908,6 +908,12 @@ private static IAlternateEqualityComparer GetAlternateComparer return Unsafe.As>(d.Comparer!); } + /// + /// Gets an alternate cache view that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// An alternate cache view. + /// The configured comparer does not support . public IAlternateCache GetAlternateCache() where TAlternateKey : notnull, allows ref struct { @@ -919,6 +925,12 @@ public IAlternateCache GetAlternateCache() return new AlternateCache(this); } + /// + /// Attempts to get an alternate cache view that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// The alternate cache view when available. + /// when the configured comparer supports ; otherwise, . public bool TryGetAlternateCache([MaybeNullWhen(false)] out IAlternateCache lookup) where TAlternateKey : notnull, allows ref struct { @@ -932,16 +944,49 @@ public bool TryGetAlternateCache([MaybeNullWhen(false)] out IAlte return false; } + /// + /// Provides alternate-key access to a cache. + /// + /// The alternate key type. + /// The cache key type. + /// The cache value type. public interface IAlternateCache where TAlternateKey : notnull, allows ref struct where TKey : notnull { + /// + /// Attempts to get a value using an alternate key. + /// + /// The alternate key. + /// The cached value when found. + /// when the key is found; otherwise, . bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out TValue value); + /// + /// Attempts to remove a value using an alternate key. + /// + /// The alternate key. + /// The removed cache key. + /// The removed value. + /// when the key is found; otherwise, . bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey, [MaybeNullWhen(false)] out TValue value); + /// + /// Gets an existing value or adds a new value using an alternate key. + /// + /// The alternate key. + /// The value factory. + /// The cached value. TValue GetOrAdd(TAlternateKey altKey, Func valueFactory); + /// + /// Gets an existing value or adds a new value using an alternate key and factory argument. + /// + /// The factory argument type. + /// The alternate key. + /// The value factory. + /// The factory argument. + /// The cached value. TValue GetOrAdd(TAlternateKey altKey, Func valueFactory, TArg factoryArgument); } From 776481e48094c419fa12cc75e7bc049f06de47bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:22:33 +0000 Subject: [PATCH 3/9] Refine alternate cache get-or-add Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/766875ac-21b6-44fa-a3cb-3b38b9beed94 --- BitFaster.Caching/Lru/ConcurrentLruCore.cs | 38 ++++++++++++++-------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index d0658c01..f1966a2d 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -974,20 +974,20 @@ public interface IAlternateCache /// /// Gets an existing value or adds a new value using an alternate key. /// - /// The alternate key. + /// The alternate key. /// The value factory. /// The cached value. - TValue GetOrAdd(TAlternateKey altKey, Func valueFactory); + TValue GetOrAdd(TAlternateKey key, Func valueFactory); /// /// Gets an existing value or adds a new value using an alternate key and factory argument. /// /// The factory argument type. - /// The alternate key. + /// The alternate key. /// The value factory. /// The factory argument. /// The cached value. - TValue GetOrAdd(TAlternateKey altKey, Func valueFactory, TArg factoryArgument); + TValue GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument); } internal readonly struct AlternateCache : IAlternateCache @@ -1032,36 +1032,46 @@ public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey, return false; } - public V GetOrAdd(TAlternateKey altKey, Func valueFactory) + public V GetOrAdd(TAlternateKey key, Func valueFactory) { while (true) { - if (this.TryGet(altKey, out var value)) + if (this.TryGet(key, out var value)) + { + return value; + } + + K actualKey = GetAlternateComparer(this.Lru.dictionary).Create(key); + if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value)) { return value; } - K key = GetAlternateComparer(this.Lru.dictionary).Create(altKey); - value = valueFactory(altKey); - if (this.Lru.TryAdd(key, value)) + value = valueFactory(key); + if (this.Lru.TryAdd(actualKey, value)) { return value; } } } - public V GetOrAdd(TAlternateKey altKey, Func valueFactory, TArg factoryArgument) + public V GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument) { while (true) { - if (this.TryGet(altKey, out var value)) + if (this.TryGet(key, out var value)) + { + return value; + } + + K actualKey = GetAlternateComparer(this.Lru.dictionary).Create(key); + if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value)) { return value; } - K key = GetAlternateComparer(this.Lru.dictionary).Create(altKey); - value = valueFactory(altKey, factoryArgument); - if (this.Lru.TryAdd(key, value)) + value = valueFactory(key, factoryArgument); + if (this.Lru.TryAdd(actualKey, value)) { return value; } From fa5b42c74621cf3327b0c43e917c931828fbaa84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:44:43 +0000 Subject: [PATCH 4/9] Extract alternate cache interface to top-level namespace Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/ccdc20c6-3b27-4e41-b068-265e05d1dc33 --- BitFaster.Caching/IAlternateCache.cs | 53 ++++++++++++++++++++++ BitFaster.Caching/Lru/ConcurrentLruCore.cs | 46 ------------------- 2 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 BitFaster.Caching/IAlternateCache.cs diff --git a/BitFaster.Caching/IAlternateCache.cs b/BitFaster.Caching/IAlternateCache.cs new file mode 100644 index 00000000..563afd5f --- /dev/null +++ b/BitFaster.Caching/IAlternateCache.cs @@ -0,0 +1,53 @@ +#if NET9_0_OR_GREATER +using System; +using System.Diagnostics.CodeAnalysis; + +namespace BitFaster.Caching +{ + /// + /// Provides alternate-key access to a cache. + /// + /// The alternate key type. + /// The cache key type. + /// The cache value type. + public interface IAlternateCache + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + /// + /// Attempts to get a value using an alternate key. + /// + /// The alternate key. + /// The cached value when found. + /// when the key is found; otherwise, . + bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out TValue value); + + /// + /// Attempts to remove a value using an alternate key. + /// + /// The alternate key. + /// The removed cache key. + /// The removed value. + /// when the key is found; otherwise, . + bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey, [MaybeNullWhen(false)] out TValue value); + + /// + /// Gets an existing value or adds a new value using an alternate key. + /// + /// The alternate key. + /// The value factory. + /// The cached value. + TValue GetOrAdd(TAlternateKey key, Func valueFactory); + + /// + /// Gets an existing value or adds a new value using an alternate key and factory argument. + /// + /// The factory argument type. + /// The alternate key. + /// The value factory. + /// The factory argument. + /// The cached value. + TValue GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument); + } +} +#endif diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index f1966a2d..d2f328dd 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -944,52 +944,6 @@ public bool TryGetAlternateCache([MaybeNullWhen(false)] out IAlte return false; } - /// - /// Provides alternate-key access to a cache. - /// - /// The alternate key type. - /// The cache key type. - /// The cache value type. - public interface IAlternateCache - where TAlternateKey : notnull, allows ref struct - where TKey : notnull - { - /// - /// Attempts to get a value using an alternate key. - /// - /// The alternate key. - /// The cached value when found. - /// when the key is found; otherwise, . - bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out TValue value); - - /// - /// Attempts to remove a value using an alternate key. - /// - /// The alternate key. - /// The removed cache key. - /// The removed value. - /// when the key is found; otherwise, . - bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey, [MaybeNullWhen(false)] out TValue value); - - /// - /// Gets an existing value or adds a new value using an alternate key. - /// - /// The alternate key. - /// The value factory. - /// The cached value. - TValue GetOrAdd(TAlternateKey key, Func valueFactory); - - /// - /// Gets an existing value or adds a new value using an alternate key and factory argument. - /// - /// The factory argument type. - /// The alternate key. - /// The value factory. - /// The factory argument. - /// The cached value. - TValue GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument); - } - internal readonly struct AlternateCache : IAlternateCache where TAlternateKey : notnull, allows ref struct { From 51324ab8b574bba94086ed3845c75f6be560ea8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:59:53 +0000 Subject: [PATCH 5/9] Use ReadOnlySpan alternate keys in positive tests Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/04f2a992-fbe7-4afa-83a1-cb7a7759a43b --- .../BitFaster.Caching.UnitTests.csproj | 5 +- .../Lru/ConcurrentLruAlternateCacheTests.cs | 66 +++++-------------- 2 files changed, 20 insertions(+), 51 deletions(-) diff --git a/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj b/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj index b5aef909..401c9617 100644 --- a/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj +++ b/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj @@ -2,7 +2,8 @@ net48;netcoreapp3.1;net6.0;net9.0 - 10.0 + 13.0 + 10.0 @@ -30,4 +31,4 @@ - \ No newline at end of file + diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs index 268cba18..078c7b0a 100644 --- a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs @@ -1,6 +1,5 @@ #if NET9_0_OR_GREATER using System; -using System.Collections.Generic; using BitFaster.Caching.Lru; using FluentAssertions; using Xunit; @@ -12,14 +11,13 @@ public class ConcurrentLruAlternateCacheTests [Fact] public void TryGetAlternateCacheReturnsLookupForCompatibleComparer() { - var comparer = new AlternateIntStringComparer(); - var cache = new ConcurrentLru(1, 3, comparer); + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); cache.GetOrAdd("42", _ => "value"); + ReadOnlySpan key = "42"; - cache.TryGetAlternateCache(out var alternate).Should().BeTrue(); - alternate.TryGet(42, out var value).Should().BeTrue(); + cache.TryGetAlternateCache>(out var alternate).Should().BeTrue(); + alternate.TryGet(key, out var value).Should().BeTrue(); value.Should().Be("value"); - comparer.CreateCallCount.Should().Be(0); } [Fact] @@ -37,12 +35,12 @@ public void GetAlternateCacheThrowsForIncompatibleComparer() [Fact] public void AlternateCacheTryRemoveReturnsActualKeyAndValue() { - var comparer = new AlternateIntStringComparer(); - var cache = new ConcurrentLru(1, 3, comparer); + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); cache.GetOrAdd("42", _ => "value"); - var alternate = cache.GetAlternateCache(); + var alternate = cache.GetAlternateCache>(); + ReadOnlySpan key = "42"; - alternate.TryRemove(42, out var actualKey, out var value).Should().BeTrue(); + alternate.TryRemove(key, out var actualKey, out var value).Should().BeTrue(); actualKey.Should().Be("42"); value.Should().Be("value"); @@ -50,58 +48,28 @@ public void AlternateCacheTryRemoveReturnsActualKeyAndValue() } [Fact] - public void AlternateCacheGetOrAddUsesAlternateComparerCreateOnlyOnMiss() + public void AlternateCacheGetOrAddUsesAlternateKeyOnMissAndHit() { - var comparer = new AlternateIntStringComparer(); - var cache = new ConcurrentLru(1, 3, comparer); - var alternate = cache.GetAlternateCache(); + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); + var alternate = cache.GetAlternateCache>(); var factoryCalls = 0; + ReadOnlySpan key = "42"; - alternate.GetOrAdd(42, key => + alternate.GetOrAdd(key, key => { factoryCalls++; - return $"value-{key}"; + return $"value-{key.ToString()}"; }).Should().Be("value-42"); - alternate.GetOrAdd(42, (_, prefix) => + alternate.GetOrAdd(key, (_, prefix) => { factoryCalls++; return prefix; }, "unused").Should().Be("value-42"); factoryCalls.Should().Be(1); - comparer.CreateCallCount.Should().Be(1); - } - - private sealed class AlternateIntStringComparer : IEqualityComparer, IAlternateEqualityComparer - { - public int CreateCallCount { get; private set; } - - public string Create(int alternate) - { - this.CreateCallCount++; - return alternate.ToString(); - } - - public bool Equals(int alternate, string other) - { - return StringComparer.Ordinal.Equals(alternate.ToString(), other); - } - - public int GetHashCode(int alternate) - { - return StringComparer.Ordinal.GetHashCode(alternate.ToString()); - } - - public bool Equals(string x, string y) - { - return StringComparer.Ordinal.Equals(x, y); - } - - public int GetHashCode(string obj) - { - return StringComparer.Ordinal.GetHashCode(obj); - } + cache.TryGet("42", out var value).Should().BeTrue(); + value.Should().Be("value-42"); } } } From b303547f0b40b9151bfd897e73f03b9c9429ff4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:08:59 +0000 Subject: [PATCH 6/9] Rename alternate cache API to alternate lookup Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/49e070c4-dba5-4443-8b0a-829987bd0f73 --- ...s => ConcurrentLruAlternateLookupTests.cs} | 20 +++++++++---------- ...IAlternateCache.cs => IAlternateLookup.cs} | 4 ++-- BitFaster.Caching/Lru/ConcurrentLruCore.cs | 20 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) rename BitFaster.Caching.UnitTests/Lru/{ConcurrentLruAlternateCacheTests.cs => ConcurrentLruAlternateLookupTests.cs} (72%) rename BitFaster.Caching/{IAlternateCache.cs => IAlternateLookup.cs} (95%) diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs similarity index 72% rename from BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs rename to BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs index 078c7b0a..c274cd17 100644 --- a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs @@ -6,38 +6,38 @@ namespace BitFaster.Caching.UnitTests.Lru { - public class ConcurrentLruAlternateCacheTests + public class ConcurrentLruAlternateLookupTests { [Fact] - public void TryGetAlternateCacheReturnsLookupForCompatibleComparer() + public void TryGetAlternateLookupReturnsLookupForCompatibleComparer() { var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); cache.GetOrAdd("42", _ => "value"); ReadOnlySpan key = "42"; - cache.TryGetAlternateCache>(out var alternate).Should().BeTrue(); + cache.TryGetAlternateLookup>(out var alternate).Should().BeTrue(); alternate.TryGet(key, out var value).Should().BeTrue(); value.Should().Be("value"); } [Fact] - public void GetAlternateCacheThrowsForIncompatibleComparer() + public void GetAlternateLookupThrowsForIncompatibleComparer() { var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); - Action act = () => cache.GetAlternateCache(); + Action act = () => cache.GetAlternateLookup(); act.Should().Throw().WithMessage("Incompatible comparer"); - cache.TryGetAlternateCache(out var alternate).Should().BeFalse(); + cache.TryGetAlternateLookup(out var alternate).Should().BeFalse(); alternate.Should().BeNull(); } [Fact] - public void AlternateCacheTryRemoveReturnsActualKeyAndValue() + public void AlternateLookupTryRemoveReturnsActualKeyAndValue() { var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); cache.GetOrAdd("42", _ => "value"); - var alternate = cache.GetAlternateCache>(); + var alternate = cache.GetAlternateLookup>(); ReadOnlySpan key = "42"; alternate.TryRemove(key, out var actualKey, out var value).Should().BeTrue(); @@ -48,10 +48,10 @@ public void AlternateCacheTryRemoveReturnsActualKeyAndValue() } [Fact] - public void AlternateCacheGetOrAddUsesAlternateKeyOnMissAndHit() + public void AlternateLookupGetOrAddUsesAlternateKeyOnMissAndHit() { var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); - var alternate = cache.GetAlternateCache>(); + var alternate = cache.GetAlternateLookup>(); var factoryCalls = 0; ReadOnlySpan key = "42"; diff --git a/BitFaster.Caching/IAlternateCache.cs b/BitFaster.Caching/IAlternateLookup.cs similarity index 95% rename from BitFaster.Caching/IAlternateCache.cs rename to BitFaster.Caching/IAlternateLookup.cs index 563afd5f..47ca7dc2 100644 --- a/BitFaster.Caching/IAlternateCache.cs +++ b/BitFaster.Caching/IAlternateLookup.cs @@ -5,12 +5,12 @@ namespace BitFaster.Caching { /// - /// Provides alternate-key access to a cache. + /// Provides an alternate-key lookup over a cache. /// /// The alternate key type. /// The cache key type. /// The cache value type. - public interface IAlternateCache + public interface IAlternateLookup where TAlternateKey : notnull, allows ref struct where TKey : notnull { diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index d2f328dd..d9bdc6a0 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -909,12 +909,12 @@ private static IAlternateEqualityComparer GetAlternateComparer } /// - /// Gets an alternate cache view that can use an alternate key type with the configured comparer. + /// Gets an alternate lookup that can use an alternate key type with the configured comparer. /// /// The alternate key type. - /// An alternate cache view. + /// An alternate lookup. /// The configured comparer does not support . - public IAlternateCache GetAlternateCache() + public IAlternateLookup GetAlternateLookup() where TAlternateKey : notnull, allows ref struct { if (!IsCompatibleKey(this.dictionary)) @@ -922,21 +922,21 @@ public IAlternateCache GetAlternateCache() Throw.IncompatibleComparer(); } - return new AlternateCache(this); + return new AlternateLookup(this); } /// - /// Attempts to get an alternate cache view that can use an alternate key type with the configured comparer. + /// Attempts to get an alternate lookup that can use an alternate key type with the configured comparer. /// /// The alternate key type. - /// The alternate cache view when available. + /// The alternate lookup when available. /// when the configured comparer supports ; otherwise, . - public bool TryGetAlternateCache([MaybeNullWhen(false)] out IAlternateCache lookup) + public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IAlternateLookup lookup) where TAlternateKey : notnull, allows ref struct { if (IsCompatibleKey(this.dictionary)) { - lookup = new AlternateCache(this); + lookup = new AlternateLookup(this); return true; } @@ -944,10 +944,10 @@ public bool TryGetAlternateCache([MaybeNullWhen(false)] out IAlte return false; } - internal readonly struct AlternateCache : IAlternateCache + internal readonly struct AlternateLookup : IAlternateLookup where TAlternateKey : notnull, allows ref struct { - internal AlternateCache(ConcurrentLruCore lru) + internal AlternateLookup(ConcurrentLruCore lru) { Debug.Assert(lru is not null); Debug.Assert(IsCompatibleKey(lru.dictionary)); From 75d5105f21acd9cc7d01364b09933b9e73e4fb2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:15:40 +0000 Subject: [PATCH 7/9] Cache alternate lookup adapter in ConcurrentLru Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/3133b4fb-b5d9-404b-8aeb-21f355942d69 --- BitFaster.Caching/Lru/ConcurrentLruCore.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index d9bdc6a0..487eba2b 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -952,15 +952,16 @@ internal AlternateLookup(ConcurrentLruCore lru) Debug.Assert(lru is not null); Debug.Assert(IsCompatibleKey(lru.dictionary)); this.Lru = lru; + this.Alternate = lru.dictionary.GetAlternateLookup(); } internal ConcurrentLruCore Lru { get; } + internal ConcurrentDictionary.AlternateLookup Alternate { get; } + public bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out V value) { - var alternate = this.Lru.dictionary.GetAlternateLookup(); - - if (alternate.TryGetValue(key, out var item)) + if (this.Alternate.TryGetValue(key, out var item)) { return this.Lru.GetOrDiscard(item, out value); } @@ -972,9 +973,7 @@ public bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out V value) public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey, [MaybeNullWhen(false)] out V value) { - var alternate = this.Lru.dictionary.GetAlternateLookup(); - - if (alternate.TryRemove(key, out actualKey, out var item)) + if (this.Alternate.TryRemove(key, out actualKey, out var item)) { this.Lru.OnRemove(actualKey, item, ItemRemovedReason.Removed); value = item.Value; From b9ea646bb3def56a627c763d399642705ab25139 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:25:48 +0000 Subject: [PATCH 8/9] Extract alternate lookup collection helpers Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/99ca357c-842e-424b-a535-ad759177ad7c --- BitFaster.Caching/CollectionExtensions.cs | 29 ++++++++++++++++++++++ BitFaster.Caching/Lru/ConcurrentLruCore.cs | 25 ++++--------------- 2 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 BitFaster.Caching/CollectionExtensions.cs diff --git a/BitFaster.Caching/CollectionExtensions.cs b/BitFaster.Caching/CollectionExtensions.cs new file mode 100644 index 00000000..808b5479 --- /dev/null +++ b/BitFaster.Caching/CollectionExtensions.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace BitFaster.Caching +{ + internal static class CollectionExtensions + { +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsCompatibleKey(this ConcurrentDictionary dictionary) + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + return dictionary.Comparer is IAlternateEqualityComparer; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static IAlternateEqualityComparer GetAlternateComparer(this ConcurrentDictionary dictionary) + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + Debug.Assert(dictionary.IsCompatibleKey()); + return Unsafe.As>(dictionary.Comparer!); + } +#endif + } +} diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index 487eba2b..9124be68 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -893,21 +893,6 @@ private static Optional> CreateEvents(ConcurrentLruCore(ConcurrentDictionary d) - where TAlternateKey : notnull, allows ref struct - { - return d.Comparer is IAlternateEqualityComparer; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static IAlternateEqualityComparer GetAlternateComparer(ConcurrentDictionary d) - where TAlternateKey : notnull, allows ref struct - { - Debug.Assert(IsCompatibleKey(d)); - return Unsafe.As>(d.Comparer!); - } - /// /// Gets an alternate lookup that can use an alternate key type with the configured comparer. /// @@ -917,7 +902,7 @@ private static IAlternateEqualityComparer GetAlternateComparer public IAlternateLookup GetAlternateLookup() where TAlternateKey : notnull, allows ref struct { - if (!IsCompatibleKey(this.dictionary)) + if (!this.dictionary.IsCompatibleKey()) { Throw.IncompatibleComparer(); } @@ -934,7 +919,7 @@ public IAlternateLookup GetAlternateLookup() public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IAlternateLookup lookup) where TAlternateKey : notnull, allows ref struct { - if (IsCompatibleKey(this.dictionary)) + if (this.dictionary.IsCompatibleKey()) { lookup = new AlternateLookup(this); return true; @@ -950,7 +935,7 @@ public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IAlt internal AlternateLookup(ConcurrentLruCore lru) { Debug.Assert(lru is not null); - Debug.Assert(IsCompatibleKey(lru.dictionary)); + Debug.Assert(lru.dictionary.IsCompatibleKey()); this.Lru = lru; this.Alternate = lru.dictionary.GetAlternateLookup(); } @@ -994,7 +979,7 @@ public V GetOrAdd(TAlternateKey key, Func valueFactory) return value; } - K actualKey = GetAlternateComparer(this.Lru.dictionary).Create(key); + K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key); if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value)) { return value; @@ -1017,7 +1002,7 @@ public V GetOrAdd(TAlternateKey key, Func valueFac return value; } - K actualKey = GetAlternateComparer(this.Lru.dictionary).Create(key); + K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key); if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value)) { return value; From b3577b429796cfef8303e863ce7804f6ddfb41bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:34:50 +0000 Subject: [PATCH 9/9] Remove redundant alternate lookup check Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/098334d3-8129-4915-988d-958e2e0cbee3 --- BitFaster.Caching/Lru/ConcurrentLruCore.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index 9124be68..22e6954b 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -980,10 +980,6 @@ public V GetOrAdd(TAlternateKey key, Func valueFactory) } K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key); - if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value)) - { - return value; - } value = valueFactory(key); if (this.Lru.TryAdd(actualKey, value)) @@ -1003,10 +999,6 @@ public V GetOrAdd(TAlternateKey key, Func valueFac } K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key); - if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value)) - { - return value; - } value = valueFactory(key, factoryArgument); if (this.Lru.TryAdd(actualKey, value))