diff --git a/BlitzCache.Tests/ApproximateValueSizerTests.cs b/BlitzCache.Tests/ApproximateValueSizerTests.cs new file mode 100644 index 0000000..4d788c9 --- /dev/null +++ b/BlitzCache.Tests/ApproximateValueSizerTests.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using BlitzCacheCore.Statistics.Memory; +using NUnit.Framework; + +namespace BlitzCacheCore.Tests +{ + [TestFixture] + public class ApproximateValueSizerTests + { + private ApproximateValueSizer _sizer; + + [SetUp] + public void SetUp() + { + _sizer = new ApproximateValueSizer(); + } + + #region Null and Primitive Types + + [Test] + public void GetSizeBytes_Null_ReturnsZero() + { + var size = _sizer.GetSizeBytes(null); + Assert.That(size, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Bool_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(true); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Byte_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes((byte)42); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_SByte_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes((sbyte)-42); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Char_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes('A'); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Short_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes((short)1000); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_UShort_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes((ushort)1000); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Int_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(100000); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_UInt_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(100000u); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Float_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(3.14f); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Long_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(1000000000L); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ULong_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(1000000000UL); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Double_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(3.14159); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Decimal_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(123.456m); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Guid_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(Guid.NewGuid()); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_DateTime_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(DateTime.UtcNow); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_TimeSpan_ReturnsAlignedSize() + { + var size = _sizer.GetSizeBytes(TimeSpan.FromHours(1)); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + #endregion + + #region String Tests + + [Test] + public void GetSizeBytes_EmptyString_ReturnsOverheadOnly() + { + var size = _sizer.GetSizeBytes(""); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ShortString_ScalesWithLength() + { + var size5 = _sizer.GetSizeBytes("Hello"); + var size10 = _sizer.GetSizeBytes("HelloWorld"); + + Assert.That(size10, Is.GreaterThan(size5)); + // Each char is 2 bytes in .NET strings, but alignment may reduce the apparent difference + var expectedDiff = (10 - 5) * 2; + Assert.That(size10 - size5, Is.GreaterThanOrEqualTo(expectedDiff - 8), + "Size should increase by approximately 2 bytes per character (accounting for alignment)"); + } + + [Test] + public void GetSizeBytes_LargeString_ScalesLinearly() + { + var str100 = new string('x', 100); + var str200 = new string('x', 200); + + var size100 = _sizer.GetSizeBytes(str100); + var size200 = _sizer.GetSizeBytes(str200); + + Assert.That(size200, Is.GreaterThan(size100)); + // Should roughly double (within alignment padding) + Assert.That(size200, Is.GreaterThanOrEqualTo(size100 * 1.8)); + } + + [Test] + public void GetSizeBytes_UnicodeString_CountsAllCharacters() + { + var unicode = "Hello 世界 🌍"; + var size = _sizer.GetSizeBytes(unicode); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + #endregion + + #region Array Tests + + [Test] + public void GetSizeBytes_EmptyByteArray_ReturnsOverheadOnly() + { + var size = _sizer.GetSizeBytes(new byte[0]); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ByteArray_ScalesWithLength() + { + var arr10 = new byte[10]; + var arr100 = new byte[100]; + + var size10 = _sizer.GetSizeBytes(arr10); + var size100 = _sizer.GetSizeBytes(arr100); + + Assert.That(size100, Is.GreaterThan(size10)); + // Accounting for 8-byte alignment padding + Assert.That(size100 - size10, Is.GreaterThanOrEqualTo(88), + "Size should increase by approximately 90 bytes (accounting for alignment)"); + } + + [Test] + public void GetSizeBytes_SByteArray_ScalesWithLength() + { + var arr = new sbyte[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(50)); // at least array length + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_BoolArray_ScalesWithLength() + { + var arr = new bool[100]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(100)); // at least array length + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_CharArray_Scales2BytesPerElement() + { + var arr = new char[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(100)); // at least 2 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ShortArray_Scales2BytesPerElement() + { + var arr = new short[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(100)); // at least 2 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_UShortArray_Scales2BytesPerElement() + { + var arr = new ushort[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(100)); // at least 2 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_IntArray_Scales4BytesPerElement() + { + var arr = new int[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(200)); // at least 4 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_UIntArray_Scales4BytesPerElement() + { + var arr = new uint[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(200)); // at least 4 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_FloatArray_Scales4BytesPerElement() + { + var arr = new float[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(200)); // at least 4 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_LongArray_Scales8BytesPerElement() + { + var arr = new long[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(400)); // at least 8 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ULongArray_Scales8BytesPerElement() + { + var arr = new ulong[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(400)); // at least 8 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_DoubleArray_Scales8BytesPerElement() + { + var arr = new double[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(400)); // at least 8 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_DecimalArray_Scales16BytesPerElement() + { + var arr = new decimal[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(800)); // at least 16 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_GuidArray_Scales16BytesPerElement() + { + var arr = new Guid[50]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(800)); // at least 16 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_StringArray_ReturnsPointerSizedEstimate() + { + var arr = new string[10]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ObjectArray_ReturnsFallbackSize() + { + var arr = new object[10]; + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + #endregion + + #region Memory and ReadOnlyMemory Tests + + [Test] + public void GetSizeBytes_MemoryOfByte_ScalesWithLength() + { + var mem = new Memory(new byte[100]); + var size = _sizer.GetSizeBytes(mem); + + Assert.That(size, Is.GreaterThan(100)); // at least length + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ReadOnlyMemoryOfByte_ScalesWithLength() + { + var mem = new ReadOnlyMemory(new byte[100]); + var size = _sizer.GetSizeBytes(mem); + + Assert.That(size, Is.GreaterThan(100)); // at least length + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_MemoryOfInt_Scales4BytesPerElement() + { + var mem = new Memory(new int[50]); + var size = _sizer.GetSizeBytes(mem); + + Assert.That(size, Is.GreaterThan(200)); // at least 4 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_MemoryOfLong_Scales8BytesPerElement() + { + var mem = new Memory(new long[50]); + var size = _sizer.GetSizeBytes(mem); + + Assert.That(size, Is.GreaterThan(400)); // at least 8 * 50 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_EmptyMemory_ReturnsOverheadOnly() + { + var mem = new Memory(Array.Empty()); + var size = _sizer.GetSizeBytes(mem); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + #endregion + + #region List Tests + + [Test] + public void GetSizeBytes_EmptyListOfInt_ReturnsOverheadOnly() + { + var list = new List(); + var size = _sizer.GetSizeBytes(list); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ListOfInt_ScalesWithCount() + { + var list = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var size = _sizer.GetSizeBytes(list); + + Assert.That(size, Is.GreaterThan(40)); // at least 4 * 10 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ListOfByte_ScalesWithCount() + { + var list = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var size = _sizer.GetSizeBytes(list); + + Assert.That(size, Is.GreaterThan(10)); // at least count + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ListOfLong_Scales8BytesPerElement() + { + var list = new List { 1L, 2L, 3L, 4L, 5L }; + var size = _sizer.GetSizeBytes(list); + + Assert.That(size, Is.GreaterThan(40)); // at least 8 * 5 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ListOfString_UsesPointerSize() + { + var list = new List { "one", "two", "three" }; + var size = _sizer.GetSizeBytes(list); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_ListOfDouble_ScalesWithCount() + { + var list = new List { 1.1, 2.2, 3.3, 4.4, 5.5 }; + var size = _sizer.GetSizeBytes(list); + + Assert.That(size, Is.GreaterThan(40)); // at least 8 * 5 + overhead + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + #endregion + + #region Unknown/Fallback Types + + [Test] + public void GetSizeBytes_CustomClass_ReturnsFallbackSize() + { + var obj = new TestClass { Value = 42 }; + var size = _sizer.GetSizeBytes(obj); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + Assert.That(size, Is.EqualTo(128), "Should return conservative fallback estimate"); + } + + [Test] + public void GetSizeBytes_AnonymousType_ReturnsFallbackSize() + { + var obj = new { A = 1, B = "test", C = 3.14 }; + var size = _sizer.GetSizeBytes(obj); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_Dictionary_ReturnsFallbackSize() + { + var dict = new Dictionary { ["one"] = 1, ["two"] = 2 }; + var size = _sizer.GetSizeBytes(dict); + + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + #endregion + + #region Edge Cases + + [Test] + public void GetSizeBytes_VeryLargeArray_HandlesLargeSize() + { + var arr = new byte[1024 * 1024]; // 1 MB + var size = _sizer.GetSizeBytes(arr); + + Assert.That(size, Is.GreaterThan(1024 * 1024)); + Assert.That(size % 8, Is.EqualTo(0), "Size should be 8-byte aligned"); + } + + [Test] + public void GetSizeBytes_MultipleCallsSameValue_ReturnsConsistentResult() + { + var value = "test string"; + var size1 = _sizer.GetSizeBytes(value); + var size2 = _sizer.GetSizeBytes(value); + var size3 = _sizer.GetSizeBytes(value); + + Assert.That(size2, Is.EqualTo(size1)); + Assert.That(size3, Is.EqualTo(size1)); + } + + [Test] + public void GetSizeBytes_MultipleDifferentTypes_AllAligned() + { + var values = new object[] + { + 42, + "test", + new byte[10], + 3.14, + DateTime.Now, + Guid.NewGuid(), + new int[5] + }; + + foreach (var value in values) + { + var size = _sizer.GetSizeBytes(value); + Assert.That(size % 8, Is.EqualTo(0), $"Size for {value.GetType().Name} should be 8-byte aligned"); + } + } + + #endregion + + #region Helper Classes + + private class TestClass + { + public int Value { get; set; } + } + + #endregion + } +} diff --git a/BlitzCache.Tests/CapacityAccountingInvariantTests.cs b/BlitzCache.Tests/CapacityAccountingInvariantTests.cs new file mode 100644 index 0000000..88047fe --- /dev/null +++ b/BlitzCache.Tests/CapacityAccountingInvariantTests.cs @@ -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}"); + } + } +} diff --git a/BlitzCache.Tests/CapacityEnforcerDoubleCountTests.cs b/BlitzCache.Tests/CapacityEnforcerDoubleCountTests.cs new file mode 100644 index 0000000..babb1d7 --- /dev/null +++ b/BlitzCache.Tests/CapacityEnforcerDoubleCountTests.cs @@ -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})"); + } + } +} diff --git a/BlitzCache.Tests/CapacityEvictionStrategyTests.cs b/BlitzCache.Tests/CapacityEvictionStrategyTests.cs new file mode 100644 index 0000000..2418a49 --- /dev/null +++ b/BlitzCache.Tests/CapacityEvictionStrategyTests.cs @@ -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})"); + } + } +} diff --git a/BlitzCache.Tests/Helpers/FaultyStatistics.cs b/BlitzCache.Tests/Helpers/FaultyStatistics.cs index 29092c9..7a148b0 100644 --- a/BlitzCache.Tests/Helpers/FaultyStatistics.cs +++ b/BlitzCache.Tests/Helpers/FaultyStatistics.cs @@ -1,4 +1,5 @@ using BlitzCacheCore.Statistics; +using BlitzCacheCore.Statistics.Speed; using System; using System.Collections.Generic; diff --git a/BlitzCache.Tests/Helpers/TestDelays.cs b/BlitzCache.Tests/Helpers/TestDelays.cs index 61370ad..b3cae8b 100644 --- a/BlitzCache.Tests/Helpers/TestDelays.cs +++ b/BlitzCache.Tests/Helpers/TestDelays.cs @@ -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); + + /// + /// Repeatedly waits (with StandardExpiration delay) until condition returns true or attempts exhausted. + /// + public static async Task WaitUntilAsync(System.Func condition, int maxAttempts = 3) + { + for (int i = 0; i < maxAttempts; i++) + { + if (condition()) return true; + await WaitForStandardExpiration(); + } + return condition(); + } + + /// + /// Synchronous variant for tests that are not async. Blocks the thread between attempts. + /// + public static bool WaitUntil(System.Func condition, int maxAttempts = 3) + { + for (int i = 0; i < maxAttempts; i++) + { + if (condition()) return true; + WaitForStandardExpiration().GetAwaiter().GetResult(); + } + return condition(); + } } } \ No newline at end of file diff --git a/BlitzCache.Tests/IServiceCollectionExtensionsTests.cs b/BlitzCache.Tests/IServiceCollectionExtensionsTests.cs index 137f29f..4bff6c4 100644 --- a/BlitzCache.Tests/IServiceCollectionExtensionsTests.cs +++ b/BlitzCache.Tests/IServiceCollectionExtensionsTests.cs @@ -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(); + 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(o => { o.DefaultMilliseconds = 3333; o.MaxTopSlowest = 4; }) + .AddBlitzCache() // picks up configured options + .BuildServiceProvider(); + + var cache = serviceProvider.GetRequiredService(); + 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(); + await AssertCacheWorksAsync(cache); + } + [Test] public void AddBlitzCache_ThrowsOnNull() => Assert.Throws(() => IServiceCollectionExtensions.AddBlitzCache(null)); @@ -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(); + Assert.IsTrue(hostedServices.Any(s => s.GetType().Name.Contains("BlitzCacheLoggingService")), "Should register BlitzCacheLoggingService via options overload"); + var cache = serviceProvider.GetService(); + await AssertCacheWorksAsync(cache); + } + [Test] public async Task AddBlitzCache_StatisticsShouldBeNull() { diff --git a/BlitzCache.Tests/MemoryLimitEvictionTests.cs b/BlitzCache.Tests/MemoryLimitEvictionTests.cs index 2d182e1..d62e9a1 100644 --- a/BlitzCache.Tests/MemoryLimitEvictionTests.cs +++ b/BlitzCache.Tests/MemoryLimitEvictionTests.cs @@ -35,7 +35,7 @@ public void CapacityLimit_TriggersEvictions_And_RespectsLimit_Sync() } // Give eviction callbacks a moment - TestDelays.WaitForStandardExpiration(); + TestDelays.WaitUntil(() => cache.Statistics!.EvictionCount > 0); var stats = cache.Statistics!; Assert.Greater(stats.EvictionCount, 0, "Capacity limit should trigger evictions"); @@ -140,5 +140,103 @@ public void CapacityLimit_Works_When_Statistics_Disabled() Assert.GreaterOrEqual(recomputed, 1, "Eviction should occur even with statistics disabled"); } + + [Test] + public void CapacityAccounting_Invariants_Sync() + { + const long maxCacheSizeBytes = 60_000; // ~6 entries of 10k + const int valueBytes = 10_000; + const int totalInsert = 16; + + using var cache = new BlitzCacheInstance( + defaultMilliseconds: TestConstants.LongTimeoutMs, + cleanupInterval: TimeSpan.FromMilliseconds(TestConstants.StandardTimeoutMs), + maxTopSlowest: 0, + valueSizer: null, + maxTopHeaviest: 0, + maxCacheSizeBytes: maxCacheSizeBytes); + cache.InitializeStatistics(); + + var before = cache.Statistics!; + long entryBefore = before.EntryCount; + long evictBefore = before.EvictionCount; + + for (int i = 0; i < totalInsert; i++) + cache.BlitzGet($"inv{i}", () => new byte[valueBytes]); + + TestDelays.WaitForStandardExpiration(); + + var after = cache.Statistics!; + long entryAfter = after.EntryCount; + long evictAfter = after.EvictionCount; + long memAfter = after.ApproximateMemoryBytes; + + // Allow eviction callbacks to register before asserting increase + if (evictAfter == evictBefore) + { + TestDelays.WaitUntil(() => (evictAfter = cache.Statistics!.EvictionCount) > evictBefore); + } + Assert.Greater(evictAfter, evictBefore, "Eviction count should increase"); + Assert.GreaterOrEqual(memAfter, 0, "Approximate memory must be non-negative"); + if (!TestDelays.WaitUntil(() => (memAfter = cache.Statistics!.ApproximateMemoryBytes) <= maxCacheSizeBytes)) + { + Assert.Fail($"Approximate memory exceeded limit after retries. mem={memAfter} limit={maxCacheSizeBytes}"); + } + + var inserted = totalInsert; + var netIncrease = entryAfter - entryBefore; + var evicted = evictAfter - evictBefore; + var expectedEvicted = inserted - netIncrease; + Assert.LessOrEqual(Math.Abs(evicted - expectedEvicted), 1, $"Eviction delta mismatch: inserted={inserted} net+={netIncrease} evicted={evicted} expected={expectedEvicted}"); + } + + [Test] + public async Task CapacityAccounting_Invariants_Async() + { + const long maxCacheSizeBytes = 70_000; // ~7 entries + const int valueBytes = 10_000; + 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); + cache.InitializeStatistics(); + + var before = cache.Statistics!; + long entryBefore = before.EntryCount; + long evictBefore = before.EvictionCount; + + 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 entryAfter = after.EntryCount; + long evictAfter = after.EvictionCount; + long memAfter = after.ApproximateMemoryBytes; + + // Allow eviction callbacks to register before asserting increase (async) + if (evictAfter == evictBefore) + { + await TestDelays.WaitUntilAsync(() => (evictAfter = cache.Statistics!.EvictionCount) > evictBefore); + } + Assert.Greater(evictAfter, evictBefore, "Eviction count should increase (async)"); + Assert.GreaterOrEqual(memAfter, 0, "Approximate memory must be non-negative (async)"); + if (!await TestDelays.WaitUntilAsync(() => (memAfter = cache.Statistics!.ApproximateMemoryBytes) <= maxCacheSizeBytes)) + { + Assert.Fail($"Approximate memory exceeded limit after retries (async). mem={memAfter} limit={maxCacheSizeBytes}"); + } + + var inserted = totalInsert; + var netIncrease = entryAfter - entryBefore; + var evicted = evictAfter - evictBefore; + var expectedEvicted = inserted - netIncrease; + Assert.LessOrEqual(Math.Abs(evicted - expectedEvicted), 1, $"Eviction delta mismatch (async): inserted={inserted} net+={netIncrease} evicted={evicted} expected={expectedEvicted}"); + } } } diff --git a/BlitzCache.Tests/ObjectGraphValueSizerBenchmarkTests.cs b/BlitzCache.Tests/ObjectGraphValueSizerBenchmarkTests.cs new file mode 100644 index 0000000..4b39b19 --- /dev/null +++ b/BlitzCache.Tests/ObjectGraphValueSizerBenchmarkTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using BlitzCacheCore.Statistics.Memory; +using NUnit.Framework; + +namespace BlitzCacheCore.Tests +{ + [TestFixture] + public class ObjectGraphValueSizerBenchmarkTests + { + private readonly IValueSizer approx = new ApproximateValueSizer(); + private readonly IValueSizer graph = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { MaxDepth = 2, MaxObjects = 512 }); + + private class Leaf { public int A; public long B; public string C = new string('x', 16); } + private class Node { public string Name = "node"; public List Leaves = new List(); public Node Next; } + + private object BuildGraph(int depth, int breadth) + { + Node root = new Node(); + Node current = root; + for (int d = 0; d < depth; d++) + { + for (int i = 0; i < breadth; i++) current.Leaves.Add(new Leaf()); + if (d < depth - 1) + { + current.Next = new Node(); + current = current.Next; + } + } + return root; + } + + private long RunSizer(IValueSizer sizer, object value, int iterations) + { + long checksum = 0; + for (int i = 0; i < iterations; i++) checksum += sizer.GetSizeBytes(value); + return checksum; + } + + private (double approxNs, double graphNs) Measure(object value, int iterations) + { + // Warmup + RunSizer(approx, value, 10); + RunSizer(graph, value, 10); + + var sw = Stopwatch.StartNew(); + RunSizer(approx, value, iterations); + sw.Stop(); + double approxPer = (sw.Elapsed.TotalMilliseconds * 1_000_000.0) / iterations; // ns + + sw.Restart(); + RunSizer(graph, value, iterations); + sw.Stop(); + double graphPer = (sw.Elapsed.TotalMilliseconds * 1_000_000.0) / iterations; + + return (approxPer, graphPer); + } + + [Test, Explicit("Micro-benchmark; run manually")] + public void Compare_Primitive_String() + { + var (a, g) = Measure(new string('x', 64), 20000); + TestContext.WriteLine($"String 64 chars: approx ~{a:F1} ns vs graph ~{g:F1} ns (x{(g/a):F2})"); + } + + [Test, Explicit("Micro-benchmark; run manually")] + public void Compare_Array_Int() + { + var (a, g) = Measure(new int[256], 10000); + TestContext.WriteLine($"int[256]: approx ~{a:F1} ns vs graph ~{g:F1} ns (x{(g/a):F2})"); + } + + [Test, Explicit("Micro-benchmark; run manually")] + public void Compare_ObjectGraph_Shallow() + { + var obj = new { A=1, B=2L, C="hello", D=DateTime.UtcNow }; + var (a, g) = Measure(obj, 20000); + TestContext.WriteLine($"Anon shallow: approx ~{a:F1} ns vs graph ~{g:F1} ns (x{(g/a):F2})"); + } + + [Test, Explicit("Micro-benchmark; run manually")] + public void Compare_ObjectGraph_Deep() + { + var graphObj = BuildGraph(depth:4, breadth:8); // up to depth 4, but sizer limited to 2 + var (a, g) = Measure(graphObj, 2000); + TestContext.WriteLine($"Node graph(depth4,breadth8): approx ~{a:F1} ns vs graph ~{g:F1} ns (x{(g/a):F2})"); + } + } +} diff --git a/BlitzCache.Tests/ObjectGraphValueSizerCoverageTests.cs b/BlitzCache.Tests/ObjectGraphValueSizerCoverageTests.cs new file mode 100644 index 0000000..732efad --- /dev/null +++ b/BlitzCache.Tests/ObjectGraphValueSizerCoverageTests.cs @@ -0,0 +1,1045 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using BlitzCacheCore.Statistics.Memory; +using NUnit.Framework; + +namespace BlitzCacheCore.Tests +{ + /// + /// Comprehensive coverage tests for ObjectGraphValueSizer to ensure all code paths are exercised. + /// + [TestFixture] + public class ObjectGraphValueSizerCoverageTests + { + private ObjectGraphValueSizer _balancedSizer; + private ObjectGraphValueSizer _fastSizer; + private ObjectGraphValueSizer _adaptiveSizer; + private ObjectGraphValueSizer _accurateSizer; + + [SetUp] + public void SetUp() + { + _balancedSizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + Mode = SizeComputationMode.Balanced, + MaxDepth = 3, + MaxObjects = 512 + }); + + _fastSizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + Mode = SizeComputationMode.Fast + }); + + _adaptiveSizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + Mode = SizeComputationMode.Adaptive, + MaxDepth = 3, + MaxObjects = 256 + }); + + _accurateSizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + Mode = SizeComputationMode.Accurate, + MaxDepth = 4, + MaxObjects = 1024 + }); + } + + #region Null and Primitive Tests + + [Test] + public void GetSizeBytes_Null_ReturnsZero() + { + Assert.That(_balancedSizer.GetSizeBytes(null), Is.EqualTo(0)); + Assert.That(_fastSizer.GetSizeBytes(null), Is.EqualTo(0)); + Assert.That(_adaptiveSizer.GetSizeBytes(null), Is.EqualTo(0)); + Assert.That(_accurateSizer.GetSizeBytes(null), Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Bool_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(true); + Assert.That(size, Is.EqualTo(1), "bool is 1 byte"); + } + + [Test] + public void GetSizeBytes_Byte_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes((byte)255); + Assert.That(size, Is.EqualTo(1), "byte is 1 byte"); + } + + [Test] + public void GetSizeBytes_SByte_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes((sbyte)-128); + Assert.That(size, Is.EqualTo(1), "sbyte is 1 byte"); + } + + [Test] + public void GetSizeBytes_Int16_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes((short)1000); + Assert.That(size, Is.EqualTo(2), "short is 2 bytes"); + } + + [Test] + public void GetSizeBytes_UInt16_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes((ushort)5000); + Assert.That(size, Is.EqualTo(2), "ushort is 2 bytes"); + } + + [Test] + public void GetSizeBytes_Char_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes('Z'); + Assert.That(size, Is.EqualTo(2), "char is 2 bytes"); + } + + [Test] + public void GetSizeBytes_Int32_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(123456); + Assert.That(size, Is.EqualTo(4), "int is 4 bytes"); + } + + [Test] + public void GetSizeBytes_UInt32_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(123456u); + Assert.That(size, Is.EqualTo(4), "uint is 4 bytes"); + } + + [Test] + public void GetSizeBytes_Float_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(3.14f); + Assert.That(size, Is.EqualTo(4), "float is 4 bytes"); + } + + [Test] + public void GetSizeBytes_Int64_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(9876543210L); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_UInt64_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(9876543210UL); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Double_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(3.141592653589793); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Decimal_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(123.456m); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Guid_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(Guid.NewGuid()); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_DateTime_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(DateTime.UtcNow); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_TimeSpan_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(TimeSpan.FromHours(2.5)); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_IntPtr_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(new IntPtr(12345)); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_UIntPtr_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(new UIntPtr(54321)); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Enum Tests + + public enum TestEnum { None = 0, First = 1, Second = 2, Third = 3 } + + [Flags] + public enum FlagsEnum { None = 0, Read = 1, Write = 2, Execute = 4 } + + [Test] + public void GetSizeBytes_Enum_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(TestEnum.Second); + Assert.That(size, Is.EqualTo(4), "enum (int-based) is 4 bytes"); + } + + [Test] + public void GetSizeBytes_FlagsEnum_ReturnsPrimitiveSize() + { + var size = _balancedSizer.GetSizeBytes(FlagsEnum.Read | FlagsEnum.Write); + Assert.That(size, Is.EqualTo(4), "flags enum (int-based) is 4 bytes"); + } + + #endregion + + #region Array Tests - Primitives + + [Test] + public void GetSizeBytes_EmptyArray_ReturnsHeaderSize() + { + var size = _balancedSizer.GetSizeBytes(new int[0]); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ByteArray_ScalesWithLength() + { + var arr = new byte[100]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(100)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_IntArray_ScalesWithLength() + { + var arr = new int[50]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(200)); // at least 4 * 50 + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_LongArray_ScalesWithLength() + { + var arr = new long[50]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(400)); // at least 8 * 50 + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_DoubleArray_ScalesWithLength() + { + var arr = new double[50]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(400)); // at least 8 * 50 + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_BoolArray_ScalesWithLength() + { + var arr = new bool[100]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(100)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_CharArray_ScalesWithLength() + { + var arr = new char[50]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(100)); // at least 2 * 50 + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_DecimalArray_ScalesWithLength() + { + var arr = new decimal[25]; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(400)); // at least 16 * 25 + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Array Tests - Structs and Reference Types + + public struct SimpleStruct + { + public int X; + public int Y; + } + + public struct NestedStruct + { + public int Value; + public SimpleStruct Inner; + public string Text; + } + + [Test] + public void GetSizeBytes_StructArray_WithReflection() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + ReflectIntoStructs = true, + MaxDepth = 2 + }); + + var arr = new SimpleStruct[10]; + for (int i = 0; i < arr.Length; i++) + arr[i] = new SimpleStruct { X = i, Y = i * 2 }; + + var size = sizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(80)); // at least 8 bytes * 10 + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_StructArray_WithoutReflection() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + ReflectIntoStructs = false + }); + + var arr = new SimpleStruct[10]; + var size = sizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_NestedStructArray_WithReflection() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + ReflectIntoStructs = true, + MaxDepth = 3 + }); + + var arr = new NestedStruct[5]; + for (int i = 0; i < arr.Length; i++) + arr[i] = new NestedStruct { Value = i, Inner = new SimpleStruct { X = i, Y = i }, Text = "test" }; + + var size = sizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_StringArray_IncludesReferences() + { + var arr = new string[] { "one", "two", "three", "four", "five" }; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ObjectArray_WithBalancedMode() + { + var arr = new object[] + { + new { A = 1 }, + new { B = 2 }, + new { C = 3 } + }; + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ObjectArray_WithFastMode() + { + var arr = new object[] + { + new { A = 1 }, + new { B = 2 }, + new { C = 3 } + }; + var size = _fastSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ObjectArray_WithAdaptiveMode() + { + var arr = new object[] + { + new { A = 1, B = "test" }, + new { C = 2, D = "data" }, + new { E = 3, F = "info" } + }; + var size = _adaptiveSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Collection Tests - Dictionary + + [Test] + public void GetSizeBytes_EmptyDictionary_ReturnsOverhead() + { + var dict = new Dictionary(); + var size = _balancedSizer.GetSizeBytes(dict); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Dictionary_WithPrimitiveValues() + { + var dict = new Dictionary + { + ["one"] = 1, + ["two"] = 2, + ["three"] = 3, + ["four"] = 4, + ["five"] = 5 + }; + var size = _balancedSizer.GetSizeBytes(dict); + Assert.That(size, Is.GreaterThan(0)); + // ObjectGraphValueSizer does not align collection sizes + } + + [Test] + public void GetSizeBytes_Dictionary_WithObjectValues() + { + var dict = new Dictionary + { + ["a"] = new { Value = 1 }, + ["b"] = new { Value = 2 }, + ["c"] = new { Value = 3 } + }; + var size = _balancedSizer.GetSizeBytes(dict); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_LargeDictionary_UsesSampling() + { + var dict = new Dictionary(); + for (int i = 0; i < 200; i++) + dict[$"key{i}"] = i; + + var size = _balancedSizer.GetSizeBytes(dict); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Dictionary_FastMode_SkipsTraversal() + { + var dict = new Dictionary + { + ["a"] = new { Value = 1, Data = new string('x', 100) }, + ["b"] = new { Value = 2, Data = new string('y', 100) } + }; + var size = _fastSizer.GetSizeBytes(dict); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Dictionary_AdaptiveMode_SelectiveTraversal() + { + var dict = new Dictionary + { + ["simple"] = 42, + ["complex"] = new { A = 1, B = "test", C = new List { 1, 2, 3 } } + }; + var size = _adaptiveSizer.GetSizeBytes(dict); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Collection Tests - IEnumerable + + private class CustomEnumerable : IEnumerable + { + private readonly List _items = new List { 1, 2, 3, 4, 5 }; + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private class CustomEnumerableWithCount : IEnumerable, ICollection + { + private readonly List _items = new List { "a", "b", "c", "d", "e" }; + + public int Count => _items.Count; + public bool IsReadOnly => true; + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Add(string item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(string item) => _items.Contains(item); + public void CopyTo(string[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public bool Remove(string item) => throw new NotSupportedException(); + } + + [Test] + public void GetSizeBytes_CustomEnumerable_WithoutCount() + { + var enumerable = new CustomEnumerable(); + var size = _balancedSizer.GetSizeBytes(enumerable); + Assert.That(size, Is.GreaterThan(0)); + // ObjectGraphValueSizer does not align collection sizes + } + + [Test] + public void GetSizeBytes_CustomEnumerable_WithCount() + { + var enumerable = new CustomEnumerableWithCount(); + var size = _balancedSizer.GetSizeBytes(enumerable); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_List_AsIEnumerable() + { + IEnumerable list = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var size = _balancedSizer.GetSizeBytes(list); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_LargeEnumerable_UsesSampling() + { + var list = Enumerable.Range(1, 200).ToList(); + var size = _balancedSizer.GetSizeBytes(list); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Enumerable_FastMode() + { + var list = new List(); + for (int i = 0; i < 50; i++) + list.Add(new { Value = i, Data = new string('x', 20) }); + + var size = _fastSizer.GetSizeBytes(list); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_Enumerable_AdaptiveMode() + { + var list = new List + { + 42, + "simple", + new { Complex = new List { 1, 2, 3 } } + }; + var size = _adaptiveSizer.GetSizeBytes(list); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region MaxObjects and MaxDepth Tests + + private class DeepNode + { + public string Name = "node"; + public DeepNode Child; + } + + [Test] + public void GetSizeBytes_ExceedsMaxObjects_StopsTraversal() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + MaxObjects = 5, + MaxDepth = 10 + }); + + var list = new List(); + for (int i = 0; i < 20; i++) + list.Add(new { Value = i }); + + var size = sizer.GetSizeBytes(list); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_DeepNesting_RespectsMaxDepth() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + MaxDepth = 2, + MaxObjects = 100 + }); + + var root = new DeepNode { Name = "L1" }; + root.Child = new DeepNode { Name = "L2" }; + root.Child.Child = new DeepNode { Name = "L3" }; + root.Child.Child.Child = new DeepNode { Name = "L4" }; + root.Child.Child.Child.Child = new DeepNode { Name = "L5" }; + + var size = sizer.GetSizeBytes(root); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_AccurateMode_IncludesExtraDepth() + { + var root = new DeepNode { Name = "L1" }; + root.Child = new DeepNode { Name = "L2" }; + root.Child.Child = new DeepNode { Name = "L3" }; + root.Child.Child.Child = new DeepNode { Name = "L4" }; + + var size = _accurateSizer.GetSizeBytes(root); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Custom Struct Tests + + public struct StructWithReferences + { + public int Value; + public string Text; + public List Numbers; + } + + [Test] + public void GetSizeBytes_StructWithReferences_ReflectEnabled() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + ReflectIntoStructs = true, + MaxDepth = 3 + }); + + var data = new StructWithReferences + { + Value = 42, + Text = "hello", + Numbers = new List { 1, 2, 3, 4, 5 } + }; + + var size = sizer.GetSizeBytes(data); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_StructWithReferences_ReflectDisabled() + { + var sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions + { + ReflectIntoStructs = false + }); + + var data = new StructWithReferences + { + Value = 42, + Text = "hello", + Numbers = new List { 1, 2, 3 } + }; + + var size = sizer.GetSizeBytes(data); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_StructWithReferences_FastMode() + { + var data = new StructWithReferences + { + Value = 42, + Text = new string('x', 100), + Numbers = new List(Enumerable.Range(1, 100)) + }; + + var size = _fastSizer.GetSizeBytes(data); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Anonymous Type and Complex Object Tests + + [Test] + public void GetSizeBytes_AnonymousType_Simple() + { + var obj = new { A = 1, B = "test", C = 3.14 }; + var size = _balancedSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_AnonymousType_Nested() + { + var obj = new + { + A = 1, + B = new { X = 10, Y = 20 }, + C = new List { 1, 2, 3 } + }; + var size = _balancedSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_AnonymousType_AllModes() + { + var obj = new + { + Name = "test", + Value = 42, + Data = new List { "a", "b", "c" }, + Nested = new { X = 1, Y = 2 } + }; + + var fastSize = _fastSizer.GetSizeBytes(obj); + var balancedSize = _balancedSizer.GetSizeBytes(obj); + var adaptiveSize = _adaptiveSizer.GetSizeBytes(obj); + var accurateSize = _accurateSizer.GetSizeBytes(obj); + + Assert.That(fastSize, Is.GreaterThan(0)); + Assert.That(balancedSize, Is.GreaterThan(0)); + Assert.That(adaptiveSize, Is.GreaterThan(0)); + Assert.That(accurateSize, Is.GreaterThan(0)); + + // Fast should be smallest or equal + Assert.That(fastSize, Is.LessThanOrEqualTo(balancedSize)); + Assert.That(fastSize, Is.LessThanOrEqualTo(adaptiveSize)); + Assert.That(fastSize, Is.LessThanOrEqualTo(accurateSize)); + } + + #endregion + + #region Complex Nested Structures + + private class ComplexObject + { + public int Id; + public string Name; + public List Numbers; + public Dictionary Metadata; + public ComplexObject Child; + } + + [Test] + public void GetSizeBytes_ComplexNestedStructure_BalancedMode() + { + var obj = new ComplexObject + { + Id = 1, + Name = "root", + Numbers = new List { 1, 2, 3, 4, 5 }, + Metadata = new Dictionary + { + ["key1"] = "value1", + ["key2"] = "value2" + }, + Child = new ComplexObject + { + Id = 2, + Name = "child", + Numbers = new List { 10, 20, 30 }, + Metadata = new Dictionary { ["key3"] = "value3" } + } + }; + + var size = _balancedSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ComplexNestedStructure_FastMode() + { + var obj = new ComplexObject + { + Id = 1, + Name = "root", + Numbers = new List { 1, 2, 3 }, + Metadata = new Dictionary { ["k"] = "v" }, + Child = new ComplexObject { Id = 2, Name = "child" } + }; + + var size = _fastSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ComplexNestedStructure_AdaptiveMode() + { + var obj = new ComplexObject + { + Id = 1, + Name = "root", + Numbers = new List(Enumerable.Range(1, 50)), + Metadata = new Dictionary(), + Child = new ComplexObject + { + Id = 2, + Name = "child", + Numbers = new List(Enumerable.Range(1, 20)) + } + }; + + var size = _adaptiveSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Cycle Detection + + private class CyclicNode + { + public string Name; + public CyclicNode Next; + public List Children = new List(); + } + + [Test] + public void GetSizeBytes_SimpleCycle_DoesNotInfiniteLoop() + { + var a = new CyclicNode { Name = "A" }; + var b = new CyclicNode { Name = "B" }; + a.Next = b; + b.Next = a; + + var size = _balancedSizer.GetSizeBytes(a); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ComplexCycle_WithCollections() + { + var a = new CyclicNode { Name = "A" }; + var b = new CyclicNode { Name = "B" }; + var c = new CyclicNode { Name = "C" }; + + a.Next = b; + b.Next = c; + c.Next = a; + + a.Children.Add(b); + b.Children.Add(c); + c.Children.Add(a); + + var size = _balancedSizer.GetSizeBytes(a); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_SelfReference_HandledCorrectly() + { + var node = new CyclicNode { Name = "Self" }; + node.Next = node; + node.Children.Add(node); + + var size = _balancedSizer.GetSizeBytes(node); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Layout Caching Tests + + private class TypeA { public int Value; } + private class TypeB { public int Value; } + private class TypeC { public int Value; } + + [Test] + public void GetSizeBytes_MultipleTypes_UsesLayoutCache() + { + var a = new TypeA { Value = 1 }; + var b = new TypeB { Value = 2 }; + var c = new TypeC { Value = 3 }; + + // First call caches the layout + var size1 = _balancedSizer.GetSizeBytes(a); + // Second call uses cached layout + var size2 = _balancedSizer.GetSizeBytes(new TypeA { Value = 10 }); + + Assert.That(size1, Is.EqualTo(size2), "Same type should have same size"); + + var sizeB = _balancedSizer.GetSizeBytes(b); + var sizeC = _balancedSizer.GetSizeBytes(c); + + Assert.That(sizeB, Is.GreaterThan(0)); + Assert.That(sizeC, Is.GreaterThan(0)); + } + + [Test] + public void GetSizeBytes_RepeatedCalls_ConsistentResults() + { + var obj = new { A = 1, B = "test", C = new List { 1, 2, 3 } }; + + var sizes = new long[10]; + for (int i = 0; i < 10; i++) + sizes[i] = _balancedSizer.GetSizeBytes(obj); + + // All sizes should be identical + Assert.That(sizes.Distinct().Count(), Is.EqualTo(1)); + } + + #endregion + + #region Edge Cases + + [Test] + public void GetSizeBytes_EmptyString_ReturnsOverheadOnly() + { + var size = _balancedSizer.GetSizeBytes(""); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_VeryLargeString_HandlesCorrectly() + { + var largeString = new string('x', 10000); + var size = _balancedSizer.GetSizeBytes(largeString); + Assert.That(size, Is.GreaterThan(20000)); // at least 2 * 10000 + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_NullFields_HandledCorrectly() + { + var obj = new ComplexObject { Id = 1, Name = null, Numbers = null, Metadata = null, Child = null }; + var size = _balancedSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_MixedNullAndNonNull_HandledCorrectly() + { + var obj = new ComplexObject + { + Id = 1, + Name = "test", + Numbers = null, + Metadata = new Dictionary { ["k"] = "v" }, + Child = null + }; + var size = _balancedSizer.GetSizeBytes(obj); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + [Test] + public void GetSizeBytes_ArrayOfNulls_HandledCorrectly() + { + var arr = new string[10]; // all null + var size = _balancedSizer.GetSizeBytes(arr); + Assert.That(size, Is.GreaterThan(0)); + Assert.That(size % 8, Is.EqualTo(0)); + } + + #endregion + + #region Mode Comparison Tests + + [Test] + public void GetSizeBytes_ModesComparison_LargeCollection() + { + var list = new List(); + for (int i = 0; i < 100; i++) + list.Add(new { Id = i, Name = $"Item{i}", Data = new int[] { i, i * 2, i * 3 } }); + + var fastSize = _fastSizer.GetSizeBytes(list); + var balancedSize = _balancedSizer.GetSizeBytes(list); + var adaptiveSize = _adaptiveSizer.GetSizeBytes(list); + var accurateSize = _accurateSizer.GetSizeBytes(list); + + Assert.That(fastSize, Is.GreaterThan(0)); + Assert.That(balancedSize, Is.GreaterThanOrEqualTo(fastSize)); + Assert.That(adaptiveSize, Is.GreaterThan(0)); + Assert.That(accurateSize, Is.GreaterThanOrEqualTo(balancedSize)); + } + + [Test] + public void GetSizeBytes_ModesComparison_DeepNesting() + { + var root = new DeepNode { Name = "L1" }; + var current = root; + for (int i = 2; i <= 10; i++) + { + current.Child = new DeepNode { Name = $"L{i}" }; + current = current.Child; + } + + var fastSize = _fastSizer.GetSizeBytes(root); + var balancedSize = _balancedSizer.GetSizeBytes(root); + var adaptiveSize = _adaptiveSizer.GetSizeBytes(root); + var accurateSize = _accurateSizer.GetSizeBytes(root); + + Assert.That(fastSize, Is.GreaterThan(0)); + Assert.That(balancedSize, Is.GreaterThan(0)); + Assert.That(adaptiveSize, Is.GreaterThan(0)); + Assert.That(accurateSize, Is.GreaterThanOrEqualTo(balancedSize)); + } + + #endregion + } +} diff --git a/BlitzCache.Tests/ObjectGraphValueSizerModesTests.cs b/BlitzCache.Tests/ObjectGraphValueSizerModesTests.cs new file mode 100644 index 0000000..838ccb4 --- /dev/null +++ b/BlitzCache.Tests/ObjectGraphValueSizerModesTests.cs @@ -0,0 +1,53 @@ +using BlitzCacheCore.Statistics.Memory; +using NUnit.Framework; +using System.Collections.Generic; + +namespace BlitzCacheCore.Tests +{ + public class ObjectGraphValueSizerModesTests + { + private class Complex + { + public string Name = new string('x', 32); + public List Numbers = new List(new int[50]); + public Complex Child; + } + + [Test] + public void FastMode_Is_NotZero() + { + var obj = new Complex(); + var fast = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = SizeComputationMode.Fast }); + var size = fast.GetSizeBytes(obj); + Assert.Greater(size, 0); + } + + [Test] + public void Mode_Relative_Order() + { + var obj = new Complex { Child = new Complex() }; + var fast = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = SizeComputationMode.Fast }); + var balanced = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = SizeComputationMode.Balanced }); + var accurate = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = SizeComputationMode.Accurate }); + var adaptive = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = SizeComputationMode.Adaptive }); + + long f = fast.GetSizeBytes(obj); + long b = balanced.GetSizeBytes(obj); + long ad = adaptive.GetSizeBytes(obj); + long a = accurate.GetSizeBytes(obj); + + Assert.LessOrEqual(f, b, "Fast should be <= Balanced"); + Assert.LessOrEqual(f, ad, "Fast should be <= Adaptive"); + Assert.LessOrEqual(b, a, "Balanced should be <= Accurate"); + Assert.LessOrEqual(ad, a, "Adaptive should be <= Accurate"); + } + + [Test] + public void Adaptive_NotZero() + { + var obj = new Complex(); + var adaptive = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = SizeComputationMode.Adaptive }); + Assert.Greater(adaptive.GetSizeBytes(obj), 0); + } + } +} diff --git a/BlitzCache.Tests/ObjectGraphValueSizerTests.cs b/BlitzCache.Tests/ObjectGraphValueSizerTests.cs new file mode 100644 index 0000000..e8ab2ef --- /dev/null +++ b/BlitzCache.Tests/ObjectGraphValueSizerTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using BlitzCacheCore.Statistics.Memory; +using NUnit.Framework; + +namespace BlitzCacheCore.Tests +{ + public class ObjectGraphValueSizerTests + { + private readonly ObjectGraphValueSizer sizer = new ObjectGraphValueSizer(new ObjectGraphSizerOptions { MaxDepth = 2, MaxObjects = 256 }); + + private class Node + { + public string Name = string.Empty; + public Node Child; + } + + private class Cycle + { + public Cycle Next; + public string Data = "abc"; + } + + [Test] + public void Sizes_Primitive_String() + { + long size = sizer.GetSizeBytes("hello"); + Assert.Greater(size, 0); + } + + [Test] + public void Sizes_Nested_Object_Two_Levels() + { + var root = new Node { Name = new string('x', 10), Child = new Node { Name = new string('y', 5) } }; + long size = sizer.GetSizeBytes(root); + long shallow = sizer.GetSizeBytes(root.Name); + Assert.Greater(size, shallow); + } + + [Test] + public void Sizes_Collections_Sampled() + { + var list = new List(); + for (int i = 0; i < 100; i++) list.Add("item" + i); + long size = sizer.GetSizeBytes(list); + Assert.Greater(size, 0); + } + + [Test] + public void Handles_Cycles_Without_Overflow() + { + var a = new Cycle(); + var b = new Cycle(); + a.Next = b; b.Next = a; + long size = sizer.GetSizeBytes(a); + Assert.Greater(size, 0); + } + + [Test] + public void Respects_MaxDepth() + { + var deep = new Node { Name = "root", Child = new Node { Name = "child", Child = new Node { Name = "grandchild" } } }; + long size = sizer.GetSizeBytes(deep); + Assert.Greater(size, 0); + } + } +} diff --git a/BlitzCache.Tests/Statistics/BlitzLoggerInstanceTests.cs b/BlitzCache.Tests/Statistics/BlitzLoggerInstanceTests.cs index 311f871..9ef3970 100644 --- a/BlitzCache.Tests/Statistics/BlitzLoggerInstanceTests.cs +++ b/BlitzCache.Tests/Statistics/BlitzLoggerInstanceTests.cs @@ -91,8 +91,8 @@ public void Log_ReportsNoTopSlowestQueriesWhenDisabled() // Assert Assert.That(logs.Count, Is.GreaterThan(0)); - Assert.That(joinedLogs, Does.Contain("Top Slowest Queries")); - Assert.That(joinedLogs, Does.Contain("Not available").Or.Not.Contains("q1"), "Should indicate no slow queries tracked"); + // New behavior: section omitted entirely when tracking disabled for performance + Assert.That(joinedLogs, Does.Not.Contain("Top Slowest Queries"), "Section should be omitted when disabled"); } } } diff --git a/BlitzCache.Tests/Statistics/DisabledTrackingOptimizationTests.cs b/BlitzCache.Tests/Statistics/DisabledTrackingOptimizationTests.cs new file mode 100644 index 0000000..2b7c1da --- /dev/null +++ b/BlitzCache.Tests/Statistics/DisabledTrackingOptimizationTests.cs @@ -0,0 +1,44 @@ +using BlitzCacheCore.Logging; +using BlitzCacheCore.Tests.Helpers; +using NUnit.Framework; +using System.Linq; + +namespace BlitzCacheCore.Tests.Statistics +{ + [TestFixture] + public class DisabledTrackingOptimizationTests + { + [Test] + public void When_TopHeaviest_And_NoSizeLimit_DisablesSizeTracking() + { + using var cache = new BlitzCacheInstance(maxTopHeaviest: 0, maxCacheSizeBytes: null); + cache.InitializeStatistics(); + + cache.BlitzGet("k1", () => new byte[2048]); + cache.BlitzGet("k2", () => new byte[4096]); + TestDelays.WaitForEvictionCallbacksSync(); + + var stats = cache.Statistics!; + Assert.AreEqual(0, stats.ApproximateMemoryBytes, "Memory bytes should remain zero when size tracking is disabled"); + Assert.IsFalse(stats.TopHeaviestEntries.Any(), "TopHeaviestEntries should be empty when disabled"); + } + + [Test] + public void Logger_Omits_Disabled_Sections() + { + // Top lists disabled + var cache = new BlitzCacheInstance(maxTopHeaviest: 0, maxTopSlowest: 0); + cache.InitializeStatistics(); + cache.BlitzGet("k1", () => "value"); + + var testLogger = new TestLoggerForBlitzCache(); + var loggerInstance = new BlitzLoggerInstance(cache, identifier: "Test", logInterval: System.TimeSpan.Zero); + + loggerInstance.Log(testLogger); + var output = string.Join("\n", testLogger.GetLogs()); + + Assert.IsFalse(output.Contains("Top Heaviest:"), "Log should omit Top Heaviest section when disabled"); + Assert.IsFalse(output.Contains("Top Slowest Queries:"), "Log should omit Top Slowest Queries section when disabled"); + } + } +} diff --git a/BlitzCache.Tests/Statistics/SlowQueryTests.cs b/BlitzCache.Tests/Statistics/SlowQueryTests.cs index d01b497..537a70d 100644 --- a/BlitzCache.Tests/Statistics/SlowQueryTests.cs +++ b/BlitzCache.Tests/Statistics/SlowQueryTests.cs @@ -1,4 +1,5 @@ using BlitzCacheCore.Statistics; +using BlitzCacheCore.Statistics.Speed; using NUnit.Framework; namespace BlitzCacheCore.Tests.Statistics diff --git a/BlitzCache.Tests/Statistics/TopSlowestQueriesTests.cs b/BlitzCache.Tests/Statistics/TopSlowestQueriesTests.cs index 735b949..9fddbc0 100644 --- a/BlitzCache.Tests/Statistics/TopSlowestQueriesTests.cs +++ b/BlitzCache.Tests/Statistics/TopSlowestQueriesTests.cs @@ -1,4 +1,5 @@ using BlitzCacheCore.Statistics; +using BlitzCacheCore.Statistics.Speed; using NUnit.Framework; using System; using System.Collections.Generic; diff --git a/BlitzCache/BlitzCache.cs b/BlitzCache/BlitzCache.cs index b6df7cd..5b2d56f 100644 --- a/BlitzCache/BlitzCache.cs +++ b/BlitzCache/BlitzCache.cs @@ -7,9 +7,9 @@ namespace BlitzCacheCore { public class BlitzCache : IBlitzCache { - private static IBlitzCacheInstance? globalInstance; - - private static readonly object globalLock = new object(); + private static IBlitzCacheInstance? globalInstance; // global singleton instance + private static readonly object globalLock = new object(); // protects lazy initialization + private const string KeyDelimiter = "|"; // unify delimiter with BlitzCacheInstance construction style /// /// Creates a new BlitzCache instance. @@ -23,16 +23,22 @@ public BlitzCache(long? defaultMilliseconds = 60000, TimeSpan? cleanupInterval = { if (defaultMilliseconds < 1) throw new ArgumentOutOfRangeException(nameof(defaultMilliseconds), "Default milliseconds must be non-negative"); - if (globalInstance is null) - { - lock (globalLock) - { - globalInstance ??= new BlitzCacheInstance(defaultMilliseconds, cleanupInterval, maxTopSlowest, null, maxTopHeaviest, maxCacheSizeBytes); - } - } + EnsureGlobalInstance(defaultMilliseconds, cleanupInterval, maxTopSlowest, maxTopHeaviest, maxCacheSizeBytes, null); + } + + /// + /// Preferred constructor accepting for forward-compatible configuration. + /// + /// Configuration options. + public BlitzCache(BlitzCacheOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + options.Validate(); + + EnsureGlobalInstance(options.DefaultMilliseconds, options.CleanupInterval, options.MaxTopSlowest, options.MaxTopHeaviest, options.MaxCacheSizeBytes, options); } - [Obsolete("Prefer using the extended constructor to enable sizing and capacity features. This overload remains for binary compatibility.")] + [Obsolete("Prefer using the Configuration options constructor to enable sizing and capacity features. This overload remains for binary compatibility.")] public BlitzCache(long? defaultMilliseconds, TimeSpan? cleanupInterval, int? maxTopSlowest) : this(defaultMilliseconds, cleanupInterval, maxTopSlowest, 5, null) { @@ -53,20 +59,20 @@ public BlitzCache(long? defaultMilliseconds, TimeSpan? cleanupInterval, int? max public void InitializeStatistics() => globalInstance?.InitializeStatistics(); public T BlitzGet(Func function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => - globalInstance!.BlitzGet(callerMemberName + sourceFilePath, nuances => function(), milliseconds); + globalInstance!.BlitzGet(callerMemberName + KeyDelimiter + sourceFilePath, nuances => function(), milliseconds); public T BlitzGet(Func function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => - globalInstance!.BlitzGet(callerMemberName + sourceFilePath, function, milliseconds); + globalInstance!.BlitzGet(callerMemberName + KeyDelimiter + sourceFilePath, function, milliseconds); public T BlitzGet(string cacheKey, Func function, long? milliseconds = null) => globalInstance!.BlitzGet(cacheKey, nuances => function(), milliseconds); public T BlitzGet(string cacheKey, Func function, long? milliseconds = null) => globalInstance!.BlitzGet(cacheKey, function, milliseconds); public Task BlitzGet(Func> function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => - globalInstance!.BlitzGet(callerMemberName + sourceFilePath, nuances => function(), milliseconds); + globalInstance!.BlitzGet(callerMemberName + KeyDelimiter + sourceFilePath, nuances => function(), milliseconds); public Task BlitzGet(Func> function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => - globalInstance!.BlitzGet(callerMemberName + sourceFilePath, function, milliseconds); + globalInstance!.BlitzGet(callerMemberName + KeyDelimiter + sourceFilePath, function, milliseconds); public Task BlitzGet(string cacheKey, Func> function, long? milliseconds = null) => globalInstance!.BlitzGet(cacheKey, nuances => function(), milliseconds); @@ -82,6 +88,7 @@ public Task BlitzGet(string cacheKey, Func> function, lon #if DEBUG internal IBlitzCacheInstance? GetInternalInstance() => globalInstance; + internal static bool IsInitialized => globalInstance != null; internal static void ClearGlobalForTesting() { @@ -91,5 +98,25 @@ internal static void ClearGlobalForTesting() globalInstance = null; } #endif + + private static void EnsureGlobalInstance(long? defaultMs, TimeSpan? cleanupInterval, int? maxTopSlowest, int? maxTopHeaviest, long? maxCacheSizeBytes, BlitzCacheOptions? options) + { + // NOTE: ValueSizer and EvictionStrategy currently applied at BlitzCacheInstance level when globalInstance created. + // To remain backward compatible (global singleton lazily created), we only consider extended parameters if instance is still null. + // If global instance does not yet exist and extended sizing options are provided, rebuild it using BlitzCacheInstance options-based constructor. + if (globalInstance != null) return; + lock (globalLock) + { + if (globalInstance != null) return; + if (options != null) + { + globalInstance = new BlitzCacheInstance(options); + } + else + { + globalInstance = new BlitzCacheInstance(defaultMs, cleanupInterval, maxTopSlowest, null, maxTopHeaviest, maxCacheSizeBytes); + } + } + } } } diff --git a/BlitzCache/BlitzCacheCore.csproj b/BlitzCache/BlitzCacheCore.csproj index f74659e..aaae398 100644 --- a/BlitzCache/BlitzCacheCore.csproj +++ b/BlitzCache/BlitzCacheCore.csproj @@ -15,24 +15,21 @@ master 1.0.0 caching;performance;threading;async;concurrency;enterprise;dotnet;thundering-herd;duplicate-prevention;memory-cache;thread-safe;zero-configuration;blazing-fast;production-ready;sql-protection;cache-invalidation;logging;monitoring;statistics;microservices - 2.1.0 - 2.1.0.0 - 2.1.0.0 - BlitzCache v2.1.0 - Capacity-Based Size Limit (Optional) + 2.1.1 + 2.1.1.0 + 2.1.1.0 + BlitzCache v2.1.1 - Zero-Overhead Conditional Features -NEW IN v2.1.0: -• Approximate memory usage tracking (Statistics.ApproximateMemoryBytes) -• TopHeaviestEntries to identify largest cached items -• Logging includes Approx. Memory and Top Heaviest sections -• Optional maxCacheSizeBytes to enable capacity-based eviction using MemoryCache.SizeLimit -• Automatic per-entry sizing via lightweight IValueSizer so the limit is enforced -• Works even when statistics are disabled -• DI: AddBlitzCache now accepts maxCacheSizeBytes -• New tests ensuring eviction occurs and memory stays within the limit +NEW IN v2.1.1: +• Conditional statistics: When maxTopSlowest or maxTopHeaviest < 1 the related tracking is fully disabled (no allocations / updates). +• Automatic skip of per-entry size calculation & capacity enforcement when no maxCacheSizeBytes configured. +• Deterministic capacity enforcement using simulated remaining bytes to avoid transient over-eviction. +• Logger now omits disabled Top sections entirely for cleaner output. +• Additional tests covering disabled tracking & eviction precision. Notes: -• Sizing is best-effort and designed to be lightweight for common types. -• Capacity-based eviction relies on Microsoft.Extensions.Caching.Memory heuristics. +• Performance-focused release with no breaking changes. +• Disabling features now truly removes their runtime cost. BlitzCache icon.png README.md @@ -63,11 +60,12 @@ Notes: + + + + - - + + diff --git a/BlitzCache/BlitzCacheInstance.cs b/BlitzCache/BlitzCacheInstance.cs index 05ac60f..18e1efa 100644 --- a/BlitzCache/BlitzCacheInstance.cs +++ b/BlitzCache/BlitzCacheInstance.cs @@ -1,9 +1,10 @@ -using BlitzCacheCore.LockDictionaries; +using BlitzCacheCore.Capacity; +using BlitzCacheCore.LockDictionaries; using BlitzCacheCore.Statistics; +using BlitzCacheCore.Statistics.Memory; using Microsoft.Extensions.Caching.Memory; using System; using System.Diagnostics; -using System.Linq; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -19,6 +20,9 @@ public class BlitzCacheInstance : IBlitzCacheInstance private readonly IValueSizer valueSizer; private readonly int maxTopHeaviest; private readonly long? configuredSizeLimitBytes; // store configured capacity for proactive compaction + private CapacityEnforcer? capacityEnforcer; + private readonly CapacityEvictionStrategy evictionStrategy; // selected eviction ordering strategy + private readonly bool capacityEnabled; /// /// Creates a new BlitzCache instance. @@ -29,22 +33,38 @@ public class BlitzCacheInstance : IBlitzCacheInstance /// Strategy to estimate value sizes for memory accounting. If null, a default approximate sizer will be used. /// Max number of heaviest entries to track (0 disables). Default 5. /// Optional maximum cache size in bytes. When specified, MemoryCache enforces this limit via capacity-based eviction. - public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupInterval = null, int? maxTopSlowest = null, IValueSizer? valueSizer = null, int? maxTopHeaviest = 5, long? maxCacheSizeBytes = null) + public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupInterval = null, int? maxTopSlowest = null, IValueSizer? valueSizer = null, int? maxTopHeaviest = 5, long? maxCacheSizeBytes = null, CapacityEvictionStrategy evictionStrategy = CapacityEvictionStrategy.SmallestFirst) { - if (defaultMilliseconds < 1) throw new ArgumentOutOfRangeException(nameof(defaultMilliseconds), "Default milliseconds must be non-negative"); + if (defaultMilliseconds < 1) throw new ArgumentOutOfRangeException(nameof(defaultMilliseconds), "Default milliseconds must be positive"); this.defaultMilliseconds = defaultMilliseconds!.Value; + capacityEnabled = maxCacheSizeBytes.HasValue && maxCacheSizeBytes.Value > 0; var options = new MemoryCacheOptions(); - if (maxCacheSizeBytes.HasValue && maxCacheSizeBytes.Value > 0) - { - options.SizeLimit = maxCacheSizeBytes.Value; - } + if (capacityEnabled) options.SizeLimit = maxCacheSizeBytes!.Value; memoryCache = new MemoryCache(options); semaphoreDictionary = new BlitzSemaphoreDictionary(cleanupInterval); this.maxTopSlowest = maxTopSlowest ?? 5; - this.valueSizer = valueSizer ?? new ApproximateValueSizer(); + this.valueSizer = valueSizer ?? new ObjectGraphValueSizer(); this.maxTopHeaviest = maxTopHeaviest ?? 5; this.configuredSizeLimitBytes = maxCacheSizeBytes; // remember for proactive compaction + this.evictionStrategy = evictionStrategy; + } + + /// + /// Preferred constructor accepting for forward-compatible configuration. + /// + /// Configuration options. + public BlitzCacheInstance(BlitzCacheOptions options) + : this(options?.DefaultMilliseconds, + options?.CleanupInterval, + options?.MaxTopSlowest, + options?.SizeComputationMode.HasValue == true ? new ObjectGraphValueSizer(new ObjectGraphSizerOptions { Mode = options!.SizeComputationMode.Value }) : null, + options?.MaxTopHeaviest, + options?.MaxCacheSizeBytes, + options?.EvictionStrategy ?? CapacityEvictionStrategy.SmallestFirst) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + options.Validate(); } /// @@ -55,7 +75,7 @@ public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupIn /// Max number of top slowest queries to store [Obsolete("Prefer using the extended constructor to enable sizing and capacity features. This overload remains for binary compatibility.")] public BlitzCacheInstance(long? defaultMilliseconds, TimeSpan? cleanupInterval, int? maxTopSlowest) - : this(defaultMilliseconds, cleanupInterval, maxTopSlowest, null, null, null) + : this(defaultMilliseconds, cleanupInterval, maxTopSlowest, null, null, null, CapacityEvictionStrategy.SmallestFirst) { } @@ -64,7 +84,18 @@ public BlitzCacheInstance(long? defaultMilliseconds, TimeSpan? cleanupInterval, /// public int GetSemaphoreCount() => semaphoreDictionary.GetNumberOfLocks(); - public void InitializeStatistics() => statistics = statistics ?? new CacheStatistics(() => semaphoreDictionary.GetNumberOfLocks(), maxTopSlowest, maxTopHeaviest, valueSizer); + public void InitializeStatistics() + { + if (statistics == null) + { + statistics = new CacheStatistics(() => semaphoreDictionary.GetNumberOfLocks(), maxTopSlowest, maxTopHeaviest, valueSizer, capacityEnabled); + if (capacityEnabled) + { + var limit = configuredSizeLimitBytes!.Value; // safe due to capacityEnabled + capacityEnforcer = new CapacityEnforcer(memoryCache, statistics, limit, evictionStrategy); + } + } + } /// /// Gets cache performance statistics and monitoring information. @@ -73,38 +104,7 @@ public BlitzCacheInstance(long? defaultMilliseconds, TimeSpan? cleanupInterval, /// public ICacheStatistics? Statistics => statistics; - private void EnforceCapacityIfNeeded() - { - // Only enforce proactively when a capacity was configured and statistics are enabled - if (!configuredSizeLimitBytes.HasValue || statistics == null) return; - - var limit = configuredSizeLimitBytes.Value; - if (statistics.ApproximateMemoryBytes <= limit) return; - - // Try deterministic removals using known sizes to get under budget - var sizes = statistics.GetKeySizesSnapshot(); - if (sizes.Length > 0) - { - // Remove smallest-first to maximize retained useful entries - foreach (var kvp in sizes.OrderBy(k => k.Value)) - { - if (statistics.ApproximateMemoryBytes <= limit) break; - memoryCache.Remove(kvp.Key); - } - } - - // If still over, ask MemoryCache to compact the remainder percentage - if (memoryCache is MemoryCache concrete && statistics.ApproximateMemoryBytes > limit) - { - var approx = statistics.ApproximateMemoryBytes; - if (approx > 0) - { - var over = approx - limit; - var percent = Math.Min(1.0, Math.Max(0.02, (double)over / approx)); - concrete.Compact(percent); - } - } - } + private void EnforceCapacityIfNeeded() => capacityEnforcer?.EnsureUnderLimit(); private T ExecuteWithCache(string cacheKey, Func function, long? milliseconds) { @@ -151,10 +151,7 @@ private T ComputeAndStoreResult(string cacheKey, Func function, l // Determine if the key existed in the cache before this Set (for accurate entry count) var existed = memoryCache.TryGetValue(cacheKey, out _); - // Compute size for MemoryCache capacity enforcement - var newSize = valueSizer.GetSizeBytes(computedResult!); - var cacheEntryOptions = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; - cacheEntryOptions.Size = newSize; + var cacheEntryOptions = CreateEntryOptions(expirationTime, computedResult!); memoryCache.Set(cacheKey, computedResult, cacheEntryOptions); statistics?.RecordSetOrUpdate(cacheKey, computedResult!, existed); @@ -195,10 +192,7 @@ private async Task ComputeAndStoreResultAsync(string cacheKey, Func ComputeAndStoreResultAsync(string cacheKey, Func(Func function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + sourceFilePath, nuances => function(), milliseconds); + public T BlitzGet(Func function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + "|" + sourceFilePath, nuances => function(), milliseconds); - public T BlitzGet(Func function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + sourceFilePath, function, milliseconds); + public T BlitzGet(Func function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + "|" + sourceFilePath, function, milliseconds); public T BlitzGet(string cacheKey, Func function, long? milliseconds = null) => BlitzGet(cacheKey, nuances => function(), milliseconds); public T BlitzGet(string cacheKey, Func function, long? milliseconds = null) => ExecuteWithCache(cacheKey, function, milliseconds); - public Task BlitzGet(Func> function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + sourceFilePath, nuances => function(), milliseconds); + public Task BlitzGet(Func> function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + "|" + sourceFilePath, nuances => function(), milliseconds); - public Task BlitzGet(Func> function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + sourceFilePath, function, milliseconds); + public Task BlitzGet(Func> function, long? milliseconds = null, [CallerMemberName] string callerMemberName = "", [CallerFilePath] string sourceFilePath = "") => BlitzGet(callerMemberName + "|" + sourceFilePath, function, milliseconds); public Task BlitzGet(string cacheKey, Func> function, long? milliseconds = null) => BlitzGet(cacheKey, nuances => function(), milliseconds); @@ -240,11 +234,7 @@ private void UpdateCacheValue(string cacheKey, T value, long milliseconds) { var existsInCache = memoryCache.TryGetValue(cacheKey, out _); var expirationTime = DateTime.UtcNow.AddMilliseconds(milliseconds); - - // Compute size for MemoryCache capacity enforcement - var newSize = valueSizer.GetSizeBytes(value!); - var cacheEntryOptions = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; - cacheEntryOptions.Size = newSize; + var cacheEntryOptions = CreateEntryOptions(expirationTime, value!); memoryCache.Set(cacheKey, value, cacheEntryOptions); @@ -278,5 +268,18 @@ public void Dispose() memoryCache?.Dispose(); statistics?.Reset(); } + + // Helper centralizing creation of MemoryCacheEntryOptions + optional Size assignment + private MemoryCacheEntryOptions CreateEntryOptions(DateTime expirationTime, object value) + { + var opts = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; + if (capacityEnabled) + { + // valueSizer may be more expensive (object graph). Called only when sizing features needed. + var size = valueSizer.GetSizeBytes(value); + if (size > 0) opts.Size = size; // avoid zero-size which would disallow eviction cost modeling + } + return opts; + } } } diff --git a/BlitzCache/BlitzCacheOptions.cs b/BlitzCache/BlitzCacheOptions.cs new file mode 100644 index 0000000..924337a --- /dev/null +++ b/BlitzCache/BlitzCacheOptions.cs @@ -0,0 +1,30 @@ +using System; +using BlitzCacheCore.Capacity; +using BlitzCacheCore.Statistics.Memory; + +namespace BlitzCacheCore +{ + /// + /// Configuration options for the global BlitzCache singleton. + /// New fields can be added here without breaking existing callers. + /// + public sealed class BlitzCacheOptions + { + public long DefaultMilliseconds { get; set; } = 60000; + public TimeSpan? CleanupInterval { get; set; } = null; + public int MaxTopSlowest { get; set; } = 5; + public int MaxTopHeaviest { get; set; } = 5; + public long? MaxCacheSizeBytes { get; set; } = null; + public SizeComputationMode? SizeComputationMode { get; set; } = null; + public CapacityEvictionStrategy EvictionStrategy { get; set; } = CapacityEvictionStrategy.SmallestFirst; + public bool EnableStatistics { get; set; } = true; // currently always on for global; reserved for future toggling + + internal void Validate() + { + if (DefaultMilliseconds < 1) throw new ArgumentOutOfRangeException(nameof(DefaultMilliseconds)); + if (MaxTopSlowest < 0) throw new ArgumentOutOfRangeException(nameof(MaxTopSlowest)); + if (MaxTopHeaviest < 0) throw new ArgumentOutOfRangeException(nameof(MaxTopHeaviest)); + if (MaxCacheSizeBytes.HasValue && MaxCacheSizeBytes.Value < 0) throw new ArgumentOutOfRangeException(nameof(MaxCacheSizeBytes)); + } + } +} diff --git a/BlitzCache/Capacity/CapacityEnforcer.cs b/BlitzCache/Capacity/CapacityEnforcer.cs new file mode 100644 index 0000000..d99ad96 --- /dev/null +++ b/BlitzCache/Capacity/CapacityEnforcer.cs @@ -0,0 +1,94 @@ +using BlitzCacheCore.Statistics; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Collections.Generic; + +namespace BlitzCacheCore.Capacity +{ + /// + /// Proactively enforces a configured capacity by deterministically removing entries when the approximate + /// tracked memory usage exceeds the configured limit. Keeps policy concerns out of BlitzCacheInstance. + /// Current strategy: smallest-first removal to maximize retained aggregate value density (can be swapped later). + /// + internal sealed class CapacityEnforcer + { + private readonly IMemoryCache memoryCache; + private readonly CacheStatistics statistics; + private readonly long sizeLimitBytes; + private readonly CapacityEvictionStrategy strategy; + + public CapacityEnforcer(IMemoryCache memoryCache, CacheStatistics statistics, long sizeLimitBytes, CapacityEvictionStrategy strategy = CapacityEvictionStrategy.SmallestFirst) + { + this.memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); + this.statistics = statistics ?? throw new ArgumentNullException(nameof(statistics)); + this.sizeLimitBytes = sizeLimitBytes; + this.strategy = strategy; + } + + /// + /// Ensure the cache stays under the configured limit. Called after insert/update operations. + /// + public void EnsureUnderLimit() + { + // Fast path: already under limit. + var current = statistics.ApproximateMemoryBytes; + if (current <= sizeLimitBytes) return; + if (sizeLimitBytes <= 0) return; // defensive; capacity feature disabled or misconfigured + + var overBytes = current - sizeLimitBytes; + if (overBytes <= 0) return; // race – another thread already corrected + + var sizes = statistics.GetKeySizesSnapshot(); + if (sizes.Length != 0) + { + // Sort in-place to avoid LINQ allocations; O(n log n) worst case. If this becomes hot we can switch + // to a partial selection (quickselect) to remove just enough. For now keep code simple & deterministic. + if (strategy == CapacityEvictionStrategy.LargestFirst) + { + Array.Sort(sizes, DescendingValueComparer.Instance); + } + else + { + Array.Sort(sizes, AscendingValueComparer.Instance); + } + + long simulatedRemaining = current; // local view; we optimistically subtract removed sizes + for (int i = 0; i < sizes.Length && simulatedRemaining > sizeLimitBytes; i++) + { + ref readonly var kvp = ref sizes[i]; + simulatedRemaining -= kvp.Value; + statistics.OnExternalRemoval(kvp.Key, kvp.Value, countAsEviction: true); // adjust stats first + memoryCache.Remove(kvp.Key); + } + } + + // After proactive removals, if still above limit (due to estimation error or concurrent adds), compact. + var after = statistics.ApproximateMemoryBytes; + if (memoryCache is MemoryCache concrete && after > sizeLimitBytes) + { + var over = after - sizeLimitBytes; + if (after > 0) + { + // Compact proportional to overage, bounded to avoid overly small no-op or full wipe. + var percent = Math.Min(1.0, Math.Max(0.02, (double)over / after)); + concrete.Compact(percent); + } + } + + // (Optional clamp) We intentionally do not clamp negative values here; atomic accounting should prevent them. + } + + // Local comparers to avoid allocations each enforcement. + private sealed class AscendingValueComparer : IComparer> + { + internal static readonly AscendingValueComparer Instance = new AscendingValueComparer(); + public int Compare(KeyValuePair x, KeyValuePair y) => x.Value.CompareTo(y.Value); + } + + private sealed class DescendingValueComparer : IComparer> + { + internal static readonly DescendingValueComparer Instance = new DescendingValueComparer(); + public int Compare(KeyValuePair x, KeyValuePair y) => y.Value.CompareTo(x.Value); + } + } +} diff --git a/BlitzCache/Capacity/CapacityEvictionStrategy.cs b/BlitzCache/Capacity/CapacityEvictionStrategy.cs new file mode 100644 index 0000000..201435c --- /dev/null +++ b/BlitzCache/Capacity/CapacityEvictionStrategy.cs @@ -0,0 +1,8 @@ +namespace BlitzCacheCore.Capacity +{ + public enum CapacityEvictionStrategy + { + SmallestFirst = 0, + LargestFirst = 1 + } +} diff --git a/BlitzCache/Extensions/IServiceCollectionExtensions.cs b/BlitzCache/Extensions/IServiceCollectionExtensions.cs index 7b0116e..90586ab 100644 --- a/BlitzCache/Extensions/IServiceCollectionExtensions.cs +++ b/BlitzCache/Extensions/IServiceCollectionExtensions.cs @@ -2,12 +2,46 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System; namespace BlitzCacheCore.Extensions { public static class IServiceCollectionExtensions { + /// + /// Adds BlitzCache using an options configuration delegate. Future-proof single overload. + /// + /// Service collection + /// Configuration action for + /// The service collection for chaining. + public static IServiceCollection AddBlitzCache(this IServiceCollection services, Action configure) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + if (configure == null) throw new ArgumentNullException(nameof(configure)); + // Use standard options pipeline to allow external configuration binding. + services.Configure(configure); + services.AddBlitzCache(); // ensure base registration if not already executed + return services; + } + + /// + /// Adds BlitzCache using any previously registered BlitzCacheOptions (via Configure / configuration binding). + /// If options are not configured yet, default values are used. + /// + public static IServiceCollection AddBlitzCache(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + OptionsServiceCollectionExtensions.AddOptions(services); + ServiceCollectionDescriptorExtensions.TryAdd(services, ServiceDescriptor.Singleton(sp => + { + var opts = sp.GetService>()?.Value ?? new BlitzCacheOptions(); + opts.Validate(); + return new BlitzCache(opts); + })); + return services; + } + /// /// Adds BlitzCache as a singleton service to the dependency injection container. /// Uses the global BlitzCache singleton to maintain backward compatibility with the original behavior. @@ -30,6 +64,30 @@ public static IServiceCollection AddBlitzCache(this IServiceCollection services, return services; } + /// + /// Adds automatic periodic logging of BlitzCache statistics using an options configuration delegate. + /// + /// Service collection + /// Configuration action for + /// The service collection for chaining. + public static IServiceCollection AddBlitzCacheLogging(this IServiceCollection services, Action configure) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + if (configure == null) throw new ArgumentNullException(nameof(configure)); + + var options = new BlitzCacheCore.Logging.BlitzCacheLoggingOptions(); + configure(options); + options.Validate(); + + services.AddHostedService(provider => new BlitzCacheLoggingService( + provider.GetRequiredService>(), + provider.GetService(), + options.GlobalCacheIdentifier, + options.LogInterval)); + + return services; + } + /// /// Adds automatic periodic logging of BlitzCache statistics. /// Statistics will be logged at the specified interval using the provided logger. diff --git a/BlitzCache/Formatters.cs b/BlitzCache/Formatters.cs index cb2e18d..798a557 100644 --- a/BlitzCache/Formatters.cs +++ b/BlitzCache/Formatters.cs @@ -4,8 +4,8 @@ internal static class Formatters { internal static string FormatDuration(long milliseconds) { - if (milliseconds < 1000) - return $"{milliseconds}ms"; + if (milliseconds < 0) return "0ms"; // clamp negative + if (milliseconds < 1000) return $"{milliseconds}ms"; var ts = System.TimeSpan.FromMilliseconds(milliseconds); @@ -29,13 +29,17 @@ internal static string FormatDuration(long milliseconds) internal static string FormatBytes(long bytes) { - if (bytes < 1024) return $"{bytes} bytes"; - var kb = bytes / 1024.0; - if (kb < 1024) return $"{kb:0.##} KB"; - var mb = kb / 1024.0; - if (mb < 1024) return $"{mb:0.##} MB"; - var gb = mb / 1024.0; - return $"{gb:0.##} GB"; + if (bytes < 0) bytes = 0; + const double K = 1024.0; + if (bytes < K) return $"{bytes} bytes"; + var kb = bytes / K; + if (kb < K) return $"{kb:0.##} KB"; // < 1 MB + var mb = kb / K; + if (mb < K) return $"{mb:0.##} MB"; // < 1 GB + var gb = mb / K; + if (gb < K) return $"{gb:0.##} GB"; // < 1 TB + var tb = gb / K; + return $"{tb:0.##} TB"; } } } diff --git a/BlitzCache/Logging/BlitzCacheLoggingOptions.cs b/BlitzCache/Logging/BlitzCacheLoggingOptions.cs new file mode 100644 index 0000000..40df4f1 --- /dev/null +++ b/BlitzCache/Logging/BlitzCacheLoggingOptions.cs @@ -0,0 +1,26 @@ +using System; + +namespace BlitzCacheCore.Logging +{ + /// + /// Configuration options for BlitzCache statistics logging. + /// Extend with new properties as logging capabilities evolve. + /// + public sealed class BlitzCacheLoggingOptions + { + /// + /// Interval between statistics log outputs. Defaults to 1 hour. + /// + public TimeSpan LogInterval { get; set; } = TimeSpan.FromHours(1); + + /// + /// Optional identifier for the global cache in log output. If null will be auto detected. + /// + public string? GlobalCacheIdentifier { get; set; } = null; + + internal void Validate() + { + if (LogInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(LogInterval), "LogInterval must be positive"); + } + } +} diff --git a/BlitzCache/Logging/BlitzLoggerInstance.cs b/BlitzCache/Logging/BlitzLoggerInstance.cs index 50cb38d..48c7129 100644 --- a/BlitzCache/Logging/BlitzLoggerInstance.cs +++ b/BlitzCache/Logging/BlitzLoggerInstance.cs @@ -30,30 +30,46 @@ internal void Log(ILogger logger) var stats = instance.Statistics!; - logger.LogInformation( - "***[{Identifier}] BlitzCache Statistics***\n" + - "Hits: {HitCount}\n" + - "Misses: {MissCount}\n" + - "Hit Ratio: {HitRatio:P2}\n" + - "Entries: {EntryCount}\n" + - "Evictions: {EvictionCount}\n" + - "Active Semaphores: {ActiveSemaphoreCount}\n" + - "Total Operations: {TotalOperations}\n" + - "Approx. Memory: {ApproxMemory}\n" + - "Top Heaviest: {TopHeaviest}\n" + - "Top Slowest Queries: {TopSlowestQueries}", - Identifier, - stats.HitCount, - stats.MissCount, - stats.HitRatio, - stats.EntryCount, - stats.EvictionCount, - stats.ActiveSemaphoreCount, - stats.TotalOperations, - Formatters.FormatBytes(stats.ApproximateMemoryBytes), - stats.TopHeaviestEntries != null && stats.TopHeaviestEntries.Any() ? string.Concat(stats.TopHeaviestEntries.Select(q => $"\n\t{q}")) : "Not available", - stats.TopSlowestQueries != null && stats.TopSlowestQueries.Count() > 0 ? string.Concat(stats.TopSlowestQueries.Select(q => $"\n\t{q}")) : "Not available" - ); + // Build the log dynamically so we can omit sections entirely when disabled (<1) for performance clarity. + var message = $"***[{Identifier}] BlitzCache Statistics***\n" + + $"Hits: {stats.HitCount}\n" + + $"Misses: {stats.MissCount}\n" + + $"Hit Ratio: {stats.HitRatio:P2}\n" + + $"Entries: {stats.EntryCount}\n" + + $"Evictions: {stats.EvictionCount}\n" + + $"Active Semaphores: {stats.ActiveSemaphoreCount}\n" + + $"Total Operations: {stats.TotalOperations}\n" + + $"Approx. Memory: {Formatters.FormatBytes(stats.ApproximateMemoryBytes)}"; + + // Attempt to detect internal tracking flags (available when concrete type is CacheStatistics) + var cacheStatsType = stats.GetType(); + bool heaviestEnabled = false; + bool slowestEnabled = false; + try + { + var heaviestProp = cacheStatsType.GetProperty("HeaviestTrackingEnabled", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); + var slowestProp = cacheStatsType.GetProperty("SlowestTrackingEnabled", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); + heaviestEnabled = heaviestProp?.GetValue(stats) as bool? == true; + slowestEnabled = slowestProp?.GetValue(stats) as bool? == true; + } + catch { } + + if (heaviestEnabled) + { + var heaviest = stats.TopHeaviestEntries != null && stats.TopHeaviestEntries.Any() + ? string.Concat(stats.TopHeaviestEntries.Select(q => $"\n\t{q}")) + : "\n\t"; + message += $"\nTop Heaviest:{heaviest}"; + } + if (slowestEnabled) + { + var slowest = stats.TopSlowestQueries != null && stats.TopSlowestQueries.Any() + ? string.Concat(stats.TopSlowestQueries.Select(q => $"\n\t{q}")) + : "\n\t"; + message += $"\nTop Slowest Queries:{slowest}"; + } + + logger.LogInformation(message); lastLogTime = DateTime.UtcNow; } diff --git a/BlitzCache/NullBlitzCacheForTesting.cs b/BlitzCache/NullBlitzCacheForTesting.cs index 7e2384a..91f8278 100644 --- a/BlitzCache/NullBlitzCacheForTesting.cs +++ b/BlitzCache/NullBlitzCacheForTesting.cs @@ -1,4 +1,5 @@ using BlitzCacheCore.Statistics; +using BlitzCacheCore.Statistics.Speed; using System; using System.Collections.Generic; using System.Runtime.CompilerServices; diff --git a/BlitzCache/Statistics/CacheStatistics.cs b/BlitzCache/Statistics/CacheStatistics.cs index 3090c5c..d874ff5 100644 --- a/BlitzCache/Statistics/CacheStatistics.cs +++ b/BlitzCache/Statistics/CacheStatistics.cs @@ -1,9 +1,10 @@ +using BlitzCacheCore.Statistics.Memory; +using BlitzCacheCore.Statistics.Speed; using Microsoft.Extensions.Caching.Memory; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading; namespace BlitzCacheCore.Statistics @@ -24,7 +25,11 @@ internal class CacheStatistics : ICacheStatistics private readonly TopNTracker? topSlowestQueries; private readonly TopNTracker? topHeaviestEntries; private readonly IValueSizer valueSizer; - private readonly ConcurrentDictionary keySizes = new ConcurrentDictionary(); + private readonly ConcurrentDictionary keySizes = new ConcurrentDictionary(); + private readonly bool sizeTrackingEnabled; // true when we must compute/value sizes (heaviest tracking or capacity limit) + // Tracks keys proactively removed (capacity enforcement, manual) whose stats have already been accounted for. + // Prevents the subsequent MemoryCache eviction callback from double-counting removals / evictions. + private readonly ConcurrentDictionary externallyRemoved = new ConcurrentDictionary(); public long HitCount => Interlocked.Read(ref hitCount); public long MissCount => Interlocked.Read(ref missCount); @@ -35,6 +40,8 @@ internal class CacheStatistics : ICacheStatistics public IEnumerable TopSlowestQueries => topSlowestQueries is null ? Array.Empty() : topSlowestQueries.Get(); public long ApproximateMemoryBytes => Interlocked.Read(ref approximateMemoryBytes); public IEnumerable TopHeaviestEntries => topHeaviestEntries is null ? Array.Empty() : topHeaviestEntries.Get(); + internal bool SlowestTrackingEnabled => topSlowestQueries != null; // for internal consumers like logger + internal bool HeaviestTrackingEnabled => topHeaviestEntries != null; public double HitRatio { get @@ -52,10 +59,11 @@ public double HitRatio /// Max number of top slowest queries to store (0 disables it for improved performance) /// Max number of top heaviest entries to track (0 disables tracking) /// Strategy for estimating value sizes - public CacheStatistics(Func getActiveSemaphoreCount, int maxTopSlowest, int maxTopHeaviest = 5, IValueSizer? valueSizer = null) + public CacheStatistics(Func getActiveSemaphoreCount, int maxTopSlowest, int maxTopHeaviest = 5, BlitzCacheCore.Statistics.Memory.IValueSizer? valueSizer = null, bool capacityLimitEnabled = false) { this.getActiveSemaphoreCount = getActiveSemaphoreCount ?? throw new ArgumentNullException(nameof(getActiveSemaphoreCount)); - this.valueSizer = valueSizer ?? new ApproximateValueSizer(); + // Default to object graph sizer for improved estimation accuracy + this.valueSizer = valueSizer ?? new ObjectGraphValueSizer(); if (maxTopSlowest > 0) { @@ -65,6 +73,9 @@ public CacheStatistics(Func getActiveSemaphoreCount, int maxTopSlowest, int { topHeaviestEntries = new TopNTracker(maxTopHeaviest, (key, size) => new HeavyEntry(key, size)); } + + // We only need to perform size computations if either heaviest tracking is enabled OR a capacity limit is active. + sizeTrackingEnabled = topHeaviestEntries != null || capacityLimitEnabled; } /// @@ -100,8 +111,11 @@ internal void RecordAddOrUpdate(string cacheKey, long newSizeBytes, long? oldSiz Interlocked.Increment(ref entryCount); // Update total bytes delta - var delta = newSizeBytes - (oldSizeBytes ?? 0); - Interlocked.Add(ref approximateMemoryBytes, delta); + if (sizeTrackingEnabled) + { + var delta = newSizeBytes - (oldSizeBytes ?? 0); + Interlocked.Add(ref approximateMemoryBytes, delta); + } // Track heaviest list topHeaviestEntries?.AddOrUpdate(cacheKey, newSizeBytes); @@ -111,10 +125,36 @@ internal void RecordRemove(string cacheKey, long sizeBytes) { // Entry count and eviction are handled by the MemoryCache eviction callback. // Here we only adjust memory accounting and heaviest list. - Interlocked.Add(ref approximateMemoryBytes, -sizeBytes); + if (sizeTrackingEnabled) + { + Interlocked.Add(ref approximateMemoryBytes, -sizeBytes); + } topHeaviestEntries?.Remove(cacheKey); } + /// + /// Used when an entry is manually removed outside the normal eviction callback path + /// (e.g., proactive capacity enforcement). Ensures statistics stay consistent. + /// + /// Key removed. + /// Previously recorded size in bytes. + /// Whether to increment eviction counters (true for policy-driven removals). + internal void OnExternalRemoval(string cacheKey, long sizeBytes, bool countAsEviction) + { + // Mark so eviction callback can short-circuit. + externallyRemoved[cacheKey] = 0; + + // Remove from key size tracking immediately to avoid future enforcement passes seeing stale entries. + if (sizeTrackingEnabled) + { + keySizes.TryRemove(cacheKey, out _); + } + + // Synchronously account for memory + heaviest tracking. + RecordRemove(cacheKey, sizeBytes); + if (countAsEviction) RecordEviction(); + } + /// /// Creates memory cache entry options with eviction callback for statistics and size tracking. /// @@ -139,9 +179,11 @@ internal MemoryCacheEntryOptions CreateEntryOptions(DateTime expirationTime, lon { // Track automatic evictions (expiration, memory pressure, etc.) // Don't count Replaced as eviction since it's just an update - if (reason != EvictionReason.Replaced) + if (reason != EvictionReason.Replaced && key is string k) { - if (key is string k && keySizes.TryRemove(k, out var s)) + // If we already accounted for this via proactive (external) removal, skip adjustments. + if (externallyRemoved.TryRemove(k, out _)) return; // already accounted synchronously + if (sizeTrackingEnabled && keySizes.TryRemove(k, out var s)) { RecordRemove(k, s); } @@ -164,13 +206,21 @@ internal MemoryCacheEntryOptions CreateEntryOptions(DateTime expirationTime) /// internal void RecordSetOrUpdate(string cacheKey, object value, bool existedInCache) { - var newSize = valueSizer.GetSizeBytes(value); + var newSize = sizeTrackingEnabled ? valueSizer.GetSizeBytes(value) : 0L; long? oldSize = null; // Only treat as update if the entry truly existed in cache prior to the Set operation. if (existedInCache && keySizes.TryGetValue(cacheKey, out var prev)) oldSize = prev; - keySizes[cacheKey] = newSize; - RecordAddOrUpdate(cacheKey, newSize, oldSize); + if (sizeTrackingEnabled) + { + keySizes[cacheKey] = newSize; + RecordAddOrUpdate(cacheKey, newSize, oldSize); + } + else if (!existedInCache) + { + // When size tracking is disabled we still need to maintain entry count. + RecordAddOrUpdate(cacheKey, 0, null); + } } /// @@ -190,7 +240,7 @@ internal void TrackEntryUpdate() { } /// /// Provides a snapshot of current key sizes for capacity enforcement. /// - internal KeyValuePair[] GetKeySizesSnapshot() => keySizes.ToArray(); + internal KeyValuePair[] GetKeySizesSnapshot() => sizeTrackingEnabled ? keySizes.ToArray() : Array.Empty>(); /// /// Resets all statistics counters to zero. Thread-safe. @@ -203,6 +253,7 @@ public void Reset() Interlocked.Exchange(ref entryCount, 0); Interlocked.Exchange(ref approximateMemoryBytes, 0); keySizes.Clear(); + externallyRemoved.Clear(); topSlowestQueries?.Clear(); topHeaviestEntries?.Clear(); } diff --git a/BlitzCache/Statistics/ICacheStatistics.cs b/BlitzCache/Statistics/ICacheStatistics.cs index 6f625ff..69bf570 100644 --- a/BlitzCache/Statistics/ICacheStatistics.cs +++ b/BlitzCache/Statistics/ICacheStatistics.cs @@ -1,3 +1,4 @@ +using BlitzCacheCore.Statistics.Speed; using System.Collections.Generic; namespace BlitzCacheCore.Statistics diff --git a/BlitzCache/Statistics/IValueSizer.cs b/BlitzCache/Statistics/IValueSizer.cs deleted file mode 100644 index c822e4b..0000000 --- a/BlitzCache/Statistics/IValueSizer.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; - -namespace BlitzCacheCore.Statistics -{ - /// - /// Strategy to estimate the approximate size in bytes of a value to be cached. - /// Implementations should be fast and allocation-friendly; the result is best-effort. - /// - public interface IValueSizer - { - long GetSizeBytes(object? value); - } - - /// - /// A basic, zero-dependency, best-effort sizer for common .NET types. - /// - internal class ApproximateValueSizer : IValueSizer - { - private const long FallbackSizeBytes = 128; // conservative estimate for unknown types - public long GetSizeBytes(object? value) - { - if (value is null) return 0; - - switch (value) - { - case string s: - // UTF-16: 2 bytes/char; add small object/header overhead approximation - return (s.Length * 2L) + 24; - case byte[] b: - return b.LongLength + 24; - case Array arr when arr.Length > 0 && arr.GetType().GetElementType() == typeof(int): - return (arr.Length * 4L) + 24; - case Array arr2 when arr2.Length > 0 && arr2.GetType().GetElementType() == typeof(long): - return (arr2.Length * 8L) + 24; - case Array arr3 when arr3.Length > 0 && arr3.GetType().GetElementType() == typeof(double): - return (arr3.Length * 8L) + 24; - case Array arr4 when arr4.Length > 0 && arr4.GetType().GetElementType() == typeof(float): - return (arr4.Length * 4L) + 24; - default: - // Fallback: rough estimate using string serialization length if available - // but avoid heavy serialization to keep it cheap; use type name overhead - return FallbackSizeBytes; - } - } - } -} diff --git a/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs b/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs new file mode 100644 index 0000000..a263525 --- /dev/null +++ b/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs @@ -0,0 +1,120 @@ +using System; + +namespace BlitzCacheCore.Statistics.Memory +{ + /// + /// A basic, zero-dependency, best-effort sizer for common .NET types. + /// + internal sealed class ApproximateValueSizer : IValueSizer + { + private const long FallbackSizeBytes = 128; // conservative estimate for unknown reference types + private const int ObjectOverhead = 24; // rough header+length field approximation for typical CLR objects/arrays on 64-bit + private static long WithOverhead(long payload) => payload + ObjectOverhead; // helper to keep formulas consistent + private static long Align8(long value) { long r = value & 7; return r == 0 ? value : value + (8 - r); } + private static long Align8WithOverhead(long value) => Align8(WithOverhead(value)); + + private static readonly System.Collections.Generic.Dictionary PrimitiveWidths = new System.Collections.Generic.Dictionary + { + { typeof(bool), 1 }, { typeof(byte), 1 }, { typeof(sbyte), 1 }, + { typeof(char), 2 }, { typeof(short), 2 }, { typeof(ushort), 2 }, + { typeof(int), 4 }, { typeof(uint), 4 }, { typeof(float), 4 }, + { typeof(long), 8 }, { typeof(ulong), 8 }, { typeof(double), 8 }, + { typeof(decimal), 16 }, { typeof(Guid), 16 }, { typeof(DateTime), 8 }, { typeof(TimeSpan), 8 } + }; + + public long GetSizeBytes(object? value) + { + // Fast path primitive scalars (approx boxed size): header + payload rounded. + switch (value) + { + case null: return 0; + case bool _: return Align8WithOverhead(1); + case byte _: return Align8WithOverhead(1); + case sbyte _: return Align8WithOverhead(1); + case char _: return Align8WithOverhead(2); + case short _: return Align8WithOverhead(2); + case ushort _: return Align8WithOverhead(2); + case int _: return Align8WithOverhead(4); + case uint _: return Align8WithOverhead(4); + case float _: return Align8WithOverhead(4); + case long _: return Align8WithOverhead(8); + case ulong _: return Align8WithOverhead(8); + case double _: return Align8WithOverhead(8); + case decimal _: return Align8WithOverhead(16); + case Guid _: return Align8WithOverhead(16); + case DateTime _: return Align8WithOverhead(8); + case TimeSpan _: return Align8WithOverhead(8); + case string s: return Align8WithOverhead(s.Length * 2L); + } + + if (value is Array arr) + { + var elemType = arr.GetType().GetElementType(); + int len = arr.Length; + if (elemType == null) return Align8(FallbackSizeBytes); // unknown + if (len == 0) return Align8(ObjectOverhead); // just header + + // Common primitive element arrays + if (elemType == typeof(byte) || elemType == typeof(sbyte)) return Align8WithOverhead(len); + if (elemType == typeof(bool)) return Align8WithOverhead(len); // 1 byte each (approx) + if (elemType == typeof(char) || elemType == typeof(short) || elemType == typeof(ushort)) return Align8WithOverhead(len * 2L); + if (elemType == typeof(int) || elemType == typeof(uint) || elemType == typeof(float)) return Align8WithOverhead(len * 4L); + if (elemType == typeof(long) || elemType == typeof(ulong) || elemType == typeof(double)) return Align8WithOverhead(len * 8L); + if (elemType == typeof(decimal) || elemType == typeof(Guid)) return Align8WithOverhead(len * 16L); + + // Fallback: assume reference array -> pointer per element + return Align8WithOverhead(len * IntPtr.Size); + } + + // Handle Memory / ReadOnlyMemory (boxed structs). Only for primitive-like T. + var t = value.GetType(); + if (t.IsGenericType) + { + var def = t.GetGenericTypeDefinition(); + if (def == typeof(Memory<>) || def == typeof(ReadOnlyMemory<>)) + { + var elem = t.GetGenericArguments()[0]; + int width; + if (PrimitiveWidths.TryGetValue(elem, out var w)) width = w; else width = IntPtr.Size; // pointer-sized for ref types + // Access Length property via reflection (cheap after JIT; could cache if needed) + try + { + var lenProp = t.GetProperty("Length"); + if (lenProp != null) + { + var lenObj = lenProp.GetValue(value); + if (lenObj is int mlen && mlen >= 0) + { + return Align8WithOverhead((long)mlen * width); + } + } + } + catch { } + } + // Handle List primitive T: Count * element width + overhead (pointer per element for refs) + if (def == typeof(System.Collections.Generic.List<>)) + { + var elem = t.GetGenericArguments()[0]; + long per; + if (PrimitiveWidths.TryGetValue(elem, out var w)) per = w; else per = IntPtr.Size; // treat reference as pointer + try + { + var countProp = t.GetProperty("Count"); + if (countProp != null) + { + var cntObj = countProp.GetValue(value); + if (cntObj is int cnt && cnt >= 0) + { + return Align8WithOverhead(cnt * per); + } + } + } + catch { } + } + } + + // Unknown reference type + return Align8(FallbackSizeBytes); + } + } +} diff --git a/BlitzCache/Statistics/Memory/IValueSizer.cs b/BlitzCache/Statistics/Memory/IValueSizer.cs new file mode 100644 index 0000000..4521615 --- /dev/null +++ b/BlitzCache/Statistics/Memory/IValueSizer.cs @@ -0,0 +1,13 @@ +using System; + +namespace BlitzCacheCore.Statistics.Memory +{ + /// + /// Strategy to estimate the approximate size in bytes of a value to be cached. + /// Implementations should be fast and allocation-friendly; the result is best-effort. + /// + public interface IValueSizer + { + long GetSizeBytes(object? value); + } +} diff --git a/BlitzCache/Statistics/Memory/ObjectGraphSizerOptions.cs b/BlitzCache/Statistics/Memory/ObjectGraphSizerOptions.cs new file mode 100644 index 0000000..87c623e --- /dev/null +++ b/BlitzCache/Statistics/Memory/ObjectGraphSizerOptions.cs @@ -0,0 +1,21 @@ +using System; + +namespace BlitzCacheCore.Statistics.Memory +{ + /// + /// Configuration options for ObjectGraphValueSizer. + /// + public sealed class ObjectGraphSizerOptions + { + public int MaxDepth { get; set; } = 2; + public int MaxObjects { get; set; } = 512; + public int MaxSampledElementsPerLevel { get; set; } = 32; + public int FallbackStructSize { get; set; } = 32; + public int ObjectHeaderSize { get; set; } = 16; + public int ReferenceSize { get; set; } = IntPtr.Size; + public int ArrayHeaderSize { get; set; } = 24; + public int StringOverhead { get; set; } = 24; + public bool ReflectIntoStructs { get; set; } = true; + public SizeComputationMode Mode { get; set; } = SizeComputationMode.Balanced; + } +} diff --git a/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs b/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs new file mode 100644 index 0000000..4505e91 --- /dev/null +++ b/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using BlitzCacheCore.Statistics.Memory; + +namespace BlitzCacheCore.Statistics.Memory +{ + /// + /// Bounded recursive object graph sizer. Provides a better approximation than the simple fallback + /// by walking fields up to a configured depth and sampling collections. Cycle safe and allocation light after warmup. + /// + internal sealed class ObjectGraphValueSizer : IValueSizer + { + private readonly ObjectGraphSizerOptions _options; + private readonly ConcurrentDictionary _layoutCache = new ConcurrentDictionary(); + // Pool to avoid allocating a new HashSet for each sizing operation. HashSet is cleared before reuse. + private static readonly ConcurrentBag> _visitedPool = new ConcurrentBag>(); + + private static readonly Dictionary PrimitiveValueSizes = new Dictionary() + { + { typeof(bool), 1 }, { typeof(byte), 1 }, { typeof(sbyte), 1 }, + { typeof(short), 2 }, { typeof(ushort), 2 }, { typeof(char), 2 }, + { typeof(int), 4 }, { typeof(uint), 4 }, { typeof(float), 4 }, + { typeof(long), 8 }, { typeof(ulong), 8 }, { typeof(double), 8 }, + { typeof(IntPtr), IntPtr.Size }, { typeof(UIntPtr), IntPtr.Size }, + { typeof(decimal), 16 }, { typeof(Guid), 16 }, { typeof(DateTime), 8 }, + { typeof(TimeSpan), 8 } + }; + + public ObjectGraphValueSizer(ObjectGraphSizerOptions? options = null) + { + _options = options ?? new ObjectGraphSizerOptions(); + } + + public long GetSizeBytes(object? value) + { + if (value is null) return 0; + if (value is string s) return SizeOfString(s); + var type = value.GetType(); + if (IsPrimitiveLike(type)) return GetPrimitiveLikeSize(type); + + var visited = AcquireVisitedSet(); + try + { + if (type.IsArray) + { + return SizeOfArray((Array)value, 0, visited); + } + return EstimateObject(value, 0, visited); + } + finally + { + ReleaseVisitedSet(visited); + } + } + + private long EstimateObject(object obj, int depth, HashSet visited) + { + if (obj is null) return 0; + if (!visited.Add(obj)) return 0; + if (visited.Count > _options.MaxObjects) return 0; + + var type = obj.GetType(); + + if (IsPrimitiveLike(type)) return GetPrimitiveLikeSize(type); + if (obj is string s) return SizeOfString(s); + if (type.IsArray) return SizeOfArray((Array)obj, depth, visited); + + if (TryCollection(obj, type, depth, visited, out var collectionSize)) + return collectionSize; + + if (type.IsValueType) return EstimateStruct(obj, type, depth, visited); + + var layout = GetLayout(type); + // Fast mode: return precomputed shallow size immediately + if (_options.Mode == SizeComputationMode.Fast) + return layout.PrecomputedShallowSize; + long total = _options.ObjectHeaderSize; + var mode = _options.Mode; + bool isAdaptive = mode == SizeComputationMode.Adaptive; + bool isAccurate = mode == SizeComputationMode.Accurate; + int effectiveMaxDepth = _options.MaxDepth + (isAccurate ? 1 : 0); + if (isAdaptive) effectiveMaxDepth = _options.MaxDepth; // bounded strictly below Accurate + if (depth >= effectiveMaxDepth && !isAdaptive) return Align8(total); + + // In Adaptive mode decide per-field if we traverse deeper using a simple heuristic budget. + int remainingObjectBudget = _options.MaxObjects - visited.Count; + bool allowDeeper = !isAdaptive || (remainingObjectBudget > 0 && depth < _options.MaxDepth); + foreach (var f in layout.InstanceFields) + { + var ft = f.FieldType; + object? fv = null; + try { fv = f.GetValue(obj); } catch { } + + if (ft.IsValueType) + { + total += EstimateValueTypeInline(ft, fv, depth, visited); + } + else + { + total += _options.ReferenceSize; + if (fv != null) + { + if (!isAdaptive) + { + if (depth < effectiveMaxDepth) total += EstimateObject(fv, depth + 1, visited); + } + else if (allowDeeper && depth < effectiveMaxDepth) + { + var childLayout = GetLayout(fv.GetType()); + if (childLayout.HasReferenceFields || childLayout.PrecomputedShallowSize > (_options.ObjectHeaderSize + 32)) + total += EstimateObject(fv, depth + 1, visited); + } + } + } + } + return Align8(total); + } + + private long EstimateStruct(object structValue, Type type, int depth, HashSet visited) + { + if (IsPrimitiveLike(type)) return GetPrimitiveLikeSize(type); + int effectiveMaxDepth = _options.MaxDepth + (_options.Mode == SizeComputationMode.Accurate ? 1 : 0); + if (_options.Mode == SizeComputationMode.Fast) return _options.FallbackStructSize; // treat as shallow approximation + if (depth >= effectiveMaxDepth || !_options.ReflectIntoStructs) return _options.FallbackStructSize; + + var layout = GetLayout(type); + long total = 0; + foreach (var f in layout.InstanceFields) + { + var ft = f.FieldType; + object? fv = null; + try { fv = f.GetValue(structValue); } catch { } + if (ft.IsValueType) + { + total += EstimateValueTypeInline(ft, fv, depth, visited); + } + else + { + total += _options.ReferenceSize; + if (fv != null && depth < _options.MaxDepth) total += EstimateObject(fv, depth + 1, visited); + } + } + return Align8(total); + } + + private long EstimateValueTypeInline(Type structType, object? value, int depth, HashSet visited) + { + if (IsPrimitiveLike(structType)) return GetPrimitiveLikeSize(structType); + if (value is null) return _options.FallbackStructSize; + return EstimateStruct(value, structType, depth + 1, visited); + } + + private long SizeOfString(string s) => Align8(_options.StringOverhead + (s.Length * 2L)); + + private long SizeOfArray(Array arr, int depth, HashSet visited) + { + var elemType = arr.GetType().GetElementType()!; + long size = _options.ArrayHeaderSize; + int len = arr.Length; + + if (IsPrimitiveLike(elemType)) + { + size += (long)len * GetPrimitiveLikeSize(elemType); + return Align8(size); + } + + if (elemType.IsValueType && !_options.ReflectIntoStructs) + { + size += (long)len * _options.FallbackStructSize; + return Align8(size); + } + + int sampleLimit = ComputeSampleLimit(_options.Mode, _options.MaxSampledElementsPerLevel, fastCap:4, adaptiveDivisor:2, adaptiveCapMultiplier:0.75, accurateMultiplier:2); + int toSample = Math.Min(len, sampleLimit); + + if (elemType.IsValueType) + { + long sampleTotal = 0; + for (int i = 0; i < toSample; i++) + { + var v = arr.GetValue(i); + sampleTotal += EstimateValueTypeInline(elemType, v, depth, visited); + } + if (toSample > 0) + { + long avg = sampleTotal / toSample; + size += avg * len; + } + return Align8(size); + } + else + { + size += (long)len * _options.ReferenceSize; + int effectiveMaxDepth = _options.MaxDepth + (_options.Mode == SizeComputationMode.Accurate ? 1 : 0); + if (_options.Mode == SizeComputationMode.Fast) + { + // Fast mode: do not traverse reference elements + return Align8(size); + } + if (_options.Mode == SizeComputationMode.Adaptive) + { + // Adaptive: traverse only a small subset (cube root) to stay lighter than Balanced for large arrays + int traverse = (int)Math.Ceiling(Math.Pow(toSample, 1.0 / 3.0)); + for (int i = 0; i < traverse; i++) + { + var v = arr.GetValue(i); + if (v != null && depth < effectiveMaxDepth) size += EstimateObject(v, depth + 1, visited); + } + return Align8(size); + } + if (depth < effectiveMaxDepth) + { + for (int i = 0; i < toSample; i++) + { + var v = arr.GetValue(i); + if (v != null) size += EstimateObject(v, depth + 1, visited); + } + } + return Align8(size); + } + } + + private bool TryCollection(object obj, Type type, int depth, HashSet visited, out long size) + { + size = 0; + if (obj is string) return false; + + if (obj is IDictionary dict) + { + size = _options.ObjectHeaderSize; + int count = dict.Count; + int sampled = 0; + int sampleLimit = ComputeSampleLimit(_options.Mode, _options.MaxSampledElementsPerLevel, fastCap:4, adaptiveDivisor:2, adaptiveCapMultiplier:0.75, accurateMultiplier:2); + foreach (DictionaryEntry de in dict) + { + if (sampled++ >= sampleLimit) break; + size += _options.ReferenceSize * 2; + if (_options.Mode != SizeComputationMode.Fast && _options.Mode != SizeComputationMode.Adaptive) + { + int effectiveMaxDepth = _options.MaxDepth + (_options.Mode == SizeComputationMode.Accurate ? 1 : 0); + if (depth < effectiveMaxDepth) + { + if (de.Key != null) size += EstimateObject(de.Key, depth + 1, visited); + if (de.Value != null) size += EstimateObject(de.Value, depth + 1, visited); + } + } + else if (_options.Mode == SizeComputationMode.Adaptive) + { + // Adaptive: traverse only if key/value shallow layout indicates complexity and depth budget allows + if (depth < _options.MaxDepth) + { + if (de.Key != null) + { + var kl = GetLayout(de.Key.GetType()); + if (kl.HasReferenceFields) size += EstimateObject(de.Key, depth + 1, visited); + } + if (de.Value != null) + { + var vl = GetLayout(de.Value.GetType()); + if (vl.HasReferenceFields) size += EstimateObject(de.Value, depth + 1, visited); + } + } + } + } + if (count > sampled) + { + int remaining = count - sampled; + size += remaining * (_options.ReferenceSize * 2); + } + return true; + } + + if (obj is IEnumerable enumerable) + { + size = _options.ObjectHeaderSize; + int? count = (obj as ICollection)?.Count; + int sampled = 0; + int sampleLimit = ComputeSampleLimit(_options.Mode, _options.MaxSampledElementsPerLevel, fastCap:8, adaptiveDivisor:2, adaptiveCapMultiplier:0.75, accurateMultiplier:2, adaptiveNeedsCount: count.HasValue); + foreach (var item in enumerable) + { + if (sampled++ >= sampleLimit) break; + size += _options.ReferenceSize; + if (_options.Mode != SizeComputationMode.Fast && _options.Mode != SizeComputationMode.Adaptive) + { + int effectiveMaxDepth = _options.MaxDepth + (_options.Mode == SizeComputationMode.Accurate ? 1 : 0); + if (item != null && depth < effectiveMaxDepth) size += EstimateObject(item, depth + 1, visited); + } + else if (_options.Mode == SizeComputationMode.Adaptive && item != null && depth < _options.MaxDepth) + { + var il = GetLayout(item.GetType()); + if (il.HasReferenceFields) size += EstimateObject(item, depth + 1, visited); + } + } + if (count.HasValue && count > sampled) + { + int remaining = count.Value - sampled; + size += remaining * _options.ReferenceSize; + } + return true; + } + return false; + } + + private static bool IsPrimitiveLike(Type t) => t.IsPrimitive || PrimitiveValueSizes.ContainsKey(t) || t.IsEnum; + + private static int GetPrimitiveLikeSize(Type t) + { + if (t.IsEnum) return GetPrimitiveLikeSize(Enum.GetUnderlyingType(t)); + if (PrimitiveValueSizes.TryGetValue(t, out var s)) return s; + return t.IsPrimitive ? System.Runtime.InteropServices.Marshal.SizeOf(t) : 0; + } + + private TypeLayoutInfo GetLayout(Type t) => _layoutCache.GetOrAdd(t, tt => + { + var fields = tt.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + bool hasRef = false; + long shallow = _options.ObjectHeaderSize; // header cost for reference types; structs will use inline calc + foreach (var f in fields) + { + var ft = f.FieldType; + if (ft.IsValueType) + { + if (IsPrimitiveLike(ft)) shallow += GetPrimitiveLikeSize(ft); else shallow += _options.FallbackStructSize; + } + else + { + hasRef = true; + shallow += _options.ReferenceSize; // pointer cost + } + } + shallow = Align8(shallow); + return new TypeLayoutInfo(fields, hasRef, shallow); + }); + + private static HashSet AcquireVisitedSet() + { + if (_visitedPool.TryTake(out var hs)) return hs; + return new HashSet(ReferenceEqualityComparer.Instance); + } + + private static void ReleaseVisitedSet(HashSet set) + { + set.Clear(); + _visitedPool.Add(set); + } + + private static long Align8(long value) + { + long rem = value & 7; + return rem == 0 ? value : (value + (8 - rem)); + } + + private static int ComputeSampleLimit(SizeComputationMode mode, int baseLimit, int fastCap, int adaptiveDivisor, double adaptiveCapMultiplier, int accurateMultiplier, bool adaptiveNeedsCount = true) + { + int sampleLimit = mode == SizeComputationMode.Accurate ? baseLimit * accurateMultiplier : baseLimit; + if (mode == SizeComputationMode.Fast) + { + return Math.Min(sampleLimit, fastCap); + } + if (mode == SizeComputationMode.Adaptive && adaptiveNeedsCount) + { + int cap = (int)Math.Ceiling(baseLimit * adaptiveCapMultiplier); + sampleLimit = Math.Min(sampleLimit / adaptiveDivisor, cap); + } + return sampleLimit; + } + + private sealed class TypeLayoutInfo + { + public FieldInfo[] InstanceFields { get; private set; } + public bool HasReferenceFields { get; private set; } + public long PrecomputedShallowSize { get; private set; } + public TypeLayoutInfo(FieldInfo[] fields, bool hasRef, long shallow) + { + InstanceFields = fields; + HasReferenceFields = hasRef; + PrecomputedShallowSize = shallow; + } + } + } + +} diff --git a/BlitzCache/Statistics/Memory/ReferenceEqualityComparer.cs b/BlitzCache/Statistics/Memory/ReferenceEqualityComparer.cs new file mode 100644 index 0000000..4e7946a --- /dev/null +++ b/BlitzCache/Statistics/Memory/ReferenceEqualityComparer.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace BlitzCacheCore.Statistics.Memory +{ + internal sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer(); + private ReferenceEqualityComparer() { } + public new bool Equals(object x, object y) => ReferenceEquals(x, y); + public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/BlitzCache/Statistics/Memory/SizeComputationMode.cs b/BlitzCache/Statistics/Memory/SizeComputationMode.cs new file mode 100644 index 0000000..eeb6953 --- /dev/null +++ b/BlitzCache/Statistics/Memory/SizeComputationMode.cs @@ -0,0 +1,13 @@ +namespace BlitzCacheCore.Statistics.Memory +{ + /// + /// Sizing strategy modes controlling traversal cost vs accuracy. + /// + public enum SizeComputationMode + { + Fast = 0, + Balanced = 1, + Accurate = 2, + Adaptive = 3 + } +} diff --git a/BlitzCache/Statistics/HeavyEntry.cs b/BlitzCache/Statistics/Speed/HeavyEntry.cs similarity index 94% rename from BlitzCache/Statistics/HeavyEntry.cs rename to BlitzCache/Statistics/Speed/HeavyEntry.cs index 9c3abb2..e1a1d7c 100644 --- a/BlitzCache/Statistics/HeavyEntry.cs +++ b/BlitzCache/Statistics/Speed/HeavyEntry.cs @@ -1,4 +1,4 @@ -namespace BlitzCacheCore.Statistics +namespace BlitzCacheCore.Statistics.Speed { /// /// Represents a cache entry approximate size for reporting purposes. diff --git a/BlitzCache/Statistics/SlowQuery.cs b/BlitzCache/Statistics/Speed/SlowQuery.cs similarity index 84% rename from BlitzCache/Statistics/SlowQuery.cs rename to BlitzCache/Statistics/Speed/SlowQuery.cs index a517ed7..cf6e3ac 100644 --- a/BlitzCache/Statistics/SlowQuery.cs +++ b/BlitzCache/Statistics/Speed/SlowQuery.cs @@ -1,6 +1,6 @@ -using System; +using System; -namespace BlitzCacheCore.Statistics +namespace BlitzCacheCore.Statistics.Speed { public class SlowQuery : IComparable, IStatisticalEntry { @@ -23,19 +23,22 @@ public SlowQuery(string cacheKey, long worstCaseMs) public SlowQuery Update(long currentExecution) { - // Update the worst case, best case, and average based on the new slow query WorstCaseMs = Math.Max(currentExecution, WorstCaseMs); BestCaseMs = Math.Min(currentExecution, BestCaseMs); - AverageMs = ((AverageMs * Occurrences) + currentExecution) / (Occurrences + 1); + try + { + AverageMs = checked(((AverageMs * Occurrences) + currentExecution) / (Occurrences + 1)); + } + catch (OverflowException) + { + AverageMs = long.MaxValue; + } Occurrences++; - return this; } void IStatisticalEntry.Update(long value) => Update(value); - public bool IsFasterThan(long durationMilliseconds) => WorstCaseMs < durationMilliseconds; - public override bool Equals(object obj) => obj is SlowQuery other && CacheKey == other.CacheKey; public override int GetHashCode() => CacheKey.GetHashCode(); public override string ToString() => $"{CacheKey} - Worse: {Formatters.FormatDuration(WorstCaseMs)} | Best: {Formatters.FormatDuration(BestCaseMs)} | Avg: {Formatters.FormatDuration(AverageMs)} | Occurrences: {Occurrences}"; diff --git a/BlitzCache/icon.png b/BlitzCache/icon.png new file mode 100644 index 0000000..8efc6c1 Binary files /dev/null and b/BlitzCache/icon.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index a2678e0..4b712a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# [2.1.1] - 2025-10-13 + +### Added +- Zero-overhead conditional statistics: when `maxTopSlowest < 1` or `maxTopHeaviest < 1`, their tracking logic is completely skipped (no allocations or timers for those lists). +- Automatic skip of per-entry size computation and capacity enforcement when no `maxCacheSizeBytes` is configured (removes all sizing overhead in that mode). +- Deterministic capacity eviction algorithm using simulated remaining byte accounting to prevent transient over-eviction caused by asynchronous eviction callbacks. +- Tests validating disabled tracking produces zero memory accounting work and logger omission behavior. + +### Changed +- `BlitzLoggerInstance` now omits (instead of printing empty placeholders for) Top Slowest / Top Heaviest sections when their tracking is disabled. +- README updated with a new "Zero‑Overhead When Disabled" section explaining how to turn off features safely for maximal performance. + +### Fixed +- Potential over-removal during capacity enforcement under concurrent eviction scenarios (now uses simulated remaining budget for precision). + +### Notes +- No breaking changes. This is a performance & ergonomics release. +- Disabling tracking now truly removes related CPU & memory cost. + # [2.1.0] - 2025-08-09 ### Added diff --git a/README.md b/README.md index 28ea4f2..9fbb4b2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ⚡ BlitzCache [![NuGet](https://img.shields.io/nuget/v/BlitzCache.svg)](https://www.nuget.org/packages/BlitzCache/) [![NuGet Downloads](https://img.shields.io/nuget/dt/BlitzCache.svg)](https://www.nuget.org/packages/BlitzCache/) -[![Tests](https://img.shields.io/badge/tests-194%20passing-brightgreen)](./BlitzCache.Tests) -[![codecov](https://codecov.io/gh/chanido/blitzcache/branch/master/graph/badge.svg)](https://codecov.io/gh/chanido/blitzcache) +[![Tests](https://img.shields.io/badge/tests-347%20passing-brightgreen)](./BlitzCache.Tests) +[![codecov](https://codecov.io/gh/chanido/blitzcache/branch/develop/graph/badge.svg)](https://codecov.io/gh/chanido/blitzcache) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![.NET Standard](https://img.shields.io/badge/.NET%20Standard-2.1-blue.svg)](https://docs.microsoft.com/en-us/dotnet/standard/net-standard) @@ -85,6 +85,7 @@ Top Slowest Queries: ✅ **Approximate Memory Usage** - As of v2.1.0, statistics include approximate memory usage for better monitoring ✅ **Top Heaviest Entries** - As of v2.1.0, easily identify the largest cached items with the top heaviest entries feature ✅ **Capacity-Based Size Limit (Optional)** - As of v2.1.0, set `maxCacheSizeBytes` to enable automatic eviction when the cache exceeds a size budget +✅ **Zero-Overhead When Disabled** - Set `MaxTopSlowest` or `MaxTopHeaviest` to `0` (or pass `maxTopSlowest: 0`, `maxTopHeaviest: 0` to constructors) to completely skip tracking logic for those features. If both are disabled and no `MaxCacheSizeBytes` is configured, BlitzCache now skips all per-entry sizing for maximum throughput. ## 📋 Table of Contents @@ -97,6 +98,7 @@ Top Slowest Queries: - [Real-World Examples](#-real-world-examples) - [Advanced Usage](#-advanced-usage) - [Cache Statistics and Monitoring](#cache-statistics-and-monitoring) + - [Configuration via appsettings.json](#-configuration-via-appsettingsjson) - [API Reference](#-api-reference) - [Comparison](#-comparison-with-alternatives) - [Contributing](#-contributing) @@ -166,13 +168,14 @@ services.AddBlitzCache(maxCacheSizeBytes: 100 * 1024 * 1024); ``` How it works: -- Each entry is assigned an approximate size using a lightweight IValueSizer. +- Each entry is assigned an approximate size using an internal lightweight sizing strategy (selectable via `SizeComputationMode`). - MemoryCache evicts entries (LRU-like with priority) when inserting would exceed SizeLimit. - Enforced regardless of whether statistics are enabled. Notes: - Sizing is best-effort and optimized for common types (string, byte[], primitive arrays). Other types use a conservative default. - You can still use expiration times; capacity-based eviction works in addition to them. +- If you do NOT set `maxCacheSizeBytes` and also disable `MaxTopHeaviest` (set to 0), BlitzCache skips object sizing entirely for faster performance. ### Automatic Cache Key Generation BlitzCache can automatically generate cache keys based on the calling method and file: @@ -250,6 +253,9 @@ static string FormatBytes(long bytes) Notes: - Memory accounting is best-effort and uses a lightweight sizer for common types (strings, byte[] and primitive arrays). Custom sizing can be added in future versions. - Heaviest list size is configurable via AddBlitzCache(..., maxTopHeaviest: 5). +- Set `MaxTopHeaviest = 0` to disable tracking and memory sizing overhead (unless a capacity limit is configured). +- Set `MaxTopSlowest = 0` to disable slow query timing aggregation. +- When both are disabled AND no capacity limit is set, BlitzCache avoids computing object sizes for minimal overhead. **Available Statistics:** - **`HitCount`**: Total cache hits since instance creation @@ -269,6 +275,64 @@ Practical guidance: - For manual removals via Remove, statistics are updated through the eviction callback automatically—no extra work needed. - In tests, very small delays are used to allow callbacks to run; in production these callbacks execute automatically on the thread pool. +### 🛠 Configuration via appsettings.json +From v2.1.x you can configure BlitzCache using the standard .NET Options pattern. This allows central configuration, environment overrides, and future expansion without API changes. + +1. Add a BlitzCache section to your appsettings.json: +```json +{ + "BlitzCache": { + "DefaultMilliseconds": 10000, + "MaxTopSlowest": 10, + "MaxTopHeaviest": 5, + "MaxCacheSizeBytes": 104857600, + "EvictionStrategy": "SmallestFirst", + "SizeComputationMode": "Balanced" // Fast | Balanced | Accurate | Adaptive + } +} +``` + +2. Wire it up in Program.cs / Startup.cs: +```csharp +builder.Services.Configure(builder.Configuration.GetSection("BlitzCache")); +builder.Services.AddBlitzCache(); // registers using configured options +builder.Services.AddBlitzCacheLogging(o => // optional logging via options delegate +{ + o.LogInterval = TimeSpan.FromMinutes(30); + o.GlobalCacheIdentifier = "OrdersService-API"; +}); +``` + +3. Inject and use: +```csharp +public class OrdersService +{ + private readonly IBlitzCache _cache; + public OrdersService(IBlitzCache cache) => _cache = cache; + + public Task GetOrder(int id) => _cache.BlitzGet($"order_{id}", () => LoadOrder(id)); +} +``` + +4. Override per environment (e.g. appsettings.Production.json) without code changes. + +5. Optional: configure inline instead of JSON: +```csharp +services.AddBlitzCache(o => +{ + o.DefaultMilliseconds = 5000; + o.MaxCacheSizeBytes = 50 * 1024 * 1024; + o.MaxTopSlowest = 8; + o.SizeComputationMode = SizeComputationMode.Fast; +}); +``` + +Notes: +- If both Configure and AddBlitzCache(Action) are used, the configurations are additive; later settings overwrite earlier ones. +- Dynamic reload (IOptionsMonitor) is supported at the configuration layer, but the current global cache instance does not hot‑swap its internal sizing/eviction strategy. For runtime reconfiguration you would introduce a proxy cache in a future version. +- All newly added option fields will automatically flow without changing registration code. +- Performance tip: set `MaxTopSlowest = 0` or `MaxTopHeaviest = 0` to completely skip their tracking logic. If both are `0` and `MaxCacheSizeBytes` is null, BlitzCache skips all size computation for the leanest possible operation. + ## 📖 API Reference ### Core Methods