Skip to content

Extract net9 alternate-key cache support for ConcurrentLru#739

Draft
Copilot wants to merge 9 commits intomainfrom
copilot/add-alternate-cache-from-concurrentlru
Draft

Extract net9 alternate-key cache support for ConcurrentLru#739
Copilot wants to merge 9 commits intomainfrom
copilot/add-alternate-cache-from-concurrentlru

Conversation

Copy link

Copilot AI commented Mar 24, 2026

This pulls the alternate-key cache work for ConcurrentLru out of PR #638 into its own reviewable change. It adds a net9-only API for alternate lookups/removals/adds on ConcurrentLru when the configured comparer implements IAlternateEqualityComparer<TAlternateKey, TKey>.

  • Alternate lookup API

    • Add GetAlternateLookup<TAlternateKey>() and TryGetAlternateLookup<TAlternateKey>(...) to ConcurrentLruCore.
    • Expose a top-level IAlternateLookup<TAlternateKey, TKey, TValue> in the BitFaster.Caching namespace with:
      • TryGet
      • TryRemove
      • GetOrAdd
      • GetOrAdd<TArg>
  • ConcurrentDictionary alternate lookup integration

    • Use ConcurrentDictionary<TKey, TValue>.GetAlternateLookup<TAlternateKey>() on net9 to perform alternate-key operations without first materializing the primary key on cache hits.
    • On misses, convert the alternate key to the primary key via IAlternateEqualityComparer<TAlternateKey, TKey>.Create(...) before falling back to the existing add path.
    • Preserve current cache semantics for telemetry, eviction, and removal callbacks.
  • Compatibility guardrails

    • Fail fast for unsupported alternate key types with Throw.IncompatibleComparer().
    • Keep the feature behind NET9_0_OR_GREATER so the public surface remains unchanged on earlier targets.
  • Targeted coverage

    • Add focused tests for:
      • compatible comparer alternate lookup
      • incompatible comparer rejection
      • alternate-key removal returning the actual stored key
      • GetOrAdd behavior with alternate keys
    • Use ReadOnlySpan<char> as the positive alternate key in tests with StringComparer.Ordinal.

Example:

var cache = new ConcurrentLru<string, string>(1, 3, StringComparer.Ordinal);

var alt = cache.GetAlternateLookup<ReadOnlySpan<char>>();

string value = alt.GetOrAdd("42".AsSpan(), key => $"value-{key.ToString()}");
bool found = alt.TryGet("42".AsSpan(), out var existing);
bool removed = alt.TryRemove("42".AsSpan(), out var actualKey, out var removedValue);

📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.

Copilot AI and others added 3 commits March 24, 2026 05:18
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
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
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
Copy link
Owner

@copilot similar to GetAlternateCache, add a method for TryGetAlternateCache

Copy link
Author

Copilot AI commented Mar 24, 2026

@copilot similar to GetAlternateCache, add a method for TryGetAlternateCache

Added in 554fe2fConcurrentLruCore now includes TryGetAlternateCache<TAlternateKey>(...) alongside GetAlternateCache<TAlternateKey>().

Copilot AI requested a review from bitfaster March 24, 2026 05:33
@bitfaster
Copy link
Owner

@copilot extract the interface IAlternateCache into a separate file in the top level namespace.

Copy link
Author

Copilot AI commented Mar 24, 2026

@copilot extract the interface IAlternateCache into a separate file in the top level namespace.

Done in fa5b42cIAlternateCache<TAlternateKey, TKey, TValue> is now in its own file under the top-level BitFaster.Caching namespace.

@coveralls
Copy link

coveralls commented Mar 24, 2026

Coverage Status

coverage: 98.955% (-0.1%) from 99.072%
when pulling fa5b42c on copilot/add-alternate-cache-from-concurrentlru
into 8ec227c on main.

@bitfaster
Copy link
Owner

@copilot in the positive unit tests. use ReadOnlySpan<char> as the alternate key, instead of integer and the AlternateIntStringComparer. The AlternateIntStringComparer can then be removed.

Copy link
Author

Copilot AI commented Mar 24, 2026

@copilot in the positive unit tests. use ReadOnlySpan<char> as the alternate key, instead of integer and the AlternateIntStringComparer. The AlternateIntStringComparer can then be remove...

Updated in 51324ab — the positive tests now use ReadOnlySpan<char> with StringComparer.Ordinal, and the AlternateIntStringComparer helper has been removed.

@bitfaster
Copy link
Owner

@copilot implement this feedback: go with "AlternateLookup" naming as opposed to "AlternateCache" as it is an adapter providing an alternative lookup over an existing cache, not an alternative cache as such. Also, "AlernativeLookup" is the established name in the .NET ecosystem.

