Skip to content
Merged
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
593 changes: 593 additions & 0 deletions BlitzCache.Tests/ApproximateValueSizerTests.cs

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions BlitzCache.Tests/CapacityAccountingInvariantTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using NUnit.Framework;
using System;
using System.Threading.Tasks;
using BlitzCacheCore.Tests.Helpers;
using BlitzCacheCore.Capacity;

namespace BlitzCacheCore.Tests
{
[TestFixture]
public class CapacityAccountingInvariantTests
{
private const int ValueBytes = 10_000;

[Test]
public void Overfill_Cache_Accounting_Invariants_Hold_Sync()
{
const long maxCacheSizeBytes = 60_000; // ~6 entries
const int totalInsert = 16; // force multiple eviction rounds

using var cache = new BlitzCacheInstance(
defaultMilliseconds: TestConstants.LongTimeoutMs,
cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs),
maxTopSlowest: 0,
valueSizer: null,
maxTopHeaviest: 0,
maxCacheSizeBytes: maxCacheSizeBytes,
evictionStrategy: CapacityEvictionStrategy.SmallestFirst);
cache.InitializeStatistics();

var beforeStats = cache.Statistics!;
long entryCountBefore = beforeStats.EntryCount;
long evictionCountBefore = beforeStats.EvictionCount;
long memoryBefore = beforeStats.ApproximateMemoryBytes;

for (int i = 0; i < totalInsert; i++)
{
cache.BlitzGet($"inv{i}", () => new byte[ValueBytes]);
}

TestDelays.WaitForStandardExpiration().GetAwaiter().GetResult();

var after = cache.Statistics!;
long entryCountAfter = after.EntryCount;
long evictionCountAfter = after.EvictionCount;
long memoryAfter = after.ApproximateMemoryBytes;

Assert.Greater(evictionCountAfter, evictionCountBefore, "Eviction count should have increased");
Assert.GreaterOrEqual(memoryAfter, 0, "Approximate memory should never be negative");
Assert.LessOrEqual(memoryAfter, maxCacheSizeBytes, "Approximate memory should be within limit");

var inserted = totalInsert; // each call creates a new key
var netEntryIncrease = entryCountAfter - entryCountBefore;
var evicted = evictionCountAfter - evictionCountBefore;
var expectedEvicted = inserted - netEntryIncrease;

// Allow small race tolerance (+/-1) for timing of eviction callbacks
Assert.LessOrEqual(Math.Abs(evicted - expectedEvicted), 1, $"Eviction delta mismatch. Inserted={inserted} NetIncrease={netEntryIncrease} Evicted={evicted} Expected={expectedEvicted}");
}

[Test]
public async Task Overfill_Cache_Accounting_Invariants_Hold_Async()
{
const long maxCacheSizeBytes = 70_000; // ~7 entries
const int totalInsert = 18;

using var cache = new BlitzCacheInstance(
defaultMilliseconds: TestConstants.LongTimeoutMs,
cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs),
maxTopSlowest: 0,
valueSizer: null,
maxTopHeaviest: 0,
maxCacheSizeBytes: maxCacheSizeBytes,
evictionStrategy: CapacityEvictionStrategy.LargestFirst);
cache.InitializeStatistics();

var beforeStats = cache.Statistics!;
long entryCountBefore = beforeStats.EntryCount;
long evictionCountBefore = beforeStats.EvictionCount;
long memoryBefore = beforeStats.ApproximateMemoryBytes;

for (int i = 0; i < totalInsert; i++)
{
await cache.BlitzGet($"ainv{i}", async () => await Task.FromResult(new byte[ValueBytes]));
}

await TestDelays.WaitForStandardExpiration();

var after = cache.Statistics!;
long entryCountAfter = after.EntryCount;
long evictionCountAfter = after.EvictionCount;
long memoryAfter = after.ApproximateMemoryBytes;

Assert.Greater(evictionCountAfter, evictionCountBefore, "Eviction count should have increased (async)");
Assert.GreaterOrEqual(memoryAfter, 0, "Approximate memory should never be negative (async)");
Assert.LessOrEqual(memoryAfter, maxCacheSizeBytes, "Approximate memory should be within limit (async)");

var inserted = totalInsert;
var netEntryIncrease = entryCountAfter - entryCountBefore;
var evicted = evictionCountAfter - evictionCountBefore;
var expectedEvicted = inserted - netEntryIncrease;
Assert.LessOrEqual(Math.Abs(evicted - expectedEvicted), 1, $"Eviction delta mismatch (async). Inserted={inserted} NetIncrease={netEntryIncrease} Evicted={evicted} Expected={expectedEvicted}");
}
}
}
76 changes: 76 additions & 0 deletions BlitzCache.Tests/CapacityEnforcerDoubleCountTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using NUnit.Framework;
using System;
using System.Threading.Tasks;
using BlitzCacheCore.Tests.Helpers;

