From 6cc532c2daf7cc4ff357e739b34f42d43d555631 Mon Sep 17 00:00:00 2001 From: Chan Date: Tue, 12 Aug 2025 11:26:01 +0100 Subject: [PATCH 01/11] Improved memory assessment procedures --- BlitzCache.Tests/Helpers/FaultyStatistics.cs | 1 + .../ObjectGraphValueSizerBenchmarkTests.cs | 90 +++++ .../ObjectGraphValueSizerModesTests.cs | 53 +++ .../ObjectGraphValueSizerTests.cs | 67 ++++ BlitzCache.Tests/Statistics/SlowQueryTests.cs | 1 + .../Statistics/TopSlowestQueriesTests.cs | 1 + BlitzCache/BlitzCacheInstance.cs | 24 +- BlitzCache/NullBlitzCacheForTesting.cs | 1 + BlitzCache/Statistics/CacheStatistics.cs | 14 +- BlitzCache/Statistics/ICacheStatistics.cs | 1 + .../ApproximateValueSizer.cs} | 16 +- BlitzCache/Statistics/Memory/IValueSizer.cs | 13 + .../Memory/ObjectGraphSizerOptions.cs | 21 + .../Memory/ObjectGraphValueSizer.cs | 379 ++++++++++++++++++ .../Memory/ReferenceEqualityComparer.cs | 12 + .../Statistics/Memory/SizeComputationMode.cs | 13 + .../Statistics/{ => Speed}/HeavyEntry.cs | 2 +- .../Statistics/{ => Speed}/SlowQuery.cs | 8 +- 18 files changed, 686 insertions(+), 31 deletions(-) create mode 100644 BlitzCache.Tests/ObjectGraphValueSizerBenchmarkTests.cs create mode 100644 BlitzCache.Tests/ObjectGraphValueSizerModesTests.cs create mode 100644 BlitzCache.Tests/ObjectGraphValueSizerTests.cs rename BlitzCache/Statistics/{IValueSizer.cs => Memory/ApproximateValueSizer.cs} (65%) create mode 100644 BlitzCache/Statistics/Memory/IValueSizer.cs create mode 100644 BlitzCache/Statistics/Memory/ObjectGraphSizerOptions.cs create mode 100644 BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs create mode 100644 BlitzCache/Statistics/Memory/ReferenceEqualityComparer.cs create mode 100644 BlitzCache/Statistics/Memory/SizeComputationMode.cs rename BlitzCache/Statistics/{ => Speed}/HeavyEntry.cs (94%) rename BlitzCache/Statistics/{ => Speed}/SlowQuery.cs (92%) 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/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/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/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/BlitzCacheInstance.cs b/BlitzCache/BlitzCacheInstance.cs index 05ac60f..f99ff31 100644 --- a/BlitzCache/BlitzCacheInstance.cs +++ b/BlitzCache/BlitzCacheInstance.cs @@ -1,5 +1,6 @@ using BlitzCacheCore.LockDictionaries; using BlitzCacheCore.Statistics; +using BlitzCacheCore.Statistics.Memory; using Microsoft.Extensions.Caching.Memory; using System; using System.Diagnostics; @@ -16,7 +17,7 @@ public class BlitzCacheInstance : IBlitzCacheInstance private readonly int maxTopSlowest; private readonly BlitzSemaphoreDictionary semaphoreDictionary; private CacheStatistics? statistics; - private readonly IValueSizer valueSizer; + private readonly BlitzCacheCore.Statistics.Memory.IValueSizer valueSizer; private readonly int maxTopHeaviest; private readonly long? configuredSizeLimitBytes; // store configured capacity for proactive compaction @@ -29,7 +30,7 @@ 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, BlitzCacheCore.Statistics.Memory.IValueSizer? valueSizer = null, int? maxTopHeaviest = 5, long? maxCacheSizeBytes = null) { if (defaultMilliseconds < 1) throw new ArgumentOutOfRangeException(nameof(defaultMilliseconds), "Default milliseconds must be non-negative"); @@ -42,7 +43,8 @@ public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupIn memoryCache = new MemoryCache(options); semaphoreDictionary = new BlitzSemaphoreDictionary(cleanupInterval); this.maxTopSlowest = maxTopSlowest ?? 5; - this.valueSizer = valueSizer ?? new ApproximateValueSizer(); + // Use the more accurate bounded object graph sizer by default + this.valueSizer = valueSizer ?? new ObjectGraphValueSizer(); this.maxTopHeaviest = maxTopHeaviest ?? 5; this.configuredSizeLimitBytes = maxCacheSizeBytes; // remember for proactive compaction } @@ -89,7 +91,23 @@ private void EnforceCapacityIfNeeded() foreach (var kvp in sizes.OrderBy(k => k.Value)) { if (statistics.ApproximateMemoryBytes <= limit) break; + // Pre-adjust statistics since manual Remove won't invoke eviction callback reasons we rely on memoryCache.Remove(kvp.Key); + // Manually mirror eviction accounting (if still over limit or removal occurred) + try + { + // RecordRemove adjusts memory bytes and heaviest list; RecordEviction increments counts + var stats = statistics as BlitzCacheCore.Statistics.CacheStatistics; // same assembly internal access + if (stats != null) + { + // Use reflection to invoke internal methods without changing visibility if needed + var recordRemove = typeof(BlitzCacheCore.Statistics.CacheStatistics).GetMethod("RecordRemove", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var recordEviction = typeof(BlitzCacheCore.Statistics.CacheStatistics).GetMethod("RecordEviction", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + recordRemove?.Invoke(stats, new object[] { kvp.Key, kvp.Value }); + recordEviction?.Invoke(stats, Array.Empty()); + } + } + catch { } } } 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..fa5ce25 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 @@ -23,7 +24,7 @@ internal class CacheStatistics : ICacheStatistics private readonly Func getActiveSemaphoreCount; private readonly TopNTracker? topSlowestQueries; private readonly TopNTracker? topHeaviestEntries; - private readonly IValueSizer valueSizer; + private readonly BlitzCacheCore.Statistics.Memory.IValueSizer valueSizer; private readonly ConcurrentDictionary keySizes = new ConcurrentDictionary(); public long HitCount => Interlocked.Read(ref hitCount); @@ -52,10 +53,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) { 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) { @@ -176,11 +178,9 @@ internal void RecordSetOrUpdate(string cacheKey, object value, bool existedInCac /// /// Tracks a new cache entry timing. /// - internal void TrackEntry(string cacheKey, Stopwatch stopwatch) - { + internal void TrackEntry(string cacheKey, Stopwatch stopwatch) => // Entry count is handled in RecordAddOrUpdate. Only record query duration here. topSlowestQueries?.AddOrUpdate(cacheKey, stopwatch.ElapsedMilliseconds); - } /// /// No-op: entry count handled in RecordAddOrUpdate. 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/Memory/ApproximateValueSizer.cs similarity index 65% rename from BlitzCache/Statistics/IValueSizer.cs rename to BlitzCache/Statistics/Memory/ApproximateValueSizer.cs index c822e4b..dc35114 100644 --- a/BlitzCache/Statistics/IValueSizer.cs +++ b/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs @@ -1,16 +1,7 @@ using System; -namespace BlitzCacheCore.Statistics +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); - } - /// /// A basic, zero-dependency, best-effort sizer for common .NET types. /// @@ -24,8 +15,7 @@ public long GetSizeBytes(object? value) switch (value) { case string s: - // UTF-16: 2 bytes/char; add small object/header overhead approximation - return (s.Length * 2L) + 24; + return (s.Length * 2L) + 24; // UTF-16 + overhead case byte[] b: return b.LongLength + 24; case Array arr when arr.Length > 0 && arr.GetType().GetElementType() == typeof(int): @@ -37,8 +27,6 @@ public long GetSizeBytes(object? value) 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/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..b00754f --- /dev/null +++ b/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs @@ -0,0 +1,379 @@ +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(); + + 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); + if (type.IsArray) return SizeOfArray((Array)value, 0, new HashSet(ReferenceEqualityComparer.Instance)); + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + return EstimateObject(value, 0, 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; + int effectiveMaxDepth = _options.MaxDepth + (_options.Mode == SizeComputationMode.Accurate ? 1 : 0); + if (_options.Mode == SizeComputationMode.Adaptive) + { + // Adaptive stays within base MaxDepth (does not use Accurate's +1) to bound size below Accurate. + effectiveMaxDepth = _options.MaxDepth; + } + bool adaptive = _options.Mode == SizeComputationMode.Adaptive; + if (depth >= effectiveMaxDepth && !adaptive) return Align8(total); + + // In Adaptive mode we decide per-field whether to traverse deeper using a simple heuristic budget. + // Budget scales with remaining object cap and depth to avoid runaway traversal. + int remainingObjectBudget = _options.MaxObjects - visited.Count; + bool allowDeeper = !adaptive || (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 (!adaptive) + { + if (depth < effectiveMaxDepth) total += EstimateObject(fv, depth + 1, visited); + } + else if (allowDeeper) + { + // Heuristic: only dive if referenced object's shallow layout suggests substantial size potential + var childLayout = GetLayout(fv.GetType()); + if (childLayout.HasReferenceFields || childLayout.PrecomputedShallowSize > (_options.ObjectHeaderSize + 32)) + { + if (depth < effectiveMaxDepth) 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 = _options.Mode == SizeComputationMode.Accurate ? _options.MaxSampledElementsPerLevel * 2 : _options.MaxSampledElementsPerLevel; + if (_options.Mode == SizeComputationMode.Fast) sampleLimit = Math.Min(sampleLimit, 4); // very small sample + if (_options.Mode == SizeComputationMode.Adaptive) + { + // Adaptive: smaller than Balanced for large arrays (cap at 0.75 * base) to ensure Accurate remains largest. + int cap = (int)Math.Ceiling(_options.MaxSampledElementsPerLevel * 0.75); + sampleLimit = Math.Min(sampleLimit / 2, cap); + } + 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 = _options.Mode == SizeComputationMode.Accurate ? _options.MaxSampledElementsPerLevel * 2 : _options.MaxSampledElementsPerLevel; + if (_options.Mode == SizeComputationMode.Fast) sampleLimit = Math.Min(sampleLimit, 4); + if (_options.Mode == SizeComputationMode.Adaptive) + { + int cap = (int)Math.Ceiling(_options.MaxSampledElementsPerLevel * 0.75); + sampleLimit = Math.Min(sampleLimit / 2, cap); + } + 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 = _options.Mode == SizeComputationMode.Accurate ? _options.MaxSampledElementsPerLevel * 2 : _options.MaxSampledElementsPerLevel; + if (_options.Mode == SizeComputationMode.Fast) sampleLimit = Math.Min(sampleLimit, 8); // allow a few elements + if (_options.Mode == SizeComputationMode.Adaptive && count.HasValue) + { + int cap = (int)Math.Ceiling(_options.MaxSampledElementsPerLevel * 0.75); + sampleLimit = Math.Min(sampleLimit / 2, cap); + } + 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) + { + return _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 if (_options.ReflectIntoStructs) + shallow += _options.FallbackStructSize; // approximate inline (could refine later) + else + shallow += _options.FallbackStructSize; + } + else + { + hasRef = true; + shallow += _options.ReferenceSize; // pointer cost + } + } + shallow = Align8(shallow); + return new TypeLayoutInfo(fields, hasRef, shallow); + }); + } + + private static long Align8(long value) + { + long rem = value & 7; + return rem == 0 ? value : (value + (8 - rem)); + } + + 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 92% rename from BlitzCache/Statistics/SlowQuery.cs rename to BlitzCache/Statistics/Speed/SlowQuery.cs index a517ed7..d9bcbbe 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,15 @@ 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); 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}"; From 1a603998cddef24c422b944e5985ec01f0c6ad6b Mon Sep 17 00:00:00 2001 From: Chan Date: Tue, 12 Aug 2025 12:30:41 +0100 Subject: [PATCH 02/11] Added capacity management features and related tests --- .../CapacityAccountingInvariantTests.cs | 104 ++++++++++++++++++ .../CapacityEnforcerDoubleCountTests.cs | 76 +++++++++++++ .../CapacityEvictionStrategyTests.cs | 61 ++++++++++ BlitzCache.Tests/Helpers/TestDelays.cs | 26 +++++ BlitzCache.Tests/MemoryLimitEvictionTests.cs | 98 +++++++++++++++++ BlitzCache/BlitzCacheCore.csproj | 5 +- BlitzCache/BlitzCacheInstance.cs | 84 +++++--------- BlitzCache/Capacity/CapacityEnforcer.cs | 73 ++++++++++++ .../Capacity/CapacityEvictionStrategy.cs | 8 ++ BlitzCache/Statistics/CacheStatistics.cs | 18 ++- 10 files changed, 489 insertions(+), 64 deletions(-) create mode 100644 BlitzCache.Tests/CapacityAccountingInvariantTests.cs create mode 100644 BlitzCache.Tests/CapacityEnforcerDoubleCountTests.cs create mode 100644 BlitzCache.Tests/CapacityEvictionStrategyTests.cs create mode 100644 BlitzCache/Capacity/CapacityEnforcer.cs create mode 100644 BlitzCache/Capacity/CapacityEvictionStrategy.cs 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..b50768f --- /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.WaitForStandardExpiration().GetAwaiter().GetResult(); + + 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/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/MemoryLimitEvictionTests.cs b/BlitzCache.Tests/MemoryLimitEvictionTests.cs index 2d182e1..d0f7a8e 100644 --- a/BlitzCache.Tests/MemoryLimitEvictionTests.cs +++ b/BlitzCache.Tests/MemoryLimitEvictionTests.cs @@ -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/BlitzCacheCore.csproj b/BlitzCache/BlitzCacheCore.csproj index f74659e..bd158b0 100644 --- a/BlitzCache/BlitzCacheCore.csproj +++ b/BlitzCache/BlitzCacheCore.csproj @@ -65,9 +65,6 @@ Notes: - + diff --git a/BlitzCache/BlitzCacheInstance.cs b/BlitzCache/BlitzCacheInstance.cs index f99ff31..7cb44d5 100644 --- a/BlitzCache/BlitzCacheInstance.cs +++ b/BlitzCache/BlitzCacheInstance.cs @@ -1,10 +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; @@ -17,9 +17,11 @@ public class BlitzCacheInstance : IBlitzCacheInstance private readonly int maxTopSlowest; private readonly BlitzSemaphoreDictionary semaphoreDictionary; private CacheStatistics? statistics; - private readonly BlitzCacheCore.Statistics.Memory.IValueSizer valueSizer; + private readonly BlitzCacheCore.Statistics.Memory.IValueSizer valueSizer; private readonly int maxTopHeaviest; private readonly long? configuredSizeLimitBytes; // store configured capacity for proactive compaction + private CapacityEnforcer? capacityEnforcer; // RT: extracted capacity policy collaborator + private readonly CapacityEvictionStrategy evictionStrategy; // selected eviction ordering strategy /// /// Creates a new BlitzCache instance. @@ -30,9 +32,9 @@ 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, BlitzCacheCore.Statistics.Memory.IValueSizer? valueSizer = null, int? maxTopHeaviest = 5, long? maxCacheSizeBytes = null) + public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupInterval = null, int? maxTopSlowest = null, BlitzCacheCore.Statistics.Memory.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; var options = new MemoryCacheOptions(); @@ -47,6 +49,7 @@ public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupIn this.valueSizer = valueSizer ?? new ObjectGraphValueSizer(); this.maxTopHeaviest = maxTopHeaviest ?? 5; this.configuredSizeLimitBytes = maxCacheSizeBytes; // remember for proactive compaction + this.evictionStrategy = evictionStrategy; } /// @@ -57,7 +60,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) { } @@ -66,7 +69,17 @@ 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); + if (configuredSizeLimitBytes.HasValue) + { + capacityEnforcer = new CapacityEnforcer(memoryCache, statistics, configuredSizeLimitBytes.Value, evictionStrategy); + } + } + } /// /// Gets cache performance statistics and monitoring information. @@ -75,54 +88,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; - // Pre-adjust statistics since manual Remove won't invoke eviction callback reasons we rely on - memoryCache.Remove(kvp.Key); - // Manually mirror eviction accounting (if still over limit or removal occurred) - try - { - // RecordRemove adjusts memory bytes and heaviest list; RecordEviction increments counts - var stats = statistics as BlitzCacheCore.Statistics.CacheStatistics; // same assembly internal access - if (stats != null) - { - // Use reflection to invoke internal methods without changing visibility if needed - var recordRemove = typeof(BlitzCacheCore.Statistics.CacheStatistics).GetMethod("RecordRemove", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var recordEviction = typeof(BlitzCacheCore.Statistics.CacheStatistics).GetMethod("RecordEviction", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - recordRemove?.Invoke(stats, new object[] { kvp.Key, kvp.Value }); - recordEviction?.Invoke(stats, Array.Empty()); - } - } - catch { } - } - } - - // 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) { @@ -226,17 +192,17 @@ private async Task 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); diff --git a/BlitzCache/Capacity/CapacityEnforcer.cs b/BlitzCache/Capacity/CapacityEnforcer.cs new file mode 100644 index 0000000..be02dbe --- /dev/null +++ b/BlitzCache/Capacity/CapacityEnforcer.cs @@ -0,0 +1,73 @@ +using BlitzCacheCore.Statistics; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Linq; + +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() + { + if (statistics.ApproximateMemoryBytes <= sizeLimitBytes) return; + + var sizes = statistics.GetKeySizesSnapshot(); + if (sizes.Length > 0) + { + var ordered = strategy switch + { + CapacityEvictionStrategy.LargestFirst => sizes.OrderByDescending(k => k.Value), + _ => sizes.OrderBy(k => k.Value) + }; + foreach (var kvp in ordered) + { + if (statistics.ApproximateMemoryBytes <= sizeLimitBytes) break; + // Remove the entry. We intentionally DO NOT call statistics.OnExternalRemoval here because + // MemoryCache.Remove triggers the eviction callback registered in CacheStatistics.CreateEntryOptions + // which already updates memory + eviction counters. Calling OnExternalRemoval caused double counting. + memoryCache.Remove(kvp.Key); + } + } + + if (memoryCache is MemoryCache concrete && statistics.ApproximateMemoryBytes > sizeLimitBytes) + { + var approx = statistics.ApproximateMemoryBytes; + if (approx > 0) + { + var over = approx - sizeLimitBytes; + var percent = Math.Min(1.0, Math.Max(0.02, (double)over / approx)); + concrete.Compact(percent); + } + } + + // Safety clamp: in case of any race conditions leading to slightly negative accounting, normalize to zero. + if (statistics.ApproximateMemoryBytes < 0) + { + // This is defensive; should not happen after removal of double accounting. + // If needed we could expose a method on CacheStatistics to clamp, but for now we rely on its atomic adds. + // (Leaving as comment: expose ClampApproximateMemory() if future strategies mutate accounting directly.) + } + } + } +} 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/Statistics/CacheStatistics.cs b/BlitzCache/Statistics/CacheStatistics.cs index fa5ce25..4672593 100644 --- a/BlitzCache/Statistics/CacheStatistics.cs +++ b/BlitzCache/Statistics/CacheStatistics.cs @@ -24,7 +24,7 @@ internal class CacheStatistics : ICacheStatistics private readonly Func getActiveSemaphoreCount; private readonly TopNTracker? topSlowestQueries; private readonly TopNTracker? topHeaviestEntries; - private readonly BlitzCacheCore.Statistics.Memory.IValueSizer valueSizer; + private readonly IValueSizer valueSizer; private readonly ConcurrentDictionary keySizes = new ConcurrentDictionary(); public long HitCount => Interlocked.Read(ref hitCount); @@ -117,6 +117,22 @@ internal void RecordRemove(string cacheKey, long 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) + { + RecordRemove(cacheKey, sizeBytes); + if (countAsEviction) + { + RecordEviction(); + } + } + /// /// Creates memory cache entry options with eviction callback for statistics and size tracking. /// From 7bcd080a48d3f6bf982594e0ecf74172932ace11 Mon Sep 17 00:00:00 2001 From: Chan Date: Tue, 12 Aug 2025 15:27:54 +0100 Subject: [PATCH 03/11] v2.1.1 --- .../IServiceCollectionExtensionsTests.cs | 58 +++++++++++++++ .../Statistics/BlitzLoggerInstanceTests.cs | 4 +- .../DisabledTrackingOptimizationTests.cs | 44 ++++++++++++ BlitzCache/BlitzCache.cs | 36 +++++++++- BlitzCache/BlitzCacheCore.csproj | 33 ++++----- BlitzCache/BlitzCacheInstance.cs | 59 +++++++++++----- BlitzCache/BlitzCacheOptions.cs | 30 ++++++++ BlitzCache/Capacity/CapacityEnforcer.cs | 10 +-- .../IServiceCollectionExtensions.cs | 58 +++++++++++++++ .../Logging/BlitzCacheLoggingOptions.cs | 26 +++++++ BlitzCache/Logging/BlitzLoggerInstance.cs | 64 ++++++++++------- BlitzCache/Statistics/CacheStatistics.cs | 40 ++++++++--- BlitzCache/icon.png | Bin 0 -> 26733 bytes CHANGELOG.md | 19 +++++ README.md | 66 +++++++++++++++++- 15 files changed, 474 insertions(+), 73 deletions(-) create mode 100644 BlitzCache.Tests/Statistics/DisabledTrackingOptimizationTests.cs create mode 100644 BlitzCache/BlitzCacheOptions.cs create mode 100644 BlitzCache/Logging/BlitzCacheLoggingOptions.cs create mode 100644 BlitzCache/icon.png 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/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/BlitzCache.cs b/BlitzCache/BlitzCache.cs index b6df7cd..978706b 100644 --- a/BlitzCache/BlitzCache.cs +++ b/BlitzCache/BlitzCache.cs @@ -32,7 +32,41 @@ public BlitzCache(long? defaultMilliseconds = 60000, TimeSpan? cleanupInterval = } } - [Obsolete("Prefer using the extended constructor to enable sizing and capacity features. This overload remains for binary compatibility.")] + /// + /// Preferred constructor accepting for forward-compatible configuration. + /// + /// Configuration options. + public BlitzCache(BlitzCacheOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + options.Validate(); + // 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 is null) + { + lock (globalLock) + { + if (globalInstance is null) + { + var instOptions = new BlitzCacheOptions + { + DefaultMilliseconds = options.DefaultMilliseconds, + CleanupInterval = options.CleanupInterval, + MaxTopSlowest = options.MaxTopSlowest, + MaxTopHeaviest = options.MaxTopHeaviest, + MaxCacheSizeBytes = options.MaxCacheSizeBytes, + SizeComputationMode = options.SizeComputationMode, + EvictionStrategy = options.EvictionStrategy, + EnableStatistics = options.EnableStatistics + }; + globalInstance = new BlitzCacheInstance(instOptions); + } + } + } + } + + [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) { diff --git a/BlitzCache/BlitzCacheCore.csproj b/BlitzCache/BlitzCacheCore.csproj index bd158b0..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,8 +60,12 @@ Notes: + + + + - + diff --git a/BlitzCache/BlitzCacheInstance.cs b/BlitzCache/BlitzCacheInstance.cs index 7cb44d5..c364af2 100644 --- a/BlitzCache/BlitzCacheInstance.cs +++ b/BlitzCache/BlitzCacheInstance.cs @@ -17,10 +17,10 @@ public class BlitzCacheInstance : IBlitzCacheInstance private readonly int maxTopSlowest; private readonly BlitzSemaphoreDictionary semaphoreDictionary; private CacheStatistics? statistics; - private readonly BlitzCacheCore.Statistics.Memory.IValueSizer valueSizer; + private readonly IValueSizer valueSizer; private readonly int maxTopHeaviest; private readonly long? configuredSizeLimitBytes; // store configured capacity for proactive compaction - private CapacityEnforcer? capacityEnforcer; // RT: extracted capacity policy collaborator + private CapacityEnforcer? capacityEnforcer; private readonly CapacityEvictionStrategy evictionStrategy; // selected eviction ordering strategy /// @@ -32,7 +32,7 @@ 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, BlitzCacheCore.Statistics.Memory.IValueSizer? valueSizer = null, int? maxTopHeaviest = 5, long? maxCacheSizeBytes = null, CapacityEvictionStrategy evictionStrategy = CapacityEvictionStrategy.SmallestFirst) + 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 positive"); @@ -45,13 +45,29 @@ public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupIn memoryCache = new MemoryCache(options); semaphoreDictionary = new BlitzSemaphoreDictionary(cleanupInterval); this.maxTopSlowest = maxTopSlowest ?? 5; - // Use the more accurate bounded object graph sizer by default 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(); + } + /// /// Backward-compatible constructor for callers compiled against older versions (pre heaviest/size-limit features). /// @@ -73,10 +89,12 @@ public void InitializeStatistics() { if (statistics == null) { - statistics = new CacheStatistics(() => semaphoreDictionary.GetNumberOfLocks(), maxTopSlowest, maxTopHeaviest, valueSizer); - if (configuredSizeLimitBytes.HasValue) + var capacityEnabled = configuredSizeLimitBytes.HasValue && configuredSizeLimitBytes.Value > 0; + statistics = new CacheStatistics(() => semaphoreDictionary.GetNumberOfLocks(), maxTopSlowest, maxTopHeaviest, valueSizer, capacityEnabled); + if (capacityEnabled) { - capacityEnforcer = new CapacityEnforcer(memoryCache, statistics, configuredSizeLimitBytes.Value, evictionStrategy); + var limit = configuredSizeLimitBytes!.Value; // safe due to capacityEnabled + capacityEnforcer = new CapacityEnforcer(memoryCache, statistics, limit, evictionStrategy); } } } @@ -135,10 +153,13 @@ 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!); + // Compute size only if capacity enforcement is enabled (MemoryCache.SizeLimit is set) var cacheEntryOptions = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; - cacheEntryOptions.Size = newSize; + if (configuredSizeLimitBytes.HasValue && configuredSizeLimitBytes.Value > 0) + { + var newSize = valueSizer.GetSizeBytes(computedResult!); + cacheEntryOptions.Size = newSize; + } memoryCache.Set(cacheKey, computedResult, cacheEntryOptions); statistics?.RecordSetOrUpdate(cacheKey, computedResult!, existed); @@ -179,10 +200,13 @@ private async Task ComputeAndStoreResultAsync(string cacheKey, Func 0) + { + var newSize = valueSizer.GetSizeBytes(computedResult!); + cacheEntryOptions.Size = newSize; + } memoryCache.Set(cacheKey, computedResult, cacheEntryOptions); statistics?.RecordSetOrUpdate(cacheKey, computedResult!, existed); @@ -225,10 +249,13 @@ 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!); + // Compute size only if capacity enforcement is enabled (MemoryCache.SizeLimit is set) var cacheEntryOptions = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; - cacheEntryOptions.Size = newSize; + if (configuredSizeLimitBytes.HasValue && configuredSizeLimitBytes.Value > 0) + { + var newSize = valueSizer.GetSizeBytes(value!); + cacheEntryOptions.Size = newSize; + } memoryCache.Set(cacheKey, value, cacheEntryOptions); 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 index be02dbe..6b5d4de 100644 --- a/BlitzCache/Capacity/CapacityEnforcer.cs +++ b/BlitzCache/Capacity/CapacityEnforcer.cs @@ -40,12 +40,14 @@ public void EnsureUnderLimit() CapacityEvictionStrategy.LargestFirst => sizes.OrderByDescending(k => k.Value), _ => sizes.OrderBy(k => k.Value) }; + + // Use a local projection of remaining bytes to avoid race with asynchronous eviction callbacks. + long simulatedRemaining = statistics.ApproximateMemoryBytes; + foreach (var kvp in ordered) { - if (statistics.ApproximateMemoryBytes <= sizeLimitBytes) break; - // Remove the entry. We intentionally DO NOT call statistics.OnExternalRemoval here because - // MemoryCache.Remove triggers the eviction callback registered in CacheStatistics.CreateEntryOptions - // which already updates memory + eviction counters. Calling OnExternalRemoval caused double counting. + if (simulatedRemaining <= sizeLimitBytes) break; + simulatedRemaining -= kvp.Value; // simulate removal immediately memoryCache.Remove(kvp.Key); } } 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/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/Statistics/CacheStatistics.cs b/BlitzCache/Statistics/CacheStatistics.cs index 4672593..d00e5e6 100644 --- a/BlitzCache/Statistics/CacheStatistics.cs +++ b/BlitzCache/Statistics/CacheStatistics.cs @@ -26,6 +26,7 @@ internal class CacheStatistics : ICacheStatistics private readonly TopNTracker? topHeaviestEntries; private readonly IValueSizer valueSizer; private readonly ConcurrentDictionary keySizes = new ConcurrentDictionary(); + private readonly bool sizeTrackingEnabled; // true when we must compute/value sizes (heaviest tracking or capacity limit) public long HitCount => Interlocked.Read(ref hitCount); public long MissCount => Interlocked.Read(ref missCount); @@ -36,6 +37,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 @@ -53,7 +56,7 @@ 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, BlitzCacheCore.Statistics.Memory.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)); // Default to object graph sizer for improved estimation accuracy @@ -67,6 +70,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; } /// @@ -102,8 +108,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); @@ -113,7 +122,10 @@ 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); } @@ -182,21 +194,31 @@ 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); + } } /// /// Tracks a new cache entry timing. /// - internal void TrackEntry(string cacheKey, Stopwatch stopwatch) => + internal void TrackEntry(string cacheKey, Stopwatch stopwatch) + { // Entry count is handled in RecordAddOrUpdate. Only record query duration here. topSlowestQueries?.AddOrUpdate(cacheKey, stopwatch.ElapsedMilliseconds); + } /// /// No-op: entry count handled in RecordAddOrUpdate. @@ -206,7 +228,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. diff --git a/BlitzCache/icon.png b/BlitzCache/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8efc6c1a436c44065d122b36dc8169593e05c469 GIT binary patch literal 26733 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 *&HQmBL>6;Jh%ux@koC8ot1!Qh0V393 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 Vpx-nv!L$GX002ovPDHLkV1i!Qd2|2( literal 0 HcmV?d00001 diff --git a/CHANGELOG.md b/CHANGELOG.md index a2678e0..cbbc4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# [2.1.1] - 2025-08-12 + +### 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 3bc3c16..ed42608 100644 --- a/README.md +++ b/README.md @@ -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 From 95da0e5641c0955bfcbbea273fdf3a62519e82a3 Mon Sep 17 00:00:00 2001 From: Chan Date: Wed, 13 Aug 2025 08:28:03 +0100 Subject: [PATCH 04/11] Waits on eviction instead of fixed delay Replaces a fixed time-based wait with a conditional wait for eviction completion to make tests deterministic. Reduces flakiness and can shorten runtime by proceeding immediately once evictions occur. --- BlitzCache.Tests/CapacityEvictionStrategyTests.cs | 2 +- BlitzCache.Tests/MemoryLimitEvictionTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/BlitzCache.Tests/CapacityEvictionStrategyTests.cs b/BlitzCache.Tests/CapacityEvictionStrategyTests.cs index b50768f..2418a49 100644 --- a/BlitzCache.Tests/CapacityEvictionStrategyTests.cs +++ b/BlitzCache.Tests/CapacityEvictionStrategyTests.cs @@ -44,7 +44,7 @@ public void LargestFirst_Removes_Fewer_Items_To_Reclaim_Same_Bytes() largestFirstCache.BlitzGet($"lf{s}", () => bytes); } - TestDelays.WaitForStandardExpiration().GetAwaiter().GetResult(); + TestDelays.WaitUntil(() => smallestFirstCache.Statistics!.EvictionCount > 0 && largestFirstCache.Statistics!.EvictionCount > 0); var sfStats = smallestFirstCache.Statistics!; var lfStats = largestFirstCache.Statistics!; diff --git a/BlitzCache.Tests/MemoryLimitEvictionTests.cs b/BlitzCache.Tests/MemoryLimitEvictionTests.cs index d0f7a8e..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"); From 1e435a4e84752dba1d09354d82d740fcc808dc28 Mon Sep 17 00:00:00 2001 From: Chan Date: Wed, 13 Aug 2025 09:06:29 +0100 Subject: [PATCH 05/11] Refactoring --- BlitzCache/Capacity/CapacityEnforcer.cs | 69 ++++++--- BlitzCache/Statistics/CacheStatistics.cs | 25 ++- .../Memory/ApproximateValueSizer.cs | 122 ++++++++++++--- .../Memory/ObjectGraphValueSizer.cs | 146 +++++++++--------- 4 files changed, 243 insertions(+), 119 deletions(-) diff --git a/BlitzCache/Capacity/CapacityEnforcer.cs b/BlitzCache/Capacity/CapacityEnforcer.cs index 6b5d4de..d99ad96 100644 --- a/BlitzCache/Capacity/CapacityEnforcer.cs +++ b/BlitzCache/Capacity/CapacityEnforcer.cs @@ -1,7 +1,7 @@ using BlitzCacheCore.Statistics; using Microsoft.Extensions.Caching.Memory; using System; -using System.Linq; +using System.Collections.Generic; namespace BlitzCacheCore.Capacity { @@ -30,46 +30,65 @@ public CapacityEnforcer(IMemoryCache memoryCache, CacheStatistics statistics, lo /// public void EnsureUnderLimit() { - if (statistics.ApproximateMemoryBytes <= sizeLimitBytes) return; + // 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) + if (sizes.Length != 0) { - var ordered = strategy switch + // 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) { - CapacityEvictionStrategy.LargestFirst => sizes.OrderByDescending(k => k.Value), - _ => sizes.OrderBy(k => k.Value) - }; - - // Use a local projection of remaining bytes to avoid race with asynchronous eviction callbacks. - long simulatedRemaining = statistics.ApproximateMemoryBytes; + Array.Sort(sizes, DescendingValueComparer.Instance); + } + else + { + Array.Sort(sizes, AscendingValueComparer.Instance); + } - foreach (var kvp in ordered) + long simulatedRemaining = current; // local view; we optimistically subtract removed sizes + for (int i = 0; i < sizes.Length && simulatedRemaining > sizeLimitBytes; i++) { - if (simulatedRemaining <= sizeLimitBytes) break; - simulatedRemaining -= kvp.Value; // simulate removal immediately + 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); } } - if (memoryCache is MemoryCache concrete && statistics.ApproximateMemoryBytes > sizeLimitBytes) + // 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 approx = statistics.ApproximateMemoryBytes; - if (approx > 0) + var over = after - sizeLimitBytes; + if (after > 0) { - var over = approx - sizeLimitBytes; - var percent = Math.Min(1.0, Math.Max(0.02, (double)over / approx)); + // 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); } } - // Safety clamp: in case of any race conditions leading to slightly negative accounting, normalize to zero. - if (statistics.ApproximateMemoryBytes < 0) - { - // This is defensive; should not happen after removal of double accounting. - // If needed we could expose a method on CacheStatistics to clamp, but for now we rely on its atomic adds. - // (Leaving as comment: expose ClampApproximateMemory() if future strategies mutate accounting directly.) - } + // (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/Statistics/CacheStatistics.cs b/BlitzCache/Statistics/CacheStatistics.cs index d00e5e6..d874ff5 100644 --- a/BlitzCache/Statistics/CacheStatistics.cs +++ b/BlitzCache/Statistics/CacheStatistics.cs @@ -25,8 +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); @@ -138,11 +141,18 @@ internal void RecordRemove(string cacheKey, long sizeBytes) /// Whether to increment eviction counters (true for policy-driven removals). internal void OnExternalRemoval(string cacheKey, long sizeBytes, bool countAsEviction) { - RecordRemove(cacheKey, sizeBytes); - if (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) { - RecordEviction(); + keySizes.TryRemove(cacheKey, out _); } + + // Synchronously account for memory + heaviest tracking. + RecordRemove(cacheKey, sizeBytes); + if (countAsEviction) RecordEviction(); } /// @@ -169,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); } @@ -241,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/Memory/ApproximateValueSizer.cs b/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs index dc35114..a263525 100644 --- a/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs +++ b/BlitzCache/Statistics/Memory/ApproximateValueSizer.cs @@ -5,30 +5,116 @@ namespace BlitzCacheCore.Statistics.Memory /// /// A basic, zero-dependency, best-effort sizer for common .NET types. /// - internal class ApproximateValueSizer : IValueSizer + internal sealed class ApproximateValueSizer : IValueSizer { - private const long FallbackSizeBytes = 128; // conservative estimate for unknown types - public long GetSizeBytes(object? value) + 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 { - if (value is null) return 0; + { 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 string s: - return (s.Length * 2L) + 24; // UTF-16 + overhead - 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: - return FallbackSizeBytes; + 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/ObjectGraphValueSizer.cs b/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs index b00754f..4505e91 100644 --- a/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs +++ b/BlitzCache/Statistics/Memory/ObjectGraphValueSizer.cs @@ -14,9 +14,11 @@ namespace BlitzCacheCore.Statistics.Memory internal sealed class ObjectGraphValueSizer : IValueSizer { private readonly ObjectGraphSizerOptions _options; - private readonly ConcurrentDictionary _layoutCache = new ConcurrentDictionary(); + 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() + 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 }, @@ -38,10 +40,20 @@ public long GetSizeBytes(object? value) if (value is string s) return SizeOfString(s); var type = value.GetType(); if (IsPrimitiveLike(type)) return GetPrimitiveLikeSize(type); - if (type.IsArray) return SizeOfArray((Array)value, 0, new HashSet(ReferenceEqualityComparer.Instance)); - var visited = new HashSet(ReferenceEqualityComparer.Instance); - return EstimateObject(value, 0, visited); + 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) @@ -65,21 +77,17 @@ private long EstimateObject(object obj, int depth, HashSet visited) // Fast mode: return precomputed shallow size immediately if (_options.Mode == SizeComputationMode.Fast) return layout.PrecomputedShallowSize; - long total = _options.ObjectHeaderSize; - int effectiveMaxDepth = _options.MaxDepth + (_options.Mode == SizeComputationMode.Accurate ? 1 : 0); - if (_options.Mode == SizeComputationMode.Adaptive) - { - // Adaptive stays within base MaxDepth (does not use Accurate's +1) to bound size below Accurate. - effectiveMaxDepth = _options.MaxDepth; - } - bool adaptive = _options.Mode == SizeComputationMode.Adaptive; - if (depth >= effectiveMaxDepth && !adaptive) return Align8(total); - - // In Adaptive mode we decide per-field whether to traverse deeper using a simple heuristic budget. - // Budget scales with remaining object cap and depth to avoid runaway traversal. + 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 = !adaptive || (remainingObjectBudget > 0 && depth < _options.MaxDepth); + bool allowDeeper = !isAdaptive || (remainingObjectBudget > 0 && depth < _options.MaxDepth); foreach (var f in layout.InstanceFields) { var ft = f.FieldType; @@ -95,18 +103,15 @@ private long EstimateObject(object obj, int depth, HashSet visited) total += _options.ReferenceSize; if (fv != null) { - if (!adaptive) + if (!isAdaptive) { if (depth < effectiveMaxDepth) total += EstimateObject(fv, depth + 1, visited); } - else if (allowDeeper) + else if (allowDeeper && depth < effectiveMaxDepth) { - // Heuristic: only dive if referenced object's shallow layout suggests substantial size potential var childLayout = GetLayout(fv.GetType()); if (childLayout.HasReferenceFields || childLayout.PrecomputedShallowSize > (_options.ObjectHeaderSize + 32)) - { - if (depth < effectiveMaxDepth) total += EstimateObject(fv, depth + 1, visited); - } + total += EstimateObject(fv, depth + 1, visited); } } } @@ -150,7 +155,7 @@ private long EstimateValueTypeInline(Type structType, object? value, int depth, private long SizeOfString(string s) => Align8(_options.StringOverhead + (s.Length * 2L)); - private long SizeOfArray(Array arr, int depth, HashSet visited) + private long SizeOfArray(Array arr, int depth, HashSet visited) { var elemType = arr.GetType().GetElementType()!; long size = _options.ArrayHeaderSize; @@ -168,14 +173,7 @@ private long SizeOfArray(Array arr, int depth, HashSet visited) return Align8(size); } - int sampleLimit = _options.Mode == SizeComputationMode.Accurate ? _options.MaxSampledElementsPerLevel * 2 : _options.MaxSampledElementsPerLevel; - if (_options.Mode == SizeComputationMode.Fast) sampleLimit = Math.Min(sampleLimit, 4); // very small sample - if (_options.Mode == SizeComputationMode.Adaptive) - { - // Adaptive: smaller than Balanced for large arrays (cap at 0.75 * base) to ensure Accurate remains largest. - int cap = (int)Math.Ceiling(_options.MaxSampledElementsPerLevel * 0.75); - sampleLimit = Math.Min(sampleLimit / 2, cap); - } + 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) @@ -235,13 +233,7 @@ private bool TryCollection(object obj, Type type, int depth, HashSet vis size = _options.ObjectHeaderSize; int count = dict.Count; int sampled = 0; - int sampleLimit = _options.Mode == SizeComputationMode.Accurate ? _options.MaxSampledElementsPerLevel * 2 : _options.MaxSampledElementsPerLevel; - if (_options.Mode == SizeComputationMode.Fast) sampleLimit = Math.Min(sampleLimit, 4); - if (_options.Mode == SizeComputationMode.Adaptive) - { - int cap = (int)Math.Ceiling(_options.MaxSampledElementsPerLevel * 0.75); - sampleLimit = Math.Min(sampleLimit / 2, cap); - } + 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; @@ -286,13 +278,7 @@ private bool TryCollection(object obj, Type type, int depth, HashSet vis size = _options.ObjectHeaderSize; int? count = (obj as ICollection)?.Count; int sampled = 0; - int sampleLimit = _options.Mode == SizeComputationMode.Accurate ? _options.MaxSampledElementsPerLevel * 2 : _options.MaxSampledElementsPerLevel; - if (_options.Mode == SizeComputationMode.Fast) sampleLimit = Math.Min(sampleLimit, 8); // allow a few elements - if (_options.Mode == SizeComputationMode.Adaptive && count.HasValue) - { - int cap = (int)Math.Ceiling(_options.MaxSampledElementsPerLevel * 0.75); - sampleLimit = Math.Min(sampleLimit / 2, cap); - } + 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; @@ -327,33 +313,38 @@ private static int GetPrimitiveLikeSize(Type t) return t.IsPrimitive ? System.Runtime.InteropServices.Marshal.SizeOf(t) : 0; } - private TypeLayoutInfo GetLayout(Type t) + private TypeLayoutInfo GetLayout(Type t) => _layoutCache.GetOrAdd(t, tt => { - return _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 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) { - var ft = f.FieldType; - if (ft.IsValueType) - { - if (IsPrimitiveLike(ft)) shallow += GetPrimitiveLikeSize(ft); - else if (_options.ReflectIntoStructs) - shallow += _options.FallbackStructSize; // approximate inline (could refine later) - else - shallow += _options.FallbackStructSize; - } - else - { - hasRef = true; - shallow += _options.ReferenceSize; // pointer cost - } + if (IsPrimitiveLike(ft)) shallow += GetPrimitiveLikeSize(ft); else shallow += _options.FallbackStructSize; } - shallow = Align8(shallow); - return new TypeLayoutInfo(fields, hasRef, shallow); - }); + 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) @@ -362,6 +353,21 @@ private static long Align8(long value) 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; } From 80b50be584bc61b67fce5295b2d27f61c63e80d4 Mon Sep 17 00:00:00 2001 From: Chan Date: Wed, 13 Aug 2025 09:16:40 +0100 Subject: [PATCH 06/11] Refactoring --- BlitzCache/BlitzCache.cs | 69 ++++++++++++++------------------ BlitzCache/BlitzCacheInstance.cs | 46 +++++++++------------ BlitzCache/Formatters.cs | 22 +++++----- 3 files changed, 63 insertions(+), 74 deletions(-) diff --git a/BlitzCache/BlitzCache.cs b/BlitzCache/BlitzCache.cs index 978706b..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,13 +23,7 @@ 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); } /// @@ -40,30 +34,8 @@ public BlitzCache(BlitzCacheOptions options) { if (options == null) throw new ArgumentNullException(nameof(options)); options.Validate(); - // 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 is null) - { - lock (globalLock) - { - if (globalInstance is null) - { - var instOptions = new BlitzCacheOptions - { - DefaultMilliseconds = options.DefaultMilliseconds, - CleanupInterval = options.CleanupInterval, - MaxTopSlowest = options.MaxTopSlowest, - MaxTopHeaviest = options.MaxTopHeaviest, - MaxCacheSizeBytes = options.MaxCacheSizeBytes, - SizeComputationMode = options.SizeComputationMode, - EvictionStrategy = options.EvictionStrategy, - EnableStatistics = options.EnableStatistics - }; - globalInstance = new BlitzCacheInstance(instOptions); - } - } - } + + EnsureGlobalInstance(options.DefaultMilliseconds, options.CleanupInterval, options.MaxTopSlowest, options.MaxTopHeaviest, options.MaxCacheSizeBytes, options); } [Obsolete("Prefer using the Configuration options constructor to enable sizing and capacity features. This overload remains for binary compatibility.")] @@ -87,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); @@ -116,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() { @@ -125,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/BlitzCacheInstance.cs b/BlitzCache/BlitzCacheInstance.cs index c364af2..18e1efa 100644 --- a/BlitzCache/BlitzCacheInstance.cs +++ b/BlitzCache/BlitzCacheInstance.cs @@ -22,6 +22,7 @@ public class BlitzCacheInstance : IBlitzCacheInstance 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. @@ -37,11 +38,9 @@ public BlitzCacheInstance(long? defaultMilliseconds = 60000, TimeSpan? cleanupIn 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; @@ -89,7 +88,6 @@ public void InitializeStatistics() { if (statistics == null) { - var capacityEnabled = configuredSizeLimitBytes.HasValue && configuredSizeLimitBytes.Value > 0; statistics = new CacheStatistics(() => semaphoreDictionary.GetNumberOfLocks(), maxTopSlowest, maxTopHeaviest, valueSizer, capacityEnabled); if (capacityEnabled) { @@ -153,13 +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 only if capacity enforcement is enabled (MemoryCache.SizeLimit is set) - var cacheEntryOptions = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; - if (configuredSizeLimitBytes.HasValue && configuredSizeLimitBytes.Value > 0) - { - var newSize = valueSizer.GetSizeBytes(computedResult!); - cacheEntryOptions.Size = newSize; - } + var cacheEntryOptions = CreateEntryOptions(expirationTime, computedResult!); memoryCache.Set(cacheKey, computedResult, cacheEntryOptions); statistics?.RecordSetOrUpdate(cacheKey, computedResult!, existed); @@ -200,13 +192,7 @@ private async Task ComputeAndStoreResultAsync(string cacheKey, Func 0) - { - var newSize = valueSizer.GetSizeBytes(computedResult!); - cacheEntryOptions.Size = newSize; - } + var cacheEntryOptions = CreateEntryOptions(expirationTime, computedResult!); memoryCache.Set(cacheKey, computedResult, cacheEntryOptions); statistics?.RecordSetOrUpdate(cacheKey, computedResult!, existed); @@ -248,14 +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 only if capacity enforcement is enabled (MemoryCache.SizeLimit is set) - var cacheEntryOptions = statistics?.CreateEntryOptions(expirationTime) ?? new MemoryCacheEntryOptions { AbsoluteExpiration = expirationTime }; - if (configuredSizeLimitBytes.HasValue && configuredSizeLimitBytes.Value > 0) - { - var newSize = valueSizer.GetSizeBytes(value!); - cacheEntryOptions.Size = newSize; - } + var cacheEntryOptions = CreateEntryOptions(expirationTime, value!); memoryCache.Set(cacheKey, value, cacheEntryOptions); @@ -289,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/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"; } } } From 7f2ef4213380bbe98b2c7590bbce2f9ead736361 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 13 Aug 2025 08:17:27 +0000 Subject: [PATCH 07/11] Update badges: codecov branch and tests count [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed42608..4e2d326 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ⚡ 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) +[![Tests](https://img.shields.io/badge/tests-220%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) From 2dc731c84bbba7c13ee4b0c7079f977a7ed7a93f Mon Sep 17 00:00:00 2001 From: Chan Date: Mon, 13 Oct 2025 10:31:19 +0100 Subject: [PATCH 08/11] Add comprehensive unit tests for ObjectGraphValueSizer - Implemented coverage tests for various size computation modes: Balanced, Fast, Adaptive, and Accurate. - Added tests for null and primitive types, enums, arrays, collections, and complex nested structures. - Included edge cases and cycle detection tests to ensure robustness. - Verified size calculations for anonymous types and custom structs. - Ensured consistent results across repeated calls and different modes. --- .../ApproximateValueSizerTests.cs | 593 ++++++++++ .../ObjectGraphValueSizerCoverageTests.cs | 1045 +++++++++++++++++ CHANGELOG.md | 2 +- 3 files changed, 1639 insertions(+), 1 deletion(-) create mode 100644 BlitzCache.Tests/ApproximateValueSizerTests.cs create mode 100644 BlitzCache.Tests/ObjectGraphValueSizerCoverageTests.cs 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/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/CHANGELOG.md b/CHANGELOG.md index cbbc4b6..4b712a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# [2.1.1] - 2025-08-12 +# [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). From 109cad3666b4c96b34a699acc95ed52e0979577a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 13 Oct 2025 09:32:26 +0000 Subject: [PATCH 09/11] Update badges: codecov branch and tests count [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e2d326..9fbb4b2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ⚡ 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-220%20passing-brightgreen)](./BlitzCache.Tests) +[![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) From 862e8a4372089c153311e51dee625b89c89a9886 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 13 Oct 2025 09:44:04 +0000 Subject: [PATCH 10/11] Update badges: codecov branch and tests count [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cdac5ff..9fbb4b2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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-347%20passing-brightgreen)](./BlitzCache.Tests) -[![codecov](https://codecov.io/gh/chanido/blitzcache/branch/master/graph/badge.svg)](https://codecov.io/gh/chanido/blitzcache) +[![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) From b0fb3b4ec4f93fbf4f5419e66310a09ad665a7d9 Mon Sep 17 00:00:00 2001 From: Chan Date: Mon, 13 Oct 2025 10:44:19 +0100 Subject: [PATCH 11/11] Update BlitzCache/Statistics/Speed/SlowQuery.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- BlitzCache/Statistics/Speed/SlowQuery.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/BlitzCache/Statistics/Speed/SlowQuery.cs b/BlitzCache/Statistics/Speed/SlowQuery.cs index d9bcbbe..cf6e3ac 100644 --- a/BlitzCache/Statistics/Speed/SlowQuery.cs +++ b/BlitzCache/Statistics/Speed/SlowQuery.cs @@ -25,7 +25,14 @@ public SlowQuery Update(long currentExecution) { 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; }