diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx b/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx
index 56c41562fcc5c8..45e480394723de 100644
--- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx
@@ -11,6 +11,9 @@
+
+
+
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs
index f1184552d6a578..430ddb7ee1fb18 100644
--- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs
@@ -175,11 +175,21 @@ public interface IHybridCacheSerializerFactory
{
bool TryCreateSerializer([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out IHybridCacheSerializer? serializer);
}
+ public sealed class HybridCacheEntryContext
+ {
+ public HybridCacheEntryContext(HybridCacheEntryOptions? options) { }
+ public System.TimeSpan? Expiration { get { throw null; } set { } }
+ public System.TimeSpan? LocalCacheExpiration { get { throw null; } set { } }
+ public HybridCacheEntryFlags? Flags { get { throw null; } set { } }
+ public long? LocalSize { get { throw null; } set { } }
+ public int Revision { get { throw null; } }
+ }
public sealed class HybridCacheEntryOptions
{
public System.TimeSpan? Expiration { get; init; }
public System.TimeSpan? LocalCacheExpiration { get; init; }
public HybridCacheEntryFlags? Flags { get; init; }
+ public long? LocalSize { get; init; }
}
[System.Flags]
public enum HybridCacheEntryFlags
@@ -203,6 +213,16 @@ public System.Threading.Tasks.ValueTask GetOrCreateAsync(string key, Syste
HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default)
=> throw null;
+ public virtual System.Threading.Tasks.ValueTask GetOrCreateAsync(string key, TState state,
+ System.Func> factory,
+ HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default)
+ => throw null;
+
+ public System.Threading.Tasks.ValueTask GetOrCreateAsync(string key,
+ System.Func> factory,
+ HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default)
+ => throw null;
+
public abstract System.Threading.Tasks.ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default);
public abstract System.Threading.Tasks.ValueTask RemoveAsync(string key, System.Threading.CancellationToken cancellationToken = default);
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs
index 57aa53f1e513f7..3878de18ca6026 100644
--- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs
@@ -34,5 +34,31 @@ public System.Threading.Tasks.ValueTask GetOrCreateAsync(
Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null,
System.Collections.Generic.IEnumerable? tags = null,
System.Threading.CancellationToken cancellationToken = default) => throw null;
+ public System.Threading.Tasks.ValueTask GetOrCreateAsync(
+ System.ReadOnlySpan key,
+ System.Func> factory,
+ Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null,
+ System.Collections.Generic.IEnumerable? tags = null,
+ System.Threading.CancellationToken cancellationToken = default) => throw null;
+ public virtual System.Threading.Tasks.ValueTask GetOrCreateAsync(
+ System.ReadOnlySpan key,
+ TState state,
+ System.Func> factory,
+ Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null,
+ System.Collections.Generic.IEnumerable? tags = null,
+ System.Threading.CancellationToken cancellationToken = default) => throw null;
+ public System.Threading.Tasks.ValueTask GetOrCreateAsync(
+ ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler key,
+ System.Func> factory,
+ Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null,
+ System.Collections.Generic.IEnumerable? tags = null,
+ System.Threading.CancellationToken cancellationToken = default) => throw null;
+ public System.Threading.Tasks.ValueTask GetOrCreateAsync(
+ ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler key,
+ TState state,
+ System.Func> factory,
+ Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null,
+ System.Collections.Generic.IEnumerable? tags = null,
+ System.Threading.CancellationToken cancellationToken = default) => throw null;
}
}
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs
index 7d93d1e45169d3..b62dd67138f5d6 100644
--- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs
@@ -103,14 +103,8 @@ public ValueTask GetOrCreateAsync(
HybridCacheEntryOptions? options = null,
IEnumerable? tags = null,
CancellationToken cancellationToken = default)
- {
- // It is *not* an error that this Clear occurs before the "await"; by definition, the implementation is *required* to copy
- // the value locally before an await, precisely because the ref-struct cannot bridge an await. Thus: we are fine to clean
- // the buffer even in the non-synchronous completion scenario.
- ValueTask result = GetOrCreateAsync(key.Text, factory, WrappedCallbackCache.Instance, options, tags, cancellationToken);
- key.Clear();
- return result;
- }
+ => ClearAndReturn(ref key,
+ GetOrCreateAsync(key.Text, factory, WrappedCallbackCache.Instance, options, tags, cancellationToken));
///
/// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
@@ -131,16 +125,224 @@ public ValueTask GetOrCreateAsync(
HybridCacheEntryOptions? options = null,
IEnumerable? tags = null,
CancellationToken cancellationToken = default)
+ => ClearAndReturn(ref key,
+ GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken));
+#endif
+
+ ///
+ /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of additional state required by .
+ /// The type of the data being considered.
+ /// The key of the entry to look for or create.
+ /// The state required for .
+ /// Provides the underlying data service if the data is not available in the cache. The passed to the factory allows it to influence cache entry options based on the result.
+ /// Additional options for this cache entry.
+ /// The tags to associate with this cache item.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The data, either from cache or the underlying data service.
+ public virtual async ValueTask GetOrCreateAsync(string key, TState state,
+ Func> factory,
+ HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken cancellationToken = default)
+ {
+ // Carries state + factory into the wrapped delegate and the context back out. The context is
+ // created lazily inside the delegate, so a cache hit (where the factory never runs) does not
+ // allocate a context or seed it.
+ var packedState = new DefaultImplState(state, factory, options);
+
+ // Suppress writes in the inner call so we can perform a single, correct SetAsync afterwards
+ // using the options that the factory ultimately produced.
+ // This introduces some race conditions, but that's considered better than not honoring the factory's options.
+ // The common options == null case reuses a shared immutable instance instead of allocating one.
+ HybridCacheEntryOptions innerOptions = options is null
+ ? s_writesDisabled
+ : CloneWithWritesDisabled(options);
+
+ T value = await GetOrCreateAsync(key, packedState, static (packed, ct) =>
+ {
+ // Reached only on a cache miss (the underlying implementation skips this for hits and for
+ // stampede followers), so the context is allocated exactly when it is needed.
+ var context = new HybridCacheEntryContext(packed.Options);
+ packed.Context = context;
+ return packed.Factory(packed.State, context, ct);
+ }, innerOptions, tags, cancellationToken).ConfigureAwait(false);
+
+ // Context is non-null only if the wrapped factory actually ran; stampede followers reuse the
+ // leader's value (and the leader's SetAsync) without issuing a duplicate write.
+ if (packedState.Context is { } context)
+ {
+ const HybridCacheEntryFlags BothWritesDisabled =
+ HybridCacheEntryFlags.DisableLocalCacheWrite
+ | HybridCacheEntryFlags.DisableDistributedCacheWrite;
+ if ((context.Flags & BothWritesDisabled) != BothWritesDisabled)
+ {
+ // If the factory left the context unchanged, reuse the caller's original options: this
+ // avoids materializing a new instance and preserves any special handling of a null value
+ // (context.ToOptions() would always produce a non-null instance).
+ HybridCacheEntryOptions? writeOptions = context.Revision == 0
+ ? packedState.Options
+ : context.ToOptions();
+
+ // Cancellation of the caller should not abort the write: the factory has already
+ // produced a value that we are about to return; canceling SetAsync here would
+ // discard a completed result and force the next caller to re-run the factory.
+ await SetAsync(key, value, writeOptions, tags, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+
+ return value;
+ }
+
+ // options == null always yields the same write-disabled options, so hoist it out of the per-call
+ // path. Safe to share because HybridCacheEntryOptions is immutable.
+ private static readonly HybridCacheEntryOptions s_writesDisabled = new()
+ {
+ Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite,
+ };
+
+ private static HybridCacheEntryOptions CloneWithWritesDisabled(HybridCacheEntryOptions options)
+ {
+ HybridCacheEntryFlags flags = (options.Flags ?? HybridCacheEntryFlags.None)
+ | HybridCacheEntryFlags.DisableLocalCacheWrite
+ | HybridCacheEntryFlags.DisableDistributedCacheWrite;
+ return new HybridCacheEntryOptions
+ {
+ Expiration = options.Expiration,
+ LocalCacheExpiration = options.LocalCacheExpiration,
+ LocalSize = options.LocalSize,
+ Flags = flags,
+ };
+ }
+
+ ///
+ /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of the data being considered.
+ /// The key of the entry to look for or create.
+ /// Provides the underlying data service if the data is not available in the cache. The passed to the factory allows it to influence cache entry options based on the result.
+ /// Additional options for this cache entry.
+ /// The tags to associate with this cache item.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The data, either from cache or the underlying data service.
+ public ValueTask GetOrCreateAsync(string key,
+ Func> factory,
+ HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken cancellationToken = default)
+ => GetOrCreateAsync(key, factory, WrappedOptionsCallbackCache.Instance, options, tags, cancellationToken);
+
+#if NET
+ ///
+ /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of the data being considered.
+ /// The key of the entry to look for or create.
+ /// Provides the underlying data service if the data is not available in the cache. The passed to the factory allows it to influence cache entry options based on the result.
+ /// Additional options for this cache entry.
+ /// The tags to associate with this cache item.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The data, either from cache or the underlying data service.
+ public ValueTask GetOrCreateAsync(
+ ReadOnlySpan key,
+ Func> factory,
+ HybridCacheEntryOptions? options = null,
+ IEnumerable? tags = null,
+ CancellationToken cancellationToken = default)
+ => GetOrCreateAsync(key, factory, WrappedOptionsCallbackCache.Instance, options, tags, cancellationToken);
+
+ ///
+ /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of additional state required by .
+ /// The type of the data being considered.
+ /// The key of the entry to look for or create.
+ /// The state required for .
+ /// Provides the underlying data service if the data is not available in the cache. The passed to the factory allows it to influence cache entry options based on the result.
+ /// Additional options for this cache entry.
+ /// The tags to associate with this cache item.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The data, either from cache or the underlying data service.
+ public virtual ValueTask GetOrCreateAsync(
+ ReadOnlySpan key,
+ TState state,
+ Func> factory,
+ HybridCacheEntryOptions? options = null,
+ IEnumerable? tags = null,
+ CancellationToken cancellationToken = default)
+ => GetOrCreateAsync(key.ToString(), state, factory, options, tags, cancellationToken);
+
+ ///
+ /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of the data being considered.
+ /// The key of the entry to look for or create.
+ /// Provides the underlying data service if the data is not available in the cache. The passed to the factory allows it to influence cache entry options based on the result.
+ /// Additional options for this cache entry.
+ /// The tags to associate with this cache item.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The data, either from cache or the underlying data service.
+ public ValueTask GetOrCreateAsync(
+ ref DefaultInterpolatedStringHandler key,
+ Func> factory,
+ HybridCacheEntryOptions? options = null,
+ IEnumerable? tags = null,
+ CancellationToken cancellationToken = default)
+ => ClearAndReturn(ref key,
+ GetOrCreateAsync(key.Text, factory, WrappedOptionsCallbackCache.Instance, options, tags, cancellationToken));
+
+ ///
+ /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
+ ///
+ /// The type of additional state required by .
+ /// The type of the data being considered.
+ /// The key of the entry to look for or create.
+ /// The state required for .
+ /// Provides the underlying data service if the data is not available in the cache. The passed to the factory allows it to influence cache entry options based on the result.
+ /// Additional options for this cache entry.
+ /// The tags to associate with this cache item.
+ /// The used to propagate notifications that the operation should be canceled.
+ /// The data, either from cache or the underlying data service.
+ public ValueTask GetOrCreateAsync(
+ ref DefaultInterpolatedStringHandler key,
+ TState state,
+ Func> factory,
+ HybridCacheEntryOptions? options = null,
+ IEnumerable? tags = null,
+ CancellationToken cancellationToken = default)
+ => ClearAndReturn(ref key,
+ GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken));
+
+ // It is *not* an error that this Clear occurs before the "await"; by definition, the implementation is *required* to copy
+ // the value locally before an await, precisely because the ref-struct cannot bridge an await. Thus: we are fine to clean
+ // the buffer even in the non-synchronous completion scenario.
+ private static ValueTask ClearAndReturn(ref DefaultInterpolatedStringHandler key, ValueTask result)
{
- // It is *not* an error that this Clear occurs before the "await"; by definition, the implementation is *required* to copy
- // the value locally before an await, precisely because the ref-struct cannot bridge an await. Thus: we are fine to clean
- // the buffer even in the non-synchronous completion scenario.
- ValueTask result = GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken);
key.Clear();
return result;
}
#endif
+ private static class WrappedOptionsCallbackCache
+ {
+ public static readonly Func>, HybridCacheEntryContext, CancellationToken, ValueTask> Instance =
+ static (callback, context, ct) => callback(context, ct);
+ }
+
+ private sealed class DefaultImplState
+ {
+ public readonly TState State;
+ public readonly Func> Factory;
+ public readonly HybridCacheEntryOptions? Options;
+
+ // Set by the wrapped delegate when the factory runs; a non-null value signals a cache miss.
+ public HybridCacheEntryContext? Context;
+
+ public DefaultImplState(TState state, Func> factory, HybridCacheEntryOptions? options)
+ {
+ State = state;
+ Factory = factory;
+ Options = options;
+ }
+ }
+
private static class WrappedCallbackCache // per-T memoized helper that allows GetOrCreateAsync and GetOrCreateAsync to share an implementation
{
// for the simple usage scenario (no TState), pack the original callback as the "state", and use a wrapper function that just unrolls and invokes from the state
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryContext.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryContext.cs
new file mode 100644
index 00000000000000..40e217e3a74a90
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryContext.cs
@@ -0,0 +1,151 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Extensions.Caching.Hybrid;
+
+///
+/// Provides a mutable view of the cache entry options in effect for a single operation,
+/// supplied to a factory callback so that it can adjust those options based on the result of the data fetch.
+///
+///
+/// When a implementation constructs the context for a factory callback, it seeds the
+/// properties from the effective options for the operation (composed from the per-call and any global options), so
+/// the factory observes the current values and can change them to influence how the resulting value is cached.
+/// A context constructed directly reflects only whatever were supplied to
+/// its constructor. Implementations can use to detect whether the factory changed any value.
+///
+public sealed class HybridCacheEntryContext
+{
+ ///
+ /// Initializes a new instance of the class, seeding its values from
+ /// the supplied options.
+ ///
+ ///
+ /// The options to seed the context from, or to start with unset values. This is
+ /// typically the effective options for the operation, so that the factory callback observes the current values.
+ ///
+ ///
+ /// This is primarily intended for implementations that need to construct a context
+ /// to pass to a factory callback.
+ ///
+ public HybridCacheEntryContext(HybridCacheEntryOptions? options)
+ {
+ if (options is not null)
+ {
+ Expiration = options.Expiration;
+ LocalCacheExpiration = options.LocalCacheExpiration;
+ Flags = options.Flags;
+ LocalSize = options.LocalSize;
+ }
+
+ // Seeding from the supplied options is not a caller change, so the context starts at revision zero.
+ Revision = 0;
+ }
+
+ ///
+ /// Gets or sets the overall cache duration of this entry, passed to the backend distributed cache.
+ ///
+ public TimeSpan? Expiration
+ {
+ get;
+ set
+ {
+ if (field != value)
+ {
+ field = value;
+ BumpRevision();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the expiration for the local (in-process) cache entry.
+ ///
+ ///
+ /// When retrieving a cached value from an external cache store, this value will be used to calculate the local
+ /// cache expiration, not exceeding the remaining overall cache lifetime.
+ ///
+ public TimeSpan? LocalCacheExpiration
+ {
+ get;
+ set
+ {
+ if (field != value)
+ {
+ field = value;
+ BumpRevision();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets additional flags that apply to the requested operation.
+ ///
+ public HybridCacheEntryFlags? Flags
+ {
+ get;
+ set
+ {
+ if (field != value)
+ {
+ field = value;
+ BumpRevision();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the size to assign to entries in the local (in-process) cache.
+ ///
+ ///
+ /// The units are determined by the underlying local cache implementation. When the local cache
+ /// is an configured with a size limit, this value corresponds to
+ /// .
+ ///
+ /// When , the implementation may compute a default size (for example, from
+ /// the serialized payload length).
+ ///
+ ///
+ public long? LocalSize
+ {
+ get;
+ set
+ {
+ if (field != value)
+ {
+ field = value;
+ BumpRevision();
+ }
+ }
+ }
+
+ ///
+ /// Gets a value that increments whenever a property on this instance is changed.
+ ///
+ ///
+ /// Implementations of can capture this value before invoking a factory
+ /// callback and compare it afterwards to determine whether the factory changed any value.
+ /// The value increments only when a setter assigns a different value than the current one.
+ ///
+ public int Revision { get; private set; }
+
+ private void BumpRevision()
+ {
+ // unchecked: rollover is fine because callers compare for inequality, not ordering.
+ unchecked
+ {
+ Revision++;
+ }
+ }
+
+ internal HybridCacheEntryOptions ToOptions()
+ => new HybridCacheEntryOptions
+ {
+ Expiration = Expiration,
+ LocalCacheExpiration = LocalCacheExpiration,
+ Flags = Flags,
+ LocalSize = LocalSize,
+ };
+}
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs
index f9708fdd6bd8f1..2aea248f7d569b 100644
--- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs
@@ -12,13 +12,24 @@ namespace Microsoft.Extensions.Caching.Hybrid;
/// most granular non-null value is used, with null values being inherited. If no value is specified at
/// any level, the implementation can choose a reasonable default.
///
+///
+/// This type is immutable. To adjust cache entry options based on the result of a data fetch, use one of the
+///
+/// overloads, whose factory callback receives a mutable .
+///
public sealed class HybridCacheEntryOptions
{
+ // memoize when possible; safe because this type is immutable.
+ private DistributedCacheEntryOptions? _dc;
+
///
- /// Gets or set the overall cache duration of this entry, passed to the backend distributed cache.
+ /// Gets or sets the overall cache duration of this entry, passed to the backend distributed cache.
///
public TimeSpan? Expiration { get; init; }
+ ///
+ /// Gets or sets the expiration for the local (in-process) cache entry.
+ ///
///
/// When retrieving a cached value from an external cache store, this value will be used to calculate the local
/// cache expiration, not exceeding the remaining overall cache lifetime.
@@ -30,8 +41,23 @@ public sealed class HybridCacheEntryOptions
///
public HybridCacheEntryFlags? Flags { get; init; }
- // memoize when possible
- private DistributedCacheEntryOptions? _dc;
+ ///
+ /// Gets or sets the size to assign to entries in the local (in-process) cache.
+ ///
+ ///
+ /// The units are determined by the underlying local cache implementation. When the local cache
+ /// is an configured with a size limit, this value corresponds to
+ /// .
+ ///
+ /// When , the implementation may compute a default size (for example, from
+ /// the serialized payload length).
+ ///
+ ///
+ public long? LocalSize { get; init; }
+
+ // DefaultHybridCache (in MS.E.Caching.Hybrid) uses this through UnsafeAccessor.
+ // Since this assembly (ME.E.Caching.Abstractions) is now part of the shared framework,
+ // it is effectively automatically updated when TFM is updated, so we shouldn't remove this method.
internal DistributedCacheEntryOptions? ToDistributedCacheEntryOptions()
=> Expiration is null ? null : (_dc ??= new() { AbsoluteExpirationRelativeToNow = Expiration });
}
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/HybridCacheTests.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/HybridCacheTests.cs
new file mode 100644
index 00000000000000..4f3f7801da6978
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/HybridCacheTests.cs
@@ -0,0 +1,400 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+#if NET
+using System.Runtime.CompilerServices;
+#endif
+using Xunit;
+
+namespace Microsoft.Extensions.Caching.Hybrid.Tests;
+
+///
+/// Contract tests for the default
+/// implementation, exercised against a subclass that does not override the new virtual.
+///
+/// The fake cache stores writes from any path (the abstract
+/// override or ) into the same observable ,
+/// so the tests describe externally observable behavior and not the specific mechanism the default
+/// implementation uses to update the cache.
+///
+///
+public class HybridCacheTests
+{
+ [Fact]
+ public async Task ReturnsFactoryValue()
+ {
+ var cache = new FakeHybridCache();
+
+ int result = await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, _, _) => new ValueTask(42));
+
+ Assert.Equal(42, result);
+ }
+
+ [Fact]
+ public async Task FactoryReceivesNonNullMutableContext()
+ {
+ var cache = new FakeHybridCache();
+ HybridCacheEntryContext? observed = null;
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, ctx, _) =>
+ {
+ observed = ctx;
+ ctx.Expiration = TimeSpan.FromMinutes(5);
+ return new ValueTask(1);
+ });
+
+ Assert.NotNull(observed);
+ Assert.Equal(TimeSpan.FromMinutes(5), observed!.Expiration);
+ }
+
+ [Fact]
+ public async Task CallerOptions_AreNotMutated_ByFactory()
+ {
+ var cache = new FakeHybridCache();
+ var callerOptions = new HybridCacheEntryOptions
+ {
+ Expiration = TimeSpan.FromMinutes(1),
+ Flags = HybridCacheEntryFlags.None,
+ };
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, ctx, _) =>
+ {
+ ctx.Expiration = TimeSpan.FromHours(1);
+ ctx.LocalSize = 12345;
+ ctx.Flags = HybridCacheEntryFlags.DisableCompression;
+ return new ValueTask(1);
+ },
+ options: callerOptions);
+
+ Assert.Equal(TimeSpan.FromMinutes(1), callerOptions.Expiration);
+ Assert.Null(callerOptions.LocalSize);
+ Assert.Equal(HybridCacheEntryFlags.None, callerOptions.Flags);
+ }
+
+ [Fact]
+ public async Task FactoryMutations_AreVisibleToCache()
+ {
+ var cache = new FakeHybridCache();
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, ctx, _) =>
+ {
+ ctx.Expiration = TimeSpan.FromHours(2);
+ ctx.LocalSize = 999;
+ return new ValueTask(7);
+ });
+
+ StoredEntry entry = Assert.Contains("k", (IReadOnlyDictionary)cache.Store);
+ Assert.Equal(7, entry.Value);
+ Assert.NotNull(entry.Options);
+ Assert.Equal(TimeSpan.FromHours(2), entry.Options!.Expiration);
+ Assert.Equal(999, entry.Options.LocalSize);
+ }
+
+ [Fact]
+ public async Task FactoryLeavesContextUnchanged_WritesWithOriginalOptions()
+ {
+ var cache = new FakeHybridCache();
+
+ // The factory does not touch the context, so the write should reuse the caller's original
+ // options (here null) rather than materializing a new all-null-valued instance.
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, _, _) => new ValueTask(5));
+
+ StoredEntry entry = Assert.Contains("k", (IReadOnlyDictionary)cache.Store);
+ Assert.Equal(5, entry.Value);
+ Assert.Null(entry.Options);
+ }
+
+ [Fact]
+ public async Task FactoryDisablingBothWrites_SuppressesCacheWrite()
+ {
+ var cache = new FakeHybridCache();
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, ctx, _) =>
+ {
+ ctx.Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite;
+ return new ValueTask(1);
+ });
+
+ Assert.DoesNotContain("k", (IReadOnlyDictionary)cache.Store);
+ }
+
+ [Fact]
+ public async Task FactoryDisablingOnlyOneWrite_StillWrites()
+ {
+ var cache = new FakeHybridCache();
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, ctx, _) =>
+ {
+ ctx.Flags = HybridCacheEntryFlags.DisableLocalCacheWrite;
+ return new ValueTask(1);
+ });
+
+ Assert.Contains("k", (IReadOnlyDictionary)cache.Store);
+ }
+
+ [Fact]
+ public async Task FactoryNotInvoked_LeavesExistingEntryUntouched()
+ {
+ var cache = new FakeHybridCache();
+ var preExistingOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) };
+ cache.Store["k"] = new StoredEntry(17, preExistingOptions, Tags: null);
+
+ int result = await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, _, _) => throw new Xunit.Sdk.XunitException("factory should not have run"));
+
+ Assert.Equal(17, result);
+ // pre-existing entry should not be touched
+ Assert.Same(preExistingOptions, cache.Store["k"].Options);
+ }
+
+ [Fact]
+ public async Task CancellingCallerToken_DoesNotPreventCacheWrite()
+ {
+ var cache = new FakeHybridCache();
+ using var cts = new CancellationTokenSource();
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, _, _) =>
+ {
+ // cancel after the factory has produced a value, before the cache write runs
+ cts.Cancel();
+ return new ValueTask(1);
+ },
+ cancellationToken: cts.Token);
+
+ // The fake skips any write whose CancellationToken is already canceled, so this
+ // assertion fails for any implementation that forwards the caller's token to the
+ // post-factory cache write.
+ Assert.Contains("k", (IReadOnlyDictionary)cache.Store);
+ }
+
+ [Fact]
+ public async Task NoStateOverload_BehavesLikeStatefulOverload()
+ {
+ var cache = new FakeHybridCache();
+
+ int result = await cache.GetOrCreateAsync(
+ "k",
+ factory: (ctx, _) =>
+ {
+ ctx.Expiration = TimeSpan.FromMinutes(3);
+ return new ValueTask(99);
+ });
+
+ Assert.Equal(99, result);
+ StoredEntry entry = cache.Store["k"];
+ Assert.Equal(TimeSpan.FromMinutes(3), entry.Options!.Expiration);
+ }
+
+ [Fact]
+ public async Task TagsArePropagatedToCacheWrite()
+ {
+ var cache = new FakeHybridCache();
+ var tags = new[] { "a", "b" };
+
+ await cache.GetOrCreateAsync(
+ "k", state: 0,
+ factory: (_, _, _) => new ValueTask(1),
+ tags: tags);
+
+ StoredEntry entry = cache.Store["k"];
+ Assert.NotNull(entry.Tags);
+ Assert.Equal(tags, entry.Tags!.ToArray());
+ }
+
+#if NET
+ [Fact]
+ public async Task ReadOnlySpanKey_FactoryMutationsReachCache()
+ {
+ var cache = new FakeHybridCache();
+ HybridCacheEntryContext? observed = null;
+
+ int result = await cache.GetOrCreateAsync(
+ "span-key".AsSpan(), state: 0,
+ factory: (_, ctx, _) =>
+ {
+ observed = ctx;
+ ctx.Expiration = TimeSpan.FromMinutes(4);
+ ctx.LocalSize = 321;
+ return new ValueTask(55);
+ });
+
+ Assert.Equal(55, result);
+ Assert.NotNull(observed);
+ StoredEntry entry = cache.Store["span-key"];
+ Assert.Equal(55, entry.Value);
+ Assert.Equal(TimeSpan.FromMinutes(4), entry.Options!.Expiration);
+ Assert.Equal(321, entry.Options.LocalSize);
+ }
+
+ [Fact]
+ public async Task ReadOnlySpanKey_NoStateOverload_FactoryMutationsReachCache()
+ {
+ var cache = new FakeHybridCache();
+
+ int result = await cache.GetOrCreateAsync(
+ "span-key-nostate".AsSpan(),
+ factory: (ctx, _) =>
+ {
+ ctx.Expiration = TimeSpan.FromMinutes(6);
+ return new ValueTask(77);
+ });
+
+ Assert.Equal(77, result);
+ StoredEntry entry = cache.Store["span-key-nostate"];
+ Assert.Equal(77, entry.Value);
+ Assert.Equal(TimeSpan.FromMinutes(6), entry.Options!.Expiration);
+ }
+
+ [Fact]
+ public async Task InterpolatedStringKey_FactoryMutationsReachCache_AndClearsHandler()
+ {
+ var cache = new FakeHybridCache();
+
+ // A DefaultInterpolatedStringHandler is a ref struct and cannot be a local in an async method,
+ // so the synchronous handler work — including asserting the overload cleared it — lives in a
+ // non-async local function that hands back the pending operation.
+ ValueTask pending = Invoke(cache);
+ int result = await pending;
+
+ Assert.Equal(42, result);
+ StoredEntry entry = cache.Store["dish-key"];
+ Assert.Equal(42, entry.Value);
+ Assert.Equal(TimeSpan.FromMinutes(9), entry.Options!.Expiration);
+
+ static ValueTask Invoke(FakeHybridCache cache)
+ {
+ var key = new DefaultInterpolatedStringHandler(literalLength: 8, formattedCount: 0);
+ key.AppendLiteral("dish-key");
+
+ ValueTask pending = cache.GetOrCreateAsync(
+ ref key, state: 0,
+ factory: (_, ctx, _) =>
+ {
+ ctx.Expiration = TimeSpan.FromMinutes(9);
+ return new ValueTask(42);
+ });
+
+ // key.Clear() must run before the overload returns, on every completion path (here the
+ // factory ran, so the underlying operation may complete asynchronously).
+ Assert.Equal(string.Empty, key.ToString());
+ return pending;
+ }
+ }
+
+ [Fact]
+ public async Task InterpolatedStringKey_ClearsHandler_OnSynchronousCacheHit()
+ {
+ var cache = new FakeHybridCache();
+ cache.Store["dish-hit"] = new StoredEntry(123, Options: null, Tags: null);
+
+ // A pre-existing entry means the factory is never invoked and the operation completes
+ // synchronously; the handler must still be cleared.
+ ValueTask pending = Invoke(cache);
+ int result = await pending;
+
+ Assert.Equal(123, result);
+
+ static ValueTask Invoke(FakeHybridCache cache)
+ {
+ var key = new DefaultInterpolatedStringHandler(literalLength: 8, formattedCount: 0);
+ key.AppendLiteral("dish-hit");
+
+ ValueTask pending = cache.GetOrCreateAsync(
+ ref key, state: 0,
+ factory: (_, _, _) => throw new Xunit.Sdk.XunitException("factory should not run on a cache hit"));
+
+ Assert.Equal(string.Empty, key.ToString());
+ return pending;
+ }
+ }
+#endif
+
+ private sealed record StoredEntry(object? Value, HybridCacheEntryOptions? Options, IEnumerable? Tags);
+
+ ///
+ /// A minimal backed by an in-memory dictionary. Writes from either the
+ /// abstract
+ /// override or land in , so the test assertions
+ /// are agnostic to which mechanism the default implementation uses.
+ ///
+ private sealed class FakeHybridCache : HybridCache
+ {
+ public Dictionary Store { get; } = new();
+
+ public override ValueTask GetOrCreateAsync(
+ string key, TState state, Func> factory,
+ HybridCacheEntryOptions? options = null, IEnumerable? tags = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (Store.TryGetValue(key, out StoredEntry? existing) && existing.Value is T cached)
+ {
+ return new ValueTask(cached);
+ }
+
+ return InvokeAndStoreAsync(key, state, factory, options, tags, cancellationToken);
+ }
+
+ private async ValueTask InvokeAndStoreAsync(
+ string key, TState state, Func> factory,
+ HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken cancellationToken)
+ {
+ T value = await factory(state, cancellationToken).ConfigureAwait(false);
+
+ const HybridCacheEntryFlags BothWritesDisabled =
+ HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite;
+ HybridCacheEntryFlags flags = options?.Flags ?? HybridCacheEntryFlags.None;
+ if ((flags & BothWritesDisabled) != BothWritesDisabled)
+ {
+ TryWrite(key, value, options, tags, cancellationToken);
+ }
+
+ return value;
+ }
+
+ public override ValueTask SetAsync(
+ string key, T value, HybridCacheEntryOptions? options = null,
+ IEnumerable? tags = null, CancellationToken cancellationToken = default)
+ {
+ TryWrite(key, value, options, tags, cancellationToken);
+ return default;
+ }
+
+ public override ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) => default;
+
+ public override ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default) => default;
+
+ // Writes that arrive with an already-canceled token are dropped, so the test for
+ // "caller cancellation must not abort the post-factory write" actually has teeth.
+ private void TryWrite(string key, object? value, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ Store[key] = new StoredEntry(value, options, tags);
+ }
+ }
+}
diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/Microsoft.Extensions.Caching.Abstractions.Tests.csproj b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/Microsoft.Extensions.Caching.Abstractions.Tests.csproj
new file mode 100644
index 00000000000000..897dba6387fdc2
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/Microsoft.Extensions.Caching.Abstractions.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ $(NetCoreAppCurrent);$(NetFrameworkCurrent)
+ true
+
+
+
+
+
+
+
+