diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs
index d55ac1a4ea1..006b23a0c59 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 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
+ /// 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..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,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 disableLocalCacheSerialization)
+ => ImmutableTypeCache.IsImmutable || disableLocalCacheSerialization
+ ? 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..228e32a9c48 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
}
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)