Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -560,5 +560,67 @@ public void AsAsyncWithAtomicWithScoped()

lru.Should().BeAssignableTo<IScopedAsyncCache<int, Disposable>>();
}

[Fact]
public void WithWeigherBuildsWeightedCache()
{
ICache<int, int> weighted = new ConcurrentLfuBuilder<int, int>()
.WithCapacity(100)
.WithWeigher(new IntValueWeigher())
.Build();

weighted.Should().BeAssignableTo<FastConcurrentLfu<int, int, WeightedAccessOrderNode<int, int>, WeightedAccessOrderPolicy<int, int, NoEventPolicy<int, int>>>>();

weighted.GetOrAdd(1, k => 5);
weighted.TryGet(1, out var value).Should().BeTrue();
value.Should().Be(5);
}

[Fact]
public void WithWeigherAndEventsBuildsWeightedCacheWithEvents()
{
ICache<int, int> weighted = new ConcurrentLfuBuilder<int, int>()
.WithCapacity(100)
.WithWeigher(new IntValueWeigher())
.WithEvents()
.Build();

weighted.Should().BeAssignableTo<WeightedConcurrentLfu<int, int, WeightedAccessOrderNode<int, int>, WeightedAccessOrderPolicy<int, int, EventPolicy<int, int>>>>();
weighted.Events.HasValue.Should().BeTrue();
}

[Fact]
public void WithWeigherAndExpireAfterWriteBuildsWeightedCacheWithExpiry()
{
ICache<int, int> weighted = new ConcurrentLfuBuilder<int, int>()
.WithCapacity(100)
.WithWeigher(new IntValueWeigher())
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
.Build();

weighted.Should().BeAssignableTo<FastConcurrentLfu<int, int, WeightedTimeOrderNode<int, int>, WeightedExpireAfterPolicy<int, int, NoEventPolicy<int, int>>>>();
weighted.Policy.ExpireAfterWrite.HasValue.Should().BeTrue();
weighted.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1));
}

[Fact]
public void WithWeigherAndExpireAfterWriteAndEventsBuildsWeightedCache()
{
ICache<int, int> weighted = new ConcurrentLfuBuilder<int, int>()
.WithCapacity(100)
.WithWeigher(new IntValueWeigher())
.WithExpireAfterWrite(TimeSpan.FromSeconds(1))
.WithEvents()
.Build();

weighted.Should().BeAssignableTo<WeightedConcurrentLfu<int, int, WeightedTimeOrderNode<int, int>, WeightedExpireAfterPolicy<int, int, EventPolicy<int, int>>>>();
weighted.Policy.ExpireAfterWrite.HasValue.Should().BeTrue();
weighted.Events.HasValue.Should().BeTrue();
}

private sealed class IntValueWeigher : IWeightCalculator<int, int>
{
public int GetWeight(int key, int value) => value;
}
}
}
24 changes: 24 additions & 0 deletions BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,28 @@ public override void DoMaintenance<K, V>(ICache<K, V> cache)
tlfu?.DoMaintenance();
}
}

public class WeightedConcurrentLfuWrapperTests : ConcurrentLfuCoreTests
{
public override ICache<K, V> Create<K, V>()
{
return new ConcurrentLfuBuilder<K, V>()
.WithCapacity(capacity)
.WithConcurrencyLevel(1)
.WithWeigher(new UnitWeigher<K, V>())
.WithEvents()
.Build();
}

public override void DoMaintenance<K, V>(ICache<K, V> cache)
{
var weighted = cache as WeightedConcurrentLfu<K, V, WeightedAccessOrderNode<K, V>, WeightedAccessOrderPolicy<K, V, EventPolicy<K, V>>>;
weighted?.DoMaintenance();
}

private sealed class UnitWeigher<K, V> : IWeightCalculator<K, V>
{
public int GetWeight(K key, V value) => 1;
}
}
}
42 changes: 42 additions & 0 deletions BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,48 @@ await Threaded.Run(threads, () =>
await RunIntegrityCheckAsync(lfu, iteration);
}

