Skip to content

Commit 13c005a

Browse files
authored
Implement LFU builder for time based expiry (#570)
* wheel+nodes * fix typo * 64bit * +ConcurrentLfuCore * undo bitops * undo bitops * node * cleanup merge * simplify generics * outline tests * more tests * comments * use Duration, cleanup * rough end to end * nullability static analysis * schedule * assert wheel pos * port all tests * test e2e * policy * cleanup * test coverage * explicit interface impl * rem comment * mem layout * tlfu builder * fix builder * cleanup ---------
1 parent de72842 commit 13c005a

13 files changed

+359
-4
lines changed

BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,219 @@ public void TestComparer()
5252
lfu.TryGet("A", out var value).Should().BeTrue();
5353
}
5454

55+
[Fact]
56+
public void TestExpireAfterAccess()
57+
{
58+
ICache<string, int> expireAfterAccess = new ConcurrentLfuBuilder<string, int>()
59+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
60+
.Build();
61+
62+
expireAfterAccess.Policy.ExpireAfterAccess.HasValue.Should().BeTrue();
63+
expireAfterAccess.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
64+
expireAfterAccess.Policy.ExpireAfterWrite.HasValue.Should().BeFalse();
65+
}
66+
67+
[Fact]
68+
public void TestExpireAfterReadAndExpireAfterWriteThrows()
69+
{
70+
var builder = new ConcurrentLfuBuilder<string, int>()
71+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
72+
.WithExpireAfterWrite(TimeSpan.FromSeconds(2));
73+
74+
Action act = () => builder.Build();
75+
act.Should().Throw<InvalidOperationException>();
76+
}
77+
78+
[Fact]
79+
public void TestExpireAfter()
80+
{
81+
ICache<string, int> expireAfter = new ConcurrentLfuBuilder<string, int>()
82+
.WithExpireAfter(new TestExpiryCalculator<string, int>((k, v) => Duration.FromMinutes(5)))
83+
.Build();
84+
85+
expireAfter.Policy.ExpireAfter.HasValue.Should().BeTrue();
86+
87+
expireAfter.Policy.ExpireAfterAccess.HasValue.Should().BeFalse();
88+
expireAfter.Policy.ExpireAfterWrite.HasValue.Should().BeFalse();
89+
}
90+
91+
[Fact]
92+
public void TestAsyncExpireAfter()
93+
{
94+
IAsyncCache<string, int> expireAfter = new ConcurrentLfuBuilder<string, int>()
95+
.AsAsyncCache()
96+
.WithExpireAfter(new TestExpiryCalculator<string, int>((k, v) => Duration.FromMinutes(5)))
97+
.Build();
98+
99+
expireAfter.Policy.ExpireAfter.HasValue.Should().BeTrue();
100+
101+
expireAfter.Policy.ExpireAfterAccess.HasValue.Should().BeFalse();
102+
expireAfter.Policy.ExpireAfterWrite.HasValue.Should().BeFalse();
103+
}
104+
105+
106+
[Fact]
107+
public void TestExpireAfterWriteAndExpireAfterThrows()
108+
{
109+
var builder = new ConcurrentLfuBuilder<string, int>()
110+
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
111+
.WithExpireAfter(new TestExpiryCalculator<string, int>((k, v) => Duration.FromMinutes(5)));
112+
113+
Action act = () => builder.Build();
114+
act.Should().Throw<InvalidOperationException>();
115+
}
116+
117+
[Fact]
118+
public void TestExpireAfterAccessAndExpireAfterThrows()
119+
{
120+
var builder = new ConcurrentLfuBuilder<string, int>()
121+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
122+
.WithExpireAfter(new TestExpiryCalculator<string, int>((k, v) => Duration.FromMinutes(5)));
123+
124+
Action act = () => builder.Build();
125+
act.Should().Throw<InvalidOperationException>();
126+
}
127+
128+
[Fact]
129+
public void TestExpireAfterAccessAndWriteAndExpireAfterThrows()
130+
{
131+
var builder = new ConcurrentLfuBuilder<string, int>()
132+
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
133+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
134+
.WithExpireAfter(new TestExpiryCalculator<string, int>((k, v) => Duration.FromMinutes(5)));
135+
136+
Action act = () => builder.Build();
137+
act.Should().Throw<InvalidOperationException>();
138+
}
139+
140+
[Fact]
141+
public void TestScopedWithExpireAfterThrows()
142+
{
143+
var builder = new ConcurrentLfuBuilder<string, Disposable>()
144+
.WithExpireAfter(new TestExpiryCalculator<string, Disposable>((k, v) => Duration.FromMinutes(5)))
145+
.AsScopedCache();
146+
147+
Action act = () => builder.Build();
148+
act.Should().Throw<InvalidOperationException>();
149+
}
150+
151+
[Fact]
152+
public void TestScopedAtomicWithExpireAfterThrows()
153+
{
154+
var builder = new ConcurrentLfuBuilder<string, Disposable>()
155+
.WithExpireAfter(new TestExpiryCalculator<string, Disposable>((k, v) => Duration.FromMinutes(5)))
156+
.AsScopedCache()
157+
.WithAtomicGetOrAdd();
158+
159+
Action act = () => builder.Build();
160+
act.Should().Throw<InvalidOperationException>();
161+
}
162+
163+
[Fact]
164+
public void TestAsyncScopedWithExpireAfterThrows()
165+
{
166+
var builder = new ConcurrentLfuBuilder<string, Disposable>()
167+
.WithExpireAfter(new TestExpiryCalculator<string, Disposable>((k, v) => Duration.FromMinutes(5)))
168+
.AsAsyncCache()
169+
.AsScopedCache();
170+
171+
Action act = () => builder.Build();
172+
act.Should().Throw<InvalidOperationException>();
173+
}
174+
175+
[Fact]
176+
public void TestAsyncScopedAtomicWithExpireAfterThrows()
177+
{
178+
var builder = new ConcurrentLfuBuilder<string, Disposable>()
179+
.WithExpireAfter(new TestExpiryCalculator<string, Disposable>((k, v) => Duration.FromMinutes(5)))
180+
.AsAsyncCache()
181+
.AsScopedCache()
182+
.WithAtomicGetOrAdd();
183+
184+
Action act = () => builder.Build();
185+
act.Should().Throw<InvalidOperationException>();
186+
}
187+
188+
[Fact]
189+
public void TestScopedWithExpireAfterWrite()
190+
{
191+
var expireAfterWrite = new ConcurrentLfuBuilder<string, Disposable>()
192+
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
193+
.AsScopedCache()
194+
.Build();
195+
196+
expireAfterWrite.Policy.ExpireAfterWrite.HasValue.Should().BeTrue();
197+
expireAfterWrite.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
198+
expireAfterWrite.Policy.ExpireAfterAccess.HasValue.Should().BeFalse();
199+
}
200+
201+
[Fact]
202+
public void TestScopedWithExpireAfterAccess()
203+
{
204+
var expireAfterAccess = new ConcurrentLfuBuilder<string, Disposable>()
205+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
206+
.AsScopedCache()
207+
.Build();
208+
209+
expireAfterAccess.Policy.ExpireAfterAccess.HasValue.Should().BeTrue();
210+
expireAfterAccess.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
211+
expireAfterAccess.Policy.ExpireAfterWrite.HasValue.Should().BeFalse();
212+
}
213+
214+
[Fact]
215+
public void TestAtomicWithExpireAfterWrite()
216+
{
217+
var expireAfterWrite = new ConcurrentLfuBuilder<string, Disposable>()
218+
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
219+
.WithAtomicGetOrAdd()
220+
.Build();
221+
222+
expireAfterWrite.Policy.ExpireAfterWrite.HasValue.Should().BeTrue();
223+
expireAfterWrite.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
224+
expireAfterWrite.Policy.ExpireAfterAccess.HasValue.Should().BeFalse();
225+
}
226+
227+
[Fact]
228+
public void TestAtomicWithExpireAfterAccess()
229+
{
230+
var expireAfterAccess = new ConcurrentLfuBuilder<string, Disposable>()
231+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
232+
.WithAtomicGetOrAdd()
233+
.Build();
234+
235+
expireAfterAccess.Policy.ExpireAfterAccess.HasValue.Should().BeTrue();
236+
expireAfterAccess.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
237+
expireAfterAccess.Policy.ExpireAfterWrite.HasValue.Should().BeFalse();
238+
}
239+
240+
[Fact]
241+
public void TestScopedAtomicWithExpireAfterWrite()
242+
{
243+
var expireAfterWrite = new ConcurrentLfuBuilder<string, Disposable>()
244+
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
245+
.AsScopedCache()
246+
.WithAtomicGetOrAdd()
247+
.Build();
248+
249+
expireAfterWrite.Policy.ExpireAfterWrite.HasValue.Should().BeTrue();
250+
expireAfterWrite.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
251+
expireAfterWrite.Policy.ExpireAfterAccess.HasValue.Should().BeFalse();
252+
}
253+
254+
[Fact]
255+
public void TestScopedAtomicWithExpireAfterAccess()
256+
{
257+
var expireAfterAccess = new ConcurrentLfuBuilder<string, Disposable>()
258+
.WithExpireAfterAccess(TimeSpan.FromSeconds(1))
259+
.AsScopedCache()
260+
.WithAtomicGetOrAdd()
261+
.Build();
262+
263+
expireAfterAccess.Policy.ExpireAfterAccess.HasValue.Should().BeTrue();
264+
expireAfterAccess.Policy.ExpireAfterAccess.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
265+
expireAfterAccess.Policy.ExpireAfterWrite.HasValue.Should().BeFalse();
266+
}
267+
55268
// 1
56269
[Fact]
57270
public void WithScopedValues()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System;
2+
using BitFaster.Caching.Lfu.Builder;
3+
using FluentAssertions;
4+
using Xunit;
5+
6+
namespace BitFaster.Caching.UnitTests.Lfu
7+
{
8+
public class LfuInfoTests
9+
{
10+
[Fact]
11+
public void WhenExpiryNullGetExpiryReturnsNull()
12+
{
13+
var info = new LfuInfo<int>();
14+
15+
info.GetExpiry<string>().Should().BeNull();
16+
}
17+
18+
[Fact]
19+
public void WhenExpiryCalcValueTypeDoesNotMatchThrows()
20+
{
21+
var info = new LfuInfo<int>();
22+
23+
info.SetExpiry<int>(new TestExpiryCalculator<int, int>());
24+
25+
Action act = () => info.GetExpiry<string>();
26+
act.Should().Throw<InvalidOperationException>();
27+
}
28+
}
29+
}

