From 4ff62036c847d99d797985bc773b8c05d8bab0c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 07:07:58 +0000 Subject: [PATCH 01/18] Add scoped alternate lookup API Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/dccf8c03-48f8-4e76-bf28-f77f9a51d303 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 104 ++++++++++++++++ BitFaster.Caching.UnitTests/CacheTests.cs | 34 ++++- .../ScopedCacheTests.cs | 104 ++++++++++++++++ .../Atomic/AtomicFactoryScopedCache.cs | 117 ++++++++++++++++++ BitFaster.Caching/IScopedAlternateLookup.cs | 68 ++++++++++ BitFaster.Caching/IScopedCache.cs | 25 +++- BitFaster.Caching/ScopedCache.cs | 98 +++++++++++++++ 7 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 BitFaster.Caching/IScopedAlternateLookup.cs diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index 7a514249..25078c83 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -131,5 +131,109 @@ 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 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/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/ScopedCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs index 44ad6dc7..20b6be96 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs @@ -65,5 +65,109 @@ 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 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..0405db4f 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs @@ -164,6 +164,123 @@ 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)); + } +#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(); + K actualKey = default!; + bool hasActualKey = false; + + while (true) + { + var scope = this.inner.GetOrAdd(key, static _ => new ScopedAtomicFactory()); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + if (!hasActualKey) + { + actualKey = this.comparer.Create(key); + hasActualKey = true; + } + + if (scope.TryCreateLifetime(actualKey, valueFactory, out lifetime)) + { + return lifetime; + } + + spinwait.SpinOnce(); + + if (c++ > ScopedCacheDefaults.MaxRetry) + Throw.ScopedRetryFailure(); + } + } + } +#endif + private class EventProxy : CacheEventProxyBase, Scoped> { public EventProxy(ICacheEvents> inner) diff --git a/BitFaster.Caching/IScopedAlternateLookup.cs b/BitFaster.Caching/IScopedAlternateLookup.cs new file mode 100644 index 00000000..4ede9b2e --- /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 } } From a57158e20e87ca4708f45fd6f5cd23fbcb8d8b10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 07:18:16 +0000 Subject: [PATCH 02/18] Format scoped alternate lookup changes Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/dccf8c03-48f8-4e76-bf28-f77f9a51d303 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs | 2 ++ BitFaster.Caching/IScopedAlternateLookup.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs index 0405db4f..6b412b90 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs @@ -245,6 +245,7 @@ public Lifetime ScopedGetOrAdd(TAlternateKey key, Func>(valueFactory, factoryArgument)); } +#pragma warning disable CA2000 // Lifetime ownership is returned to the caller. private Lifetime ScopedGetOrAdd(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IValueFactory> { int c = 0; @@ -278,6 +279,7 @@ private Lifetime ScopedGetOrAdd(TAlternateKey key, TFactory valueFa Throw.ScopedRetryFailure(); } } +#pragma warning restore CA2000 // Lifetime ownership is returned to the caller. } #endif diff --git a/BitFaster.Caching/IScopedAlternateLookup.cs b/BitFaster.Caching/IScopedAlternateLookup.cs index 4ede9b2e..76238ed1 100644 --- a/BitFaster.Caching/IScopedAlternateLookup.cs +++ b/BitFaster.Caching/IScopedAlternateLookup.cs @@ -1,4 +1,4 @@ -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER using System; using System.Diagnostics.CodeAnalysis; From f205f9d757dcafe9de4a309d8748abb180c3d9e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:03:48 +0000 Subject: [PATCH 03/18] Add scoped alternate lookup soak test Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/72ba7d13-f135-44e5-ab2e-e3421636eee4 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../ScopedCacheSoakTests.cs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index a4a02b34..23abc996 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,37 @@ await Threaded.Run(4, () => }); } } + +#if NET9_0_OR_GREATER + [Fact] + public async Task AlternateScopedGetOrAddWhenConcurrentWithRemoveReturnedLifetimeIsAlive() + { + var cache = new ScopedCache(new ConcurrentLru>(1, 1, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + for (int i = 0; i < 10; i++) + { + await Threaded.Run(4, r => + { + for (int j = 0; j < 100000; j++) + { + ReadOnlySpan key = "42"; + + if (r == 0 && (j & 1) == 0) + { + alternate.TryRemove(key, out _); + } + + using var lifetime = (r & 1) == 0 + ? alternate.ScopedGetOrAdd(key, static k => new Scoped(new Disposable(int.Parse(k)))) + : alternate.ScopedGetOrAdd(key, static (k, offset) => new Scoped(new Disposable(int.Parse(k) + offset)), 0); + + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + lifetime.Value.State.Should().Be(42); + } + }); + } + } +#endif } } From a3558be7a672a98ba4264ad98909b68c2412442c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:06:40 +0000 Subject: [PATCH 04/18] Align scoped soak test naming Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/72ba7d13-f135-44e5-ab2e-e3421636eee4 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index 23abc996..d39d9876 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -32,10 +32,10 @@ await Threaded.Run(4, () => #if NET9_0_OR_GREATER [Fact] - public async Task AlternateScopedGetOrAddWhenConcurrentWithRemoveReturnedLifetimeIsAlive() + public async Task ScopedGetOrAdd_ConcurrentWithRemove_ReturnedLifetimeIsAlive() { - var cache = new ScopedCache(new ConcurrentLru>(1, 1, StringComparer.Ordinal)); - var alternate = cache.GetAlternateLookup>(); + var scopedCache = new ScopedCache(new ConcurrentLru>(1, 1, StringComparer.Ordinal)); + var alternateLookup = scopedCache.GetAlternateLookup>(); for (int i = 0; i < 10; i++) { @@ -47,12 +47,12 @@ await Threaded.Run(4, r => if (r == 0 && (j & 1) == 0) { - alternate.TryRemove(key, out _); + alternateLookup.TryRemove(key, out _); } using var lifetime = (r & 1) == 0 - ? alternate.ScopedGetOrAdd(key, static k => new Scoped(new Disposable(int.Parse(k)))) - : alternate.ScopedGetOrAdd(key, static (k, offset) => new Scoped(new Disposable(int.Parse(k) + offset)), 0); + ? alternateLookup.ScopedGetOrAdd(key, static k => new Scoped(new Disposable(int.Parse(k)))) + : alternateLookup.ScopedGetOrAdd(key, static (k, offset) => new Scoped(new Disposable(int.Parse(k) + offset)), 0); lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); lifetime.Value.State.Should().Be(42); From 99d95c4b6374d07ac7c561289ab45f5e9ceaeebd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:40:26 +0000 Subject: [PATCH 05/18] Align alternate scoped soak test Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/53059f04-e895-4186-a464-9cd3159d01ec Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../ScopedCacheSoakTests.cs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index d39d9876..9be261d3 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -32,30 +32,21 @@ await Threaded.Run(4, () => #if NET9_0_OR_GREATER [Fact] - public async Task ScopedGetOrAdd_ConcurrentWithRemove_ReturnedLifetimeIsAlive() + public async Task WhenSoakAlternateScopedGetOrAddValueIsAlwaysAlive() { - var scopedCache = new ScopedCache(new ConcurrentLru>(1, 1, StringComparer.Ordinal)); + var scopedCache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternateLookup = scopedCache.GetAlternateLookup>(); for (int i = 0; i < 10; i++) { - await Threaded.Run(4, r => + await Threaded.Run(4, () => { for (int j = 0; j < 100000; j++) { - ReadOnlySpan key = "42"; - - if (r == 0 && (j & 1) == 0) - { - alternateLookup.TryRemove(key, out _); - } - - using var lifetime = (r & 1) == 0 - ? alternateLookup.ScopedGetOrAdd(key, static k => new Scoped(new Disposable(int.Parse(k)))) - : alternateLookup.ScopedGetOrAdd(key, static (k, offset) => new Scoped(new Disposable(int.Parse(k) + offset)), 0); + string key = j.ToString(); + using var lifetime = alternateLookup.ScopedGetOrAdd(key.AsSpan(), static k => new Scoped(new Disposable(int.Parse(k)))); lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); - lifetime.Value.State.Should().Be(42); } }); } From 94c2328be9dc29046529ccd198762d6272f6aa6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:53:03 +0000 Subject: [PATCH 06/18] Avoid string allocation in soak test Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/7b7e1bc4-582b-4e81-b6a3-d21883ba1a98 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index 9be261d3..0a804591 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -41,10 +41,11 @@ public async Task WhenSoakAlternateScopedGetOrAddValueIsAlwaysAlive() { await Threaded.Run(4, () => { + var key = new char[8]; for (int j = 0; j < 100000; j++) { - string key = j.ToString(); - using var lifetime = alternateLookup.ScopedGetOrAdd(key.AsSpan(), static k => new Scoped(new Disposable(int.Parse(k)))); + 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}"); } From cef27a3a10e3ca249e62bbbcb4bb29a572ab4ee9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:56:02 +0000 Subject: [PATCH 07/18] Name scoped soak buffer length Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/7b7e1bc4-582b-4e81-b6a3-d21883ba1a98 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index 0a804591..0883092c 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -34,6 +34,7 @@ await Threaded.Run(4, () => [Fact] public async Task WhenSoakAlternateScopedGetOrAddValueIsAlwaysAlive() { + const int keyBufferLength = 5; var scopedCache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternateLookup = scopedCache.GetAlternateLookup>(); @@ -41,7 +42,7 @@ public async Task WhenSoakAlternateScopedGetOrAddValueIsAlwaysAlive() { await Threaded.Run(4, () => { - var key = new char[8]; + var key = new char[keyBufferLength]; for (int j = 0; j < 100000; j++) { j.TryFormat(key, out int written); From 32705c15ff7dec50504db921f8dd8e54791aeeaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:58:37 +0000 Subject: [PATCH 08/18] Reuse per-thread soak key buffers Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/7b7e1bc4-582b-4e81-b6a3-d21883ba1a98 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index 0883092c..cb5a81ae 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -35,14 +35,21 @@ await Threaded.Run(4, () => 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>(); + var keys = new char[threadCount][]; + + for (int i = 0; i < threadCount; i++) + { + keys[i] = new char[keyBufferLength]; + } for (int i = 0; i < 10; i++) { - await Threaded.Run(4, () => + await Threaded.Run(threadCount, r => { - var key = new char[keyBufferLength]; + var key = keys[r]; for (int j = 0; j < 100000; j++) { j.TryFormat(key, out int written); From 330d9bf551b718c516d3ba8384c7caa2d12a4480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:21:35 +0000 Subject: [PATCH 09/18] Remove soak keys array capture Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/430078a5-1591-4951-9db4-852f58455984 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs index cb5a81ae..45296247 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheSoakTests.cs @@ -38,18 +38,12 @@ public async Task WhenSoakAlternateScopedGetOrAddValueIsAlwaysAlive() const int threadCount = 4; var scopedCache = new ScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternateLookup = scopedCache.GetAlternateLookup>(); - var keys = new char[threadCount][]; - - for (int i = 0; i < threadCount; i++) - { - keys[i] = new char[keyBufferLength]; - } for (int i = 0; i < 10; i++) { - await Threaded.Run(threadCount, r => + await Threaded.Run(threadCount, _ => { - var key = keys[r]; + var key = new char[keyBufferLength]; for (int j = 0; j < 100000; j++) { j.TryFormat(key, out int written); From 3457e80754d5aa6925991b8e069dcc9a0e196952 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Sat, 18 Apr 2026 17:18:26 -0700 Subject: [PATCH 10/18] cleanup --- .../Atomic/AtomicFactoryScopedCache.cs | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs index 6b412b90..3461a04a 100644 --- a/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs +++ b/BitFaster.Caching/Atomic/AtomicFactoryScopedCache.cs @@ -233,42 +233,44 @@ public void AddOrUpdate(TAlternateKey key, V value) { this.inner.AddOrUpdate(key, new ScopedAtomicFactory(value)); } -#pragma warning restore CA2000 // Dispose objects before losing scope 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)); } -#pragma warning disable CA2000 // Lifetime ownership is returned to the caller. private Lifetime ScopedGetOrAdd(TAlternateKey key, TFactory valueFactory) where TFactory : struct, IValueFactory> { int c = 0; var spinwait = new SpinWait(); - K actualKey = default!; - bool hasActualKey = false; + K actualKey = this.comparer.Create(key); while (true) { var scope = this.inner.GetOrAdd(key, static _ => new ScopedAtomicFactory()); - if (scope.TryCreateLifetime(out var lifetime)) - { - return lifetime; - } - - if (!hasActualKey) - { - actualKey = this.comparer.Create(key); - hasActualKey = true; - } - - if (scope.TryCreateLifetime(actualKey, valueFactory, out lifetime)) + if (scope.TryCreateLifetime(actualKey, valueFactory, out var lifetime)) { return lifetime; } @@ -279,7 +281,7 @@ private Lifetime ScopedGetOrAdd(TAlternateKey key, TFactory valueFa Throw.ScopedRetryFailure(); } } -#pragma warning restore CA2000 // Lifetime ownership is returned to the caller. +#pragma warning restore CA2000 // Dispose objects before losing scope } #endif From 7c04796e3d247ff91051337a71774f5a376c86ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:40:30 +0000 Subject: [PATCH 11/18] Add atomic scoped alternate soak tests Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/9e152adc-b66d-4ffd-90d7-7a0e53da6c48 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index 25078c83..a08fa19f 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; @@ -236,4 +237,90 @@ public void AlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() } #endif } + +#if NET9_0_OR_GREATER + [Collection("Soak")] + public class AtomicFactoryScopedCacheSoakTests + { + private const int capacity = 6; + private const int threadCount = 4; + private const int iterations = 10; + + [Fact] + public async Task AlternateLookupScopedGetOrAddWhenInitializationIsContendedLifetimeIsAlwaysAlive() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + for (int i = 0; i < iterations; i++) + { + using var enteredFactory = new ManualResetEventSlim(); + using var releaseFactory = new ManualResetEventSlim(); + var expected = i; + var keyText = expected.ToString(); + var factoryCalls = 0; + + var run = Threaded.Run(threadCount, _ => + { + ReadOnlySpan key = keyText.AsSpan(); + using var lifetime = alternate.ScopedGetOrAdd(key, k => + { + Interlocked.Increment(ref factoryCalls); + enteredFactory.Set(); + releaseFactory.Wait(); + return new Scoped(new Disposable(int.Parse(k))); + }); + + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + lifetime.Value.State.Should().Be(expected); + }); + + var factoryEntered = enteredFactory.Wait(TimeSpan.FromSeconds(5)); + releaseFactory.Set(); + + factoryEntered.Should().BeTrue(); + await run; + factoryCalls.Should().Be(1); + } + } + + [Fact] + public async Task AlternateLookupScopedGetOrAddWithArgWhenInitializationIsContendedLifetimeIsAlwaysAlive() + { + var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); + var alternate = cache.GetAlternateLookup>(); + + for (int i = 0; i < iterations; i++) + { + using var enteredFactory = new ManualResetEventSlim(); + using var releaseFactory = new ManualResetEventSlim(); + var expected = i + 1; + var keyText = i.ToString(); + var factoryCalls = 0; + + var run = Threaded.Run(threadCount, _ => + { + ReadOnlySpan key = keyText.AsSpan(); + using var lifetime = alternate.ScopedGetOrAdd(key, (k, offset) => + { + Interlocked.Increment(ref factoryCalls); + enteredFactory.Set(); + releaseFactory.Wait(); + return new Scoped(new Disposable(int.Parse(k) + offset)); + }, 1); + + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + lifetime.Value.State.Should().Be(expected); + }); + + var factoryEntered = enteredFactory.Wait(TimeSpan.FromSeconds(5)); + releaseFactory.Set(); + + factoryEntered.Should().BeTrue(); + await run; + factoryCalls.Should().Be(1); + } + } + } +#endif } From 6735203590c73f7a10329a8ae1c0fac0c405b029 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:43:02 +0000 Subject: [PATCH 12/18] Rename atomic scoped soak tests Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/9e152adc-b66d-4ffd-90d7-7a0e53da6c48 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index a08fa19f..f5c4d973 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -247,7 +247,7 @@ public class AtomicFactoryScopedCacheSoakTests private const int iterations = 10; [Fact] - public async Task AlternateLookupScopedGetOrAddWhenInitializationIsContendedLifetimeIsAlwaysAlive() + public async Task ScopedGetOrAddWhenAlternateInitializationIsContendedLifetimeIsAlwaysAlive() { var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAlternateLookup>(); @@ -285,7 +285,7 @@ public async Task AlternateLookupScopedGetOrAddWhenInitializationIsContendedLife } [Fact] - public async Task AlternateLookupScopedGetOrAddWithArgWhenInitializationIsContendedLifetimeIsAlwaysAlive() + public async Task ScopedGetOrAddWithArgWhenAlternateInitializationIsContendedLifetimeIsAlwaysAlive() { var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAlternateLookup>(); From 3dbdb2d47aa521066251ca5e5c09feed42b46c0b Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Sat, 18 Apr 2026 19:10:32 -0700 Subject: [PATCH 13/18] fix tests --- .../AtomicFactoryScopedCacheSoakTests.cs | 74 +++++++++++++++ .../Atomic/AtomicFactoryScopedCacheTests.cs | 89 +++++++------------ 2 files changed, 106 insertions(+), 57 deletions(-) create mode 100644 BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs new file mode 100644 index 00000000..a27e1b77 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs @@ -0,0 +1,74 @@ +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 +{ + [Collection("Soak")] + public class AtomicFactoryScopedCacheSoakTests + { + 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 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++) + { + (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}"); + } + } + }); + + await run; + } +#endif + } +} diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index f5c4d973..b79bb6ef 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -244,82 +244,57 @@ public class AtomicFactoryScopedCacheSoakTests { private const int capacity = 6; private const int threadCount = 4; - private const int iterations = 10; + private const int soakIterations = 10; + private const int loopIterations = 100_000; - [Fact] - public async Task ScopedGetOrAddWhenAlternateInitializationIsContendedLifetimeIsAlwaysAlive() + [Theory] + [Repeat(soakIterations)] + public async Task ScopedGetOrAddAlternateLifetimeIsAlwaysAlive(int _) { var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAlternateLookup>(); - for (int i = 0; i < iterations; i++) + var run = Threaded.Run(threadCount, _ => { - using var enteredFactory = new ManualResetEventSlim(); - using var releaseFactory = new ManualResetEventSlim(); - var expected = i; - var keyText = expected.ToString(); - var factoryCalls = 0; + var key = new char[8]; - var run = Threaded.Run(threadCount, _ => + for (int i = 0; i < loopIterations; i++) { - ReadOnlySpan key = keyText.AsSpan(); - using var lifetime = alternate.ScopedGetOrAdd(key, 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))); })) { - Interlocked.Increment(ref factoryCalls); - enteredFactory.Set(); - releaseFactory.Wait(); - return new Scoped(new Disposable(int.Parse(k))); - }); - - lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); - lifetime.Value.State.Should().Be(expected); - }); - - var factoryEntered = enteredFactory.Wait(TimeSpan.FromSeconds(5)); - releaseFactory.Set(); - - factoryEntered.Should().BeTrue(); - await run; - factoryCalls.Should().Be(1); - } + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + } + } + }); + + await run; } - [Fact] - public async Task ScopedGetOrAddWithArgWhenAlternateInitializationIsContendedLifetimeIsAlwaysAlive() + [Theory] + [Repeat(soakIterations)] + public async Task ScopedGetOrAddAlternateArgLifetimeIsAlwaysAlive(int _) { var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, StringComparer.Ordinal)); var alternate = cache.GetAlternateLookup>(); - for (int i = 0; i < iterations; i++) + var run = Threaded.Run(threadCount, _ => { - using var enteredFactory = new ManualResetEventSlim(); - using var releaseFactory = new ManualResetEventSlim(); - var expected = i + 1; - var keyText = i.ToString(); - var factoryCalls = 0; + var key = new char[8]; - var run = Threaded.Run(threadCount, _ => + for (int i = 0; i < loopIterations; i++) { - ReadOnlySpan key = keyText.AsSpan(); - using var lifetime = alternate.ScopedGetOrAdd(key, (k, offset) => + (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)) { - Interlocked.Increment(ref factoryCalls); - enteredFactory.Set(); - releaseFactory.Wait(); - return new Scoped(new Disposable(int.Parse(k) + offset)); - }, 1); - - lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); - lifetime.Value.State.Should().Be(expected); - }); - - var factoryEntered = enteredFactory.Wait(TimeSpan.FromSeconds(5)); - releaseFactory.Set(); - - factoryEntered.Should().BeTrue(); - await run; - factoryCalls.Should().Be(1); - } + lifetime.Value.IsDisposed.Should().BeFalse($"ref count {lifetime.ReferenceCount}"); + } + } + }); + + await run; } } #endif From 406a5ef8a59edacece95b05d36b041da9e6aa5d3 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Sat, 18 Apr 2026 19:29:38 -0700 Subject: [PATCH 14/18] cleanup --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index b79bb6ef..23172000 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -237,65 +237,4 @@ public void AlternateLookupAddOrUpdateAddsMissingValueAndUpdatesExistingValue() } #endif } - -#if NET9_0_OR_GREATER - [Collection("Soak")] - public class AtomicFactoryScopedCacheSoakTests - { - private const int capacity = 6; - private const int threadCount = 4; - private const int soakIterations = 10; - private const int loopIterations = 100_000; - - [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++) - { - (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}"); - } - } - }); - - await run; - } - } -#endif } From fea62777fc9d03298b53c91a3616e5564f483305 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:41:08 +0000 Subject: [PATCH 15/18] Fix merge follow-up review comments Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/9d82c197-0392-4339-9bb3-a8357a50afc9 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- .../Atomic/AtomicFactoryScopedCacheSoakTests.cs | 2 +- BitFaster.Caching/Atomic/ScopedAtomicFactory.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs index 1ff9b71a..9afd5f0f 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheSoakTests.cs @@ -18,7 +18,7 @@ public class AtomicFactoryScopedCacheSoakTests [Theory] [Repeat(soakIterations)] - public async Task ScopedGetOrAddLifetimeIsAlwaysAlive(int _) + public async Task ScopedGetOrAddWhenAccessIsConcurrentLifetimeIsAlwaysAlive(int _) { var cache = new AtomicFactoryScopedCache(new ConcurrentLru>(1, capacity, EqualityComparer.Default)); 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 From 0c42ee330cb4a397059c5283a7be375f39da917b Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Sun, 19 Apr 2026 18:33:00 -0700 Subject: [PATCH 16/18] extra tests --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 25 +++++++++++++++++++ .../Buffers/MpscBoundedBufferSoakTests.cs | 2 +- .../ScopedCacheTests.cs | 25 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index 23172000..664cca0f 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -198,6 +198,31 @@ public void AlternateLookupScopedGetOrAddUsesActualKeyOnMissAndHit() 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 AlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() { 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/ScopedCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs index 20b6be96..989f0dc1 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs @@ -131,6 +131,31 @@ public void AlternateLookupScopedGetOrAddUsesActualKeyOnMissAndHit() 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 AlternateLookupTryUpdateReturnsFalseForMissingKeyAndUpdatesExistingValue() { From a82e95fedcd6bb41c0ff3991d44736816a1182cb Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Sun, 19 Apr 2026 18:37:33 -0700 Subject: [PATCH 17/18] tests --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 14 ++++++++++++++ BitFaster.Caching.UnitTests/ScopedCacheTests.cs | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index 664cca0f..ad52a032 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -223,6 +223,20 @@ public void WhenKeyExistsTryRemoveAltReturnsTrue() key.Should().Be("a"); } + [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() { diff --git a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs index 989f0dc1..e67839cc 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs @@ -156,6 +156,20 @@ public void WhenKeyExistsTryRemoveAltReturnsTrue() key.Should().Be("a"); } + [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() { From 43f7e49de9a94f8993eb08a1cfbf8fdccaf60ad6 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Sun, 19 Apr 2026 20:08:08 -0700 Subject: [PATCH 18/18] tests --- .../Atomic/AtomicFactoryScopedCacheTests.cs | 16 ++++++++++++++++ BitFaster.Caching.UnitTests/ScopedCacheTests.cs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs index ad52a032..cd602540 100644 --- a/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/Atomic/AtomicFactoryScopedCacheTests.cs @@ -223,6 +223,22 @@ public void WhenKeyExistsTryRemoveAltReturnsTrue() 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() { diff --git a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs index e67839cc..6b1c4314 100644 --- a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs @@ -156,6 +156,22 @@ public void WhenKeyExistsTryRemoveAltReturnsTrue() 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() {