namespace BlitzCacheCore.Tests
{
[TestFixture]
public class CapacityEnforcerDoubleCountTests
{
[Test]
public void ProactiveRemovals_AreCountedOnce()
{
const long maxCacheSizeBytes = 60_000; // ~6 entries of 10k
const int valueBytes = 10_000;
const int totalEntries = 14; // ensure multiple proactive passes

using var cache = new BlitzCacheInstance(
defaultMilliseconds: TestConstants.LongTimeoutMs,
cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs),
maxTopSlowest: 0,
valueSizer: null,
maxTopHeaviest: 0,
maxCacheSizeBytes: maxCacheSizeBytes);

cache.InitializeStatistics();

for (int i = 0; i < totalEntries; i++)
{
var key = $"dc{i}";
cache.BlitzGet(key, () => new byte[valueBytes]);
}

TestDelays.WaitForStandardExpiration().GetAwaiter().GetResult();

var stats = cache.Statistics!;
// EntryCount + EvictionCount should be close to totalEntries (allow 1 off for timing races)
var accounted = stats.EntryCount + stats.EvictionCount;
Assert.Greater(stats.EvictionCount, 0, "Should have evictions after exceeding size limit");
Assert.LessOrEqual(stats.ApproximateMemoryBytes, maxCacheSizeBytes, "Approximate memory should be within limit");
Assert.LessOrEqual(Math.Abs(accounted - totalEntries), 1, $"Inconsistent accounting: entries({stats.EntryCount}) + evictions({stats.EvictionCount}) vs inserted({totalEntries})");
}

[Test]
public async Task ProactiveRemovals_AreCountedOnce_Async()
{
const long maxCacheSizeBytes = 70_000; // ~7 entries of 10k
const int valueBytes = 10_000;
const int totalEntries = 18;

using var cache = new BlitzCacheInstance(
defaultMilliseconds: TestConstants.LongTimeoutMs,
cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs),
maxTopSlowest: 0,
valueSizer: null,
maxTopHeaviest: 0,
maxCacheSizeBytes: maxCacheSizeBytes);

cache.InitializeStatistics();

for (int i = 0; i < totalEntries; i++)
{
var key = $"adc{i}";
await cache.BlitzGet(key, async () => await Task.FromResult(new byte[valueBytes]));
}

await TestDelays.WaitForStandardExpiration();

var stats = cache.Statistics!;
var accounted = stats.EntryCount + stats.EvictionCount;
Assert.Greater(stats.EvictionCount, 0, "Should have evictions after exceeding size limit (async)");
Assert.LessOrEqual(stats.ApproximateMemoryBytes, maxCacheSizeBytes, "Approximate memory should be within limit (async)");
Assert.LessOrEqual(Math.Abs(accounted - totalEntries), 1, $"Inconsistent accounting (async): entries({stats.EntryCount}) + evictions({stats.EvictionCount}) vs inserted({totalEntries})");
}
}
}
61 changes: 61 additions & 0 deletions BlitzCache.Tests/CapacityEvictionStrategyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using NUnit.Framework;
using System;
using BlitzCacheCore.Tests.Helpers;
using BlitzCacheCore.Capacity;

