diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs index efe64f73..1ff9b71a 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs @@ -1,13 +1,9 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; using BitFaster.Caching.Atomic; using BitFaster.Caching.Lru; using FluentAssertions; -using Moq; using Xunit; namespace BitFaster.Caching.UnitTests.Atomic @@ -26,13 +22,62 @@ public async Task ScopedGetOrAddLifetimeIsAlwaysAlive(int _) { var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, EqualityComparer.Default)); + var run = Threaded.Run(threadCount, _ => + { + for (int i = 0; i < loopIterations; i++) + { + using (var lifetime = cache.ScopedGetOrAdd(i, k => { return new Scoped(new Disposable(k)); })) + { + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + } + } + }); + + await run; + } + +#if NET9_0_OR_GREATER + [Theory] + [Repeat(soakIterations)] + public async Task ScopedGetOrAddAlternateLifetimeIsAlwaysAlive(int _) + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + var run = Threaded.Run(threadCount, _ => { var key = new char[8]; for (int i = 0; i < loopIterations; i++) { - using (var lifetime = cache.ScopedGetOrAdd(i, k => { return new Scoped(new Disposable(k)); })) + (i + 1).TryFormat(key, out int written); + + using (var lifetime = alternate.ScopedGetOrAdd(key.AsSpan().Slice(0, written), k => { return new Scoped(new Disposable(int.Parse(k))); })) + { + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + } + } + }); + + await run; + } + + [Theory] + [Repeat(soakIterations)] + public async Task ScopedGetOrAddAlternateArgLifetimeIsAlwaysAlive(int _) + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + var run = Threaded.Run(threadCount, _ => + { + var key = new char[8]; + + for (int i = 0; i < loopIterations; i++) + { + (i + 1).TryFormat(key, out int written); + + using (var lifetime = alternate.ScopedGetOrAdd(key.AsSpan().Slice(0, written), (k, offset) => { return new Scoped(new Disposable(int.Parse(k) + offset)); }, 1)) { lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); } @@ -41,5 +86,6 @@ public async Task ScopedGetOrAddLifetimeIsAlwaysAlive(int _) await run; } +#endif } } diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index 7a514249..cd602540 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using BitFaster.Caching.Atomic; using BitFaster.Caching.Lru; @@ -131,5 +132,164 @@ public void WhenFactoryThrowsEmptyKeyIsNotEnumerable() cache.Keys.Count().Should().Be(0); } + +#if NET9_0_OR_GREATER + [Fact] + public void TryGetAlternateLookupReturnsLookupForCompatibleComparer() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = cache.ScopedGetOrAdd("42", _ => new Scoped(new Disposable(42))); + ReadOnlySpan key = "42"; + + cache.TryGetAlternateLookup>(out var alternate).Should().BeTrue(); + alternate.ScopedTryGet(key, out var alternateLifetime).Should().BeTrue(); + alternateLifetime.Value.State.Should().Be(42); + alternateLifetime.Dispose(); + } + + [Fact] + public void GetAlternateLookupThrowsForIncompatibleComparer() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + + Action act = () => cache.GetAlternateLookup(); + + act.Should().Throw().WithMessage("Incompatible comparer"); + cache.TryGetAlternateLookup(out var alternate).Should().BeFalse(); + alternate.Should().BeNull(); + } + + [Fact] + public void AlternateLookupTryRemoveReturnsActualKey() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = cache.ScopedGetOrAdd("42", _ => new Scoped(new Disposable(42))); + var alternate = cache.GetAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryRemove(key, out var actualKey).Should().BeTrue(); + + actualKey.Should().Be("42"); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public void AlternateLookupScopedGetOrAddUsesActualKeyOnMissAndHit() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + var factoryCalls = 0; + ReadOnlySpan key = "42"; + + using var lifetime = alternate.ScopedGetOrAdd(key, k => + { + factoryCalls++; + return new Scoped(new Disposable(int.Parse(k))); + }); + + using var sameLifetime = alternate.ScopedGetOrAdd(key, (k, offset) => + { + factoryCalls++; + return 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 void WhenScopeIsDisposedTryGetAltReturnsFalse() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + var scope = new Scoped(new Disposable()); + + cache.ScopedGetOrAdd("a", k => scope); + + scope.Dispose(); + + alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryRemoveAltReturnsTrue() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + cache.AddOrUpdate("a", new Disposable()); + alternate.TryRemove("a", out var key).Should().BeTrue(); + key.Should().Be("a"); + } + + [Fact] + public void WhenItemDoesNotExistTryGetAltReturnsFalse() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistTryRemoveAltReturnsFalse() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + alternate.TryRemove("a", out _).Should().BeFalse(); + } + + [Fact] + public void GetOrAddAltDisposedScopeThrows() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + Action getOrAdd = () => { alternate.ScopedGetOrAdd("a", k => scope); }; + + getOrAdd.Should().Throw(); + } + + [Fact] + public void AlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryUpdate(key, new Disposable(42)).Should().BeFalse(); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + + using var lifetime = cache.ScopedGetOrAdd("42", _ => 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 AlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + 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/Buffers/MpscBoundedBufferSoakTests.cs b/BitFaster.Caching.UnitTests/Buffers/MpscBoundedBufferSoakTests.cs index d3e0da4d..09b8173d 100644 --- a/BitFaster.Caching.UnitTests/Buffers/MpscBoundedBufferSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Buffers/MpscBoundedBufferSoakTests.cs @@ -90,7 +90,7 @@ public async Task WhileBufferIsFilledBufferCanBeDrained() [Theory] [Repeat(SoakIterations)] - public async Task Count_WhileBufferIsConcurrentlyFilled_IsMonotonic(int iteration) + public async Task WhileBufferIsFilledCountCanBeTaken(int iteration) { this.testOutputHelper.WriteLine($"Iteration {iteration}"); this.testOutputHelper.WriteLine($"ProcessorCount={Environment.ProcessorCount}."); diff --git a/BitFaster.Caching.UnitTests/CacheTests.cs b/BitFaster.Caching.UnitTests/CacheTests.cs index 3f000f3a..658feaf1 100644 --- a/BitFaster.Caching.UnitTests/CacheTests.cs +++ b/BitFaster.Caching.UnitTests/CacheTests.cs @@ -150,12 +150,34 @@ public void WhenCacheInterfaceDefaultGetAlternateLookupThrows() } [Fact] - public void WhenCacheInterfaceDefaultTryGetAlternateLookupThrows() - { - var cache = new Mock>(); - cache.CallBase = true; - - Action tryGetAlternateLookup = () => { cache.Object.TryGetAlternateLookup(out var lookup); }; + public void WhenCacheInterfaceDefaultTryGetAlternateLookupThrows() + { + var cache = new Mock>(); + cache.CallBase = true; + + Action tryGetAlternateLookup = () => { cache.Object.TryGetAlternateLookup(out var lookup); }; + + tryGetAlternateLookup.Should().Throw(); + } + + [Fact] + public void WhenScopedCacheInterfaceDefaultGetAlternateLookupThrows() + { + var cache = new Mock>(); + cache.CallBase = true; + + Action getAlternateLookup = () => { cache.Object.GetAlternateLookup(); }; + + getAlternateLookup.Should().Throw(); + } + + [Fact] + public void WhenScopedCacheInterfaceDefaultTryGetAlternateLookupThrows() + { + var cache = new Mock>(); + cache.CallBase = true; + + Action tryGetAlternateLookup = () => { cache.Object.TryGetAlternateLookup(out var lookup); }; tryGetAlternateLookup.Should().Throw(); } diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index a4a02b34..45296247 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using BitFaster.Caching.Lru; using FluentAssertions; using Xunit; @@ -28,5 +29,31 @@ await Threaded.Run(4, () => }); } } + +#if NET9_0_OR_GREATER + [Fact] + public async Task WhenSoakAlternateScopedGetOrAddValueIsAlwaysAlive() + { + const int keyBufferLength = 5; + const int threadCount = 4; + var scopedCache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternateLookup = scopedCache.GetAlternateLookup>(); + + for (int i = 0; i < 10; i++) + { + await Threaded.Run(threadCount, _ => + { + var key = new char[keyBufferLength]; + for (int j = 0; j < 100000; j++) + { + j.TryFormat(key, out int written); + using var lifetime = alternateLookup.ScopedGetOrAdd(key.AsSpan(0, written), static k => new Scoped(new Disposable(int.Parse(k)))); + + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + } + }); + } + } +#endif } } diff --git a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs index 44ad6dc7..6b1c4314 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs @@ -65,5 +65,164 @@ public void GetOrAddDisposedScopeThrows() getOrAdd.Should().Throw(); } + +#if NET9_0_OR_GREATER + [Fact] + public void TryGetAlternateLookupReturnsLookupForCompatibleComparer() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = cache.ScopedGetOrAdd("42", _ => new Scoped(new Disposable(42))); + ReadOnlySpan key = "42"; + + cache.TryGetAlternateLookup>(out var alternate).Should().BeTrue(); + alternate.ScopedTryGet(key, out var alternateLifetime).Should().BeTrue(); + alternateLifetime.Value.State.Should().Be(42); + alternateLifetime.Dispose(); + } + + [Fact] + public void GetAlternateLookupThrowsForIncompatibleComparer() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + + Action act = () => cache.GetAlternateLookup(); + + act.Should().Throw().WithMessage("Incompatible comparer"); + cache.TryGetAlternateLookup(out var alternate).Should().BeFalse(); + alternate.Should().BeNull(); + } + + [Fact] + public void AlternateLookupTryRemoveReturnsActualKey() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + using var lifetime = cache.ScopedGetOrAdd("42", _ => new Scoped(new Disposable(42))); + var alternate = cache.GetAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryRemove(key, out var actualKey).Should().BeTrue(); + + actualKey.Should().Be("42"); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public void AlternateLookupScopedGetOrAddUsesActualKeyOnMissAndHit() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + var factoryCalls = 0; + ReadOnlySpan key = "42"; + + using var lifetime = alternate.ScopedGetOrAdd(key, k => + { + factoryCalls++; + return new Scoped(new Disposable(int.Parse(k))); + }); + + using var sameLifetime = alternate.ScopedGetOrAdd(key, (k, offset) => + { + factoryCalls++; + return 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 void WhenScopeIsDisposedTryGetAltReturnsFalse() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + var scope = new Scoped(new Disposable()); + + cache.ScopedGetOrAdd("a", k => scope); + + scope.Dispose(); + + alternate.ScopedTryGet("a", out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryRemoveAltReturnsTrue() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + cache.AddOrUpdate("a", new Disposable()); + alternate.TryRemove("a", out var key).Should().BeTrue(); + key.Should().Be("a"); + } + + [Fact] + public void WhenItemDoesNotExistTryGetAltReturnsFalse() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + alternate.ScopedTryGet("a", out _).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistTryRemoveAltReturnsFalse() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + alternate.TryRemove("a", out _).Should().BeFalse(); + } + + [Fact] + public void GetOrAddAltDisposedScopeThrows() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + Action getOrAdd = () => { alternate.ScopedGetOrAdd("a", k => scope); }; + + getOrAdd.Should().Throw(); + } + + [Fact] + public void AlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryUpdate(key, new Disposable(42)).Should().BeFalse(); + cache.ScopedTryGet("42", out _).Should().BeFalse(); + + using var lifetime = cache.ScopedGetOrAdd("42", _ => 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 AlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() + { + var cache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + 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/Atomic/AtomicFactoryScopedCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs index b8ea810b..3461a04a 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs @@ -164,6 +164,127 @@ IEnumerator IEnumerable.GetEnumerator() return ((AtomicFactoryScopedCache)this).GetEnumerator(); } +#if NET9_0_OR_GREATER + /// + public IScopedAlternateLookup GetAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + var inner = this.cache.GetAlternateLookup(); + var comparer = (IAlternateEqualityComparer)this.cache.Comparer; + return new AlternateLookup(inner, comparer); + } + + /// + public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IScopedAlternateLookup 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 : IScopedAlternateLookup + 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 ScopedAtomicFactory(value)); + } + + public void AddOrUpdate(TAlternateKey key, V value) + { + this.inner.AddOrUpdate(key, new ScopedAtomicFactory(value)); + } + + public Lifetime ScopedGetOrAdd(TAlternateKey key, Func> valueFactory) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAtomicFactory()); + + // fast path: create the lifetime without materializing the key + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + return ScopedGetOrAdd(key, new ValueFactory>(valueFactory)); + } + + public Lifetime ScopedGetOrAdd(TAlternateKey key, Func> valueFactory, TArg factoryArgument) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAtomicFactory()); + + // fast path: create the lifetime without materializing the key + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + return ScopedGetOrAdd(key, new ValueFactoryArg>(valueFactory, factoryArgument)); + } + + private Lifetime ScopedGetOrAdd(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IValueFactory> + { + int c = 0; + var spinwait = new SpinWait(); + K actualKey = this.comparer.Create(key); + + while (true) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAtomicFactory()); + + if (scope.TryCreateLifetime(actualKey, valueFactory, out var lifetime)) + { + return lifetime; + } + + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } + } +#pragma warning restore CA2000 // Dispose objects before losing scope + } +#endif + private class EventProxy : CacheEventProxyBase, Scoped> { public EventProxy(ICacheEvents> inner) diff --git a/BitFaster.Caching/Atomic/ScopedAtomicFactory.cs b/BitFaster.Caching/Atomic/ScopedAtomicFactory.cs index ef1765bb..67abb4e0 100644 --- a/BitFaster.Caching/Atomic/ScopedAtomicFactory.cs +++ b/BitFaster.Caching/Atomic/ScopedAtomicFactory.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using System.Threading; namespace BitFaster.Caching.Atomic diff --git a/BitFaster.Caching/IScopedAlternateLookup.cs b/BitFaster.Caching/IScopedAlternateLookup.cs new file mode 100644 index 00000000..76238ed1 --- /dev/null +++ b/BitFaster.Caching/IScopedAlternateLookup.cs @@ -0,0 +1,68 @@ +#if NET9_0_OR_GREATER +using System; +using System.Diagnostics.CodeAnalysis; + +namespace BitFaster.Caching +{ + /// + /// Provides an alternate-key lookup over a scoped cache. + /// + /// The alternate key type. + /// The cache key type. + /// The cache value type. + public interface IScopedAlternateLookup + 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 using an alternate key. + /// + /// The alternate key. + /// The value factory, invoked with the actual cache key when a value must be created. + /// The cached value lifetime. + Lifetime ScopedGetOrAdd(TAlternateKey key, Func> valueFactory); + + /// + /// Gets an existing value lifetime or adds a new value 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. + /// The cached value lifetime. + Lifetime ScopedGetOrAdd(TAlternateKey key, Func> valueFactory, TArg factoryArgument); + } +} +#endif diff --git a/BitFaster.Caching/IScopedCache.cs b/BitFaster.Caching/IScopedCache.cs index c6a58eb8..a62d80e0 100644 --- a/BitFaster.Caching/IScopedCache.cs +++ b/BitFaster.Caching/IScopedCache.cs @@ -45,6 +45,29 @@ public interface IScopedCache : IEnumerable>> wh /// Gets the key comparer used by the cache. /// IEqualityComparer Comparer => throw new NotSupportedException(); + +// backcompat: add not null constraint to IScopedCache (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 alternate lookup that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// An alternate lookup. + /// The configured comparer does not support . + IScopedAlternateLookup GetAlternateLookup() + where TAlternateKey : notnull, allows ref struct + => throw new NotSupportedException(); + + /// + /// Attempts to get an alternate lookup that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// The alternate lookup when available. + /// when the configured comparer supports ; otherwise, . + bool TryGetAlternateLookup([MaybeNullWhen(false)] out IScopedAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + => throw new NotSupportedException(); +#pragma warning restore CS8714 #endif /// @@ -76,7 +99,7 @@ public interface IScopedCache : IEnumerable>> wh /// The type of an argument to pass into valueFactory. /// The key of the element to add. /// The factory function used to generate a scoped value for the key. - /// + /// An argument value to pass into . /// The lifetime for the value associated with the key. The lifetime will be either reference the /// existing value for the key if the key is already in the cache, or the new value if the key was not in /// the cache. diff --git a/BitFaster.Caching/ScopedCache.cs b/BitFaster.Caching/ScopedCache.cs index 4018ebef..a0e130ad 100644 --- a/BitFaster.Caching/ScopedCache.cs +++ b/BitFaster.Caching/ScopedCache.cs @@ -151,5 +151,103 @@ IEnumerator IEnumerable.GetEnumerator() { return ((ScopedCache)this).GetEnumerator(); } + +#if NET9_0_OR_GREATER + /// + public IScopedAlternateLookup GetAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + return new AlternateLookup(this.cache.GetAlternateLookup()); + } + + /// + public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IScopedAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + if (this.cache.TryGetAlternateLookup(out var inner)) + { + lookup = new AlternateLookup(inner); + return true; + } + + lookup = default; + return false; + } + + internal readonly struct AlternateLookup : IScopedAlternateLookup + where TAlternateKey : notnull, allows ref struct + { + private readonly IAlternateLookup> inner; + + internal AlternateLookup(IAlternateLookup> 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 Lifetime ScopedGetOrAdd(TAlternateKey key, Func> valueFactory) + { + return ScopedGetOrAdd(key, new ValueFactory>(valueFactory)); + } + + public Lifetime ScopedGetOrAdd(TAlternateKey key, Func> valueFactory, TArg factoryArgument) + { + return ScopedGetOrAdd(key, new ValueFactoryArg>(valueFactory, factoryArgument)); + } + + private Lifetime ScopedGetOrAdd(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IValueFactory> + { + int c = 0; + var spinwait = new SpinWait(); + while (true) + { + var scope = this.inner.GetOrAdd(key, static (k, factory) => factory.Create(k), valueFactory); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } + } + } +#endif } }