diff --git a/BitFaster.Caching.Benchmarks/Lru/LruJustGetOrAdd.cs b/BitFaster.Caching.Benchmarks/Lru/LruJustGetOrAdd.cs index f24fcd44..e3330b8e 100644 --- a/BitFaster.Caching.Benchmarks/Lru/LruJustGetOrAdd.cs +++ b/BitFaster.Caching.Benchmarks/Lru/LruJustGetOrAdd.cs @@ -46,6 +46,7 @@ public class LruJustGetOrAdd private static readonly FastConcurrentTLru fastConcurrentTLru = new FastConcurrentTLru(8, 9, EqualityComparer.Default, TimeSpan.FromMinutes(1)); private static readonly ICache atomicFastLru = new ConcurrentLruBuilder().WithConcurrencyLevel(8).WithCapacity(9).WithAtomicGetOrAdd().Build(); + private static readonly ICache lruAfterAccess = new ConcurrentLruBuilder().WithConcurrencyLevel(8).WithCapacity(9).WithExpireAfterAccess(TimeSpan.FromMinutes(10)).Build(); private static readonly BackgroundThreadScheduler background = new BackgroundThreadScheduler(); private static readonly ConcurrentLfu concurrentLfu = new ConcurrentLfu(1, 9, background, EqualityComparer.Default); @@ -103,6 +104,13 @@ public void FastConcurrentTLru() { Func func = x => x; fastConcurrentTLru.GetOrAdd(1, func); + } + + [Benchmark()] + public void FastConcLruAfter() + { + Func func = x => x; + lruAfterAccess.GetOrAdd(1, func); } [Benchmark()] @@ -110,7 +118,7 @@ public void ConcurrentTLru() { Func func = x => x; concurrentTlru.GetOrAdd(1, func); - } + } [Benchmark()] public void ConcurrentLfu() diff --git a/BitFaster.Caching.UnitTests/Lru/AfterAccessLongTicksPolicyTests.cs b/BitFaster.Caching.UnitTests/Lru/AfterAccessLongTicksPolicyTests.cs new file mode 100644 index 00000000..ed09b371 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lru/AfterAccessLongTicksPolicyTests.cs @@ -0,0 +1,195 @@ +using FluentAssertions; +using BitFaster.Caching.Lru; +using System; +using System.Threading.Tasks; +using Xunit; + +#if NETFRAMEWORK +using System.Diagnostics; +#endif + +namespace BitFaster.Caching.UnitTests.Lru +{ + public class AfterAccessLongTicksPolicyTests + { + private readonly AfterAccessLongTicksPolicy policy = new AfterAccessLongTicksPolicy(TimeSpan.FromSeconds(10)); + + [Fact] + public void WhenTtlIsTimeSpanMaxThrow() + { + Action constructor = () => { new AfterAccessLongTicksPolicy(TimeSpan.MaxValue); }; + + constructor.Should().Throw(); + } + + [Fact] + public void WhenTtlIsZeroThrow() + { + Action constructor = () => { new AfterAccessLongTicksPolicy(TimeSpan.Zero); }; + + constructor.Should().Throw(); + } + + [Fact] + public void WhenTtlIsMaxSetAsMax() + { +#if NETFRAMEWORK + var maxRepresentable = TimeSpan.FromTicks((long)(long.MaxValue / 100.0d)) - TimeSpan.FromTicks(10); +#else + var maxRepresentable = Time.MaxRepresentable; +#endif + var policy = new AfterAccessLongTicksPolicy(maxRepresentable); + policy.TimeToLive.Should().BeCloseTo(maxRepresentable, TimeSpan.FromTicks(20)); + } + + [Fact] + public void TimeToLiveShouldBeTenSecs() + { + this.policy.TimeToLive.Should().Be(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void CreateItemInitializesKeyAndValue() + { + var item = this.policy.CreateItem(1, 2); + + item.Key.Should().Be(1); + item.Value.Should().Be(2); + } + + [Fact] + public void CreateItemInitializesTimestampToNow() + { + var item = this.policy.CreateItem(1, 2); + +#if NETFRAMEWORK + var expected = Stopwatch.GetTimestamp(); + ulong epsilon = (ulong)(TimeSpan.FromMilliseconds(20).TotalSeconds * Stopwatch.Frequency); +#else + var expected = Environment.TickCount64; + ulong epsilon = 20; +#endif + item.TickCount.Should().BeCloseTo(expected, epsilon); + } + + [Fact] + public void TouchUpdatesItemWasAccessed() + { + var item = this.policy.CreateItem(1, 2); + item.WasAccessed = false; + + this.policy.Touch(item); + + item.WasAccessed.Should().BeTrue(); + } + + [Fact] + public async Task TouchUpdatesTicksCount() + { + var item = this.policy.CreateItem(1, 2); + var tc = item.TickCount; + await Task.Delay(TimeSpan.FromMilliseconds(1)); + + this.policy.ShouldDiscard(item); // set the time in the policy + this.policy.Touch(item); + + item.TickCount.Should().BeGreaterThan(tc); + } + + [Fact] + public async Task UpdateUpdatesTickCount() + { + var item = this.policy.CreateItem(1, 2); + var tc = item.TickCount; + + await Task.Delay(TimeSpan.FromMilliseconds(1)); + + this.policy.Update(item); + + item.TickCount.Should().BeGreaterThan(tc); + } + + [Fact] + public void WhenItemIsExpiredShouldDiscardIsTrue() + { + var item = this.policy.CreateItem(1, 2); + item.TickCount = Environment.TickCount - (int)TimeSpan.FromSeconds(11).ToEnvTick64(); + + this.policy.ShouldDiscard(item).Should().BeTrue(); + } + + [Fact] + public void WhenItemIsNotExpiredShouldDiscardIsFalse() + { + var item = this.policy.CreateItem(1, 2); + +#if NETFRAMEWORK + item.TickCount = Stopwatch.GetTimestamp() - StopwatchTickConverter.ToTicks(TimeSpan.FromSeconds(9)); +#else + item.TickCount = Environment.TickCount - (int)TimeSpan.FromSeconds(9).ToEnvTick64(); +#endif + + this.policy.ShouldDiscard(item).Should().BeFalse(); + } + + [Fact] + public void CanDiscardIsTrue() + { + this.policy.CanDiscard().Should().BeTrue(); + } + + [Theory] + [InlineData(false, true, ItemDestination.Remove)] + [InlineData(true, true, ItemDestination.Remove)] + [InlineData(true, false, ItemDestination.Warm)] + [InlineData(false, false, ItemDestination.Cold)] + public void RouteHot(bool wasAccessed, bool isExpired, ItemDestination expectedDestination) + { + var item = CreateItem(wasAccessed, isExpired); + + this.policy.RouteHot(item).Should().Be(expectedDestination); + } + + [Theory] + [InlineData(false, true, ItemDestination.Remove)] + [InlineData(true, true, ItemDestination.Remove)] + [InlineData(true, false, ItemDestination.Warm)] + [InlineData(false, false, ItemDestination.Cold)] + public void RouteWarm(bool wasAccessed, bool isExpired, ItemDestination expectedDestination) + { + var item = CreateItem(wasAccessed, isExpired); + + this.policy.RouteWarm(item).Should().Be(expectedDestination); + } + + [Theory] + [InlineData(false, true, ItemDestination.Remove)] + [InlineData(true, true, ItemDestination.Remove)] + [InlineData(true, false, ItemDestination.Warm)] + [InlineData(false, false, ItemDestination.Remove)] + public void RouteCold(bool wasAccessed, bool isExpired, ItemDestination expectedDestination) + { + var item = CreateItem(wasAccessed, isExpired); + + this.policy.RouteCold(item).Should().Be(expectedDestination); + } + + private LongTickCountLruItem CreateItem(bool wasAccessed, bool isExpired) + { + var item = this.policy.CreateItem(1, 2); + + item.WasAccessed = wasAccessed; + + if (isExpired) + { +#if NETFRAMEWORK + item.TickCount = Stopwatch.GetTimestamp() - StopwatchTickConverter.ToTicks(TimeSpan.FromSeconds(11)); +#else + item.TickCount = Environment.TickCount - TimeSpan.FromSeconds(11).ToEnvTick64(); +#endif + } + + return item; + } + } +} diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAfterAccessTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAfterAccessTests.cs new file mode 100644 index 00000000..e7f753fb --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAfterAccessTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using BitFaster.Caching.Lru; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Lru +{ + public class ConcurrentLruAfterAccessTests + { + private readonly TimeSpan timeToLive = TimeSpan.FromMilliseconds(10); + private readonly ICapacityPartition capacity = new EqualCapacityPartition(9); + private ICache lru; + + private ValueFactory valueFactory = new ValueFactory(); + + private List> removedItems = new List>(); + + // on MacOS time measurement seems to be less stable, give longer pause + private int ttlWaitMlutiplier = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? 8 : 2; + + private void OnLruItemRemoved(object sender, ItemRemovedEventArgs e) + { + removedItems.Add(e); + } + + public ConcurrentLruAfterAccessTests() + { + lru = new ConcurrentLruBuilder() + .WithCapacity(capacity) + .WithExpireAfterAccess(timeToLive) + .Build(); + } + + [Fact] + public void CanExpireIsTrue() + { + this.lru.Policy.ExpireAfterAccess.HasValue.Should().BeTrue(); + } + + [Fact] + public void TimeToLiveIsCtorArg() + { + this.lru.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(timeToLive); + } + + [Fact] + public void WhenItemIsNotExpiredItIsNotRemoved() + { + lru.GetOrAdd(1, valueFactory.Create); + + lru.TryGet(1, out var value).Should().BeTrue(); + } + + [Fact] + public async Task WhenItemIsExpiredItIsRemoved() + { + lru.GetOrAdd(1, valueFactory.Create); + + await Task.Delay(timeToLive.MultiplyBy(ttlWaitMlutiplier)); + + lru.TryGet(1, out var value).Should().BeFalse(); + } + + [Fact] + public async Task WhenItemIsUpdatedTtlIsExtended() + { + lru.GetOrAdd(1, valueFactory.Create); + + await Task.Delay(timeToLive.MultiplyBy(ttlWaitMlutiplier)); + + lru.TryUpdate(1, "3"); + + lru.TryGet(1, out var value).Should().BeTrue(); + } + + // Using async/await makes this very unstable due to xunit + // running new tests on the yielding thread. Using sleep + // forces the test to stay on the same thread. + [Fact] + public void WhenItemIsReadTtlIsExtended() + { + int attempts = 0; + while (true) + { + var sw = Stopwatch.StartNew(); + + lru = new ConcurrentLruBuilder() + .WithCapacity(capacity) + .WithExpireAfterAccess(TimeSpan.FromMilliseconds(100)) + .Build(); + + lru.GetOrAdd(1, valueFactory.Create); + + Thread.Sleep(50); + + if (sw.Elapsed < TimeSpan.FromMilliseconds(75)) + { + lru.TryGet(1, out _).Should().BeTrue($"First {sw.Elapsed}"); + + Thread.Sleep(75); + + if (sw.Elapsed < TimeSpan.FromMilliseconds(150)) + { + lru.TryGet(1, out var value).Should().BeTrue($"Second {sw.Elapsed}"); + break; + } + } + + Thread.Sleep(200); + attempts++.Should().BeLessThan(128, "Unable to run test within verification margin"); + } + } + + [Fact] + public void WhenValueEvictedItemRemovedEventIsFired() + { + var lruEvents = new ConcurrentLruBuilder() + .WithCapacity(new EqualCapacityPartition(6)) + .WithExpireAfterAccess(TimeSpan.FromSeconds(10)) + .WithMetrics() + .Build(); + + lruEvents.Events.Value.ItemRemoved += OnLruItemRemoved; + + // First 6 adds + // hot[6, 5], warm[2, 1], cold[4, 3] + // => + // hot[8, 7], warm[1, 0], cold[6, 5], evicted[4, 3] + for (int i = 0; i < 8; i++) + { + lruEvents.GetOrAdd(i + 1, i => i + 1); + } + + removedItems.Count.Should().Be(2); + + removedItems[0].Key.Should().Be(1); + removedItems[0].Value.Should().Be(2); + removedItems[0].Reason.Should().Be(ItemRemovedReason.Evicted); + + removedItems[1].Key.Should().Be(4); + removedItems[1].Value.Should().Be(5); + removedItems[1].Reason.Should().Be(ItemRemovedReason.Evicted); + } + + [Fact] + public async Task WhenItemsAreExpiredExpireRemovesExpiredItems() + { + lru.AddOrUpdate(1, "1"); + lru.AddOrUpdate(2, "2"); + lru.AddOrUpdate(3, "3"); + lru.GetOrAdd(1, valueFactory.Create); + lru.GetOrAdd(2, valueFactory.Create); + lru.GetOrAdd(3, valueFactory.Create); + + lru.AddOrUpdate(4, "4"); + lru.AddOrUpdate(5, "5"); + lru.AddOrUpdate(6, "6"); + + lru.AddOrUpdate(7, "7"); + lru.AddOrUpdate(8, "8"); + lru.AddOrUpdate(9, "9"); + + await Task.Delay(timeToLive.MultiplyBy(ttlWaitMlutiplier)); + + lru.Policy.ExpireAfterAccess.Value.TrimExpired(); + + lru.Count.Should().Be(0); + } + + [Fact] + public async Task WhenCacheHasExpiredAndFreshItemsExpireRemovesOnlyExpiredItems() + { + lru.AddOrUpdate(1, "1"); + lru.AddOrUpdate(2, "2"); + lru.AddOrUpdate(3, "3"); + + lru.AddOrUpdate(4, "4"); + lru.AddOrUpdate(5, "5"); + lru.AddOrUpdate(6, "6"); + + await Task.Delay(timeToLive.MultiplyBy(ttlWaitMlutiplier)); + + lru.GetOrAdd(1, valueFactory.Create); + lru.GetOrAdd(2, valueFactory.Create); + lru.GetOrAdd(3, valueFactory.Create); + + lru.Policy.ExpireAfterAccess.Value.TrimExpired(); + + lru.Count.Should().Be(3); + } + + [Fact] + public async Task WhenItemsAreExpiredTrimRemovesExpiredItems() + { + lru.AddOrUpdate(1, "1"); + lru.AddOrUpdate(2, "2"); + lru.AddOrUpdate(3, "3"); + + await Task.Delay(timeToLive.MultiplyBy(ttlWaitMlutiplier)); + + lru.Policy.Eviction.Value.Trim(1); + + lru.Count.Should().Be(0); + } + } +} diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruBuilderTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruBuilderTests.cs index 0c13993f..75f6f748 100644 --- a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruBuilderTests.cs +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruBuilderTests.cs @@ -140,6 +140,44 @@ public void TestPartitionCapacity() lru.Policy.Eviction.Value.Capacity.Should().Be(6); } + [Fact] + public void TestExpireAfterAccess() + { + ICache expireAfterAccess = new ConcurrentLruBuilder() + .WithExpireAfterAccess(TimeSpan.FromSeconds(1)) + .Build(); + + expireAfterAccess.Metrics.HasValue.Should().BeFalse(); + expireAfterAccess.Policy.ExpireAfterAccess.HasValue.Should().BeTrue(); + expireAfterAccess.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1)); + expireAfterAccess.Policy.ExpireAfterWrite.HasValue.Should().BeFalse(); + } + + [Fact] + public void TestExpireAfterAccessWithMetrics() + { + ICache expireAfterAccess = new ConcurrentLruBuilder() + .WithExpireAfterAccess(TimeSpan.FromSeconds(1)) + .WithMetrics() + .Build(); + + expireAfterAccess.Metrics.HasValue.Should().BeTrue(); + expireAfterAccess.Policy.ExpireAfterAccess.HasValue.Should().BeTrue(); + expireAfterAccess.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1)); + expireAfterAccess.Policy.ExpireAfterWrite.HasValue.Should().BeFalse(); + } + + [Fact] + public void TestExpireAfterReadAndExpireAfterWriteThrows() + { + var builder = new ConcurrentLruBuilder() + .WithExpireAfterAccess(TimeSpan.FromSeconds(1)) + .WithExpireAfterWrite(TimeSpan.FromSeconds(2)); + + Action act = () => builder.Build(); + act.Should().Throw(); + } + // There are 15 combinations to test: // ----------------------------- //1 WithAtomic diff --git a/BitFaster.Caching/CachePolicy.cs b/BitFaster.Caching/CachePolicy.cs index 91172ba3..10873b88 100644 --- a/BitFaster.Caching/CachePolicy.cs +++ b/BitFaster.Caching/CachePolicy.cs @@ -18,6 +18,19 @@ public CachePolicy(Optional eviction, Optional expi this.ExpireAfterWrite = expireAfterWrite; } + /// + /// Initializes a new instance of the CachePolicy class with the specified policies. + /// + /// The eviction policy. + /// The expire after write policy. + /// The expire after access policy. + public CachePolicy(Optional eviction, Optional expireAfterWrite, Optional expireAfterAccess) + { + this.Eviction = eviction; + this.ExpireAfterWrite = expireAfterWrite; + this.ExpireAfterAccess = expireAfterAccess; + } + /// /// Gets the bounded size eviction policy. This policy evicts items from the cache /// if it exceeds capacity. @@ -29,5 +42,11 @@ public CachePolicy(Optional eviction, Optional expi /// fixed duration since an entry's creation or most recent replacement. /// public Optional ExpireAfterWrite { get; } + + /// + /// Gets the expire after access policy, if any. This policy evicts items after a + /// fixed duration since an entry's creation or most recent read/write access. + /// + public Optional ExpireAfterAccess { get; } } } diff --git a/BitFaster.Caching/Lru/AfterReadStopwatchPolicy.cs b/BitFaster.Caching/Lru/AfterReadStopwatchPolicy.cs new file mode 100644 index 00000000..fcb0a793 --- /dev/null +++ b/BitFaster.Caching/Lru/AfterReadStopwatchPolicy.cs @@ -0,0 +1,127 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace BitFaster.Caching.Lru +{ +#if !NETCOREAPP3_0_OR_GREATER + /// + /// Implement an expire after access policy. + /// + /// + /// This class measures time using Stopwatch.GetTimestamp() with a resolution of ~1us. + /// + public readonly struct AfterAccessLongTicksPolicy : IItemPolicy> + { + private readonly long timeToLive; + private readonly Time time; + + /// + public TimeSpan TimeToLive => StopwatchTickConverter.FromTicks(timeToLive); + + /// + /// Initializes a new instance of the TLruLongTicksPolicy class with the specified time to live. + /// + /// The time to live. + public AfterAccessLongTicksPolicy(TimeSpan timeToLive) + { + this.timeToLive = StopwatchTickConverter.ToTicks(timeToLive); + this.time = new Time(); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LongTickCountLruItem CreateItem(K key, V value) + { + return new LongTickCountLruItem(key, value, Stopwatch.GetTimestamp()); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Touch(LongTickCountLruItem item) + { + item.TickCount = this.time.Last; + item.WasAccessed = true; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Update(LongTickCountLruItem item) + { + item.TickCount = Stopwatch.GetTimestamp(); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ShouldDiscard(LongTickCountLruItem item) + { + this.time.Last = Stopwatch.GetTimestamp(); + if (this.time.Last - item.TickCount > this.timeToLive) + { + return true; + } + + return false; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanDiscard() + { + return true; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ItemDestination RouteHot(LongTickCountLruItem item) + { + if (this.ShouldDiscard(item)) + { + return ItemDestination.Remove; + } + + if (item.WasAccessed) + { + return ItemDestination.Warm; + } + + return ItemDestination.Cold; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ItemDestination RouteWarm(LongTickCountLruItem item) + { + if (this.ShouldDiscard(item)) + { + return ItemDestination.Remove; + } + + if (item.WasAccessed) + { + return ItemDestination.Warm; + } + + return ItemDestination.Cold; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ItemDestination RouteCold(LongTickCountLruItem item) + { + if (this.ShouldDiscard(item)) + { + return ItemDestination.Remove; + } + + if (item.WasAccessed) + { + return ItemDestination.Warm; + } + + return ItemDestination.Remove; + } + + } +#endif +} diff --git a/BitFaster.Caching/Lru/AfterReadTickCount64Policy.cs b/BitFaster.Caching/Lru/AfterReadTickCount64Policy.cs new file mode 100644 index 00000000..92360dc1 --- /dev/null +++ b/BitFaster.Caching/Lru/AfterReadTickCount64Policy.cs @@ -0,0 +1,131 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace BitFaster.Caching.Lru +{ +#if NETCOREAPP3_0_OR_GREATER + /// + /// Implement an expire after access policy. + /// + /// + /// This class measures time using Environment.TickCount64, which is significantly faster + /// than both Stopwatch.GetTimestamp and DateTime.UtcNow. However, resolution is lower (typically + /// between 10-16ms), vs 1us for Stopwatch.GetTimestamp. + /// + public readonly struct AfterAccessLongTicksPolicy : IItemPolicy> + { + private readonly long timeToLive; + private readonly Time time; + + /// + public TimeSpan TimeToLive => TimeSpan.FromMilliseconds(timeToLive); + + /// + /// Initializes a new instance of the AfterReadTickCount64Policy class with the specified time to live. + /// + /// The time to live. + public AfterAccessLongTicksPolicy(TimeSpan timeToLive) + { + if (timeToLive <= TimeSpan.Zero || timeToLive > Time.MaxRepresentable) + Throw.ArgOutOfRange(nameof(timeToLive), $"Value must greater than zero and less than {Time.MaxRepresentable}"); + + this.timeToLive = (long)timeToLive.TotalMilliseconds; + this.time = new Time(); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LongTickCountLruItem CreateItem(K key, V value) + { + return new LongTickCountLruItem(key, value, Environment.TickCount64); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Touch(LongTickCountLruItem item) + { + item.TickCount = this.time.Last; + item.WasAccessed = true; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Update(LongTickCountLruItem item) + { + item.TickCount = Environment.TickCount64; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ShouldDiscard(LongTickCountLruItem item) + { + this.time.Last = Environment.TickCount64; + if (this.time.Last - item.TickCount > this.timeToLive) + { + return true; + } + + return false; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanDiscard() + { + return true; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ItemDestination RouteHot(LongTickCountLruItem item) + { + if (this.ShouldDiscard(item)) + { + return ItemDestination.Remove; + } + + if (item.WasAccessed) + { + return ItemDestination.Warm; + } + + return ItemDestination.Cold; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ItemDestination RouteWarm(LongTickCountLruItem item) + { + if (this.ShouldDiscard(item)) + { + return ItemDestination.Remove; + } + + if (item.WasAccessed) + { + return ItemDestination.Warm; + } + + return ItemDestination.Cold; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ItemDestination RouteCold(LongTickCountLruItem item) + { + if (this.ShouldDiscard(item)) + { + return ItemDestination.Remove; + } + + if (item.WasAccessed) + { + return ItemDestination.Warm; + } + + return ItemDestination.Remove; + } + } +#endif +} diff --git a/BitFaster.Caching/Lru/Builder/LruBuilderBase.cs b/BitFaster.Caching/Lru/Builder/LruBuilderBase.cs index 90ae584e..77834fac 100644 --- a/BitFaster.Caching/Lru/Builder/LruBuilderBase.cs +++ b/BitFaster.Caching/Lru/Builder/LruBuilderBase.cs @@ -87,6 +87,17 @@ public TBuilder WithExpireAfterWrite(TimeSpan expiration) return this as TBuilder; } + /// + /// Evict after a fixed duration since an entry's most recent read or write. + /// + /// The length of time before an entry is automatically removed. + /// A ConcurrentLruBuilder + public TBuilder WithExpireAfterAccess(TimeSpan expiration) + { + this.info.TimeToExpireAfterAccess = expiration; + return this as TBuilder; + } + /// /// Builds a cache configured via the method calls invoked on the builder instance. /// diff --git a/BitFaster.Caching/Lru/Builder/LruInfo.cs b/BitFaster.Caching/Lru/Builder/LruInfo.cs index 3d57c698..166b32c4 100644 --- a/BitFaster.Caching/Lru/Builder/LruInfo.cs +++ b/BitFaster.Caching/Lru/Builder/LruInfo.cs @@ -25,6 +25,11 @@ public sealed class LruInfo /// public TimeSpan? TimeToExpireAfterWrite { get; set; } = null; + /// + /// Gets or sets the time to expire after access. + /// + public TimeSpan? TimeToExpireAfterAccess { get; set; } = null; + /// /// Gets or sets a value indicating whether to use metrics. /// diff --git a/BitFaster.Caching/Lru/ConcurrentLruBuilder.cs b/BitFaster.Caching/Lru/ConcurrentLruBuilder.cs index b13f4900..96f5192a 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruBuilder.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruBuilder.cs @@ -1,5 +1,4 @@  -using System.Diagnostics.CodeAnalysis; using BitFaster.Caching.Lru.Builder; namespace BitFaster.Caching.Lru @@ -15,7 +14,8 @@ namespace BitFaster.Caching.Lru /// The following features can be selected which change the underlying cache implementation: /// /// Collect metrics (e.g. hit rate). Small perf penalty. - /// Time based expiration, measured since write. + /// Time based expiration, measured since last write. + /// Time based expiration, measured since last read. /// Scoped IDisposable values. /// Atomic value factory. /// @@ -40,13 +40,24 @@ internal ConcurrentLruBuilder(LruInfo info) /// public override ICache Build() { + if (info.TimeToExpireAfterWrite.HasValue && info.TimeToExpireAfterAccess.HasValue) + Throw.InvalidOp("Specifying both ExpireAfterWrite and ExpireAfterAccess is not supported."); + return info switch { - LruInfo i when i.WithMetrics && !i.TimeToExpireAfterWrite.HasValue => new ConcurrentLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer), - LruInfo i when i.WithMetrics && i.TimeToExpireAfterWrite.HasValue => new ConcurrentTLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer, info.TimeToExpireAfterWrite.Value), - LruInfo i when i.TimeToExpireAfterWrite.HasValue => new FastConcurrentTLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer, info.TimeToExpireAfterWrite.Value), + LruInfo i when i.WithMetrics && !i.TimeToExpireAfterWrite.HasValue && !i.TimeToExpireAfterAccess.HasValue => new ConcurrentLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer), + LruInfo i when i.WithMetrics && i.TimeToExpireAfterWrite.HasValue && !i.TimeToExpireAfterAccess.HasValue => new ConcurrentTLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer, info.TimeToExpireAfterWrite.Value), + LruInfo i when i.TimeToExpireAfterWrite.HasValue && !i.TimeToExpireAfterAccess.HasValue => new FastConcurrentTLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer, info.TimeToExpireAfterWrite.Value), + LruInfo i when i.WithMetrics && !i.TimeToExpireAfterWrite.HasValue && i.TimeToExpireAfterAccess.HasValue => CreateExpireAfterAccess>(info), + LruInfo i when !i.TimeToExpireAfterWrite.HasValue && i.TimeToExpireAfterAccess.HasValue => CreateExpireAfterAccess>(info), _ => new FastConcurrentLru(info.ConcurrencyLevel, info.Capacity, info.KeyComparer), }; } + + private static ICache CreateExpireAfterAccess(LruInfo info) where TP : struct, ITelemetryPolicy + { + return new ConcurrentLruCore, AfterAccessLongTicksPolicy, TP>( + info.ConcurrencyLevel, info.Capacity, info.KeyComparer, new AfterAccessLongTicksPolicy(info.TimeToExpireAfterAccess.Value), default); + } } } diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index e6b81123..b3b75593 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -778,7 +778,13 @@ IEnumerator IEnumerable.GetEnumerator() private static CachePolicy CreatePolicy(ConcurrentLruCore lru) { - var p = new Proxy(lru); + var p = new Proxy(lru); + + if (typeof(P) == typeof(AfterAccessLongTicksPolicy)) + { + return new CachePolicy(new Optional(p), Optional.None(), new Optional(p)); + } + return new CachePolicy(new Optional(p), lru.itemPolicy.CanDiscard() ? new Optional(p) : Optional.None()); } diff --git a/BitFaster.Caching/Lru/Time.cs b/BitFaster.Caching/Lru/Time.cs new file mode 100644 index 00000000..07dcab10 --- /dev/null +++ b/BitFaster.Caching/Lru/Time.cs @@ -0,0 +1,19 @@ +using System; + +namespace BitFaster.Caching.Lru +{ + /// + /// During reads, the policy evaluates ShouldDiscard and Touch. To avoid Getting the current time twice + /// introduce a simple time class that holds the last time. This is class with a mutable field, because the + /// policy structs are readonly. + /// + internal class Time + { + internal static readonly TimeSpan MaxRepresentable = TimeSpan.FromTicks(9223372036854769664); + + /// + /// Gets or sets the last time. + /// + internal long Last { get; set; } + } +} diff --git a/BitFaster.Caching/Lru/TlruTickCount64Policy.cs b/BitFaster.Caching/Lru/TlruTickCount64Policy.cs index 167d9f36..78f160a2 100644 --- a/BitFaster.Caching/Lru/TlruTickCount64Policy.cs +++ b/BitFaster.Caching/Lru/TlruTickCount64Policy.cs @@ -29,9 +29,8 @@ namespace BitFaster.Caching.Lru /// The time to live. public TLruLongTicksPolicy(TimeSpan timeToLive) { - TimeSpan maxRepresentable = TimeSpan.FromTicks(9223372036854769664); - if (timeToLive <= TimeSpan.Zero || timeToLive > maxRepresentable) - Throw.ArgOutOfRange(nameof(timeToLive), $"Value must greater than zero and less than {maxRepresentable}"); + if (timeToLive <= TimeSpan.Zero || timeToLive > Time.MaxRepresentable) + Throw.ArgOutOfRange(nameof(timeToLive), $"Value must greater than zero and less than {Time.MaxRepresentable}"); this.timeToLive = (long)timeToLive.TotalMilliseconds; }