Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

<PropertyGroup>
<TargetFrameworks>net48;netcoreapp3.1;net6.0;net9.0</TargetFrameworks>
<LangVersion>10.0</LangVersion>
<LangVersion Condition="'$(TargetFramework)' == 'net9.0'">13.0</LangVersion>
<LangVersion Condition="'$(TargetFramework)' != 'net9.0'">10.0</LangVersion>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -30,4 +31,4 @@
<ProjectReference Include="..\BitFaster.Caching\BitFaster.Caching.csproj" />
</ItemGroup>

</Project>
</Project>
Original file line number Diff line number Diff line change
@@ -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<string, string>(1, 3, StringComparer.Ordinal);
cache.GetOrAdd("42", _ => "value");
ReadOnlySpan<char> key = "42";

cache.TryGetAlternateLookup<ReadOnlySpan<char>>(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<string, string>(1, 3, StringComparer.Ordinal);

Action act = () => cache.GetAlternateLookup<int>();

act.Should().Throw<InvalidOperationException>().WithMessage("Incompatible comparer");
cache.TryGetAlternateLookup<int>(out var alternate).Should().BeFalse();
alternate.Should().BeNull();
}

[Fact]
public void AlternateLookupTryRemoveReturnsActualKeyAndValue()
{
var cache = new ConcurrentLru<string, string>(1, 3, StringComparer.Ordinal);
cache.GetOrAdd("42", _ => "value");
var alternate = cache.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> 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<string, string>(1, 3, StringComparer.Ordinal);
var alternate = cache.GetAlternateLookup<ReadOnlySpan<char>>();
var factoryCalls = 0;
ReadOnlySpan<char> 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
29 changes: 29 additions & 0 deletions BitFaster.Caching/CollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<TAlternateKey, TKey, TValue>(this ConcurrentDictionary<TKey, TValue> dictionary)
where TAlternateKey : notnull, allows ref struct
where TKey : notnull
{
return dictionary.Comparer is IAlternateEqualityComparer<TAlternateKey, TKey>;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static IAlternateEqualityComparer<TAlternateKey, TKey> GetAlternateComparer<TAlternateKey, TKey, TValue>(this ConcurrentDictionary<TKey, TValue> dictionary)
where TAlternateKey : notnull, allows ref struct
where TKey : notnull
{
Debug.Assert(dictionary.IsCompatibleKey<TAlternateKey, TKey, TValue>());
return Unsafe.As<IAlternateEqualityComparer<TAlternateKey, TKey>>(dictionary.Comparer!);
}
#endif
}
}
53 changes: 53 additions & 0 deletions BitFaster.Caching/IAlternateLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#if NET9_0_OR_GREATER
using System;
using System.Diagnostics.CodeAnalysis;

namespace BitFaster.Caching
{
/// <summary>
/// Provides an alternate-key lookup over a cache.
/// </summary>
/// <typeparam name="TAlternateKey">The alternate key type.</typeparam>
/// <typeparam name="TKey">The cache key type.</typeparam>
/// <typeparam name="TValue">The cache value type.</typeparam>
public interface IAlternateLookup<TAlternateKey, TKey, TValue>
where TAlternateKey : notnull, allows ref struct
where TKey : notnull
{
/// <summary>
/// Attempts to get a value using an alternate key.
/// </summary>
/// <param name="key">The alternate key.</param>
/// <param name="value">The cached value when found.</param>
/// <returns><see langword="true" /> when the key is found; otherwise, <see langword="false" />.</returns>
bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out TValue value);

/// <summary>
/// Attempts to remove a value using an alternate key.
/// </summary>
/// <param name="key">The alternate key.</param>
/// <param name="actualKey">The removed cache key.</param>
/// <param name="value">The removed value.</param>
/// <returns><see langword="true" /> when the key is found; otherwise, <see langword="false" />.</returns>
bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey, [MaybeNullWhen(false)] out TValue value);

/// <summary>
/// Gets an existing value or adds a new value using an alternate key.
/// </summary>
/// <param name="key">The alternate key.</param>
/// <param name="valueFactory">The value factory.</param>
/// <returns>The cached value.</returns>
TValue GetOrAdd(TAlternateKey key, Func<TAlternateKey, TValue> valueFactory);
Copy link
Owner

@bitfaster bitfaster Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetOrAdd implies there is IAsyncAlternativeLookup with GetOrAddAsync.

It may be simpler to only have TryGet and TryRemove, thus the async variants are not needed. ConcurrentDictionary supports whole API surface including indexer, so precedent was set to be complete.