namespace BlitzCacheCore.Tests
{
[TestFixture]
public class CapacityEvictionStrategyTests
{
[Test]
public void LargestFirst_Removes_Fewer_Items_To_Reclaim_Same_Bytes()
{
const int valueBytesBase = 5_000;
const long maxCacheSizeBytes = 40_000;
// We'll insert mixed-size items so largest-first should evict fewer.
var sizes = new[]{1,2,3,4,5,6,7,8}; // scaled by base -> 5k .. 40k

using var smallestFirstCache = new BlitzCacheInstance(
defaultMilliseconds: TestConstants.LongTimeoutMs,
cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs),
maxTopSlowest: 0,
valueSizer: null,
maxTopHeaviest: 0,
maxCacheSizeBytes: maxCacheSizeBytes,
evictionStrategy: CapacityEvictionStrategy.SmallestFirst);
smallestFirstCache.InitializeStatistics();

using var largestFirstCache = new BlitzCacheInstance(
defaultMilliseconds: TestConstants.LongTimeoutMs,
cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs),
maxTopSlowest: 0,
valueSizer: null,
maxTopHeaviest: 0,
maxCacheSizeBytes: maxCacheSizeBytes,
evictionStrategy: CapacityEvictionStrategy.LargestFirst);
largestFirstCache.InitializeStatistics();

// Insert all entries (this will overshoot once we insert more than 8*5k == 40k because of overhead)
foreach (var s in sizes)
{
var bytes = new byte[s * valueBytesBase];
smallestFirstCache.BlitzGet($"sf{s}", () => bytes);
largestFirstCache.BlitzGet($"lf{s}", () => bytes);
}

TestDelays.WaitUntil(() => smallestFirstCache.Statistics!.EvictionCount > 0 && largestFirstCache.Statistics!.EvictionCount > 0);

var sfStats = smallestFirstCache.Statistics!;
var lfStats = largestFirstCache.Statistics!;

Assert.Greater(sfStats.EvictionCount, 0, "Smallest-first should have evictions");
Assert.Greater(lfStats.EvictionCount, 0, "Largest-first should have evictions");
Assert.LessOrEqual(sfStats.ApproximateMemoryBytes, maxCacheSizeBytes);
Assert.LessOrEqual(lfStats.ApproximateMemoryBytes, maxCacheSizeBytes);

// Largest-first should usually require fewer evictions (allow equality to avoid flakiness from overhead)
Assert.LessOrEqual(lfStats.EvictionCount, sfStats.EvictionCount, $"Expected largest-first to evict fewer or equal entries (sf={sfStats.EvictionCount}, lf={lfStats.EvictionCount})");
}
}
}
1 change: 1 addition & 0 deletions BlitzCache.Tests/Helpers/FaultyStatistics.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using BlitzCacheCore.Statistics;
using BlitzCacheCore.Statistics.Speed;
using System;
using System.Collections.Generic;

Expand Down
26 changes: 26 additions & 0 deletions BlitzCache.Tests/Helpers/TestDelays.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,31 @@ internal static class TestDelays

public static Task WaitForSemaphoreExpiration() => Task.Delay(BlitzSemaphore.BlitzSemaphoreExpirationSeconds * 1500);
public static Task WaitForStandardExpiration() => Task.Delay(TestConstants.StandardTimeoutMs + TestConstants.ExpirationBufferMs);

/// <summary>
/// Repeatedly waits (with StandardExpiration delay) until condition returns true or attempts exhausted.
/// </summary>
public static async Task<bool> WaitUntilAsync(System.Func<bool> condition, int maxAttempts = 3)
{
for (int i = 0; i < maxAttempts; i++)
{
if (condition()) return true;
await WaitForStandardExpiration();
}
return condition();
}

