From d70f9e2019cfd7b4ad2508c034e525b0355ae78e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:44:27 +0000 Subject: [PATCH 1/8] Add scoped async alternate lookup API Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/9888fa8c-0e9f-4bfa-80c1-68d580e00c18 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../AtomicFactoryScopedAsyncCacheTests.cs | 159 ++++++++++++++++++ .../ScopedAsyncCacheTests.cs | 157 +++++++++++++++++ .../Atomic/AtomicFactoryScopedAsyncCache.cs | 121 +++++++++++++ .../IScopedAsyncAlternateLookup.cs | 69 ++++++++ BitFaster.Caching/IScopedAsyncCache.cs | 23 +++ BitFaster.Caching/ScopedAsyncCache.cs | 98 +++++++++++ 6 files changed, 627 insertions(+) create mode 100644 BitFaster.Caching/IScopedAsyncAlternateLookup.cs diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index 1b7f5c45..d3255c74 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -35,6 +35,70 @@ public void ComparerReturnsConfiguredComparer() cache.Comparer.Should().BeSameAs(comparer); } + + [Fact] + public async Task TryGetAsyncAlternateLookupReturnsLookupForCompatibleComparer() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); + ReadOnlySpan key = "42"; + + cache.TryGetAsyncAlternateLookup>(out var alternate).Should().BeTrue(); + alternate.ScopedTryGet(key, out var alternateLifetime).Should().BeTrue(); + alternateLifetime.Value.State.Should().Be(42); + alternateLifetime.Dispose(); + } + + [Fact] + public void GetAsyncAlternateLookupThrowsForIncompatibleComparer() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + + Action act = () => cache.GetAsyncAlternateLookup(); + + act.Should().Throw().WithMessage("Incompatible comparer"); + cache.TryGetAsyncAlternateLookup(out var alternate).Should().BeFalse(); + alternate.Should().BeNull(); + } + + [Fact] + public async Task AsyncAlternateLookupTryRemoveReturnsActualKey() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryRemove(key, out var actualKey).Should().BeTrue(); + + actualKey.Should().Be("42"); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndHit() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var factoryCalls = 0; + ReadOnlySpan key = "42"; + + using var lifetime = await alternate.ScopedGetOrAddAsync(key, k => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); + }); + + using var sameLifetime = await alternate.ScopedGetOrAddAsync(key, (k, offset) => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); + }, 1); + + lifetime.Value.State.Should().Be(42); + sameLifetime.Value.State.Should().Be(42); + factoryCalls.Should().Be(1); + } #endif [Fact] @@ -94,5 +158,100 @@ public void WhenEntryIsUpdatedOldEntryIsDisposed() this.cache.TryUpdate(1, new Disposable()).Should().BeTrue(); disposable2.IsDisposed.Should().BeTrue(); } + +#if NET9_0_OR_GREATER + [Fact] + public async Task WhenScopeIsDisposedTryGetAsyncAltReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var scope = new Scoped(new Disposable()); + + await cache.ScopedGetOrAddAsync("a", _ => Task.FromResult(scope)); + + scope.Dispose(); + + alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryRemoveAsyncAltReturnsTrue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + + cache.AddOrUpdate("a", new Disposable()); + alternate.TryRemove("a", out var key).Should().BeTrue(); + key.Should().Be("a"); + } + + [Fact] + public void WhenItemDoesNotExistTryGetAsyncAltReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistTryRemoveAsyncAltReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.TryRemove("a", out _).Should().BeFalse(); + } + + [Fact] + public async Task GetOrAddAsyncAltDisposedScopeThrows() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + Func getOrAdd = async () => { await alternate.ScopedGetOrAddAsync("a", _ => Task.FromResult(scope)); }; + + await getOrAdd.Should().ThrowAsync(); + } + + [Fact] + public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryUpdate(key, new Disposable(42)).Should().BeFalse(); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + + using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(1)))); + lifetime.Dispose(); + + alternate.TryUpdate(key, new Disposable(2)).Should().BeTrue(); + + alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); + updatedLifetime.Value.State.Should().Be(2); + updatedLifetime.Dispose(); + } + + [Fact] + public void AsyncAlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.AddOrUpdate(key, new Disposable(42)); + alternate.ScopedTryGet(key, out var lifetime).Should().BeTrue(); + lifetime.Value.State.Should().Be(42); + lifetime.Dispose(); + + alternate.AddOrUpdate(key, new Disposable(43)); + alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); + updatedLifetime.Value.State.Should().Be(43); + updatedLifetime.Dispose(); + } +#endif } } diff --git a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs index 584b2344..31f7cfdf 100644 --- a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs @@ -30,6 +30,163 @@ public void ComparerReturnsConfiguredComparer() cache.Comparer.Should().BeSameAs(comparer); } + + [Fact] + public async Task TryGetAsyncAlternateLookupReturnsLookupForCompatibleComparer() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); + ReadOnlySpan key = "42"; + + cache.TryGetAsyncAlternateLookup>(out var alternate).Should().BeTrue(); + alternate.ScopedTryGet(key, out var alternateLifetime).Should().BeTrue(); + alternateLifetime.Value.State.Should().Be(42); + alternateLifetime.Dispose(); + } + + [Fact] + public void GetAsyncAlternateLookupThrowsForIncompatibleComparer() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + + Action act = () => cache.GetAsyncAlternateLookup(); + + act.Should().Throw().WithMessage("Incompatible comparer"); + cache.TryGetAsyncAlternateLookup(out var alternate).Should().BeFalse(); + alternate.Should().BeNull(); + } + + [Fact] + public async Task AsyncAlternateLookupTryRemoveReturnsActualKey() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryRemove(key, out var actualKey).Should().BeTrue(); + + actualKey.Should().Be("42"); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndHit() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var factoryCalls = 0; + ReadOnlySpan key = "42"; + + using var lifetime = await alternate.ScopedGetOrAddAsync(key, k => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); + }); + + using var sameLifetime = await alternate.ScopedGetOrAddAsync(key, (k, offset) => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); + }, 1); + + lifetime.Value.State.Should().Be(42); + sameLifetime.Value.State.Should().Be(42); + factoryCalls.Should().Be(1); + } + + [Fact] + public async Task WhenScopeIsDisposedTryGetAsyncAltReturnsFalse() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var scope = new Scoped(new Disposable()); + + await cache.ScopedGetOrAddAsync("a", _ => Task.FromResult(scope)); + + scope.Dispose(); + + alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryRemoveAsyncAltReturnsTrue() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + + cache.AddOrUpdate("a", new Disposable()); + alternate.TryRemove("a", out var key).Should().BeTrue(); + key.Should().Be("a"); + } + + [Fact] + public void WhenItemDoesNotExistTryGetAsyncAltReturnsFalse() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistTryRemoveAsyncAltReturnsFalse() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.TryRemove("a", out _).Should().BeFalse(); + } + + [Fact] + public async Task GetOrAddAsyncAltDisposedScopeThrows() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + Func getOrAdd = async () => { await alternate.ScopedGetOrAddAsync("a", _ => Task.FromResult(scope)); }; + + await getOrAdd.Should().ThrowAsync(); + } + + [Fact] + public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryUpdate(key, new Disposable(42)).Should().BeFalse(); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + + using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(1)))); + lifetime.Dispose(); + + alternate.TryUpdate(key, new Disposable(2)).Should().BeTrue(); + + alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); + updatedLifetime.Value.State.Should().Be(2); + updatedLifetime.Dispose(); + } + + [Fact] + public void AsyncAlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.AddOrUpdate(key, new Disposable(42)); + alternate.ScopedTryGet(key, out var lifetime).Should().BeTrue(); + lifetime.Value.State.Should().Be(42); + lifetime.Dispose(); + + alternate.AddOrUpdate(key, new Disposable(43)); + alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); + updatedLifetime.Value.State.Should().Be(43); + updatedLifetime.Dispose(); + } #endif [Fact] diff --git a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs index 3ae7af5d..7a1003a4 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs @@ -147,6 +147,127 @@ public bool TryUpdate(K key, V value) } #pragma warning restore CA2000 // Dispose objects before losing scope +#if NET9_0_OR_GREATER + /// + public IScopedAsyncAlternateLookup GetAsyncAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + var inner = this.cache.GetAlternateLookup(); + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + return new AlternateLookup(inner, comparer); + } + + /// + public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out IScopedAsyncAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + if (this.cache.TryGetAlternateLookup(out var inner)) + { + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + lookup = new AlternateLookup(inner, comparer); + return true; + } + + lookup = default; + return false; + } + + internal readonly struct AlternateLookup : IScopedAsyncAlternateLookup + where TAlternateKey : notnull, allows ref struct + { + private readonly IAlternateLookup> inner; + private readonly IAlternateEqualityComparer comparer; + + internal AlternateLookup(IAlternateLookup> inner, IAlternateEqualityComparer comparer) + { + this.inner = inner; + this.comparer = comparer; + } + + public bool ScopedTryGet(TAlternateKey key, [MaybeNullWhen(false)] out Lifetime lifetime) + { + if (this.inner.TryGet(key, out var scope) && scope.TryCreateLifetime(out lifetime)) + { + return true; + } + + lifetime = default; + return false; + } + + public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey) + { + if (this.inner.TryRemove(key, out actualKey, out _)) + { + return true; + } + + actualKey = default; + return false; + } + +#pragma warning disable CA2000 // Dispose objects before losing scope + public bool TryUpdate(TAlternateKey key, V value) + { + return this.inner.TryUpdate(key, new ScopedAsyncAtomicFactory(value)); + } + + public void AddOrUpdate(TAlternateKey key, V value) + { + this.inner.AddOrUpdate(key, new ScopedAsyncAtomicFactory(value)); + } +#pragma warning restore CA2000 // Dispose objects before losing scope + + public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAsyncAtomicFactory()); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return new ValueTask>(lifetime); + } + + return ScopedGetOrAddAsync(key, new AsyncValueFactory>(valueFactory)); + } + + public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory, TArg factoryArgument) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAsyncAtomicFactory()); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return new ValueTask>(lifetime); + } + + return ScopedGetOrAddAsync(key, new AsyncValueFactoryArg>(valueFactory, factoryArgument)); + } + + private async ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> + { + int c = 0; + var spinwait = new SpinWait(); + K actualKey = this.comparer.Create(key); + + while (true) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAsyncAtomicFactory()); + + var (success, lifetime) = await scope.TryCreateLifetimeAsync(actualKey, valueFactory).ConfigureAwait(false); + + if (success) + { + return lifetime!; + } + + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } + } + } +#endif + /// public IEnumerator>> GetEnumerator() { diff --git a/BitFaster.Caching/IScopedAsyncAlternateLookup.cs b/BitFaster.Caching/IScopedAsyncAlternateLookup.cs new file mode 100644 index 00000000..c7a9d55a --- /dev/null +++ b/BitFaster.Caching/IScopedAsyncAlternateLookup.cs @@ -0,0 +1,69 @@ +#if NET9_0_OR_GREATER +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace BitFaster.Caching +{ + /// + /// Provides an async alternate-key lookup over a scoped cache. + /// + /// The alternate key type. + /// The cache key type. + /// The cache value type. + public interface IScopedAsyncAlternateLookup + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + where TValue : IDisposable + { + /// + /// Attempts to get a value lifetime using an alternate key. + /// + /// The alternate key. + /// The value lifetime when found. + /// when the key is found; otherwise, . + bool ScopedTryGet(TAlternateKey key, [MaybeNullWhen(false)] out Lifetime lifetime); + + /// + /// Attempts to remove a value using an alternate key. + /// + /// The alternate key. + /// The removed cache key. + /// when the key is found; otherwise, . + bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey); + + /// + /// Attempts to update an existing value using an alternate key. + /// + /// The alternate key. + /// The value to update. + /// when the key was updated; otherwise, . + bool TryUpdate(TAlternateKey key, TValue value); + + /// + /// Adds a value using an alternate key or updates the existing value. + /// + /// The alternate key. + /// The value to add or update. + void AddOrUpdate(TAlternateKey key, TValue value); + + /// + /// Gets an existing value lifetime or adds a new value asynchronously using an alternate key. + /// + /// The alternate key. + /// The value factory, invoked with the actual cache key when a value must be created. + /// A task that represents the asynchronous scoped GetOrAdd operation. + ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory); + + /// + /// Gets an existing value lifetime or adds a new value asynchronously using an alternate key and factory argument. + /// + /// The factory argument type. + /// The alternate key. + /// The value factory, invoked with the actual cache key when a value must be created. + /// The factory argument. + /// A task that represents the asynchronous scoped GetOrAdd operation. + ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory, TArg factoryArgument); + } +} +#endif diff --git a/BitFaster.Caching/IScopedAsyncCache.cs b/BitFaster.Caching/IScopedAsyncCache.cs index b9ecad73..1b1560f3 100644 --- a/BitFaster.Caching/IScopedAsyncCache.cs +++ b/BitFaster.Caching/IScopedAsyncCache.cs @@ -46,6 +46,29 @@ public interface IScopedAsyncCache : IEnumerable /// Gets the key comparer used by the cache. /// IEqualityComparer Comparer => throw new NotSupportedException(); + +// backcompat: add not null constraint to IScopedAsyncCache (where K : notnull) +#pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. + /// + /// Gets an async alternate lookup that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// An async alternate lookup. + /// The configured comparer does not support . + IScopedAsyncAlternateLookup GetAsyncAlternateLookup() + where TAlternateKey : notnull, allows ref struct + => throw new NotSupportedException(); + + /// + /// Attempts to get an async alternate lookup that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// The async alternate lookup when available. + /// when the configured comparer supports ; otherwise, . + bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out IScopedAsyncAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + => throw new NotSupportedException(); +#pragma warning restore CS8714 #endif /// diff --git a/BitFaster.Caching/ScopedAsyncCache.cs b/BitFaster.Caching/ScopedAsyncCache.cs index d23d85f2..4efd65c3 100644 --- a/BitFaster.Caching/ScopedAsyncCache.cs +++ b/BitFaster.Caching/ScopedAsyncCache.cs @@ -152,6 +152,104 @@ public bool TryUpdate(K key, V value) } #pragma warning restore CA2000 // Dispose objects before losing scope +#if NET9_0_OR_GREATER + /// + public IScopedAsyncAlternateLookup GetAsyncAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + return new AlternateLookup(this.cache.GetAsyncAlternateLookup()); + } + + /// + public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out IScopedAsyncAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + if (this.cache.TryGetAsyncAlternateLookup(out var inner)) + { + lookup = new AlternateLookup(inner); + return true; + } + + lookup = default; + return false; + } + + internal readonly struct AlternateLookup : IScopedAsyncAlternateLookup + where TAlternateKey : notnull, allows ref struct + { + private readonly IAsyncAlternateLookup> inner; + + internal AlternateLookup(IAsyncAlternateLookup> inner) + { + this.inner = inner; + } + + public bool ScopedTryGet(TAlternateKey key, [MaybeNullWhen(false)] out Lifetime lifetime) + { + if (this.inner.TryGet(key, out var scope) && scope.TryCreateLifetime(out lifetime)) + { + return true; + } + + lifetime = default; + return false; + } + + public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey) + { + if (this.inner.TryRemove(key, out actualKey, out _)) + { + return true; + } + + actualKey = default; + return false; + } + +#pragma warning disable CA2000 // Dispose objects before losing scope + public bool TryUpdate(TAlternateKey key, V value) + { + return this.inner.TryUpdate(key, new Scoped(value)); + } + + public void AddOrUpdate(TAlternateKey key, V value) + { + this.inner.AddOrUpdate(key, new Scoped(value)); + } +#pragma warning restore CA2000 // Dispose objects before losing scope + + public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory) + { + return ScopedGetOrAddAsync(key, new AsyncValueFactory>(valueFactory)); + } + + public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory, TArg factoryArgument) + { + return ScopedGetOrAddAsync(key, new AsyncValueFactoryArg>(valueFactory, factoryArgument)); + } + + private async ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> + { + int c = 0; + var spinwait = new SpinWait(); + while (true) + { + var scope = await this.inner.GetOrAddAsync(key, static (k, factory) => factory.CreateAsync(k), valueFactory).ConfigureAwait(false); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } + } + } +#endif + /// public IEnumerator>> GetEnumerator() { From d21a4d937e4bfcab3df6a0cb293dd53101ca0b04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:48:05 +0000 Subject: [PATCH 2/8] Add scoped async alternate lookup tests Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/9888fa8c-0e9f-4bfa-80c1-68d580e00c18 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../AtomicFactoryScopedAsyncCacheTests.cs | 14 +++--- .../ScopedAsyncCacheTests.cs | 14 +++--- .../Atomic/AtomicFactoryScopedAsyncCache.cs | 41 +++++++++++------- .../IScopedAsyncAlternateLookup.cs | 2 +- BitFaster.Caching/ScopedAsyncCache.cs | 43 ++++++++++++------- 5 files changed, 68 insertions(+), 46 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index d3255c74..5ae5ea3b 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -81,15 +81,15 @@ public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndH var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); var factoryCalls = 0; - ReadOnlySpan key = "42"; + var key = "42"; - using var lifetime = await alternate.ScopedGetOrAddAsync(key, k => + using var lifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); }); - using var sameLifetime = await alternate.ScopedGetOrAddAsync(key, (k, offset) => + using var sameLifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); @@ -220,17 +220,17 @@ public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdat { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); - ReadOnlySpan key = "42"; + var key = "42"; - alternate.TryUpdate(key, new Disposable(42)).Should().BeFalse(); + alternate.TryUpdate(key.AsSpan(), new Disposable(42)).Should().BeFalse(); cache.ScopedTryGet("42", out _).Should().BeFalse(); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(1)))); lifetime.Dispose(); - alternate.TryUpdate(key, new Disposable(2)).Should().BeTrue(); + alternate.TryUpdate(key.AsSpan(), new Disposable(2)).Should().BeTrue(); - alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); + alternate.ScopedTryGet(key.AsSpan(), out var updatedLifetime).Should().BeTrue(); updatedLifetime.Value.State.Should().Be(2); updatedLifetime.Dispose(); } diff --git a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs index 31f7cfdf..164f983c 100644 --- a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs @@ -76,15 +76,15 @@ public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndH var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); var factoryCalls = 0; - ReadOnlySpan key = "42"; + var key = "42"; - using var lifetime = await alternate.ScopedGetOrAddAsync(key, k => + using var lifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); }); - using var sameLifetime = await alternate.ScopedGetOrAddAsync(key, (k, offset) => + using var sameLifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); @@ -155,17 +155,17 @@ public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdat { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); - ReadOnlySpan key = "42"; + var key = "42"; - alternate.TryUpdate(key, new Disposable(42)).Should().BeFalse(); + alternate.TryUpdate(key.AsSpan(), new Disposable(42)).Should().BeFalse(); cache.ScopedTryGet("42", out _).Should().BeFalse(); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(1)))); lifetime.Dispose(); - alternate.TryUpdate(key, new Disposable(2)).Should().BeTrue(); + alternate.TryUpdate(key.AsSpan(), new Disposable(2)).Should().BeTrue(); - alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); + alternate.ScopedTryGet(key.AsSpan(), out var updatedLifetime).Should().BeTrue(); updatedLifetime.Value.State.Should().Be(2); updatedLifetime.Dispose(); } diff --git a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs index 7a1003a4..03063947 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs @@ -154,7 +154,7 @@ public IScopedAsyncAlternateLookup GetAsyncAlternateLookup< { var inner = this.cache.GetAlternateLookup(); var comparer = (IAlternateEqualityComparer)this.cache.Comparer; - return new AlternateLookup(inner, comparer); + return new AlternateLookup(this.cache, inner, comparer); } /// @@ -164,7 +164,7 @@ public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out if (this.cache.TryGetAlternateLookup(out var inner)) { var comparer = (IAlternateEqualityComparer)this.cache.Comparer; - lookup = new AlternateLookup(inner, comparer); + lookup = new AlternateLookup(this.cache, inner, comparer); return true; } @@ -175,11 +175,13 @@ public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out internal readonly struct AlternateLookup : IScopedAsyncAlternateLookup where TAlternateKey : notnull, allows ref struct { + private readonly ICache> cache; private readonly IAlternateLookup> inner; private readonly IAlternateEqualityComparer comparer; - internal AlternateLookup(IAlternateLookup> inner, IAlternateEqualityComparer comparer) + internal AlternateLookup(ICache> cache, IAlternateLookup> inner, IAlternateEqualityComparer comparer) { + this.cache = cache; this.inner = inner; this.comparer = comparer; } @@ -218,6 +220,7 @@ public void AddOrUpdate(TAlternateKey key, V value) } #pragma warning restore CA2000 // Dispose objects before losing scope +#pragma warning disable CA2000 // Dispose objects before losing scope public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory) { var scope = this.inner.GetOrAdd(key, static _ => new ScopedAsyncAtomicFactory()); @@ -241,28 +244,34 @@ public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func< return ScopedGetOrAddAsync(key, new AsyncValueFactoryArg>(valueFactory, factoryArgument)); } +#pragma warning restore CA2000 // Dispose objects before losing scope - private async ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> + private ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> { - int c = 0; - var spinwait = new SpinWait(); K actualKey = this.comparer.Create(key); + return CompleteAsync(this.cache, actualKey, valueFactory); - while (true) + static async ValueTask> CompleteAsync(ICache> cache, K actualKey, TFactory valueFactory) { - var scope = this.inner.GetOrAdd(key, static _ => new ScopedAsyncAtomicFactory()); + int c = 0; + var spinwait = new SpinWait(); - var (success, lifetime) = await scope.TryCreateLifetimeAsync(actualKey, valueFactory).ConfigureAwait(false); - - if (success) + while (true) { - return lifetime!; - } + var scope = cache.GetOrAdd(actualKey, static _ => new ScopedAsyncAtomicFactory()); - spinwait.SpinOnce(); + var (success, lifetime) = await scope.TryCreateLifetimeAsync(actualKey, valueFactory).ConfigureAwait(false); - if (c++ > ScopedCacheDefaults.MaxRetry) - Throw.ScopedRetryFailure(); + if (success) + { + return lifetime!; + } + + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } } } } diff --git a/BitFaster.Caching/IScopedAsyncAlternateLookup.cs b/BitFaster.Caching/IScopedAsyncAlternateLookup.cs index c7a9d55a..4b9787c8 100644 --- a/BitFaster.Caching/IScopedAsyncAlternateLookup.cs +++ b/BitFaster.Caching/IScopedAsyncAlternateLookup.cs @@ -1,4 +1,4 @@ -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; diff --git a/BitFaster.Caching/ScopedAsyncCache.cs b/BitFaster.Caching/ScopedAsyncCache.cs index 4efd65c3..ff3459a7 100644 --- a/BitFaster.Caching/ScopedAsyncCache.cs +++ b/BitFaster.Caching/ScopedAsyncCache.cs @@ -157,7 +157,9 @@ public bool TryUpdate(K key, V value) public IScopedAsyncAlternateLookup GetAsyncAlternateLookup() where TAlternateKey : notnull, allows ref struct { - return new AlternateLookup(this.cache.GetAsyncAlternateLookup()); + var inner = this.cache.GetAsyncAlternateLookup(); + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + return new AlternateLookup(this.cache, inner, comparer); } /// @@ -166,7 +168,8 @@ public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out { if (this.cache.TryGetAsyncAlternateLookup(out var inner)) { - lookup = new AlternateLookup(inner); + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + lookup = new AlternateLookup(this.cache, inner, comparer); return true; } @@ -177,11 +180,15 @@ public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out internal readonly struct AlternateLookup : IScopedAsyncAlternateLookup where TAlternateKey : notnull, allows ref struct { + private readonly IAsyncCache> cache; private readonly IAsyncAlternateLookup> inner; + private readonly IAlternateEqualityComparer comparer; - internal AlternateLookup(IAsyncAlternateLookup> inner) + internal AlternateLookup(IAsyncCache> cache, IAsyncAlternateLookup> inner, IAlternateEqualityComparer comparer) { + this.cache = cache; this.inner = inner; + this.comparer = comparer; } public bool ScopedTryGet(TAlternateKey key, [MaybeNullWhen(false)] out Lifetime lifetime) @@ -228,23 +235,29 @@ public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func< return ScopedGetOrAddAsync(key, new AsyncValueFactoryArg>(valueFactory, factoryArgument)); } - private async ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> + private ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> { - int c = 0; - var spinwait = new SpinWait(); - while (true) - { - var scope = await this.inner.GetOrAddAsync(key, static (k, factory) => factory.CreateAsync(k), valueFactory).ConfigureAwait(false); + K actualKey = this.comparer.Create(key); + return CompleteAsync(this.cache, actualKey, valueFactory); - if (scope.TryCreateLifetime(out var lifetime)) + static async ValueTask> CompleteAsync(IAsyncCache> cache, K actualKey, TFactory valueFactory) + { + int c = 0; + var spinwait = new SpinWait(); + while (true) { - return lifetime; - } + var scope = await cache.GetOrAddAsync(actualKey, static (k, factory) => factory.CreateAsync(k), valueFactory).ConfigureAwait(false); - spinwait.SpinOnce(); + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } - if (c++ > ScopedCacheDefaults.MaxRetry) - Throw.ScopedRetryFailure(); + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } } } } From 7d5a749925bdc5bd06ea9922f600d71854376cc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:55:31 +0000 Subject: [PATCH 3/8] Refine scoped async alternate lookup tests Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/9888fa8c-0e9f-4bfa-80c1-68d580e00c18 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../AtomicFactoryScopedAsyncCacheTests.cs | 39 +++++++++++++------ .../ScopedAsyncCacheTests.cs | 39 +++++++++++++------ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index 5ae5ea3b..e425c9ab 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -37,7 +37,7 @@ public void ComparerReturnsConfiguredComparer() } [Fact] - public async Task TryGetAsyncAlternateLookupReturnsLookupForCompatibleComparer() + public async Task TryGetAsyncAlternateLookupCompatibleComparerReturnsLookup() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); @@ -50,7 +50,7 @@ public async Task TryGetAsyncAlternateLookupReturnsLookupForCompatibleComparer() } [Fact] - public void GetAsyncAlternateLookupThrowsForIncompatibleComparer() + public void GetAsyncAlternateLookupIncompatibleComparerThrowsInvalidOperationException() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); @@ -62,7 +62,7 @@ public void GetAsyncAlternateLookupThrowsForIncompatibleComparer() } [Fact] - public async Task AsyncAlternateLookupTryRemoveReturnsActualKey() + public async Task TryRemoveExistingKeyReturnsActualKey() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); @@ -76,7 +76,7 @@ public async Task AsyncAlternateLookupTryRemoveReturnsActualKey() } [Fact] - public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndHit() + public async Task ScopedGetOrAddAsyncMissAndHitUsesActualKey() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -161,7 +161,7 @@ public void WhenEntryIsUpdatedOldEntryIsDisposed() #if NET9_0_OR_GREATER [Fact] - public async Task WhenScopeIsDisposedTryGetAsyncAltReturnsFalse() + public async Task ScopedTryGetDisposedScopeReturnsFalse() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -175,7 +175,7 @@ public async Task WhenScopeIsDisposedTryGetAsyncAltReturnsFalse() } [Fact] - public void WhenKeyExistsTryRemoveAsyncAltReturnsTrue() + public void TryRemoveExistingKeyReturnsTrue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -186,7 +186,7 @@ public void WhenKeyExistsTryRemoveAsyncAltReturnsTrue() } [Fact] - public void WhenItemDoesNotExistTryGetAsyncAltReturnsFalse() + public void ScopedTryGetNonExistentKeyReturnsFalse() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -194,7 +194,7 @@ public void WhenItemDoesNotExistTryGetAsyncAltReturnsFalse() } [Fact] - public void WhenKeyDoesNotExistTryRemoveAsyncAltReturnsFalse() + public void TryRemoveNonExistentKeyReturnsFalse() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -202,7 +202,7 @@ public void WhenKeyDoesNotExistTryRemoveAsyncAltReturnsFalse() } [Fact] - public async Task GetOrAddAsyncAltDisposedScopeThrows() + public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -216,7 +216,7 @@ public async Task GetOrAddAsyncAltDisposedScopeThrows() } [Fact] - public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() + public void TryUpdateMissingKeyReturnsFalse() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -224,6 +224,14 @@ public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdat alternate.TryUpdate(key.AsSpan(), new Disposable(42)).Should().BeFalse(); cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public async Task TryUpdateExistingKeyUpdatesValue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var key = "42"; using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(1)))); lifetime.Dispose(); @@ -236,7 +244,7 @@ public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdat } [Fact] - public void AsyncAlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() + public void AddOrUpdateMissingKeyAddsValue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -246,7 +254,16 @@ public void AsyncAlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingVal alternate.ScopedTryGet(key, out var lifetime).Should().BeTrue(); lifetime.Value.State.Should().Be(42); lifetime.Dispose(); + } + [Fact] + public void AddOrUpdateExistingKeyUpdatesValue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.AddOrUpdate(key, new Disposable(42)); alternate.AddOrUpdate(key, new Disposable(43)); alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); updatedLifetime.Value.State.Should().Be(43); diff --git a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs index 164f983c..1e29bdaf 100644 --- a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs @@ -32,7 +32,7 @@ public void ComparerReturnsConfiguredComparer() } [Fact] - public async Task TryGetAsyncAlternateLookupReturnsLookupForCompatibleComparer() + public async Task TryGetAsyncAlternateLookupCompatibleComparerReturnsLookup() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); @@ -45,7 +45,7 @@ public async Task TryGetAsyncAlternateLookupReturnsLookupForCompatibleComparer() } [Fact] - public void GetAsyncAlternateLookupThrowsForIncompatibleComparer() + public void GetAsyncAlternateLookupIncompatibleComparerThrowsInvalidOperationException() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); @@ -57,7 +57,7 @@ public void GetAsyncAlternateLookupThrowsForIncompatibleComparer() } [Fact] - public async Task AsyncAlternateLookupTryRemoveReturnsActualKey() + public async Task TryRemoveExistingKeyReturnsActualKey() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); @@ -71,7 +71,7 @@ public async Task AsyncAlternateLookupTryRemoveReturnsActualKey() } [Fact] - public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndHit() + public async Task ScopedGetOrAddAsyncMissAndHitUsesActualKey() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -96,7 +96,7 @@ public async Task AsyncAlternateLookupScopedGetOrAddAsyncUsesActualKeyOnMissAndH } [Fact] - public async Task WhenScopeIsDisposedTryGetAsyncAltReturnsFalse() + public async Task ScopedTryGetDisposedScopeReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -110,7 +110,7 @@ public async Task WhenScopeIsDisposedTryGetAsyncAltReturnsFalse() } [Fact] - public void WhenKeyExistsTryRemoveAsyncAltReturnsTrue() + public void TryRemoveExistingKeyReturnsTrue() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -121,7 +121,7 @@ public void WhenKeyExistsTryRemoveAsyncAltReturnsTrue() } [Fact] - public void WhenItemDoesNotExistTryGetAsyncAltReturnsFalse() + public void ScopedTryGetNonExistentKeyReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -129,7 +129,7 @@ public void WhenItemDoesNotExistTryGetAsyncAltReturnsFalse() } [Fact] - public void WhenKeyDoesNotExistTryRemoveAsyncAltReturnsFalse() + public void TryRemoveNonExistentKeyReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -137,7 +137,7 @@ public void WhenKeyDoesNotExistTryRemoveAsyncAltReturnsFalse() } [Fact] - public async Task GetOrAddAsyncAltDisposedScopeThrows() + public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -151,7 +151,7 @@ public async Task GetOrAddAsyncAltDisposedScopeThrows() } [Fact] - public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() + public void TryUpdateMissingKeyReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -159,6 +159,14 @@ public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdat alternate.TryUpdate(key.AsSpan(), new Disposable(42)).Should().BeFalse(); cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public async Task TryUpdateExistingKeyUpdatesValue() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var key = "42"; using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(1)))); lifetime.Dispose(); @@ -171,7 +179,7 @@ public async Task AsyncAlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdat } [Fact] - public void AsyncAlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() + public void AddOrUpdateMissingKeyAddsValue() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -181,7 +189,16 @@ public void AsyncAlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingVal alternate.ScopedTryGet(key, out var lifetime).Should().BeTrue(); lifetime.Value.State.Should().Be(42); lifetime.Dispose(); + } + [Fact] + public void AddOrUpdateExistingKeyUpdatesValue() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.AddOrUpdate(key, new Disposable(42)); alternate.AddOrUpdate(key, new Disposable(43)); alternate.ScopedTryGet(key, out var updatedLifetime).Should().BeTrue(); updatedLifetime.Value.State.Should().Be(43); From 1940c6e5963ea41daf2548b961db9e2591b29f94 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 27 Apr 2026 11:07:41 -0700 Subject: [PATCH 4/8] additional tests --- .../AtomicFactoryScopedAsyncCacheTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index e425c9ab..af329358 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -99,6 +99,61 @@ public async Task ScopedGetOrAddAsyncMissAndHitUsesActualKey() sameLifetime.Value.State.Should().Be(42); factoryCalls.Should().Be(1); } + + [Fact] + public async Task AltScopedTryGetDisposedScopeReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); + var alternate = cache.GetAsyncAlternateLookup>(); + var scope = new Scoped(new Disposable()); + + await cache.ScopedGetOrAddAsync("a", _ => Task.FromResult(scope)); + + scope.Dispose(); + + alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); + } + + [Fact] + public void AltTryRemoveExistingKeyReturnsTrue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); + var alternate = cache.GetAsyncAlternateLookup>(); + + cache.AddOrUpdate("a", new Disposable()); + alternate.TryRemove("a", out var key).Should().BeTrue(); + key.Should().Be("a"); + } + + [Fact] + public void WhenItemDoesNotExistTryGetAltReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistTryRemoveAltReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.TryRemove("a", out _).Should().BeFalse(); + } + + [Fact] + public async Task GetOrAddAltDisposedScopeThrows() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); + var alternate = cache.GetAsyncAlternateLookup>(); + + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + Func getOrAdd = async () => { await this.cache.ScopedGetOrAddAsync(1, k => Task.FromResult(scope)); }; + + await getOrAdd.Should().ThrowAsync(); + } #endif [Fact] From f5160d56c55e3d23ff63fcef0cc28c6138654f81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:39:13 +0000 Subject: [PATCH 5/8] Add scoped async alternate soak test Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/b0dac3b4-d350-4fdf-a649-dde98d68135c Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../AtomicFactoryScopedAsyncCacheSoakTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheSoakTests.cs diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheSoakTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheSoakTests.cs new file mode 100644 index 00000000..70333c99 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheSoakTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BitFaster.Caching.Atomic; +using BitFaster.Caching.Lru; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Atomic +{ + [Collection("Soak")] + public class AtomicFactoryScopedAsyncCacheSoakTests + { + private const int capacity = 6; + private const int threadCount = 4; + private const int soakIterations = 10; + private const int loopIterations = 100_000; + +#if NET9_0_OR_GREATER + [Theory] + [Repeat(soakIterations)] + public async Task ScopedGetOrAddAsyncAlternateLifetimeIsAlwaysAlive(int _) + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + + var run = Threaded.RunAsync(threadCount, async _ => + { + var key = new char[8]; + + for (int i = 0; i < loopIterations; i++) + { + (i + 1).TryFormat(key, out int written); + + using var lifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan().Slice(0, written), static k => Task.FromResult(new Scoped(new Disposable(int.Parse(k))))); + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + } + }); + + await run; + } +#endif + } +} From 735c5fc4efc5cfee7baf6c0cb14109aed27c6fe7 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 27 Apr 2026 14:23:39 -0700 Subject: [PATCH 6/8] test arg method --- .../AtomicFactoryScopedAsyncCacheTests.cs | 35 ++++++++++++++++--- .../Atomic/AtomicFactoryScopedAsyncCache.cs | 2 -- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index af329358..3aec677c 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -76,27 +76,52 @@ public async Task TryRemoveExistingKeyReturnsActualKey() } [Fact] - public async Task ScopedGetOrAddAsyncMissAndHitUsesActualKey() + public async Task AltScopedGetOrAddAsyncRetrievesCachedValue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); var factoryCalls = 0; var key = "42"; - using var lifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => + using var lifetime1 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); }); - using var sameLifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => + using var lifetime2 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(123))); + }); + + lifetime1.Value.State.Should().Be(42); + lifetime2.Value.State.Should().Be(42); + factoryCalls.Should().Be(1); + } + + [Fact] + public async Task AltScopedGetOrAddArgAsyncRetrievesCachedValue() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var factoryCalls = 0; + var key = "42"; + + using var lifetime1 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); }, 1); - lifetime.Value.State.Should().Be(42); - sameLifetime.Value.State.Should().Be(42); + using var lifetime2 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); + }, 2); + + lifetime1.Value.State.Should().Be(43); + lifetime2.Value.State.Should().Be(43); factoryCalls.Should().Be(1); } diff --git a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs index 03063947..c901dec1 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs @@ -218,9 +218,7 @@ public void AddOrUpdate(TAlternateKey key, V value) { this.inner.AddOrUpdate(key, new ScopedAsyncAtomicFactory(value)); } -#pragma warning restore CA2000 // Dispose objects before losing scope -#pragma warning disable CA2000 // Dispose objects before losing scope public ValueTask> ScopedGetOrAddAsync(TAlternateKey key, Func>> valueFactory) { var scope = this.inner.GetOrAdd(key, static _ => new ScopedAsyncAtomicFactory()); From a3faf7ddcd2a34b409ee04ab7472376cd097a112 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 27 Apr 2026 17:23:28 -0700 Subject: [PATCH 7/8] remove dupe tests --- .../AtomicFactoryScopedAsyncCacheTests.cs | 62 ++++--------------- 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index 3aec677c..f64ceb8e 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -140,22 +140,22 @@ public async Task AltScopedTryGetDisposedScopeReturnsFalse() } [Fact] - public void AltTryRemoveExistingKeyReturnsTrue() + public void WhenItemDoesNotExistTryGetAltReturnsFalse() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); var alternate = cache.GetAsyncAlternateLookup>(); - - cache.AddOrUpdate("a", new Disposable()); - alternate.TryRemove("a", out var key).Should().BeTrue(); - key.Should().Be("a"); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); } [Fact] - public void WhenItemDoesNotExistTryGetAltReturnsFalse() + public void WhenKeyExistsTryRemoveAltReturnsTrue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); var alternate = cache.GetAsyncAlternateLookup>(); - alternate.ScopedTryGet("a", out _).Should().BeFalse(); + + cache.AddOrUpdate("a", new Disposable()); + alternate.TryRemove("a", out var key).Should().BeTrue(); + key.Should().Be("a"); } [Fact] @@ -240,46 +240,6 @@ public void WhenEntryIsUpdatedOldEntryIsDisposed() } #if NET9_0_OR_GREATER - [Fact] - public async Task ScopedTryGetDisposedScopeReturnsFalse() - { - var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); - var alternate = cache.GetAsyncAlternateLookup>(); - var scope = new Scoped(new Disposable()); - - await cache.ScopedGetOrAddAsync("a", _ => Task.FromResult(scope)); - - scope.Dispose(); - - alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); - } - - [Fact] - public void TryRemoveExistingKeyReturnsTrue() - { - var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); - var alternate = cache.GetAsyncAlternateLookup>(); - - cache.AddOrUpdate("a", new Disposable()); - alternate.TryRemove("a", out var key).Should().BeTrue(); - key.Should().Be("a"); - } - - [Fact] - public void ScopedTryGetNonExistentKeyReturnsFalse() - { - var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); - var alternate = cache.GetAsyncAlternateLookup>(); - alternate.ScopedTryGet("a", out _).Should().BeFalse(); - } - - [Fact] - public void TryRemoveNonExistentKeyReturnsFalse() - { - var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); - var alternate = cache.GetAsyncAlternateLookup>(); - alternate.TryRemove("a", out _).Should().BeFalse(); - } [Fact] public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() @@ -296,7 +256,7 @@ public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationExceptio } [Fact] - public void TryUpdateMissingKeyReturnsFalse() + public void AltTryUpdateMissingKeyReturnsFalse() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -307,7 +267,7 @@ public void TryUpdateMissingKeyReturnsFalse() } [Fact] - public async Task TryUpdateExistingKeyUpdatesValue() + public async Task AltTryUpdateExistingKeyUpdatesValue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -324,7 +284,7 @@ public async Task TryUpdateExistingKeyUpdatesValue() } [Fact] - public void AddOrUpdateMissingKeyAddsValue() + public void AltAddOrUpdateMissingKeyAddsValue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -337,7 +297,7 @@ public void AddOrUpdateMissingKeyAddsValue() } [Fact] - public void AddOrUpdateExistingKeyUpdatesValue() + public void AltAddOrUpdateExistingKeyUpdatesValue() { var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); From 9c37fb6542af0e9c866427a25685cbc58c46a29f Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 27 Apr 2026 18:09:05 -0700 Subject: [PATCH 8/8] cleanup tests --- .../ScopedAsyncCacheTests.cs | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs index 1e29bdaf..66e1bddf 100644 --- a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs @@ -39,9 +39,7 @@ public async Task TryGetAsyncAlternateLookupCompatibleComparerReturnsLookup() ReadOnlySpan key = "42"; cache.TryGetAsyncAlternateLookup>(out var alternate).Should().BeTrue(); - alternate.ScopedTryGet(key, out var alternateLifetime).Should().BeTrue(); - alternateLifetime.Value.State.Should().Be(42); - alternateLifetime.Dispose(); + alternate.Should().BeAssignableTo, string, Disposable>>(); } [Fact] @@ -57,7 +55,7 @@ public void GetAsyncAlternateLookupIncompatibleComparerThrowsInvalidOperationExc } [Fact] - public async Task TryRemoveExistingKeyReturnsActualKey() + public async Task AltTryRemoveExistingKeyReturnsActualKey() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); using var lifetime = await cache.ScopedGetOrAddAsync("42", _ => Task.FromResult(new Scoped(new Disposable(42)))); @@ -71,32 +69,32 @@ public async Task TryRemoveExistingKeyReturnsActualKey() } [Fact] - public async Task ScopedGetOrAddAsyncMissAndHitUsesActualKey() + public async Task AltScopedGetOrAddAsyncMissAndHitUsesActualKey() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); var factoryCalls = 0; var key = "42"; - using var lifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => + using var lifetime1 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), k => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); }); - using var sameLifetime = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => + using var lifetime2 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => { factoryCalls++; return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); }, 1); - lifetime.Value.State.Should().Be(42); - sameLifetime.Value.State.Should().Be(42); + lifetime1.Value.State.Should().Be(42); + lifetime2.Value.State.Should().Be(42); factoryCalls.Should().Be(1); } [Fact] - public async Task ScopedTryGetDisposedScopeReturnsFalse() + public async Task AltScopedTryGetDisposedScopeReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -110,7 +108,7 @@ public async Task ScopedTryGetDisposedScopeReturnsFalse() } [Fact] - public void TryRemoveExistingKeyReturnsTrue() + public void AltTryRemoveExistingKeyReturnsTrue() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -121,7 +119,7 @@ public void TryRemoveExistingKeyReturnsTrue() } [Fact] - public void ScopedTryGetNonExistentKeyReturnsFalse() + public void AltScopedTryGetNonExistentKeyReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -129,7 +127,7 @@ public void ScopedTryGetNonExistentKeyReturnsFalse() } [Fact] - public void TryRemoveNonExistentKeyReturnsFalse() + public void AltTryRemoveNonExistentKeyReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -137,7 +135,7 @@ public void TryRemoveNonExistentKeyReturnsFalse() } [Fact] - public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() + public async Task AltScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -151,7 +149,7 @@ public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationExceptio } [Fact] - public void TryUpdateMissingKeyReturnsFalse() + public void AltTryUpdateMissingKeyReturnsFalse() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -162,7 +160,7 @@ public void TryUpdateMissingKeyReturnsFalse() } [Fact] - public async Task TryUpdateExistingKeyUpdatesValue() + public async Task AltTryUpdateExistingKeyUpdatesValue() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -179,7 +177,7 @@ public async Task TryUpdateExistingKeyUpdatesValue() } [Fact] - public void AddOrUpdateMissingKeyAddsValue() + public void AltAddOrUpdateMissingKeyAddsValue() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>(); @@ -192,7 +190,7 @@ public void AddOrUpdateMissingKeyAddsValue() } [Fact] - public void AddOrUpdateExistingKeyUpdatesValue() + public void AltAddOrUpdateExistingKeyUpdatesValue() { var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAsyncAlternateLookup>();