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/ConcurrentLruAlternateLookupTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs
new file mode 100644
index 00000000..c274cd17
--- /dev/null
+++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs
@@ -0,0 +1,76 @@
+#if NET9_0_OR_GREATER
+using System;
+using BitFaster.Caching.Lru;
+using FluentAssertions;
+using Xunit;
+
+namespace BitFaster.Caching.UnitTests.Lru
+{
+ public class ConcurrentLruAlternateLookupTests
+ {
+ [Fact]
+ public void TryGetAlternateLookupReturnsLookupForCompatibleComparer()
+ {
+ var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal);
+ cache.GetOrAdd("42", _ => "value");
+ ReadOnlySpan key = "42";
+
+ cache.TryGetAlternateLookup>(out var alternate).Should().BeTrue();
+ alternate.TryGet(key, out var value).Should().BeTrue();
+ value.Should().Be("value");
+ }
+
+ [Fact]
+ public void GetAlternateLookupThrowsForIncompatibleComparer()
+ {
+ var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal);
+
+ Action act = () => cache.GetAlternateLookup();
+
+ act.Should().Throw().WithMessage("Incompatible comparer");
+ cache.TryGetAlternateLookup(out var alternate).Should().BeFalse();
+ alternate.Should().BeNull();
+ }
+
+ [Fact]
+ public void AlternateLookupTryRemoveReturnsActualKeyAndValue()
+ {
+ var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal);
+ cache.GetOrAdd("42", _ => "value");
+ var alternate = cache.GetAlternateLookup>();
+ ReadOnlySpan key = "42";
+
+ alternate.TryRemove(key, 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 AlternateLookupGetOrAddUsesAlternateKeyOnMissAndHit()
+ {
+ var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal);
+ var alternate = cache.GetAlternateLookup>();
+ var factoryCalls = 0;
+ ReadOnlySpan key = "42";
+
+ alternate.GetOrAdd(key, key =>
+ {
+ factoryCalls++;
+ return $"value-{key.ToString()}";
+ }).Should().Be("value-42");
+
+ alternate.GetOrAdd(key, (_, prefix) =>
+ {
+ factoryCalls++;
+ return prefix;
+ }, "unused").Should().Be("value-42");
+
+ factoryCalls.Should().Be(1);
+ cache.TryGet("42", out var value).Should().BeTrue();
+ value.Should().Be("value-42");
+ }
+ }
+}
+#endif
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/IAlternateLookup.cs b/BitFaster.Caching/IAlternateLookup.cs
new file mode 100644
index 00000000..47ca7dc2
--- /dev/null
+++ b/BitFaster.Caching/IAlternateLookup.cs
@@ -0,0 +1,53 @@
+#if NET9_0_OR_GREATER
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace BitFaster.Caching
+{
+ ///
+ /// Provides an alternate-key lookup over a cache.
+ ///
+ /// The alternate key type.
+ /// The cache key type.
+ /// The cache value type.
+ public interface IAlternateLookup
+ 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 76f979f6..22e6954b 100644
--- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs
+++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs
@@ -882,21 +882,139 @@ 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
+ ///
+ /// Gets an alternate lookup that can use an alternate key type with the configured comparer.
+ ///
+ /// The alternate key type.
+ /// An alternate lookup.
+ /// The configured comparer does not support .
+ public IAlternateLookup GetAlternateLookup()
+ where TAlternateKey : notnull, allows ref struct
+ {
+ if (!this.dictionary.IsCompatibleKey())
+ {
+ Throw.IncompatibleComparer();
+ }
+
+ return new AlternateLookup(this);
+ }
+
+ ///
+ /// Attempts to get an alternate lookup that can use an alternate key type with the configured comparer.
+ ///
+ /// The alternate key type.
+ /// The alternate lookup when available.
+ /// when the configured comparer supports ; otherwise, .
+ public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IAlternateLookup lookup)
+ where TAlternateKey : notnull, allows ref struct
+ {
+ if (this.dictionary.IsCompatibleKey())
+ {
+ lookup = new AlternateLookup(this);
+ return true;
+ }
+
+ lookup = default;
+ return false;
+ }
+
+ internal readonly struct AlternateLookup : IAlternateLookup
+ where TAlternateKey : notnull, allows ref struct
+ {
+ internal AlternateLookup(ConcurrentLruCore lru)
+ {
+ Debug.Assert(lru is not null);
+ Debug.Assert(lru.dictionary.IsCompatibleKey());
+ 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)
+ {
+ if (this.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)
+ {
+ if (this.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 key, Func valueFactory)
+ {
+ while (true)
+ {
+ if (this.TryGet(key, out var value))
+ {
+ return value;
+ }
+
+ K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key);
+
+ value = valueFactory(key);
+ if (this.Lru.TryAdd(actualKey, value))
+ {
+ return value;
+ }
+ }
+ }
+
+ public V GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument)
+ {
+ while (true)
+ {
+ if (this.TryGet(key, out var value))
+ {
+ return value;
+ }
+
+ K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key);
+
+ value = valueFactory(key, factoryArgument);
+ if (this.Lru.TryAdd(actualKey, 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);