diff --git a/BitFaster.Caching.UnitTests/Disposable.cs b/BitFaster.Caching.UnitTests/Disposable.cs new file mode 100644 index 00000000..480106a0 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Disposable.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; + +namespace BitFaster.Caching.UnitTests +{ + public class Disposable : IDisposable + { + public bool IsDisposed { get; set; } + + public void Dispose() + { + this.IsDisposed.Should().BeFalse(); + IsDisposed = true; + } + } +} diff --git a/BitFaster.Caching.UnitTests/DisposableValueFactory.cs b/BitFaster.Caching.UnitTests/DisposableValueFactory.cs new file mode 100644 index 00000000..0d1fb472 --- /dev/null +++ b/BitFaster.Caching.UnitTests/DisposableValueFactory.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BitFaster.Caching.UnitTests +{ + public class DisposableValueFactory + { + public Disposable Disposable { get; } = new Disposable(); + + public Scoped Create(int key) + { + return new Scoped(this.Disposable); + } + } +} diff --git a/BitFaster.Caching.UnitTests/ScopedCacheTests.cs b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs new file mode 100644 index 00000000..b6675af7 --- /dev/null +++ b/BitFaster.Caching.UnitTests/ScopedCacheTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BitFaster.Caching.Lru; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests +{ + public class ScopedCacheTests + { + private const int capacity = 6; + private readonly ScopedCache cache = new (new ConcurrentLru>(capacity)); + + [Fact] + public void WhenInnerCacheIsNullCtorThrows() + { + Action constructor = () => { var x = new ScopedCache(null); }; + + constructor.Should().Throw(); + } + + [Fact] + public void WhenCreatedCapacityPropertyWrapsInnerCache() + { + this.cache.Capacity.Should().Be(capacity); + } + + [Fact] + public void WhenItemIsAddedCountIsCorrect() + { + this.cache.Count.Should().Be(0); + + this.cache.AddOrUpdate(1, new Disposable()); + + this.cache.Count.Should().Be(1); + } + + [Fact] + public void WhenKeyDoesNotExistAddOrUpdateAddsNewItem() + { + var d = new Disposable(); + this.cache.AddOrUpdate(1, d); + + this.cache.ScopedTryGet(1, out var lifetime).Should().BeTrue(); + lifetime.Value.Should().Be(d); + } + + [Fact] + public void WhenKeyExistsAddOrUpdateUpdatesExistingItem() + { + var d1 = new Disposable(); + var d2 = new Disposable(); + this.cache.AddOrUpdate(1, d1); + this.cache.AddOrUpdate(1, d2); + + this.cache.ScopedTryGet(1, out var lifetime).Should().BeTrue(); + lifetime.Value.Should().Be(d2); + } + + [Fact] + public void WhenItemUpdatedOldValueIsAliveUntilLifetimeCompletes() + { + var d1 = new Disposable(); + var d2 = new Disposable(); + + // start a lifetime on 1 + this.cache.AddOrUpdate(1, d1); + this.cache.ScopedTryGet(1, out var lifetime1).Should().BeTrue(); + + using (lifetime1) + { + // replace 1 + this.cache.AddOrUpdate(1, d2); + + // cache reflects replacement + this.cache.ScopedTryGet(1, out var lifetime2).Should().BeTrue(); + lifetime2.Value.Should().Be(d2); + + d1.IsDisposed.Should().BeFalse(); + } + + d1.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void WhenClearedItemsAreDisposed() + { + var d = new Disposable(); + this.cache.AddOrUpdate(1, d); + + this.cache.Clear(); + + d.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void WhenItemExistsTryGetReturnsLifetime() + { + this.cache.AddOrUpdate(1, new Disposable()); + this.cache.ScopedTryGet(1, out var lifetime).Should().BeTrue(); + + lifetime.Should().NotBeNull(); + } + + [Fact] + public void WhenItemDoesNotExistTryGetReturnsFalse() + { + this.cache.ScopedTryGet(1, out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenScopeIsDisposedTryGetReturnsFalse() + { + var scope = new Scoped(new Disposable()); + + this.cache.ScopedGetOrAdd(1, k => scope); + + scope.Dispose(); + + this.cache.ScopedTryGet(1, out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistGetOrAddAddsValue() + { + this.cache.ScopedGetOrAdd(1, k => new Scoped(new Disposable())); + + this.cache.ScopedTryGet(1, out var lifetime).Should().BeTrue(); + } + + [Fact] + public async Task WhenKeyDoesNotExistGetOrAddAsyncAddsValue() + { + await this.cache.ScopedGetOrAddAsync(1, k => Task.FromResult(new Scoped(new Disposable()))); + + this.cache.ScopedTryGet(1, out var lifetime).Should().BeTrue(); + } + + [Fact] + public void GetOrAddDisposedScopeThrows() + { + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + + Action getOrAdd = () => { this.cache.ScopedGetOrAdd(1, k => scope); }; + + getOrAdd.Should().Throw(); + } + + [Fact] + public void GetOrAddAsyncDisposedScopeThrows() + { + var scope = new Scoped(new Disposable()); + scope.Dispose(); + + Func getOrAdd = async () => { await this.cache.ScopedGetOrAddAsync(1, k => Task.FromResult(scope)); }; + + getOrAdd.Should().ThrowAsync(); + } + + [Fact] + public void WhenCacheContainsValuesTrim1RemovesColdestValue() + { + this.cache.AddOrUpdate(0, new Disposable()); + this.cache.AddOrUpdate(1, new Disposable()); + this.cache.AddOrUpdate(2, new Disposable()); + + this.cache.Trim(1); + + this.cache.ScopedTryGet(0, out var lifetime).Should().BeFalse(); + } + + [Fact] + public void WhenKeyDoesNotExistTryRemoveReturnsFalse() + { + this.cache.TryRemove(1).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryRemoveReturnsTrue() + { + this.cache.AddOrUpdate(1, new Disposable()); + this.cache.TryRemove(1).Should().BeTrue(); + } + + [Fact] + public void WhenKeyDoesNotExistTryUpdateReturnsFalse() + { + this.cache.TryUpdate(1, new Disposable()).Should().BeFalse(); + } + + [Fact] + public void WhenKeyExistsTryUpdateReturnsTrue() + { + this.cache.AddOrUpdate(1, new Disposable()); + + this.cache.TryUpdate(1, new Disposable()).Should().BeTrue(); + } + } +} diff --git a/BitFaster.Caching.UnitTests/ScopedTests.cs b/BitFaster.Caching.UnitTests/ScopedTests.cs index 1cd1706c..3bfb08ce 100644 --- a/BitFaster.Caching.UnitTests/ScopedTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedTests.cs @@ -77,26 +77,5 @@ public void WhenScopedIsCreatedFromCacheItemHasExpectedLifetime() valueFactory.Disposable.IsDisposed.Should().BeTrue(); } - - private class DisposableValueFactory - { - public Disposable Disposable { get; } = new Disposable(); - - public Scoped Create(int key) - { - return new Scoped(this.Disposable); - } - } - - private class Disposable : IDisposable - { - public bool IsDisposed { get; set; } - - public void Dispose() - { - this.IsDisposed.Should().BeFalse(); - IsDisposed = true; - } - } } } diff --git a/BitFaster.Caching/ICache.cs b/BitFaster.Caching/ICache.cs index 0ca8e792..87d307f5 100644 --- a/BitFaster.Caching/ICache.cs +++ b/BitFaster.Caching/ICache.cs @@ -38,7 +38,7 @@ public interface ICache /// The key of the element to add. /// The factory function used to generate a value for the key. /// The value for the key. This will be either 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 dictionary. + /// in the cache, or the new value if the key was not in the cache. V GetOrAdd(K key, Func valueFactory); /// diff --git a/BitFaster.Caching/IScopedCache.cs b/BitFaster.Caching/IScopedCache.cs new file mode 100644 index 00000000..5ae4ae63 --- /dev/null +++ b/BitFaster.Caching/IScopedCache.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BitFaster.Caching +{ + /// + /// Represents a generic cache of key/scoped IDisposable value pairs. + /// + /// The type of keys in the cache. + /// The type of values in the cache. + public interface IScopedCache where V : IDisposable + { + /// + /// Gets the total number of items that can be stored in the cache. + /// + int Capacity { get; } + + /// + /// Gets the number of items currently held in the cache. + /// + int Count { get; } + + /// + /// Attempts to create a lifetime for the value associated with the specified key from the cache + /// + /// The key of the value to get. + /// When this method returns, contains a lifetime for the object from the cache that has the specified key, or the default value of the type if the operation failed. + /// true if the key was found in the cache; otherwise, false. + bool ScopedTryGet(K key, out Lifetime lifetime); + + /// + /// Adds a key/scoped value pair to the cache if the key does not already exist. Returns a lifetime for either the new value, or the + /// existing value if the key already exists. + /// + /// The key of the element to add. + /// The factory function used to generate a scoped value for the key. + /// 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. + Lifetime ScopedGetOrAdd(K key, Func> valueFactory); + + /// + /// Adds a key/scoped value pair to the cache if the key does not already exist. Returns a lifetime for either the new value, or the + /// existing value if the key already exists. + /// + /// The key of the element to add. + /// The factory function used to asynchronously generate a scoped value for the key. + /// A task that represents the asynchronous ScopedGetOrAdd operation. + Task> ScopedGetOrAddAsync(K key, Func>> valueFactory); + + /// + /// Attempts to remove the value that has the specified key. + /// + /// The key of the element to remove. + /// true if the object was removed successfully; otherwise, false. + bool TryRemove(K key); + + /// + /// Attempts to update the value that has the specified key. + /// + /// The key of the element to update. + /// The new value. + /// true if the object was updated successfully; otherwise, false. + bool TryUpdate(K key, V value); + + /// + /// Adds a key/value pair to the cache if the key does not already exist, or updates a key/value pair if the + /// key already exists. + /// + /// The key of the element to update. + /// The new value. + void AddOrUpdate(K key, V value); + + /// + /// Removes all keys and values from the cache. + /// + void Clear(); + + /// + /// Trim the specified number of items from the cache. + /// + /// The number of items to remove. + void Trim(int itemCount); + } +} diff --git a/BitFaster.Caching/ScopedCache.cs b/BitFaster.Caching/ScopedCache.cs new file mode 100644 index 00000000..4180170e --- /dev/null +++ b/BitFaster.Caching/ScopedCache.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace BitFaster.Caching +{ + /// + /// A cache decorator for working with Scoped IDisposable values. The Scoped methods (e.g. ScopedGetOrAdd) + /// are threadsafe and create lifetimes that guarantee the value will not be disposed until the + /// lifetime is disposed. + /// + /// The type of keys in the cache. + /// The type of values in the cache. + public class ScopedCache : IScopedCache where V : IDisposable + { + private readonly ICache> cache; + + private const int MaxRetry = 5; + private static readonly string RetryFailureMessage = $"Exceeded {MaxRetry} attempts to create a lifetime."; + + public ScopedCache(ICache> cache) + { + if (cache == null) + { + throw new ArgumentNullException(nameof(cache)); + } + + this.cache = cache; + } + + /// + public int Capacity => this.cache.Capacity; + + /// + public int Count => this.cache.Count; + + /// + public void AddOrUpdate(K key, V value) + { + this.cache.AddOrUpdate(key, new Scoped(value)); + } + + /// + public void Clear() + { + this.cache.Clear(); + } + + /// + public Lifetime ScopedGetOrAdd(K key, Func> valueFactory) + { + int c = 0; + var spinwait = new SpinWait(); + while (true) + { + var scope = cache.GetOrAdd(key, k => valueFactory(k)); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + spinwait.SpinOnce(); + + if (c++ > MaxRetry) + { + throw new InvalidOperationException(RetryFailureMessage); + } + } + } + + /// + public async Task> ScopedGetOrAddAsync(K key, Func>> valueFactory) + { + int c = 0; + var spinwait = new SpinWait(); + while (true) + { + var scope = await cache.GetOrAddAsync(key, valueFactory); + + if (scope.TryCreateLifetime(out var lifetime)) + { + return lifetime; + } + + spinwait.SpinOnce(); + + if (c++ > MaxRetry) + { + throw new InvalidOperationException(RetryFailureMessage); + } + } + } + + /// + public void Trim(int itemCount) + { + this.cache.Trim(itemCount); + } + + /// + public bool ScopedTryGet(K key, out Lifetime lifetime) + { + if (this.cache.TryGet(key, out var scope)) + { + if (scope.TryCreateLifetime(out lifetime)) + { + return true; + } + } + + lifetime = default; + return false; + } + + /// + public bool TryRemove(K key) + { + return this.cache.TryRemove(key); + } + + /// + public bool TryUpdate(K key, V value) + { + return this.cache.TryUpdate(key, new Scoped(value)); + } + } +}