BitFaster.Caching/Lfu/Builder/AsyncConcurrentLfuBuilder.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,21 @@ internal AsyncConcurrentLfuBuilder(LfuInfo<K> info)
1414
{
1515
}
1616

17+
/// <summary>
18+
/// Evict after a duration calculated for each item using the specified IExpiryCalculator.
19+
/// </summary>
20+
/// <param name="expiry">The expiry calculator that determines item time to expire.</param>
21+
/// <returns>A ConcurrentLruBuilder</returns>
22+
public AsyncConcurrentLfuBuilder<K, V> WithExpireAfter(IExpiryCalculator<K, V> expiry)
23+
{
24+
this.info.SetExpiry(expiry);
25+
return this;
26+
}
27+
1728
///<inheritdoc/>
1829
public override IAsyncCache<K, V> Build()
1930
{
20-
return new ConcurrentLfu<K, V>(info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer);
31+
return(ConcurrentLfuFactory.Create<K, V>(this.info) as IAsyncCache<K, V>)!;
2132
}
2233
}
2334
}

BitFaster.Caching/Lfu/Builder/AtomicAsyncConcurrentLfuBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ internal AtomicAsyncConcurrentLfuBuilder(ConcurrentLfuBuilder<K, AsyncAtomicFact
2222
///<inheritdoc/>
2323
public override IAsyncCache<K, V> Build()
2424
{
25+
info.ThrowIfExpirySpecified("AsAtomic");
26+
2527
var level1 = inner.Build();
2628
return new AtomicFactoryAsyncCache<K, V>(level1);
2729
}

BitFaster.Caching/Lfu/Builder/AtomicConcurrentLfuBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ internal AtomicConcurrentLfuBuilder(ConcurrentLfuBuilder<K, AtomicFactory<K, V>>
2222
///<inheritdoc/>
2323
public override ICache<K, V> Build()
2424
{
25+
info.ThrowIfExpirySpecified("AsAtomic");
26+
2527
var level1 = inner.Build();
2628
return new AtomicFactoryCache<K, V>(level1);
2729
}

BitFaster.Caching/Lfu/Builder/AtomicScopedAsyncConcurrentLfuBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ internal AtomicScopedAsyncConcurrentLfuBuilder(AsyncConcurrentLfuBuilder<K, Scop
2323
///<inheritdoc/>
2424
public override IScopedAsyncCache<K, V> Build()
2525
{
26+
info.ThrowIfExpirySpecified("AsAtomic or AsScoped");
27+
2628
// this is a legal type conversion due to the generic constraint on W
2729
var scopedInnerCache = inner.Build() as ICache<K, ScopedAsyncAtomicFactory<K, V>>;
2830

BitFaster.Caching/Lfu/Builder/AtomicScopedConcurrentLfuBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ internal AtomicScopedConcurrentLfuBuilder(ConcurrentLfuBuilder<K, ScopedAtomicFa
2323
///<inheritdoc/>
2424
public override IScopedCache<K, V> Build()
2525
{
26+
info.ThrowIfExpirySpecified("AsAtomic or AsScoped");
27+
2628
var level1 = inner.Build() as ICache<K, ScopedAtomicFactory<K, V>>;
2729
return new AtomicFactoryScopedCache<K, V>(level1);
2830
}

BitFaster.Caching/Lfu/Builder/LfuBuilderBase.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Collections.Generic;
1+
using System;
2+
using System.Collections.Generic;
23
using BitFaster.Caching.Scheduler;
34

45
namespace BitFaster.Caching.Lfu.Builder
@@ -64,6 +65,28 @@ public TBuilder WithKeyComparer(IEqualityComparer<K> comparer)
6465
return (this as TBuilder)!;
6566
}
6667

68+
/// <summary>
69+
/// Evict after a fixed duration since an entry's creation or most recent replacement.
70+
/// </summary>
71+
/// <param name="expiration">The length of time before an entry is automatically removed.</param>
72+
/// <returns>A ConcurrentLfuBuilder</returns>
73+
public TBuilder WithExpireAfterWrite(TimeSpan expiration)
74+
{
75+
this.info.TimeToExpireAfterWrite = expiration;
76+
return (this as TBuilder)!;
77+
}
78+
79+
/// <summary>
80+
/// Evict after a fixed duration since an entry's most recent read or write.
81+
/// </summary>
82+
/// <param name="expiration">The length of time before an entry is automatically removed.</param>
83+
/// <returns>A ConcurrentLfuBuilder</returns>
84+
public TBuilder WithExpireAfterAccess(TimeSpan expiration)
85+
{
86+
this.info.TimeToExpireAfterAccess = expiration;
87+
return (this as TBuilder)!;
88+
}
89+
6790
/// <summary>
6891
/// Builds a cache configured via the method calls invoked on the builder instance.
6992
/// </summary>

BitFaster.Caching/Lfu/Builder/LfuInfo.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
using System;
23
using System.Collections.Generic;
34
using BitFaster.Caching.Lru;
45
using BitFaster.Caching.Scheduler;
@@ -7,12 +8,41 @@ namespace BitFaster.Caching.Lfu.Builder
78
{
89
internal sealed class LfuInfo<K>
910
{
11+
private object? expiry = null;
12+
1013
public int Capacity { get; set; } = 128;
1114

1215
public int ConcurrencyLevel { get; set; } = Defaults.ConcurrencyLevel;
1316

1417
public IScheduler Scheduler { get; set; } = new ThreadPoolScheduler();
1518

1619
public IEqualityComparer<K> KeyComparer { get; set; } = EqualityComparer<K>.Default;
20+
21+
public TimeSpan? TimeToExpireAfterWrite { get; set; } = null;
22+
23+
public TimeSpan? TimeToExpireAfterAccess { get; set; } = null;
24+
25+
public void SetExpiry<V>(IExpiryCalculator<K, V> expiry) => this.expiry = expiry;
26+
27+
public IExpiryCalculator<K, V>? GetExpiry<V>()
28+
{
29+
if (this.expiry == null)
30+
{
31+
return null;
32+
}
33+
34+
var e = this.expiry as IExpiryCalculator<K, V>;
35+
36+
if (e == null)
37+
Throw.InvalidOp($"Incompatible IExpiryCalculator value generic type argument, expected {typeof(IExpiryCalculator<K, V>)} but found {this.expiry.GetType()}");
38+
39+
return e;
40+
}
41+
42+
internal void ThrowIfExpirySpecified(string extensionName)
43+
{
44+
if (this.expiry != null)
45+
Throw.InvalidOp("WithExpireAfter is not compatible with " + extensionName);
46+
}
1747
}
1848
}

0 commit comments

Comments
 (0)