From 3344c2d8d092e0e135970b170c1bb425b2eb079a Mon Sep 17 00:00:00 2001 From: snakex64 Date: Fri, 17 Jul 2026 17:15:36 -0300 Subject: [PATCH 1/3] ``` Add DisableLocalCacheSerialization to hybrid cache. Partially fixes #6063 Enables the hybrid cache to skip serialization when fetching from L1 (in memory) cache. This speeds up the cache when possible, and allows getting the same object instance. ``` --- .../HybridCacheOptions.cs | 12 +++++ .../Internal/DefaultHybridCache.CacheItem.cs | 6 ++- .../DefaultHybridCache.StampedeStateT.cs | 7 +-- .../Microsoft.Extensions.Caching.Hybrid.json | 4 ++ .../BufferReleaseTests.cs | 4 +- .../L2Tests.cs | 52 ++++++++++++++++++- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs index d55ac1a4ea1..1a4dc3d9f91 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs @@ -25,6 +25,18 @@ public class HybridCacheOptions /// public bool DisableCompression { get; set; } + /// + /// Gets or sets a value indicating whether values stored in the local cache should be returned by reference + /// instead of being serialized and deserialized to provide defensive copies. + /// + /// + /// When enabled, mutable values may be shared between callers and changes made to a cached value are visible + /// to subsequent callers. Values are still serialized when required for the distributed cache. Local cache + /// size accounting continues to use the serialized payload size and may not reflect the size of the retained + /// object graph. + /// + public bool DisableLocalCacheSerialization { get; set; } + /// /// Gets or sets the maximum size of cache items. /// diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs index 4bfcd18ac29..a73d03b4732 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs @@ -133,7 +133,9 @@ public T GetReservedValue(ILogger log) static void Throw() => throw new ObjectDisposedException("The cache item has been recycled before the value was obtained"); } - internal static CacheItem Create(long creationTimestamp, TagSet tags) => ImmutableTypeCache.IsImmutable - ? new ImmutableCacheItem(creationTimestamp, tags) : new MutableCacheItem(creationTimestamp, tags); + internal static CacheItem Create(long creationTimestamp, TagSet tags, bool disableLocalCacheWrite) + => ImmutableTypeCache.IsImmutable || disableLocalCacheWrite + ? new ImmutableCacheItem(creationTimestamp, tags) + : new MutableCacheItem(creationTimestamp, tags); } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 34a68ef30aa..7566f961d56 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -31,13 +31,13 @@ internal void SetResultDirect(CacheItem value) => _result?.TrySetResult(value); public StampedeState(DefaultHybridCache cache, in StampedeKey key, TagSet tags, bool canBeCanceled) - : base(cache, key, CacheItem.Create(cache.CurrentTimestamp(), tags), canBeCanceled) + : base(cache, key, CacheItem.Create(cache.CurrentTimestamp(), tags, cache.Options.DisableLocalCacheSerialization), canBeCanceled) { _result = new(TaskCreationOptions.RunContinuationsAsynchronously); } public StampedeState(DefaultHybridCache cache, in StampedeKey key, TagSet tags, CancellationToken token) - : base(cache, key, CacheItem.Create(cache.CurrentTimestamp(), tags), token) + : base(cache, key, CacheItem.Create(cache.CurrentTimestamp(), tags, cache.Options.DisableLocalCacheSerialization), token) { // no TCS in this case - this is for SetValue only } @@ -323,9 +323,6 @@ private async Task BackgroundFetchAsync() // SizeLimit (we can't know - it is an abstraction), and for *that* we need to know the item size. // Likewise, if we're writing to a MutableCacheItem, we'll be serializing *anyway* for the payload. // - // Rephrasing that: the only scenario in which we *do not* need to serialize is if: - // - it is an ImmutableCacheItem (so we don't need bytes for the CacheItem, L1) - // - we're not writing to L2 CacheItem cacheItem = CacheItem; bool skipSerialize = cacheItem is ImmutableCacheItem && (activeFlags & FlagsDisableL1AndL2Write) == FlagsDisableL1AndL2Write; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json index 10be31168ba..0290303fc57 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json @@ -41,6 +41,10 @@ "Member": "bool Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression { get; set; }", "Stage": "Stable" }, + { + "Member": "bool Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableLocalCacheSerialization { get; set; }", + "Stage": "Stable" + }, { "Member": "object? Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DistributedCacheServiceKey { get; set; }", "Stage": "Stable" diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs index b0a2d4a839a..453684790b3 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs @@ -243,7 +243,7 @@ private static bool Write(IBufferWriter destination, byte[]? buffer) [Fact] public void ImmutableCacheItem_Reservation() { - var obj = Assert.IsType>(CacheItem.Create(12345, TagSet.Empty)); + var obj = Assert.IsType>(CacheItem.Create(12345, TagSet.Empty, false)); Assert.True(obj.DebugIsImmutable); obj.SetValue("abc", 3); Assert.False(obj.TryReserveBuffer(out _)); @@ -258,7 +258,7 @@ public void MutableCacheItem_Reservation() { using ServiceProvider services = new ServiceCollection().BuildServiceProvider(); - var obj = Assert.IsType>(CacheItem.Create(12345, TagSet.Empty)); + var obj = Assert.IsType>(CacheItem.Create(12345, TagSet.Empty, false)); Assert.True(new DefaultJsonSerializerFactory(services).TryCreateSerializer(out var serializer)); var target = RecyclableArrayBufferWriter.Create(int.MaxValue); diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs index e54ac679ae2..40aaac8b3f3 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs @@ -32,13 +32,13 @@ private class Options(T value) : IOptions T IOptions.Value => value; } - private ServiceProvider GetDefaultCache(bool buffers, out DefaultHybridCache cache) + private ServiceProvider GetDefaultCache(bool buffers, out DefaultHybridCache cache, bool disableLocalCacheSerialization = false) { var services = new ServiceCollection(); var localCacheOptions = new Options(new()); var localCache = new MemoryDistributedCache(localCacheOptions); services.AddSingleton(buffers ? new BufferLoggingCache(Log, localCache) : new LoggingCache(Log, localCache)); - services.AddHybridCache(); + services.AddHybridCache(options => options.DisableLocalCacheSerialization = disableLocalCacheSerialization); ServiceProvider provider = services.BuildServiceProvider(); cache = Assert.IsType(provider.GetRequiredService()); return provider; @@ -155,6 +155,54 @@ public async Task AssertL2Operations_Mutable(bool buffers) Assert.Equal(12, backend.OpCount); // GET, SET } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DisableLocalCacheSerialization_ReturnsSameMutableInstance(bool buffers) + { + using var provider = GetDefaultCache(buffers, out var cache, disableLocalCacheSerialization: true); + var backend = Assert.IsAssignableFrom(cache.BackendCache); + + Foo original = await cache.GetOrCreateAsync(Me(), _ => new ValueTask(new Foo { Value = "value" })); + Foo fromL1 = await cache.GetOrCreateAsync(Me(), _ => new ValueTask(new Foo { Value = "unused" })); + + Assert.Same(original, fromL1); + + cache.LocalCache.Remove(Me()); + Foo fromL2 = await cache.GetOrCreateAsync(Me(), _ => new ValueTask(new Foo { Value = "unused" })); + Foo fromL1AfterL2 = await cache.GetOrCreateAsync(Me(), _ => new ValueTask(new Foo { Value = "unused" })); + + Assert.NotSame(original, fromL2); + Assert.Same(fromL2, fromL1AfterL2); + Assert.Equal(4, backend.OpCount); // wildcard timestamp GET, GET, SET, GET + } + + [Fact] + public async Task DisableLocalCacheSerialization_DoesNotSerializeWhenCacheWritesAreDisabled() + { + var services = new ServiceCollection(); + services.AddHybridCache(options => options.DisableLocalCacheSerialization = true); + services.AddSingleton>(new ThrowingFooSerializer()); + using ServiceProvider provider = services.BuildServiceProvider(); + HybridCache cache = provider.GetRequiredService(); + var value = new Foo { Value = "value" }; + var options = new HybridCacheEntryOptions + { + Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite, + }; + + Foo actual = await cache.GetOrCreateAsync(Me(), _ => new ValueTask(value), options); + + Assert.Same(value, actual); + } + + private sealed class ThrowingFooSerializer : IHybridCacheSerializer + { + public Foo Deserialize(ReadOnlySequence source) => throw new NotSupportedException(); + + public void Serialize(Foo value, IBufferWriter target) => throw new NotSupportedException(); + } + private class BufferLoggingCache : LoggingCache, IBufferDistributedCache { public BufferLoggingCache(ITestOutputHelper log, IDistributedCache tail) From e17b411013b0478be7a9c9b724cf106c20a55729 Mon Sep 17 00:00:00 2001 From: Pascal <39806655+snakex64@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:47:37 -0300 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs | 2 +- .../Internal/DefaultHybridCache.CacheItem.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs index 1a4dc3d9f91..006b23a0c59 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs @@ -27,7 +27,7 @@ public class HybridCacheOptions /// /// Gets or sets a value indicating whether values stored in the local cache should be returned by reference - /// instead of being serialized and deserialized to provide defensive copies. + /// instead of using serialization round-trips to create defensive copies. /// /// /// When enabled, mutable values may be shared between callers and changes made to a cached value are visible diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs index a73d03b4732..f8749db9a40 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs @@ -133,8 +133,8 @@ public T GetReservedValue(ILogger log) static void Throw() => throw new ObjectDisposedException("The cache item has been recycled before the value was obtained"); } - internal static CacheItem Create(long creationTimestamp, TagSet tags, bool disableLocalCacheWrite) - => ImmutableTypeCache.IsImmutable || disableLocalCacheWrite + internal static CacheItem Create(long creationTimestamp, TagSet tags, bool disableLocalCacheSerialization) + => ImmutableTypeCache.IsImmutable || disableLocalCacheSerialization ? new ImmutableCacheItem(creationTimestamp, tags) : new MutableCacheItem(creationTimestamp, tags); } From b5c539b894d1900d89f924fd8d9ff00b1a35e4f6 Mon Sep 17 00:00:00 2001 From: snakex64 Date: Fri, 17 Jul 2026 18:58:21 -0300 Subject: [PATCH 3/3] cleanup comments --- .../Internal/DefaultHybridCache.StampedeStateT.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 7566f961d56..228e32a9c48 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -323,6 +323,9 @@ private async Task BackgroundFetchAsync() // SizeLimit (we can't know - it is an abstraction), and for *that* we need to know the item size. // Likewise, if we're writing to a MutableCacheItem, we'll be serializing *anyway* for the payload. // + // Rephrasing that: the only scenario in which we *do not* need to serialize is if: + // - it is an ImmutableCacheItem (so we don't need bytes for the CacheItem, L1) + // - we're not writing to L2 CacheItem cacheItem = CacheItem; bool skipSerialize = cacheItem is ImmutableCacheItem && (activeFlags & FlagsDisableL1AndL2Write) == FlagsDisableL1AndL2Write;