/// <summary>
/// Gets an existing value or adds a new value using an alternate key and factory argument.
/// </summary>
/// <typeparam name="TArg">The factory argument type.</typeparam>
/// <param name="key">The alternate key.</param>
/// <param name="valueFactory">The value factory.</param>
/// <param name="factoryArgument">The factory argument.</param>
/// <returns>The cached value.</returns>
TValue GetOrAdd<TArg>(TAlternateKey key, Func<TAlternateKey, TArg, TValue> valueFactory, TArg factoryArgument);
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • TryAdd
  • TryUpdate
  • AddOrUpdate
  • indexer

}
#endif
148 changes: 133 additions & 15 deletions BitFaster.Caching/Lru/ConcurrentLruCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -882,21 +882,139 @@ private static Optional<ICacheMetrics> CreateMetrics(ConcurrentLruCore<K, V, I,
return new(new Proxy(lru));
}

private static Optional<ICacheEvents<K, V>> CreateEvents(ConcurrentLruCore<K, V, I, P, T> lru)
{
if (typeof(T) == typeof(NoTelemetryPolicy<K, V>))
{
return Optional<ICacheEvents<K, V>>.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<ICacheEvents<K, V>> CreateEvents(ConcurrentLruCore<K, V, I, P, T> lru)
{
if (typeof(T) == typeof(NoTelemetryPolicy<K, V>))
{
return Optional<ICacheEvents<K, V>>.None();
}

return new(new Proxy(lru));
}

#if NET9_0_OR_GREATER
/// <summary>
/// Gets an alternate lookup that can use an alternate key type with the configured comparer.
/// </summary>
/// <typeparam name="TAlternateKey">The alternate key type.</typeparam>
/// <returns>An alternate lookup.</returns>
/// <exception cref="InvalidOperationException">The configured comparer does not support <typeparamref name="TAlternateKey" />.</exception>
public IAlternateLookup<TAlternateKey, K, V> GetAlternateLookup<TAlternateKey>()
where TAlternateKey : notnull, allows ref struct
{
if (!this.dictionary.IsCompatibleKey<TAlternateKey, K, I>())
{
Throw.IncompatibleComparer();
}

return new AlternateLookup<TAlternateKey>(this);
}

/// <summary>
/// Attempts to get an alternate lookup that can use an alternate key type with the configured comparer.
/// </summary>
/// <typeparam name="TAlternateKey">The alternate key type.</typeparam>
/// <param name="lookup">The alternate lookup when available.</param>
/// <returns><see langword="true" /> when the configured comparer supports <typeparamref name="TAlternateKey" />; otherwise, <see langword="false" />.</returns>
public bool TryGetAlternateLookup<TAlternateKey>([MaybeNullWhen(false)] out IAlternateLookup<TAlternateKey, K, V> lookup)
where TAlternateKey : notnull, allows ref struct
{
if (this.dictionary.IsCompatibleKey<TAlternateKey, K, I>())
{
lookup = new AlternateLookup<TAlternateKey>(this);
return true;
}

lookup = default;
return false;
}

internal readonly struct AlternateLookup<TAlternateKey> : IAlternateLookup<TAlternateKey, K, V>
where TAlternateKey : notnull, allows ref struct
{
internal AlternateLookup(ConcurrentLruCore<K, V, I, P, T> lru)
{
Debug.Assert(lru is not null);
Debug.Assert(lru.dictionary.IsCompatibleKey<TAlternateKey, K, I>());
this.Lru = lru;
this.Alternate = lru.dictionary.GetAlternateLookup<TAlternateKey>();
}

internal ConcurrentLruCore<K, V, I, P, T> Lru { get; }

internal ConcurrentDictionary<K, I>.AlternateLookup<TAlternateKey> 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<TAlternateKey, V> valueFactory)
{
while (true)
{
if (this.TryGet(key, out var value))
{
return value;
}

K actualKey = this.Lru.dictionary.GetAlternateComparer<TAlternateKey, K, I>().Create(key);

value = valueFactory(key);
if (this.Lru.TryAdd(actualKey, value))
{
return value;
}
}
}

public V GetOrAdd<TArg>(TAlternateKey key, Func<TAlternateKey, TArg, V> valueFactory, TArg factoryArgument)
{
while (true)
{
if (this.TryGet(key, out var value))
{
return value;
}

K actualKey = this.Lru.dictionary.GetAlternateComparer<TAlternateKey, K, I>().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
Expand Down
13 changes: 8 additions & 5 deletions BitFaster.Caching/Throw.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ internal static class Throw
[DoesNotReturn]
public static void ScopedRetryFailure() => throw CreateScopedRetryFailure();

[DoesNotReturn]
public static void Disposed<T>() => throw CreateObjectDisposedException<T>();

[MethodImpl(MethodImplOptions.NoInlining)]
private static ArgumentNullException CreateArgumentNullException(ExceptionArgument arg) => new ArgumentNullException(GetArgumentString(arg));
[DoesNotReturn]
public static void Disposed<T>() => throw CreateObjectDisposedException<T>();

[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);
Expand Down
Loading