/// <summary>
/// Synchronous variant for tests that are not async. Blocks the thread between attempts.
/// </summary>
public static bool WaitUntil(System.Func<bool> condition, int maxAttempts = 3)
{
for (int i = 0; i < maxAttempts; i++)
{
if (condition()) return true;
WaitForStandardExpiration().GetAwaiter().GetResult();
}
return condition();
}
}
}
58 changes: 58 additions & 0 deletions BlitzCache.Tests/IServiceCollectionExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,49 @@ public async Task AddBlitzCache_RegistersGlobalSingleton()
await AssertCacheWorksAsync(cache2, true);
}

[Test]
public async Task AddBlitzCache_WithOptionsDelegate_RegistersGlobalSingleton()
{
serviceProvider = new ServiceCollection()
.AddBlitzCache(o => { o.DefaultMilliseconds = 5000; o.MaxTopSlowest = 3; o.MaxTopHeaviest = 2; })
.BuildServiceProvider();

var cache = serviceProvider.GetRequiredService<IBlitzCache>();
await AssertCacheWorksAsync(cache);
}

[Test]
public async Task BlitzCache_DirectOptionsConstructor_Works()
{
BlitzCache.ClearGlobalForTesting();
var cache = new BlitzCache(new BlitzCacheOptions { DefaultMilliseconds = 2500, MaxTopSlowest = 2, MaxTopHeaviest = 2 });
await AssertCacheWorksAsync(cache);
}

[Test]
public async Task AddBlitzCache_WithConfigurePipeline_UsesConfiguredOptions()
{
BlitzCache.ClearGlobalForTesting();
serviceProvider = new ServiceCollection()
.Configure<BlitzCacheOptions>(o => { o.DefaultMilliseconds = 3333; o.MaxTopSlowest = 4; })
.AddBlitzCache() // picks up configured options
.BuildServiceProvider();

var cache = serviceProvider.GetRequiredService<IBlitzCache>();
await AssertCacheWorksAsync(cache);
}

[Test]
public async Task AddBlitzCache_ActionConfigure_ComposesWithOptionsPattern()
{
BlitzCache.ClearGlobalForTesting();
serviceProvider = new ServiceCollection()
.AddBlitzCache(o => { o.DefaultMilliseconds = 4444; o.MaxTopSlowest = 1; })
.BuildServiceProvider();
var cache = serviceProvider.GetRequiredService<IBlitzCache>();
await AssertCacheWorksAsync(cache);
}

[Test]
public void AddBlitzCache_ThrowsOnNull() => Assert.Throws<ArgumentNullException>(() => IServiceCollectionExtensions.AddBlitzCache(null));

Expand Down Expand Up @@ -76,6 +119,21 @@ public async Task AddBlitzCacheLogging_WithoutStatistics_LogsWarningAndDoesNotTh
await AssertCacheWorksAsync(cache);
}

[Test]
public async Task AddBlitzCacheLogging_WithOptionsDelegate_RegistersHostedService()
{
serviceProvider = new ServiceCollection()
.AddBlitzCache()
.AddLogging(b => b.AddDebug())
.AddBlitzCacheLogging(o => { o.LogInterval = TimeSpan.FromMilliseconds(10); o.GlobalCacheIdentifier = "TestAppOpt"; })
.BuildServiceProvider();

var hostedServices = serviceProvider.GetServices<IHostedService>();
Assert.IsTrue(hostedServices.Any(s => s.GetType().Name.Contains("BlitzCacheLoggingService")), "Should register BlitzCacheLoggingService via options overload");
var cache = serviceProvider.GetService<IBlitzCache>();
await AssertCacheWorksAsync(cache);
}

[Test]
public async Task AddBlitzCache_StatisticsShouldBeNull()
{
Expand Down
Loading