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 + } +} diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs index 1b7f5c45..f64ceb8e 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedAsyncCacheTests.cs @@ -35,6 +35,150 @@ public void ComparerReturnsConfiguredComparer() cache.Comparer.Should().BeSameAs(comparer); } + + [Fact] + 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)))); + 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 GetAsyncAlternateLookupIncompatibleComparerThrowsInvalidOperationException() + { + 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 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)))); + 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 AltScopedGetOrAddAsyncRetrievesCachedValue() + { + 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 => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); + }); + + 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); + + 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); + } + + [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 WhenItemDoesNotExistTryGetAltReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(capacity)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryRemoveAltReturnsTrue() + { + 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 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] @@ -94,5 +238,77 @@ public void WhenEntryIsUpdatedOldEntryIsDisposed() this.cache.TryUpdate(1, new Disposable()).Should().BeTrue(); disposable2.IsDisposed.Should().BeTrue(); } + +#if NET9_0_OR_GREATER + + [Fact] + public async Task ScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() + { + 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 void AltTryUpdateMissingKeyReturnsFalse() + { + var cache = new AtomicFactoryScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var key = "42"; + + alternate.TryUpdate(key.AsSpan(), new Disposable(42)).Should().BeFalse(); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public async Task AltTryUpdateExistingKeyUpdatesValue() + { + 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(); + + alternate.TryUpdate(key.AsSpan(), new Disposable(2)).Should().BeTrue(); + + alternate.ScopedTryGet(key.AsSpan(), out var updatedLifetime).Should().BeTrue(); + updatedLifetime.Value.State.Should().Be(2); + updatedLifetime.Dispose(); + } + + [Fact] + public void AltAddOrUpdateMissingKeyAddsValue() + { + 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(); + } + + [Fact] + public void AltAddOrUpdateExistingKeyUpdatesValue() + { + 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); + updatedLifetime.Dispose(); + } +#endif } } diff --git a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs index 584b2344..66e1bddf 100644 --- a/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedAsyncCacheTests.cs @@ -30,6 +30,178 @@ public void ComparerReturnsConfiguredComparer() cache.Comparer.Should().BeSameAs(comparer); } + + [Fact] + 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)))); + ReadOnlySpan key = "42"; + + cache.TryGetAsyncAlternateLookup>(out var alternate).Should().BeTrue(); + alternate.Should().BeAssignableTo, string, Disposable>>(); + } + + [Fact] + public void GetAsyncAlternateLookupIncompatibleComparerThrowsInvalidOperationException() + { + 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 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)))); + 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 AltScopedGetOrAddAsyncMissAndHitUsesActualKey() + { + var cache = new ScopedAsyncCache(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 => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k)))); + }); + + using var lifetime2 = await alternate.ScopedGetOrAddAsync(key.AsSpan(), (k, offset) => + { + factoryCalls++; + return Task.FromResult(new Scoped(new Disposable(int.Parse(k) + offset))); + }, 1); + + lifetime1.Value.State.Should().Be(42); + lifetime2.Value.State.Should().Be(42); + factoryCalls.Should().Be(1); + } + + [Fact] + public async Task AltScopedTryGetDisposedScopeReturnsFalse() + { + 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 AltTryRemoveExistingKeyReturnsTrue() + { + 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 AltScopedTryGetNonExistentKeyReturnsFalse() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); + } + + [Fact] + public void AltTryRemoveNonExistentKeyReturnsFalse() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + alternate.TryRemove("a", out _).Should().BeFalse(); + } + + [Fact] + public async Task AltScopedGetOrAddAsyncDisposedScopeThrowsInvalidOperationException() + { + 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 void AltTryUpdateMissingKeyReturnsFalse() + { + var cache = new ScopedAsyncCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAsyncAlternateLookup>(); + var key = "42"; + + alternate.TryUpdate(key.AsSpan(), new Disposable(42)).Should().BeFalse(); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public async Task AltTryUpdateExistingKeyUpdatesValue() + { + 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(); + + alternate.TryUpdate(key.AsSpan(), new Disposable(2)).Should().BeTrue(); + + alternate.ScopedTryGet(key.AsSpan(), out var updatedLifetime).Should().BeTrue(); + updatedLifetime.Value.State.Should().Be(2); + updatedLifetime.Dispose(); + } + + [Fact] + public void AltAddOrUpdateMissingKeyAddsValue() + { + 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(); + } + + [Fact] + public void AltAddOrUpdateExistingKeyUpdatesValue() + { + 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); + updatedLifetime.Dispose(); + } #endif [Fact] diff --git a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs index 3ae7af5d..c901dec1 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedAsyncCache.cs @@ -147,6 +147,134 @@ 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(this.cache, 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(this.cache, inner, comparer); + return true; + } + + lookup = default; + return false; + } + + 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(ICache> cache, IAlternateLookup> inner, IAlternateEqualityComparer comparer) + { + this.cache = cache; + 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)); + } + + 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)); + } +#pragma warning restore CA2000 // Dispose objects before losing scope + + private ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> + { + K actualKey = this.comparer.Create(key); + return CompleteAsync(this.cache, actualKey, valueFactory); + + static async ValueTask> CompleteAsync(ICache> cache, K actualKey, TFactory valueFactory) + { + int c = 0; + var spinwait = new SpinWait(); + + while (true) + { + var scope = cache.GetOrAdd(actualKey, 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..4b9787c8 --- /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..ff3459a7 100644 --- a/BitFaster.Caching/ScopedAsyncCache.cs +++ b/BitFaster.Caching/ScopedAsyncCache.cs @@ -152,6 +152,117 @@ 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.GetAsyncAlternateLookup(); + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + return new AlternateLookup(this.cache, inner, comparer); + } + + /// + public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out IScopedAsyncAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + if (this.cache.TryGetAsyncAlternateLookup(out var inner)) + { + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + lookup = new AlternateLookup(this.cache, inner, comparer); + return true; + } + + lookup = default; + return false; + } + + 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(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) + { + 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 ValueTask> ScopedGetOrAddAsync(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IAsyncValueFactory> + { + K actualKey = this.comparer.Create(key); + return CompleteAsync(this.cache, actualKey, valueFactory); + + static async ValueTask> CompleteAsync(IAsyncCache> cache, K actualKey, TFactory valueFactory) + { + int c = 0; + var spinwait = new SpinWait(); + while (true) + { + var scope = await cache.GetOrAddAsync(actualKey, 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() {