[Theory]
[Repeat(soakIterations)]
public async Task WhenConcurrentWeightedGetUpdateRemoveCacheEndsInConsistentState(int iteration)
{
this.output.WriteLine($"Weighted soak iteration {iteration}.");

var cache = new ConcurrentLfuBuilder<int, string>()
.WithConcurrencyLevel(threads)
.WithCapacity(9)
.WithScheduler(new BackgroundThreadScheduler())
.WithWeigher(new StringLengthWeigher())
.WithEvents()
.Build();

await Threaded.Run(threads, () =>
{
for (int i = 0; i < loopIterations; i++)
{
int k = i % 100;
cache.GetOrAdd(k, x => x.ToString());
cache.TryUpdate(k, "updated");

if ((i & 7) == 0)
{
cache.TryRemove(k);
}
}
});

var weighted = (WeightedConcurrentLfu<int, string, WeightedAccessOrderNode<int, string>, WeightedAccessOrderPolicy<int, string, EventPolicy<int, string>>>)cache;
new ConcurrentLfuIntegrityChecker<int, string, WeightedAccessOrderNode<int, string>, WeightedAccessOrderPolicy<int, string, EventPolicy<int, string>>, EventPolicy<int, string>>(weighted.Core).Validate(output);

// weighted invariant: total weight is non-negative and bounded by the weight capacity
weighted.Core.WeightedSize.Should().BeGreaterThanOrEqualTo(0);
weighted.Core.WeightedSize.Should().BeLessThanOrEqualTo(weighted.Capacity);
}

private sealed class StringLengthWeigher : IWeightCalculator<int, string>
{
public int GetWeight(int key, string value) => value.Length;
}

[Theory]
[Repeat(soakIterations)]
public async Task WhenConcurrentGetAndRemoveKvpCacheEndsInConsistentState(int iteration)
Expand Down
215 changes: 215 additions & 0 deletions BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using BitFaster.Caching.Lfu;
using BitFaster.Caching.Scheduler;
using FluentAssertions;
using Xunit;