Also, the alternate lookup on the underlying ConcurrentDictionary<K, V> should be retrieved and cached in a private field of the AlternateLookup struct, as opposed to calling this.Lru.dictionary.GetAlternateLookup() on each TryGet/TryRemove/GetOrAdd invocation.

Copy link
Author

Copilot AI commented Mar 24, 2026

@copilot implement this feedback: go with "AlternateLookup" naming as opposed to "AlternateCache" as it is an adapter providing an alternative lookup over an existing cache, not an alternative...

Updated in b303547 — the API and tests now use AlternateLookup naming throughout, including IAlternateLookup, GetAlternateLookup, and TryGetAlternateLookup, aligned with the underlying ConcurrentDictionary terminology.


public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey, [MaybeNullWhen(false)] out V value)
{
var alternate = this.Lru.dictionary.GetAlternateLookup<TAlternateKey>();
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot can you make a new iteration where the AlternateLookup returned on this line is cached as a member variable?

Copy link
Author

Choose a reason for hiding this comment

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

Done in 75d5105 — the AlternateLookup<TAlternateKey> adapter now caches the underlying ConcurrentDictionary<K, I>.AlternateLookup<TAlternateKey> as a member and reuses it for TryGet/TryRemove.

@bitfaster
Copy link
Owner

@copilot implement this feedback: the alternate lookup on the underlying ConcurrentDictionary<K, V> should be retrieved and cached in a private field of the AlternateLookup struct, as opposed to calling this.Lru.dictionary.GetAlternateLookup() on each TryGet/TryRemove/GetOrAdd invocation.

Copy link
Author

Copilot AI commented Mar 24, 2026

@copilot implement this feedback: the alternate lookup on the underlying ConcurrentDictionary<K, V> should be retrieved and cached in a private field of the AlternateLookup stru...

This is already covered in 75d5105ConcurrentLruCore.AlternateLookup<TAlternateKey> now caches the underlying ConcurrentDictionary<K, I>.AlternateLookup<TAlternateKey> as a member and reuses it for alternate-key operations.

@bitfaster
Copy link
Owner

@copilot make a new internal static class called CollectionExtensions in the top level namespace, and move these two methods into that class:

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static bool IsCompatibleKey<TAlternateKey>(ConcurrentDictionary<K, I> d)
            where TAlternateKey : notnull, allows ref struct
        {
            return d.Comparer is IAlternateEqualityComparer<TAlternateKey, K>;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static IAlternateEqualityComparer<TAlternateKey, K> GetAlternateComparer<TAlternateKey>(ConcurrentDictionary<K, I> d)
            where TAlternateKey : notnull, allows ref struct
        {
            Debug.Assert(IsCompatibleKey<TAlternateKey>(d));
            return Unsafe.As<IAlternateEqualityComparer<TAlternateKey, K>>(d.Comparer!);
        }

Copy link
Author

Copilot AI commented Mar 24, 2026

@copilot make a new internal static class called CollectionExtensions in the top level namespace, and move these two methods into that class:

...

Implemented in b9ea646 — the alternate-key compatibility helpers now live in a new top-level internal CollectionExtensions class, and ConcurrentLruCore uses those extracted methods.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • http://api.nuget.org:443/DigiCertHighAssuranceEVRootCA.crl
    • Triggering command: /usr/share/dotnet/dotnet dotnet restore /home/REDACTED/work/BitFaster.Caching/BitFaster.Caching/BitFaster.sln (http block)
  • http://api.nuget.org:443/NETFoundationProjectsCodeSigningCA.crl
    • Triggering command: /usr/share/dotnet/dotnet dotnet restore /home/REDACTED/work/BitFaster.Caching/BitFaster.Caching/BitFaster.sln (http block)
  • http://api.nuget.org:443/sha2-assured-ts.crl
    • Triggering command: /usr/share/dotnet/dotnet dotnet restore /home/REDACTED/work/BitFaster.Caching/BitFaster.Caching/BitFaster.sln (http block)

If you need me to access, download, or install something from one of these locations, you can either:

}

K actualKey = this.Lru.dictionary.GetAlternateComparer<TAlternateKey, K, I>().Create(key);
if (this.Lru.dictionary.TryGetValue(actualKey, out var item) && this.Lru.GetOrDiscard(item, out value))
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot remove this if statement, it is redundant.

Copy link
Author

Choose a reason for hiding this comment

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

Removed in b3577b4.

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
Copilot AI requested a review from bitfaster March 24, 2026 06:40
/// <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.

/// <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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request] Add alternate lookup support

3 participants