namespace BitFaster.Caching.UnitTests.Lfu
{
public class WeightedNodePolicyTests
{
private const int Capacity = 100;

private ConcurrentLfuCore<int, int, WeightedAccessOrderNode<int, int>, WeightedAccessOrderPolicy<int, int, NoEventPolicy<int, int>>, NoEventPolicy<int, int>> core;

public WeightedNodePolicyTests()
{
core = CreateWeighted(Capacity, new ValueWeigher());
}

private static ConcurrentLfuCore<int, int, WeightedAccessOrderNode<int, int>, WeightedAccessOrderPolicy<int, int, NoEventPolicy<int, int>>, NoEventPolicy<int, int>> CreateWeighted(int capacity, IWeightCalculator<int, int> weigher)
{
var policy = new WeightedAccessOrderPolicy<int, int, NoEventPolicy<int, int>>(weigher);
return new(1, capacity, new NullScheduler(), EqualityComparer<int>.Default, () => { }, policy, default);
}

[Fact]
public void WhenItemsAddedWithinCapacityWeightedSizeEqualsTotalWeight()
{
core.AddOrUpdate(1, 10);
core.AddOrUpdate(2, 20);
core.AddOrUpdate(3, 30);
core.DoMaintenance();

core.Count.Should().Be(3);
core.WeightedSize.Should().Be(60);
}

[Fact]
public void WhenTotalWeightExceedsCapacityWeightedSizeStaysWithinMaximum()
{
for (int i = 0; i < 20; i++)
{
core.AddOrUpdate(i, 30);
}
core.DoMaintenance();

core.WeightedSize.Should().BeLessThanOrEqualTo(Capacity);
}

[Fact]
public void WhenItemWeightExceedsMaximumItemIsEvicted()
{
core.AddOrUpdate(1, 200);
core.DoMaintenance();

core.TryGet(1, out _).Should().BeFalse();
core.Count.Should().Be(0);
core.WeightedSize.Should().Be(0);
}

[Fact]
public void WhenItemHasZeroWeightItIsNotEvicted()
{
core.AddOrUpdate(1, 0);
core.AddOrUpdate(2, 60);
core.AddOrUpdate(3, 60);
core.DoMaintenance();

core.TryGet(1, out _).Should().BeTrue();
core.WeightedSize.Should().BeLessThanOrEqualTo(Capacity);
}

[Fact]
public void WhenItemWeightIncreasedWeightedSizeIncreases()
{
core.AddOrUpdate(1, 30);
core.DoMaintenance();
core.WeightedSize.Should().Be(30);

core.AddOrUpdate(1, 50);
core.DoMaintenance();

core.TryGet(1, out _).Should().BeTrue();
core.WeightedSize.Should().Be(50);
}

[Fact]
public void WhenItemWeightDecreasedWeightedSizeDecreases()
{
core.AddOrUpdate(1, 80);
core.DoMaintenance();
core.WeightedSize.Should().Be(80);

core.AddOrUpdate(1, 10);
core.DoMaintenance();

core.TryGet(1, out _).Should().BeTrue();
core.WeightedSize.Should().Be(10);
}

[Fact]
public void WhenItemRemovedWeightedSizeIsDiscounted()
{
core.AddOrUpdate(1, 30);
core.AddOrUpdate(2, 40);
core.DoMaintenance();
core.WeightedSize.Should().Be(70);

core.TryRemove(1);
core.DoMaintenance();

core.WeightedSize.Should().Be(40);
core.Count.Should().Be(1);
}

[Fact]
public void WhenProbationItemReadItIsPromotedAndProtectedWeightTracked()
{
core.AddOrUpdate(1, 10);
core.AddOrUpdate(2, 20);
core.AddOrUpdate(3, 30);
core.DoMaintenance();

// read item 1 so it is promoted from probation to protected during maintenance
core.TryGet(1, out _);
core.TryGet(1, out _);
core.DoMaintenance();

core.TryGet(1, out _).Should().BeTrue();
core.WeightedSize.Should().Be(60);
core.MainProtectedWeightedSize.Should().Be(10);
}

[Fact]
public void WhenRecencyWorkloadWindowMaximumIncreasesAndInvariantHolds()
{
var weighted = CreateWeighted(200, new ValueWeigher());

for (int i = 0; i < 10; i++)
{
weighted.AddOrUpdate(i, 10);
}
weighted.DoMaintenance();
long initialWindowMaximum = weighted.WindowMaximum;

// a steady stream of hits on resident items drives the hill climber to grow the window
for (int round = 0; round < 100; round++)
{
for (int r = 0; r < 15; r++)
{
for (int i = 0; i < 10; i++)
{
weighted.TryGet(i, out _);
}
}
weighted.DoMaintenance();
}

weighted.WindowMaximum.Should().BeGreaterThan(initialWindowMaximum);

// invariants must hold throughout adaptation
weighted.WeightedSize.Should().BeLessThanOrEqualTo(200);
weighted.WindowWeightedSize.Should().BeGreaterThanOrEqualTo(0);
weighted.MainProtectedWeightedSize.Should().BeGreaterThanOrEqualTo(0);
weighted.WindowMaximum.Should().BeGreaterThanOrEqualTo(1);
weighted.MainProtectedMaximum.Should().BeGreaterThanOrEqualTo(0);
}

[Fact]
public void WhenWeightedItemEvictedRemovedEventFires()
{
var removed = new List<ItemRemovedEventArgs<int, int>>();
var cache = new ConcurrentLfuBuilder<int, int>()
.WithCapacity(100)
.WithConcurrencyLevel(1)
.WithWeigher(new ValueWeigher())
.WithEvents()
.Build();
cache.Events.Value.ItemRemoved += (s, e) => removed.Add(e);

// weight 30 each, total 300 exceeds the weight capacity of 100
for (int i = 0; i < 10; i++)
{
cache.GetOrAdd(i, k => 30);
}

var weighted = cache as WeightedConcurrentLfu<int, int, WeightedAccessOrderNode<int, int>, WeightedAccessOrderPolicy<int, int, EventPolicy<int, int>>>;
weighted!.DoMaintenance();

removed.Should().NotBeEmpty();
removed.Should().OnlyContain(e => e.Reason == ItemRemovedReason.Evicted);
}

[Fact]
public void WhenWeightedWithExpiryTimeToExpireIsReturned()
{
var cache = new ConcurrentLfuBuilder<int, int>()
.WithCapacity(100)
.WithWeigher(new ValueWeigher())
.WithExpireAfterWrite(TimeSpan.FromMinutes(10))
.Build();

cache.GetOrAdd(1, k => 10);

cache.Policy.ExpireAfterWrite.HasValue.Should().BeTrue();
cache.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromMinutes(10));
}

private sealed class ValueWeigher : IWeightCalculator<int, int>
{
public int GetWeight(int key, int value) => value;
}
}
}
20 changes: 20 additions & 0 deletions BitFaster.Caching/IWeightCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace BitFaster.Caching
{
/// <summary>
/// Calculates the weight of cache entries. The total weight is used to bound the cache size,
/// and to determine when an eviction is required. Entry weights are relative to each other and
/// have no unit.
/// </summary>
/// <typeparam name="K">The type of keys.</typeparam>
/// <typeparam name="V">The type of values.</typeparam>
public interface IWeightCalculator<K, V>
{
/// <summary>
/// Returns the weight of a cache entry. The weight must be non-negative.
/// </summary>
/// <param name="key">The key to weigh.</param>
/// <param name="value">The value to weigh.</param>
/// <returns>The weight of the entry.</returns>
int GetWeight(K key, V value);
}
}
Loading
Loading